diff --git a/.changeset/adp-owner-self-entry-roundtrip.md b/.changeset/adp-owner-self-entry-roundtrip.md new file mode 100644 index 000000000..559bba48c --- /dev/null +++ b/.changeset/adp-owner-self-entry-roundtrip.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Canonicalize the grantor's own default-privilege self-entry at extraction so `pg_default_acl` rows round-trip. For a PER-SCHEMA row, one built by explicit grants to the owner (`{owner=arwdDxtm/owner, other=…}`) and one built purely from grants to other roles (`{other=…}`, no owner entry) are behaviorally identical — Postgres re-adds the owner's `acldefault` entry to every new object at creation time regardless of the stored row. Previously the extractor emitted a spurious `revoked_default` marker for the owner whenever it was absent from the stored ACL, so re-exporting a replayed database produced a spurious `alter default privileges … revoke all … from ` self-revoke. The owner's own revoked-default marker is now suppressed where its absence is a behavioral no-op (PUBLIC and other-role markers are unaffected; a partial owner self-reduction that differs from `acldefault` is still represented as a positive fact; a GLOBAL row where the owner's absence is a real revoke keeps its marker — see the companion global-self-revoke changeset). diff --git a/.changeset/adp-owner-self-revoke-global.md b/.changeset/adp-owner-self-revoke-global.md new file mode 100644 index 000000000..30dda64aa --- /dev/null +++ b/.changeset/adp-owner-self-revoke-global.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Extract a GLOBAL (cluster-wide) default-privilege owner self-revoke as a real `revoked_default` marker instead of silently dropping it. The previous canonicalization suppressed the grantor's own revoked-default marker unconditionally, which is only correct for per-schema rows (Postgres always re-merges the owner's `acldefault` entry at object creation) and for a bare global self-revoke with an empty stored ACL (the created object's relacl degenerates to NULL and the owner keeps its privileges). On a GLOBAL row that still carries other grantees (e.g. `alter default privileges for role alice revoke all on tables from alice; ... grant select on tables to bob` → `{bob=r/alice}`), Postgres uses the stored ACL verbatim at creation, so the owner really loses its own privileges — a genuine customization that must survive export/apply/reverse. The suppression is now conditional (per-schema and bare-empty-global stay no-ops; global-with-other-grantees emits the owner marker so the `revoke`/`grant` round-trips). diff --git a/.changeset/agg-sig-render-prune-strict-labels-enum-guard.md b/.changeset/agg-sig-render-prune-strict-labels-enum-guard.md new file mode 100644 index 000000000..29c80dd34 --- /dev/null +++ b/.changeset/agg-sig-render-prune-strict-labels-enum-guard.md @@ -0,0 +1,11 @@ +--- +"@supabase/pg-delta": patch +--- + +Several correctness and packaging fixes: + +- **Ordered-set aggregate metadata**: `COMMENT ON` / `SECURITY LABEL ON` an ordered-set or hypothetical-set aggregate now address it with the `agg(direct ORDER BY ordered)` signature (reusing the aggregate DDL's `aggSig`), instead of the flat argument list that PostgreSQL rejects at apply. +- **`render` prunes stale segments**: re-rendering a plan to the same `--out` base now deletes the previous render's segment files (`.sql` / `_.sql`) that the new render no longer produces, so a runner scanning the directory can no longer replay obsolete (possibly destructive) segments. Only render-owned files matching that naming scheme are touched; foreign files are left in place. +- **`--strict-coverage` blocks unresolved security labels**: a valid `SECURITY LABEL` on an unsupported object (language / database / large object / tablespace) now escalates to a blocking diagnostic under `--strict-coverage`, matching `unmodeled_kind`, instead of silently producing an artifact that omits the label. +- **Enum value-set rebuild guard**: removing or reordering enum values while a non-column dependent (a `DOMAIN` over the enum, a `COMPOSITE` attribute using it, or a `RANGE` over it) survives now fails loudly at plan time. The rebuild only migrates column dependents, so such objects would otherwise leave the final `DROP TYPE` failing at apply. Full migration of non-column dependents remains out of scope. +- **ESM-only packaging**: removed the misleading `require` conditions from every `exports` entry. The package is `type: module` with a NodeNext build and ships no CommonJS output, so a `require` condition pointing at ESM was a false CJS signal (`ERR_REQUIRE_ESM` on Node <22). CommonJS consumers must use dynamic `import()`, or Node >=22 (which can `require()` ESM synchronously). ESM consumers are unaffected. diff --git a/.changeset/apply-owner-shadow-guard-and-absent-verbose.md b/.changeset/apply-owner-shadow-guard-and-absent-verbose.md new file mode 100644 index 000000000..4afb55b57 --- /dev/null +++ b/.changeset/apply-owner-shadow-guard-and-absent-verbose.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +`schema apply` now (1) fails closed when an explicit `--shadow`'s connection role differs from the export's stamped default owner — the shadow would otherwise load omitted-`OWNER TO` objects as its own role and plan spurious ownership drift — and (2) treats a directory with no manifest default-owner record as verbose, honoring every explicit `OWNER TO` in the files instead of synthesizing a target default and pruning owner edges to it (which silently dropped an explicit owner change when the target object was owned by a different role). diff --git a/.changeset/canonical-search-path.md b/.changeset/canonical-search-path.md new file mode 100644 index 000000000..b3f1b08fd --- /dev/null +++ b/.changeset/canonical-search-path.md @@ -0,0 +1,20 @@ +--- +"@supabase/pg-delta": minor +--- + +Canonicalize the extraction session's `search_path` to `pg_catalog` (pg_dump +convention). Postgres deparsers (`format_type`, `pg_get_*def`, `pg_get_expr`) +path-relativize names, so any object visible on the session path previously came +back UNQUALIFIED — meaning the same catalog extracted under different search_paths +(e.g. a target database carrying `ALTER DATABASE … SET search_path`, versus a +freshly-created shadow with the default path) produced DIFFERENT payloads and +hashes, causing mass false drift in the shadow-vs-target compare and shifting +routine stable-ids. Extraction now pins the deparse path so identical catalogs +hash identically regardless of session/database/role path settings, and rendered +DDL is fully schema-qualified. + +The plan preamble now also pins `search_path` to `pg_catalog` at apply time so +rendered DDL resolves identically regardless of the applier role's defaults. + +`ENGINE_VERSION` is bumped to `0.2.0` (hash-invalidating): plan artifacts, +snapshots, and baselines captured before this change must be regenerated. diff --git a/.changeset/composite-attribute-order.md b/.changeset/composite-attribute-order.md new file mode 100644 index 000000000..c8feb4277 --- /dev/null +++ b/.changeset/composite-attribute-order.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Preserve composite type attribute order. The composite `CREATE TYPE … AS (…)` rule assembled attributes in encoded-id (name) order, silently reordering columns (e.g. `errors` before `wal`) on every reconstruction — a row-layout change that broke composite-returning dependents at body validation. The extractor now carries the declared attribute position (as the non-semantic `_position` payload key, excluded from hash/diff), and the composite create renders attributes in that order. diff --git a/.changeset/concurrent-indexes-partitioned.md b/.changeset/concurrent-indexes-partitioned.md new file mode 100644 index 000000000..3b53eccc1 --- /dev/null +++ b/.changeset/concurrent-indexes-partitioned.md @@ -0,0 +1,9 @@ +--- +"@supabase/pg-delta": patch +--- + +The `concurrentIndexes` serialize option no longer inserts `CONCURRENTLY` for +indexes on partitioned tables. PostgreSQL rejects `CREATE INDEX CONCURRENTLY` +on a partitioned table's parent index (relkind `p`), so such a plan failed at +apply time. Those indexes are now created plainly (transactionally) while +indexes on regular tables keep the concurrent, non-transactional path. diff --git a/.changeset/createrole-self-grant-load.md b/.changeset/createrole-self-grant-load.md new file mode 100644 index 000000000..d3172a0d4 --- /dev/null +++ b/.changeset/createrole-self-grant-load.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +`loadSqlFiles` enables `createrole_self_grant` on PG 16+ so a CREATEROLE non-superuser (Supabase `postgres`) can load `CREATE SCHEMA … AUTHORIZATION new_role`, and strips the resulting bootstrap memberships from the extracted fact base so plans do not emit a failing `GRANT … TO WITH ADMIN OPTION`. Assumed-schema seeding uses the same GUC on a dedicated pool client. diff --git a/.changeset/drop-role-no-drop-owned-by.md b/.changeset/drop-role-no-drop-owned-by.md new file mode 100644 index 000000000..d328c37ef --- /dev/null +++ b/.changeset/drop-role-no-drop-owned-by.md @@ -0,0 +1,12 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix role drop to no longer emit `DROP OWNED BY ` ahead of `DROP ROLE `. +`DROP OWNED BY` swept up anything the role owned outside the managed/projected +view (objects the engine never extracted), silently destroying unmanaged data +when applying the plan. Managed grants, default ACLs, and owned objects are +already revoked/reassigned/dropped by their own plan actions before the role +drop runs, so a plain `DROP ROLE` succeeds when everything is managed, and now +Postgres fails loud ("role cannot be dropped because some objects depend on +it") instead of silently destroying data when unmanaged ownership remains. diff --git a/.changeset/enum-tempname-column-order.md b/.changeset/enum-tempname-column-order.md new file mode 100644 index 000000000..ac47b3c91 --- /dev/null +++ b/.changeset/enum-tempname-column-order.md @@ -0,0 +1,7 @@ +--- +"@supabase/pg-delta": minor +--- + +Enum value-set rebuilds now pick a namespace- and length-safe temp name for the old enum: the collision check consults every occupant of the type namespace (enums/composites/ranges, domains, and the implicit row types of tables/views/matviews/foreign tables/sequences), and the generated identifier is clipped to ≤ 63 bytes so PostgreSQL never truncates it back onto an occupied name. + +Table `CREATE`s and schema exports now render columns in DECLARED order instead of alphabetical name order. Column position (`pg_attribute.attnum`) is captured at extract time as a non-semantic field, so a from-empty create/export reproduces the original `SELECT *`, positional-INSERT, and row-type layout; order-only differences on an existing table remain undiffed by design (the field is excluded from the fact hash and diff). diff --git a/.changeset/export-constraint-on-deferred-column.md b/.changeset/export-constraint-on-deferred-column.md new file mode 100644 index 000000000..b19f16ffd --- /dev/null +++ b/.changeset/export-constraint-on-deferred-column.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix `schema export` folding a table constraint inline into `CREATE TABLE` when the column it references was deferred to a later `ALTER TABLE … ADD COLUMN` (a domain-typed column whose fold crosses the domain-create edge, or a generated column that never hints). The constraint fold pass bypassed the crossing guard for all constraints (to keep validated FKs to later-created tables foldable), which produced `CREATE TABLE … CONSTRAINT … UNIQUE (slug)` where `slug` was not yet a column, so the export failed to reload with `column "slug" named in key does not exist`. The guard now vetoes a constraint fold only when a same-table column of the fold target is deferred, while still tolerating crossings to other relations (an FK's referenced table, backing indexes/types elsewhere). Such constraints now render as standalone `ALTER TABLE … ADD CONSTRAINT`. diff --git a/.changeset/export-default-owner.md b/.changeset/export-default-owner.md new file mode 100644 index 000000000..29c32bca9 --- /dev/null +++ b/.changeset/export-default-owner.md @@ -0,0 +1,25 @@ +--- +"@supabase/pg-delta": minor +--- + +`schema export` now serializes object ownership as `ALTER … OWNER TO` (an assumed +role reference, consistent with how ACLs already round-trip) instead of dropping +it at the default `--scope database`. Ownership is suppressed only for the +resolved DEFAULT owner so exports stay human-readable: the default resolves +`--default-owner ` (new flag) > the profile-declared default (Supabase +→ `postgres`) > the database owner (`datdba`). `--default-owner none` emits every +`OWNER TO` for maximum fidelity. + +Previously, database-scope exports dropped all ownership, so objects owned by a +non-applier role (e.g. Supabase's `auth_admin`) reloaded applier-owned and then +showed up as spurious `ALTER … OWNER TO` / `REVOKE … FROM postgres` drift. This +now holds even when the database has extensions or assumed schemas present: the +managed view is rebuilt to attach reference-only marks in that case, and the +rebuild no longer silently re-prunes the retained owner references (which had made +a real Supabase export emit zero `OWNER TO`). + +The export manifest stamps the resolved default owner (a role name, or `null` for +a verbose export; a field-absent directory is treated as pre-feature). `schema +apply` reconstructs the identical view and fails closed (exit 2) when the target +connection role differs from a role-name default. Policy-based owner exclusion is +unchanged. diff --git a/.changeset/export-identity-sequence-name.md b/.changeset/export-identity-sequence-name.md new file mode 100644 index 000000000..652205516 --- /dev/null +++ b/.changeset/export-identity-sequence-name.md @@ -0,0 +1,11 @@ +--- +"@supabase/pg-delta": patch +--- + +`schema export` now emits `SEQUENCE NAME` for identity columns whose implicit +backing sequence name differs from the `__seq` default (renamed +sequences, or ones created via `SEQUENCE NAME`). Previously the export rendered +a bare `GENERATED … AS IDENTITY`, so reload let PostgreSQL re-derive the default +name and the next diff produced a spurious `ALTER SEQUENCE … RENAME`. Renamed +identity sequences now round-trip cleanly; default-named identity columns stay +bare so ordinary exports remain minimal. diff --git a/.changeset/famous-eagles-fry.md b/.changeset/famous-eagles-fry.md new file mode 100644 index 000000000..4321f7fb5 --- /dev/null +++ b/.changeset/famous-eagles-fry.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix dependency-cycle planning when an accepted role rename is referenced by an RLS policy. diff --git a/.changeset/fix-owner-edge-policy-exclusion.md b/.changeset/fix-owner-edge-policy-exclusion.md new file mode 100644 index 000000000..f93588bf7 --- /dev/null +++ b/.changeset/fix-owner-edge-policy-exclusion.md @@ -0,0 +1,15 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix policy hard-exclusion laundering an excluded owner role back into the plan. +`excludeFactsAndDescendants` no longer mints a dangling `owner -> role` edge for +a role that THIS exclusion removes (it only preserves edges that were already +dangling on input, the seed-rebuild case). Previously a policy-excluded role +retained its owner edge, was auto-assumed in `plan.ts`, and re-emerged as +`CREATE SCHEMA … AUTHORIZATION ` / `OWNER TO ` while silencing the +missing-requirement guard. Now the guard correctly fires when a kept object's +ACL (or ownership) references a policy-excluded role. Also fixes the +"typo'd function body is caught by re-validation" test to opt into +`strictFunctionBodies` (a user-routine body-lint is a warning by default under +lenient function bodies). diff --git a/.changeset/foreign-server-extension-subpartition-fixes.md b/.changeset/foreign-server-extension-subpartition-fixes.md new file mode 100644 index 000000000..362116a14 --- /dev/null +++ b/.changeset/foreign-server-extension-subpartition-fixes.md @@ -0,0 +1,11 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix five round-trip fidelity gaps in the planner: + +- Multi-level partitions keep their own `PARTITION BY` clause, so a partition that is itself partitioned can have sub-partitions attached. +- Removing a foreign server `VERSION` (which has no `ALTER SERVER` grammar) now routes to a drop + recreate instead of crashing planning. +- `ALTER EXTENSION … SET SCHEMA` is no longer emitted for non-relocatable extensions; relocation is planned as a drop + recreate in the new schema. +- Zero-argument aggregate `COMMENT` / `SECURITY LABEL` targets render `name(*)` instead of the invalid `name()`. +- Replacing a foreign server that has dependent foreign tables / user mappings now drops and recreates those children around the replace (the parent `DROP SERVER` does not cascade), instead of failing on the surviving dependents. diff --git a/.changeset/in-place-alter-consumes-releases.md b/.changeset/in-place-alter-consumes-releases.md new file mode 100644 index 000000000..85e25631c --- /dev/null +++ b/.changeset/in-place-alter-consumes-releases.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Make in-place `ALTER` actions participate in the plan's dependency walk by declaring `consumes`/`releases` on four rule sites, so they no longer sort before the `CREATE` of a new dependency or after the `DROP` of an old one. Column `TYPE …` changes now consume the new column type and release the old one; `ALTER EXTENSION … SET SCHEMA` releases the old schema; sequence `OWNED BY` reassignment releases the old owning column; and `ALTER POLICY … TO` consumes newly-listed roles and releases removed roles. Previously each of these could be emitted against a not-yet-created target ("type/relation/policy does not exist") or block a same-plan `DROP` of the object it stopped referencing. diff --git a/.changeset/issue-333-domain-enum-array-notvalid.md b/.changeset/issue-333-domain-enum-array-notvalid.md new file mode 100644 index 000000000..3c3a5adab --- /dev/null +++ b/.changeset/issue-333-domain-enum-array-notvalid.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Three planning fixes from issue #333: (1) a domain whose `baseType`/`collation` change is a drop+recreate — the planner now fails loud at plan time (instead of emitting a plan Postgres rejects at apply) when a surviving table column still depends on the domain, mirroring the existing in-use range-type guard. (2) An enum value-set rebuild (removal/reorder) migrated every dependent column with a scalar `col::text::` cast regardless of the column's own declared type; an `enum[]` column now casts correctly (`TYPE [] USING col::text[]::[]`) instead of erroring or silently narrowing to scalar. (3) A constraint's `validated` attribute going from `true` to `false` (VALIDATED → NOT VALID) threw `constraint cannot be de-validated in place` instead of planning a fix; it now replaces the constraint (`DROP CONSTRAINT` + `ADD CONSTRAINT … NOT VALID`), matching how `create()` already renders a fresh NOT VALID constraint. diff --git a/.changeset/lenient-function-bodies.md b/.changeset/lenient-function-bodies.md new file mode 100644 index 000000000..bcabfd81f --- /dev/null +++ b/.changeset/lenient-function-bodies.md @@ -0,0 +1,7 @@ +--- +"@supabase/pg-delta": minor +--- + +`schema apply` (and the `loadSqlFiles` loader) now treat a USER routine whose body fails the post-load `check_function_bodies = on` re-validation as a loud WARNING instead of a fatal error. Postgres itself accepts such a function under `check_function_bodies = off` — which pg-delta's own apply executor emits in every plan preamble — so refusing to READ back a function pg-delta would happily WRITE was an asymmetry that blocked round-tripping any schema relying on check-off (legacy forward references, tolerated casts, etc.). The warning still flows through the diagnostics output loudly; the load now proceeds and apply faithfully materializes exactly what was declared. Pass `--strict-function-bodies` (loader option `strictFunctionBodies: true`) to restore the fatal gate for CI. + +Seeded/reference-only routine failures are unchanged (still a warning) and now carry the distinct `invalid_seeded_routine_body` code so they can be told apart from user-routine failures. Changing an assumed-schema routine (a new overload, or a `CREATE OR REPLACE` that alters the body of a seeded routine) still fails loud, because assumed-schema facts are reference-only in the diff and such a change would otherwise be a silent no-op. diff --git a/.changeset/loader-snapshot-baseline-fixes.md b/.changeset/loader-snapshot-baseline-fixes.md new file mode 100644 index 000000000..28716c2c1 --- /dev/null +++ b/.changeset/loader-snapshot-baseline-fixes.md @@ -0,0 +1,9 @@ +--- +"@supabase/pg-delta": patch +--- + +Three fixes to the SQL loader, snapshot metadata, and baseline subtraction: + +- **Loader:** `CREATE|ALTER|DROP USER MAPPING …` statements are no longer misclassified as cluster-global role DDL. The role-lifecycle scanners now use a `user(?!\s+mapping)` negative lookahead, so database-scope `schema apply` accepts (and `--skip-cluster-ddl` no longer strips) the user mappings pg-delta itself emits in foreign-data exports. +- **Snapshots:** `pgdelta snapshot` now stamps the profile it was captured under (a declared id, `null` for a raw capture, or absent for pre-feature legacy snapshots — never folded into the digest). `drift` and `prove` reconcile that stamp against any `--profile` flag: an omitted flag adopts the stamped profile, a contradicting flag fails closed with an actionable error, and a legacy (un-stamped) snapshot keeps the previous behavior with a one-line note. +- **Baseline subtraction:** `subtractBaseline` now compares each fact's outgoing-edge signature alongside its payload hash, so an equal-payload fact whose ownership/provenance edge changed (e.g. `OWNER` A→B) is no longer subtracted and pruned away invisibly. Owner→role edges to subtracted platform roles are retained as dangling assumed references so ownership still serializes. diff --git a/.changeset/non-superuser-extract.md b/.changeset/non-superuser-extract.md new file mode 100644 index 000000000..7b2be7116 --- /dev/null +++ b/.changeset/non-superuser-extract.md @@ -0,0 +1,11 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix `extract()` failing with `permission denied for table pg_user_mapping` when connecting as a non-superuser: user mappings now fall back to the world-readable `pg_user_mappings` view (with a warning diagnostic, since the view hides options the role isn't authorized on). Mappings whose options the view hides from the current role are skipped with that diagnostic instead of being recorded with fabricated empty options. `plan()` now refuses to plan changes touching a user mapping whose state was unreadable (and therefore unknown) on either side, instead of silently emitting a wrong CREATE/DROP USER MAPPING. + +The unreadable-user-mapping diagnostic now survives extension-handler profiles (e.g. Supabase) instead of being silently dropped by the handler-triggered fact-base rebuild. Snapshots now carry `FactBase.diagnostics` (excluded from the digest), so the `plan()` gate still fires when one side is a deserialized snapshot rather than a live extraction; old snapshots without this field simply remain ungated, same as before. The gate itself now also refuses a `DROP SERVER` or `DROP ROLE` that would implicitly destroy a hidden mapping, not just a direct change to the mapping itself. + +`pgdelta drift` now surfaces diagnostics carried by the snapshot side; the plan gate also covers replace-class server changes (`server.type`/`server.fdw`, which have no in-place ALTER and would otherwise silently drop-and-recreate the server, destroying an unreadable mapping's server, instead of throwing). + +`pgdelta prove` now surfaces diagnostics carried by the desired snapshot and annotates a passing proof with their count. diff --git a/.changeset/non-superuser-extraction.md b/.changeset/non-superuser-extraction.md deleted file mode 100644 index d3c0cd31c..000000000 --- a/.changeset/non-superuser-extraction.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@supabase/pg-delta": patch ---- - -fix(pg-delta): allow catalog extraction as a non-superuser role - -Extraction previously SELECTed from `pg_catalog.pg_user_mapping` (superuser-only), so any non-superuser connection — e.g. the `postgres` role on Supabase hosted projects — failed with `permission denied for table pg_user_mapping` (42501). User mappings are now read from the world-readable `pg_user_mappings` view; option values hidden from unprivileged readers degrade to an empty option list instead of erroring. The subscription extractor similarly stops selecting the superuser-only `pg_subscription.subconninfo` column unless the reader has privilege, degrading to the existing redacted-conninfo placeholder. Fixes supabase/cli#5826. diff --git a/.changeset/nonsuperuser-library-caller-fixes.md b/.changeset/nonsuperuser-library-caller-fixes.md new file mode 100644 index 000000000..923b24212 --- /dev/null +++ b/.changeset/nonsuperuser-library-caller-fixes.md @@ -0,0 +1,11 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix five non-superuser / library-caller correctness gaps (issue #333, items 13-17): + +- Role security-label extraction joined `pg_authid` (superuser-only); it now joins `pg_roles`, so a non-superuser caller no longer hits `permission denied for table pg_authid` when a role security label exists. +- Subscription extraction selected `pg_subscription.subconninfo` unconditionally; that column is revoked from non-superusers by default (unlike every other column on the table), so the whole query failed for such a caller. The column is now probed with `has_column_privilege` and conditionally included in the query text (a runtime `CASE WHEN` guard does not work — Postgres's column permission check is static and fires on any reference to the column, not on which branch runs); when unreadable, the fact falls back to the existing `SUBSCRIPTION_CONNINFO_PLACEHOLDER`. +- A user mapping whose foreign server was added to an extension (`ALTER EXTENSION … ADD SERVER …`) orphaned `buildFactBase` with a missing-parent error, because the user-mapping query lacked the extension-member anti-join the server query already has. It is now excluded consistently with its server. +- `apply()`'s and `provePlan()`'s fingerprint/proof re-extraction ignored `Plan.redactSecrets`, always re-extracting the target with the default (redacted) mode. A plan built from `extract({ redactSecrets: false })` was therefore spuriously rejected (or reported as drifted) even with zero actual delta. Both now honor the plan's stamped redaction mode when no custom `reextract` is supplied. +- `ALTER DEFAULT PRIVILEGES ... ON LARGE OBJECTS` (PG18+) was rendered as `ON TABLES` (the `DEFACL_OBJTYPE` map had no `L` entry and silently fell back); an unmapped `defaclobjtype` now also fails loudly instead of guessing. diff --git a/.changeset/nonsuperuser-shadow-seed.md b/.changeset/nonsuperuser-shadow-seed.md new file mode 100644 index 000000000..a32ef3712 --- /dev/null +++ b/.changeset/nonsuperuser-shadow-seed.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Make the co-located shadow seed (`schema apply --profile supabase` without `--shadow`) replayable by non-superuser roles. Real Supabase Cloud gives users a privileged NON-superuser `postgres`, so the assumed-schema seed previously failed at the seed step. The seed now omits (never rewrites) the two fact classes a non-superuser cannot replay: a routine whose `proconfig` SETs a superuser-only GUC (detected from structured catalog data, context-driven — e.g. `SET log_min_messages`, never `search_path`) is skipped whole along with anything depending on it (transitively, including the contained children — e.g. columns — of any excluded container object), and platform default-privilege entries (`ALTER DEFAULT PRIVILEGES FOR ROLE …`) are omitted. Both are inert to omit: a seeded object re-extracts reference-only and cancels in the diff, so its absence is symmetric, and a default-privilege entry has no possible dependents. diff --git a/.changeset/parse-column-acl-ids.md b/.changeset/parse-column-acl-ids.md new file mode 100644 index 000000000..598dc5381 --- /dev/null +++ b/.changeset/parse-column-acl-ids.md @@ -0,0 +1,9 @@ +--- +"@supabase/pg-delta": patch +--- + +The stable-id parser now accepts column-qualified ACL ids +(`acl:(table:...).grantee.column`), which the encoder produces for +column-level grants. Snapshots and baselines that contain column-level grants +now load correctly in `drift`/`prove` instead of failing with a +"trailing input" parse error. diff --git a/.changeset/pg-delta-clean-room-rewrite.md b/.changeset/pg-delta-clean-room-rewrite.md new file mode 100644 index 000000000..de1b9706f --- /dev/null +++ b/.changeset/pg-delta-clean-room-rewrite.md @@ -0,0 +1,56 @@ +--- +"@supabase/pg-delta": major +--- + +**`pg-delta` is now a clean-room rewrite.** The published `@supabase/pg-delta` +package no longer wraps the legacy per-object-type diff engine — it is the +ground-up rebuild previously developed as `@supabase/pg-delta-next`, promoted +into place. This is a hard breaking change: the CLI, the public API, and every +persisted artifact format are new. Nothing carries over from the legacy engine +for compatibility. The legacy engine remains available in git history and on +npm as `1.0.0-alpha.31`. + +**Why it's different.** PostgreSQL is the only elaborator: state is resolved by +a real Postgres instance (a live DB, or a shadow DB populated from your `.sql` +files) and read back out of the catalog into a normalized, content-addressed +fact base. Diffing is generic (no per-object-type change classes), ordering +needs no hand-written cycle-breakers, and every migration is **proved** before +you trust it — applied to a clone, re-extracted, and checked for both state +convergence and data preservation. + +**New CLI surface** (`pgdelta`): `plan`, `apply`, `render`, `prove`, `diff`, +`drift`, `snapshot`, `schema export`, `schema apply`, `schema lint`. Redesigned +public API across the `.`, `./extract`, `./plan`, `./apply`, `./proof`, +`./frontends`, `./sql-order`, `./sql-format`, `./core`, `./policy`, and +`./integrations` subpaths. + +Highlights folded into this release (previously tracked as individual +`pg-delta-next` changes): + +- **Declarative schema export/apply** with `by-object` / `ordered` / `grouped` + layouts, co-located shadow/seed, management scope (`database` | `cluster`), and + an optional `@supabase/pg-topo`-backed statement-reordering assist plus a + database-free `schema lint`. **Export is a source-of-truth artifact**: SQL is + pretty-printed by default (`--no-format` opts out), validated constraints fold + inline into `CREATE TABLE`, indexes co-locate with their owning relation, and + mutually-referencing foreign keys round-trip — all under the + `load(export(db)) ≡ db` fidelity gate. +- **Integration profiles** (`raw` | `supabase` | loadable custom profiles) with + extension intent handlers (e.g. `pg_cron`, `pg_partman`) and assumed + schemas/roles for platform-managed ambient dependencies. A profile can declare + its own **baseline** — a `snapshot` subtracted from both sides so platform + objects (base-image roles, extension-owned schemas) stay invisible with no + per-command flag; its digest is stamped on plan/export artifacts and reconciled + at apply/prove so `plan == prove == apply` holds (a swapped/edited/missing + baseline fails loud). `diff` / `drift` / `snapshot` gained `--profile` for parity. +- **Privilege correctness**: ALTER DEFAULT PRIVILEGES routing/elision, + owner-ACL and revoked-PUBLIC-default convergence, aggregate/FDW grant + handling. +- **Object coverage & ordering**: generated columns, domains (incl. NOT VALID + constraints), publications (PG14 `FOR TABLE`), security labels, extension + membership, array-of-composite ordering, standalone unique indexes referenced + by foreign keys. +- **Secret redaction**: FDW option secrets and required passwords, with + redaction-mode carried into `apply`/`prove` and a guard against + snapshot/plan redaction mismatch. +- **PostgreSQL 14–18** support. diff --git a/.changeset/pg-delta-review-fidelity-fixes.md b/.changeset/pg-delta-review-fidelity-fixes.md new file mode 100644 index 000000000..fb05fae25 --- /dev/null +++ b/.changeset/pg-delta-review-fidelity-fixes.md @@ -0,0 +1,26 @@ +--- +"@supabase/pg-delta": patch +--- + +Four PR-review fidelity/correctness fixes: + +- **Range type in-use replacement guard.** Changing a range type's attributes + (`subtype`, `subtype_opclass`, …) is a drop+create. When a surviving table + column still uses the type, PostgreSQL rejects the `DROP TYPE` at apply time; + the planner now fails loud at plan time with an actionable message instead of + emitting a plan that crashes on apply (mirrors the in-use composite + `ALTER ATTRIBUTE` guard). +- **Rewrite-rule enabled state on create.** A freshly created rule always lands + enabled; the create path now appends the follow-up + `ALTER TABLE … {DISABLE | ENABLE REPLICA | ENABLE ALWAYS} RULE …` when the + desired rule is not origin-enabled, so a disabled/replica/always rule + converges (its `ev_enabled` is hashed). +- **Deterministic inheritance-parent extraction.** The single captured + `parentTable` for a multiple-inheritance table now sorts the `pg_inherits` + subquery by `inhseqno`, so the first-declared parent is captured + deterministically and no longer flaps the fact hash across extractions. +- **Column-level grant extraction/render.** `pg_attribute.attacl` + (`GRANT SELECT (col) ON t TO r`) is now extracted and rendered as + column-qualified GRANT/REVOKE actions, so a from-empty export no longer + silently drops column privileges and schemas differing only by column grants + no longer hash equal. diff --git a/.changeset/pg-topo-total-ordered.md b/.changeset/pg-topo-total-ordered.md new file mode 100644 index 000000000..2b74a9b07 --- /dev/null +++ b/.changeset/pg-topo-total-ordered.md @@ -0,0 +1,7 @@ +--- +"@supabase/pg-topo": minor +--- + +`analyzeAndSort` now returns a total order: statements trapped in a dependency cycle are appended to `ordered` (in the same deterministic tie-break order) instead of being silently dropped. `CYCLE_DETECTED` diagnostics and `graph.cycleGroups` are unchanged. Consumers that feed `ordered` into a defer-and-retry applier now receive every input statement exactly once. + +This is a consumer-observable behavior change (hence minor, not patch): a defer-and-retry applier fed a genuinely unbreakable cycle now attempts the cycle members and fails loudly rather than silently applying a partial schema. For example, `@supabase/pg-delta`'s declarative apply now reports `stuck` for a mutual-foreign-key or mutual-view cycle instead of reporting success with the cycle statements missing. diff --git a/.changeset/pgdelta-cli-version-and-schema-help.md b/.changeset/pgdelta-cli-version-and-schema-help.md new file mode 100644 index 000000000..1a35dab46 --- /dev/null +++ b/.changeset/pgdelta-cli-version-and-schema-help.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +fix(pg-delta): `pgdelta --version` (and `-v`/`version`) now prints the package version instead of "Unknown command", and `pgdelta schema --help` (and `-h`/`help`) prints the schema subcommand usage to stdout and exits 0 instead of erroring with "Unknown schema subcommand". diff --git a/.changeset/prove-autoseed-outcomes.md b/.changeset/prove-autoseed-outcomes.md new file mode 100644 index 000000000..73ae5f8d1 --- /dev/null +++ b/.changeset/prove-autoseed-outcomes.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +prove: per-table autoSeed outcome reporting (seeded/skipped/failed) surfaced in the proof verdict. `provePlan({ autoSeed: true })` no longer swallows insert failures — each empty kept table now reports `seeded`, `skipped`, or `failed` on `ProofVerdict.seedOutcomes`. A `skipped` is either an expected class-23 integrity-constraint violation (the SQLSTATE as `reasonCode`) or the synthetic `no_row` code, meaning the `DEFAULT VALUES` insert resolved but a trigger/rule left the row absent from the final pre-apply snapshot (persistence is judged by reconciling provisional seeds against that one snapshot, since the command tag / rowCount can lie — even a later table's trigger can undo an earlier seed). Anything else is a `failed` with its message, so a genuinely-unseedable table is no longer confused with one that failed for a reason nobody saw. Data that was already present remains anchored to the pre-seed snapshot, and populated kept tables are compared immediately after seeding, preventing trigger side effects from synthetic inserts from being silently accepted or hidden by a later schema change. After seeding, the complete extracted managed-state fingerprint must still match the plan source, catching trigger DDL outside the column signature such as RLS, replica identity, reloptions, constraints, and other modeled facts. Seed safety failures are surfaced separately so an `EXPECTED_RED` corpus pin cannot swallow them. The concurrent corpus runner also treats reverse-direction seed files containing cluster-global role DDL as serial work. The corpus gate rejects stale skip exemptions that are no longer observed. diff --git a/.changeset/public-schema-frontends.md b/.changeset/public-schema-frontends.md new file mode 100644 index 000000000..afef35832 --- /dev/null +++ b/.changeset/public-schema-frontends.md @@ -0,0 +1,12 @@ +--- +"@supabase/pg-delta": minor +--- + +feat(pg-delta): publish reusable schema export/plan/render/shadow frontends + +Extract the schema export, plan-from-files, render, and co-located shadow +orchestration from the private CLI into public `@supabase/pg-delta` / +`@supabase/pg-delta/frontends` APIs (`buildSchemaExport`, `planSchemaFiles`, +`renderPlanFiles`, `provisionCoLocatedShadow`, export manifest helpers, and +`ManagementScope`). The `pgdelta` CLI now calls these functions so there is a +single implementation for library and CLI consumers. diff --git a/.changeset/range-type-body-validation-scope.md b/.changeset/range-type-body-validation-scope.md new file mode 100644 index 000000000..67089042c --- /dev/null +++ b/.changeset/range-type-body-validation-scope.md @@ -0,0 +1,7 @@ +--- +"@supabase/pg-delta": patch +--- + +fix(pg-delta): scope shadow-load body validation to sql/plpgsql routines + +`loadSqlFiles`'s post-load body-validation pass re-ran every non-extension-member routine's definition with `check_function_bodies = on`, regardless of language. `check_function_bodies` only validates `sql`/`plpgsql` bodies — Postgres never checks other languages — so re-running an `internal`/`c` routine added no coverage and could break the load outright: `CREATE TYPE ... AS RANGE (...)` auto-creates `LANGUAGE internal` constructor/support functions, and re-running those as a non-superuser role (the production-faithful Supabase case) fails with `permission denied for language internal`. The validation query now filters to `sql`/`plpgsql` routines. diff --git a/.changeset/rename-carry-column-acl.md b/.changeset/rename-carry-column-acl.md new file mode 100644 index 000000000..b6abb608f --- /dev/null +++ b/.changeset/rename-carry-column-acl.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Role-rename carry now preserves the `column` field (and all other id fields) when relabeling ACL ids, so a pure role rename involving a COLUMN-level grant (`GRANT SELECT (col) ON t TO r`) no longer emits a spurious REVOKE/GRANT pair around the rename. PostgreSQL carries the column grant across the rename by OID; the planner no longer re-issues DDL that would also require table-grant privileges a rename-only migration should not need. diff --git a/.changeset/render-preamble-no-searchpath.md b/.changeset/render-preamble-no-searchpath.md new file mode 100644 index 000000000..857fc5fa1 --- /dev/null +++ b/.changeset/render-preamble-no-searchpath.md @@ -0,0 +1,12 @@ +--- +"@supabase/pg-delta": patch +--- + +Rendered migration files no longer pin `search_path` in their preamble. The +rendered DDL is already fully schema-qualified, so the pin was redundant — and +it broke third-party migration runners such as dbmate, which append their own +unqualified bookkeeping (`INSERT INTO schema_migrations ...`) inside the same +transaction as the migration file; a pinned `search_path = pg_catalog` resolved +that insert to `pg_catalog.schema_migrations` (which does not exist) and failed +the migration. `check_function_bodies = off` is still emitted, and `apply()` +keeps pinning `search_path` on its own dedicated connection. diff --git a/.changeset/render-prove-extension-safety.md b/.changeset/render-prove-extension-safety.md new file mode 100644 index 000000000..831f25959 --- /dev/null +++ b/.changeset/render-prove-extension-safety.md @@ -0,0 +1,25 @@ +--- +"@supabase/pg-delta": patch +--- + +Three safety/reporting fixes: + +- **Rendered migration files restore session settings.** `render` (both the + multi-file `renderPlanFiles` and the single-file `renderPlanSql`) previously + emitted the plan preamble (`search_path = pg_catalog`, + `check_function_bodies = off`) as plain session-level `SET`s with no restore, + so a reused runner session (sequential migration runners) silently inherited + them. It now mirrors `apply()`: `SET LOCAL` inside transactional files (reverts + at COMMIT) and plain `SET` + a trailing `RESET` for non-transactional + files/scripts. +- **`prove` no longer over-claims data preservation.** After a passing proof, + the "data preservation verified" line is now qualified with honest coverage + when tables were only count-verified (schema changed) or not compared + (recreated/dropped), naming the affected tables. `ok`/exit semantics are + unchanged — reporting honesty only. +- **`DROP EXTENSION` is flagged destructive when it owns data.** A dropped + extension that owns a data-bearing persisted member (table / materialized + view) now carries `dataLoss: "destructive"`, derived from the member closure; + an extension whose members are only functions/types stays non-destructive. + Previously every extension drop defaulted to non-destructive because its + members are projected out of the diff. diff --git a/.changeset/require-pg-topo-alpha3.md b/.changeset/require-pg-topo-alpha3.md new file mode 100644 index 000000000..829ce0f12 --- /dev/null +++ b/.changeset/require-pg-topo-alpha3.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Require `@supabase/pg-topo@^1.0.0-alpha.3` (was `^1.0.0-alpha.2`). `schema apply`'s reorder assist now relies on pg-topo alpha.3's total-order behavior, where cycle members remain in the `ordered` output so pg-delta fails loudly instead of silently loading a partial schema (alpha.2's behavior). A consumer with only alpha.2 installed satisfied the old range but could hit the silent-omission path. diff --git a/.changeset/restart-refinement-seclabel-indisvalid.md b/.changeset/restart-refinement-seclabel-indisvalid.md new file mode 100644 index 000000000..747b7321f --- /dev/null +++ b/.changeset/restart-refinement-seclabel-indisvalid.md @@ -0,0 +1,32 @@ +--- +"@supabase/pg-delta": patch +--- + +Three correctness fixes to schema diffing: + +- **Sequence/identity `RESTART` only on disjoint ranges.** A combined + `ALTER SEQUENCE` / `ALTER COLUMN … SET` that moved a bound and the START + together appended `RESTART` unconditionally, resetting the live counter even + when the new range still contained it (e.g. a sequence at 500 with + `MINVALUE 1→0` + `START 1→2`) — replaying already-issued values and risking + duplicate keys. `RESTART` is now emitted only when the old and new ranges are + provably DISJOINT (the counter is then guaranteed invalid); an overlapping + change leaves the unmodeled runtime counter alone, and if it happens to fall + outside the new range PostgreSQL rejects the ALTER loudly rather than silently + resetting. + +- **Security labels on unmodeled `pg_type` kinds no longer mis-resolve.** The + `pg_type` label resolver mapped every non-domain row to a `type` fact, but + extraction only models enums, standalone composites, and ranges. A label on a + base type, shell type, or a table's row type therefore attached to a + nonexistent parent (dropped as an `orphaned_satellite` at severity `info`, + slipping past `--strict-coverage`). Such labels now fall through to the + `unresolved_security_label` diagnostic like other unmanaged targets. + +- **Invalid indexes no longer converge against valid ones.** A failed or + cancelled `CREATE INDEX CONCURRENTLY` leaves `indisvalid=false` with a def + identical to the desired valid index, so the unusable index hashed EQUAL and + planning saw zero drift. `indisvalid` is now a semantic payload field, so an + invalid regular index differs from a valid one and is repaired via drop + + recreate. Partitioned parent indexes (relkind `'I'`) are forced valid because + their `indisvalid` tracks unmodeled child attach-state (#332), not corruption. diff --git a/.changeset/safety-batch-fixes.md b/.changeset/safety-batch-fixes.md new file mode 100644 index 000000000..31cc7f1d8 --- /dev/null +++ b/.changeset/safety-batch-fixes.md @@ -0,0 +1,11 @@ +--- +"@supabase/pg-delta": patch +--- + +Five safety fixes across the plan, proof, and SQL-file frontends: + +- **Composite `DROP ATTRIBUTE` is now marked destructive.** `ALTER TYPE … DROP ATTRIBUTE … CASCADE` nulls the stored value of that field across every row of every table using the composite, but carried no `dataLoss` flag, so the safety report called it non-destructive and the renderer (which gates on `dataLoss`) emitted it silently. The proof loop cannot catch this because a composite attribute change folds into the table's schema signature and degrades the content check to count-only. The drop spec now declares `dataLoss: "destructive"`, which also covers the collation-only attribute change that routes through the attribute "replace" (drop + recreate) strategy. +- **Role-membership revoke no longer emits `CASCADE`.** On PG16+, revoking an `ADMIN OPTION` membership with `CASCADE` also deleted downstream `pg_auth_members` rows the member had granted onward — including grants present on both diff sides that were meant to be kept — and extraction is grantor-blind, so nothing planned a corrective re-grant. The drop now emits a plain `REVOKE`; on PG16+ with a dependent grant it fails loudly ("dependent privileges exist") instead of silently destroying the kept grant (convergent regrant is tracked separately). +- **Declarative export prune only deletes files it owns.** The export manifest now records the list of files it wrote (`files`). Re-exporting prunes only paths the previous manifest owned; unrecognized `.sql` files (hand-authored, or a pre-feature directory) are refused with a hard error naming them rather than silently deleted, and a new `--prune-unmanaged` flag restores delete behavior. +- **Scratch-mode shadow loads contain cluster-object leaks.** In `databaseScratch` mode, `loadSqlFiles` now preflights every file for cluster-global DDL (roles/memberships) before executing anything, and runs a best-effort restore (drop created roles, invert membership deltas) on all exit paths — including error paths that previously skipped the leak check — so a committed `CREATE ROLE` or a DO-block dynamic role no longer survives a failed load. +- **The proof loop now covers renamed tables.** Accepted table renames are stamped on the plan artifact; the proof maps the old table to its new name so its row count and (when the schema signature is unchanged) content fingerprint are verified as CHECKED, instead of being skipped as recreated and left with zero data-preservation coverage. diff --git a/.changeset/schema-apply-error-channel.md b/.changeset/schema-apply-error-channel.md new file mode 100644 index 000000000..da4a37e49 --- /dev/null +++ b/.changeset/schema-apply-error-channel.md @@ -0,0 +1,7 @@ +--- +"@supabase/pg-delta": patch +--- + +CLI commands are now embedder-safe: command handlers (`schema apply`, `apply`, `drift`, `render`, `prove`, …) and the shared frontends/diagnostics helpers no longer call `process.exit` themselves. They throw instead (`UsageError` / `SchemaFrontendError` → exit 2, or `CliExit(code)` for operation-result exits), and `main()` is the sole exiter mapping those to the same CLI exit codes as before. Previously a guard such as the `schema apply` baseline-mismatch / pg_cron precheck aborted the host process mid-run when the command was invoked in-process (library use, tests), tearing everything down; those errors now propagate to the caller. + +Extraction no longer emits owner edges to built-in (`pg_`-prefixed) roles such as `pg_database_owner` (the owner of the `public` schema). Those edges were always pruned as dangling, so the fact base is unchanged — this only removes the recurring `WARNING [dangling_edge] role:pg_database_owner` noise. diff --git a/.changeset/schema-apply-shadow-leak-owner-guard.md b/.changeset/schema-apply-shadow-leak-owner-guard.md new file mode 100644 index 000000000..20026b84f --- /dev/null +++ b/.changeset/schema-apply-shadow-leak-owner-guard.md @@ -0,0 +1,10 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix `schema apply` leaking its co-located shadow database when the default-owner +guard rejects a divergent applier. The guard's `process.exit(2)` fired before the +`finally` that drops the throwaway shadow, so a `pgdelta_shadow_*` database was +left behind on the target's cluster. The shadow is now released (respecting +`--keep-shadow`) before the guard exits, and the same cleanup runs on the +apply-failure exit path. diff --git a/.changeset/scope-owner-exclusion-ordering.md b/.changeset/scope-owner-exclusion-ordering.md new file mode 100644 index 000000000..418ca3479 --- /dev/null +++ b/.changeset/scope-owner-exclusion-ordering.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix `schema apply --profile ...` at the default `database` scope wrongly planning to DROP platform objects owned by system roles (e.g. `DROP EVENT TRIGGER` owned by `supabase_admin`). Apply now resolves the policy managed view BEFORE projecting the management scope out — the same order `schema export` uses — so a policy's owner-exclusion rule still sees the `owner` edges that `projectManagementScope("database")` would otherwise strip. The scope projection is applied as the single managed-view-under-scope definition in the planner, the apply fingerprint gate, and the proof loop, preserving `plan == prove == run`. diff --git a/.changeset/scope-seeded-body-validation.md b/.changeset/scope-seeded-body-validation.md new file mode 100644 index 000000000..ab07314f9 --- /dev/null +++ b/.changeset/scope-seeded-body-validation.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Scope shadow body validation to non-seeded schemas: under `--profile supabase`, a broken routine in a pre-seeded platform schema (auth/storage/realtime/...) now surfaces as a warning instead of aborting the load, since seeded objects are reference-only on both sides of the diff. Body-validation diagnostics now name the failing routine (`schema.name: ...`), and the CLI's top-level error handler prints per-item error details instead of only the summary message. diff --git a/.changeset/seeded-routine-validation-identity.md b/.changeset/seeded-routine-validation-identity.md new file mode 100644 index 000000000..b448ba854 --- /dev/null +++ b/.changeset/seeded-routine-validation-identity.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Scope post-load routine body-validation leniency to the routines the Phase 2b assumed-schema seed actually created, by full overload-safe identity and unchanged body — instead of by schema name. A user-authored routine in an assumed/seeded schema (e.g. a broken function in `auth` on a Supabase quick apply), a new overload of a seeded routine name, or a `CREATE OR REPLACE` that changes a seeded routine's body now fails loudly again rather than merely warning and being silently never applied. diff --git a/.changeset/seq-bounds-public-roles-parse-guard-acl-dedup.md b/.changeset/seq-bounds-public-roles-parse-guard-acl-dedup.md new file mode 100644 index 000000000..3d4749f9f --- /dev/null +++ b/.changeset/seq-bounds-public-roles-parse-guard-acl-dedup.md @@ -0,0 +1,28 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix four correctness/fidelity bugs: + +- **Sequence & identity bounds now apply atomically.** Moving more than one + sequence option in a single diff (e.g. `MINVALUE 100 MAXVALUE 200` → + `MINVALUE 1 MAXVALUE 50`) emitted one `ALTER SEQUENCE`/`ALTER COLUMN` + statement per field, so a transient `MAXVALUE 50` ran while `MINVALUE` was + still 100 and Postgres rejected the intermediate range. Both seams now emit a + single combined statement that validates the final state, and realign the + backing sequence's counter (`RESTART`) when the range moves entirely off the + old start. +- **`orderForShadow` no longer silently drops unparseable input.** When + `@supabase/pg-topo` cannot parse a statement it returns an empty statement + list, so the offending file vanished from the reordered output and a library + caller built an incomplete desired state. The convenience API now throws a + descriptive `ReorderParseError` instead (callers wanting graceful + degrade-to-raw use `analyzeForShadow` and inspect its diagnostics). +- **ACL privileges are de-duplicated across grantors.** `aclexplode()` emits one + row per grantor, so the same privilege granted to a grantee by two grantors + was recorded twice and rendered `GRANT SELECT, SELECT …`, which Postgres + collapses on apply — breaking re-extract convergence. +- **A security label on a view/matview column no longer crashes extraction.** + View columns produce no column facts, so a label on one was parented on a + missing fact and threw. Such labels are now reported via an + `unresolved_security_label` diagnostic (strict mode blocks, default warns). diff --git a/.changeset/subscriptions-composite-cycles-filename.md b/.changeset/subscriptions-composite-cycles-filename.md new file mode 100644 index 000000000..32e987aa8 --- /dev/null +++ b/.changeset/subscriptions-composite-cycles-filename.md @@ -0,0 +1,11 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix five correctness issues in planning and extraction: + +- **Subscription `two_phase` change no longer drops the subscription.** It was classified as a "replace", so a `two_phase` flip emitted `DROP SUBSCRIPTION` + `CREATE SUBSCRIPTION` — dropping the publisher's replication slot and silently breaking replication. On PostgreSQL 18+ (which added `ALTER SUBSCRIPTION … SET (two_phase)`) the change now goes through `DISABLE` → `SET (two_phase)` → optional `ENABLE` and preserves the slot; on PG < 18 it fails loudly at plan time instead of doing the destructive recreate. +- **Redacted subscriptions stay disabled.** A subscription rebuilt from a redacted extraction carries a placeholder connection string; the plan no longer emits the `ENABLE` follow-up (which would start a replication worker against a bogus host), and the redacted `CREATE` now carries a note telling the operator to set a real connection and enable it manually. +- **Composite-attribute type dependencies order before `DROP TYPE`.** When a composite type's attribute stops using a user type that the same plan drops, the `ALTER TYPE … ALTER ATTRIBUTE … TYPE` now releases the old type (and consumes the new one), so it is ordered before the `DROP TYPE` instead of after it. +- **`buildFactBase` rejects parent cycles.** A self-parent or parent cycle previously passed the missing-parent check yet reached no root, so the whole component was silently dropped from the fingerprint. Construction now throws, naming the cycle members. +- **`file_fdw`'s `filename` option is no longer redacted.** `filename` (and `program`, `null`, `force_not_null`, `force_null`) are non-secret and are now preserved verbatim, so a default-redacted export no longer creates foreign tables pointing at the literal `__OPTION_FILENAME__`. diff --git a/.changeset/supabase-adp-for-system-role.md b/.changeset/supabase-adp-for-system-role.md new file mode 100644 index 000000000..9bcc2affa --- /dev/null +++ b/.changeset/supabase-adp-for-system-role.md @@ -0,0 +1,7 @@ +--- +"@supabase/pg-delta": patch +--- + +fix(pg-delta): exclude Supabase default privileges declared FOR a system role from the managed view + +`schema export --profile supabase` was emitting `ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "public" …` statements. A real Supabase user connects as `postgres` (a non-superuser) and can never execute an ADP declared FOR another role — that requires membership in the reserved role — so these statements made the export unappliable and polluted round-trips. The `supabase` policy now excludes default-privilege facts whose FOR-role is a system role, mirroring the existing owner-based exclusion for other object kinds. ADP declared FOR ROLE `postgres` (the user-owned API-role default) is unaffected regardless of which role is the grantee. diff --git a/.changeset/suset-probe-profile-resolution.md b/.changeset/suset-probe-profile-resolution.md new file mode 100644 index 000000000..474403ee8 --- /dev/null +++ b/.changeset/suset-probe-profile-resolution.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Move the SUSET-GUC (`pg_settings.context = 'superuser'`) probe used to strip a co-located-shadow seed's non-replayable `SET` clauses out of the `schema apply` CLI command and into `resolveProfile`, as `ResolvedProfile.susetGucs`. The probe is now gated on the applier actually being a non-superuser: a superuser applier seeds SUSET-proconfig routines instead of skipping them. diff --git a/.changeset/window-function-and-table-rule-depend-edges.md b/.changeset/window-function-and-table-rule-depend-edges.md new file mode 100644 index 000000000..14d6527b8 --- /dev/null +++ b/.changeset/window-function-and-table-rule-depend-edges.md @@ -0,0 +1,13 @@ +--- +"@supabase/pg-delta": patch +--- + +Fix two dropped pg_depend endpoint resolutions in extract that lost real +dependency edges (issue #333). A user-defined window function (`prokind 'w'`) +is now resolved as a `function` fact, so a view or rule that uses it is ordered +and rebuilt against it. A user-created rule on a plain table (or any rule other +than a view/matview `_RETURN`) now resolves to its own `rule` fact instead of +being dropped, so the rule is rebuilt before a function it references is +dropped. Previously either endpoint resolved to NULL and the edge was silently +skipped, causing `apply` to fail with "cannot drop function … because other +objects depend on it". diff --git a/.github/agents/pg-toolbelt.md b/.github/agents/pg-toolbelt.md index 9b869dd08..a12499f9a 100644 --- a/.github/agents/pg-toolbelt.md +++ b/.github/agents/pg-toolbelt.md @@ -18,110 +18,105 @@ Bun-based monorepo containing PostgreSQL tooling packages. ## Packages -- **packages/pg-delta** (`@supabase/pg-delta`): PostgreSQL schema diff and migration tool. Compares two live databases and generates DDL migration scripts. -- **packages/pg-topo** (`@supabase/pg-topo`): Topological sorting for SQL DDL statements. Pure library that accepts SQL content strings, extracts dependencies, and produces a deterministic execution order. Includes an optional filesystem adapter for discovering/reading `.sql` files. +- **packages/pg-delta** (`@supabase/pg-delta`): PostgreSQL schema-diff and migration engine (a clean-room rewrite; the CLI binary is `pgdelta`). It extracts two schemas into a normalized, content-addressed **fact base** using a live/shadow Postgres, diffs generically, emits an ordered DDL plan, and **proves** the plan converges (state + data preservation) on a clone. See `packages/pg-delta/README.md` and `docs/architecture/` for depth. +- **packages/pg-topo** (`@supabase/pg-topo`): Topological sorting for SQL DDL statements. Pure library that accepts SQL content strings, extracts dependencies, and produces a deterministic execution order. Includes an optional filesystem adapter for discovering/reading `.sql` files. It is an **optional peer** of pg-delta (used only by the reorder-assist / `schema lint` frontends), never by the diffing core. ## Quick Reference -> **Important:** Always use `bun run test`, never bare `bun test`. The `test` script in `package.json` includes required flags. - ```bash # Install all dependencies bun install -# Build all packages +# Build all packages (tsc -> dist for Node/Deno consumers) bun run build -# Test all packages -bun run test - # Test specific package -bun run test:pg-delta +bun run test:pg-delta # pg-delta unit tests (bun test src/) bun run test:pg-topo -# Type check all +# Type check / lint / knip (all packages) bun run check-types - -# Lint and format all bun run format-and-lint +bun run knip # Run a single package's tests directly -cd packages/pg-delta && bun run test src/ # Unit tests only -cd packages/pg-delta && bun run test tests/ # Integration tests (Docker required) -cd packages/pg-topo && bun run test # All tests (Docker required) +cd packages/pg-delta && bun test src/ # Unit tests (no Docker) +cd packages/pg-delta && bun test tests/ # Integration tests (Docker required) +cd packages/pg-topo && bun run test # All tests (Docker required) -# Test against a specific PostgreSQL version -PGDELTA_TEST_POSTGRES_VERSIONS=17 bun run test tests/ +# Choose the Postgres image for pg-delta integration/corpus tests +PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts ``` ## Architecture -- Both packages are runtime-agnostic: importable in Bun, Node.js, or Deno -- Conditional exports: `bun` condition serves TypeScript source directly, `import` serves compiled JS -- `pg-delta` uses the `pg` npm library for database connections (works in Bun via Node.js compat) -- `pg-topo` is pure static analysis — no runtime database dependency in the library itself -- Integration tests use `testcontainers` to spin up PostgreSQL Docker containers -- Oxc handles formatting and linting: `oxfmt` (config at `.oxfmtrc.json`) and `oxlint` (config at `.oxlintrc.json`) -- Changesets manage versioning across both packages - -### pg-delta core is self-contained - -`pg-delta`'s core diffing / planning path (`src/core/objects/**`, `src/core/catalog*`, `src/core/plan/**`, `src/core/sort/**`) must stay runnable from **pg_catalog + its own utilities only**. Do not reach into `@supabase/pg-topo` — or any other SQL-parser / AST library — from this path, even as a "best-effort" helper. - -When a change class needs dependency edges for `requires` or `creates`: - -1. **First, check `pg_depend`.** Postgres records expression-level dependencies automatically (policy `USING` / `WITH CHECK` via `recordDependencyOnExpr`, `CHECK` constraints, generated columns, column defaults, view rewrite rules, trigger functions, SQL-language function bodies, sequence ownership, etc.). That catalog is authoritative and already used extensively in `src/core/depend.ts`; extend it instead of inventing a second source of truth. -2. **Source the list at extract time.** Join `pg_depend` in the object's extractor (`.model.ts`) so the resolved schema+name (or stable-id) list is carried on the model. The change class then iterates that list in `requires` — no parsing happens while diffing. -3. **Keep derived metadata out of `dataFields`.** Fields populated from `pg_depend` change lockstep with their source expression (`using_expression`, `with_check_expression`, etc.), so including them in equality adds no signal and creates noisy diffs. -4. **Don't re-parse what Postgres already parsed.** Re-parsing `pg_get_expr()` output with an external AST library to recover references is a sign you missed a `pg_depend` row. Find it. - -`pg-topo` is fine as a **dev-time** utility inside pg-delta — for example, `src/core/test-utils/assert-valid-sql.ts` uses `validateSqlSyntax` to sanity-check serialized DDL in unit tests. That usage is scoped to tests and does not leak into the diffing path. - -### Serialize Options - -When adding or changing a serialize option in `pg-delta`, keep the typing and ownership split consistent: - -- Define the shared serializer option fields in `packages/pg-delta/src/core/integrations/serialize/serialize.types.ts`. This file is the single source of truth for `SerializeOptions`. -- If an option is only relevant to one change family, derive a local alias from the shared type in `serialize.types.ts` with `Pick<...>` (for example `SchemaSerializeOptions` or `ExtensionSerializeOptions`) instead of creating a new standalone options type. -- Do not define a separate local `SerializeOptions` type in `packages/pg-delta/src/core/integrations/serialize/dsl.ts`. The DSL should import the shared type and pass it through. -- `packages/pg-delta/src/core/objects/base.change.ts` should expose `serialize(options?: SerializeOptions)`. -- Concrete change classes under `packages/pg-delta/src/core/objects/**/changes/*.ts` must accept either the shared `SerializeOptions` or a derived alias, even when the option is unused. Use `_options?: SerializeOptions` for unused parameters so the full `Change` union accepts `change.serialize(rule.options)`. -- Keep product-specific serialization behavior in integrations such as `packages/pg-delta/src/core/integrations/supabase.ts` unless the behavior is truly generic for all users. Integration-specific rules belong in the serialize DSL before they belong in core change logic. -- Do not redesign the global serializer options as a union of per-change option types unless the serialize DSL itself is also being redesigned to tie `when` clauses to specific change subtypes. With the current free-form `FilterPattern`, one shared global contract is the intended model. - -When adding a new serialize option, update tests at the same time: - -- Add or update focused coverage in `packages/pg-delta/src/core/integrations/serialize/dsl.test.ts`. -- Add or update the relevant object serializer test next to the concrete change class (for example `extension.create.test.ts`). -- If the behavior is user-facing, update one existing end-to-end regression or add one targeted integration test. Prefer reusing an existing regression over creating duplicate integration coverage. +- Both packages are runtime-agnostic: importable in Bun, Node.js, or Deno. +- Conditional exports: the `bun` condition serves TypeScript source directly; `import`/`require`/`default` serve compiled `dist/` JS (produced by `bun run build` — `tsc` with `rewriteRelativeImportExtensions`, so the `.ts` import specifiers become `.js` on emit). `dist/` is gitignored; consumers get it from the published tarball. +- `pg-delta` uses the `pg` npm library for database connections (works in Bun via Node.js compat). +- `pg-topo` is pure static analysis — no runtime database dependency in the library itself. +- Integration tests use `testcontainers` to spin up PostgreSQL Docker containers. +- Oxc handles formatting and linting: `oxfmt` (config at `.oxfmtrc.json`) and `oxlint` (config at `.oxlintrc.json`). +- Changesets manage versioning across both packages. + +### pg-delta: Postgres is the only elaborator + +The engine never parses SQL to understand it. Every state is resolved by a real +PostgreSQL instance (a live DB, or a shadow DB loaded from `.sql` files) and read +back out of the catalog into a normalized, content-addressed fact base +(`src/core/fact.ts`, `src/core/hash.ts`, `src/core/stable-id.ts`). Diffing is +generic (`src/core/diff.ts`) — there are **no per-object-type change classes**. + +- **Do not reach for an external SQL parser / AST library in the diffing path** + (`src/core/**`, `src/extract/**`, `src/plan/**`). If you need a dependency edge, + it comes from `pg_depend`, sourced at **extract time** in `src/extract/**` and + carried on the fact as a dependency edge — never re-derived by parsing + `pg_get_expr()` output while diffing. +- **Never edit or regex-transform SQL text anywhere in the engine** — including + `pg_get_functiondef` / `pg_get_expr` output, and including non-diffing paths + like the seed and export frontends. If a statement cannot be replayed verbatim + in some context, skip the fact with a clear diagnostic or source structured + data from the catalog (e.g. `pg_proc.proconfig`) to decide — never rewrite the + DDL. +- `@supabase/pg-topo` is an **optional peer** used only by the dev-experience + frontends (`src/frontends/sql-order.ts`, `schema lint`) — importing the core + never pulls it in. `canReorder()` probes availability; absence throws + `ReorderUnavailableError` with an install hint. +- Ordering falls out of the fact grain (cycles are structurally hard to form); + the failure mode is a more verbose script, not an unsortable plan. +- Every plan is validated by the **proof loop** (`src/proof/prove.ts`): apply to + a clone, re-extract, compare hashes (state proof) and check seeded rows survive + (data proof). The corpus (`tests/engine.test.ts`) runs this end-to-end. + +Integration/policy behavior lives in `src/policy/**` (baselines, extension +handlers) and `src/integrations/**` (profiles: `raw` | `supabase` | custom). +SQL rendering/formatting is in `src/frontends/sql-format/**` and `src/plan/render-sql.ts`. ## Test Patterns ### pg-delta unit tests -Standard `describe`/`test`/`expect` from `bun:test`. No database needed. Located in `packages/pg-delta/src/**/*.test.ts`. +Standard `describe`/`test`/`expect` from `bun:test`. No database needed. Located in `packages/pg-delta/src/**/*.test.ts`. Run with `bun test src/`. ### pg-delta integration tests -Use `withDb(pgVersion, callback)` / `withDbIsolated(pgVersion, callback)` wrapper from `tests/utils.ts`. Located in `packages/pg-delta/tests/**/*.test.ts`. +Located in `packages/pg-delta/tests/**/*.test.ts`. They provision Postgres via +`testcontainers`, keyed on the `PGDELTA_TEST_IMAGE` env var (default +`postgres:17-alpine`). Use the helpers in `tests/containers.ts`: ```typescript -import { describe, test } from "bun:test"; -import { withDb } from "../utils.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`my feature (pg${pgVersion})`, () => { - test( - "test name", - withDb(pgVersion, async (db) => { - // db.main and db.branch are pg Pool instances - }), - ); - }); -} +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createTestDb, type TestDb } from "./containers.ts"; +import { extract } from "../src/extract/extract.ts"; + +// createTestDb() gives an isolated database on the shared cluster singleton. ``` +- The **corpus** (`tests/engine.test.ts`, scenarios under `corpus/`) is the + primary correctness gate — see "corpus progress" below. +- Supabase-image tests (`supabase-*.test.ts`, `dbdev-*.test.ts`, + `extension-intent-*.test.ts`, etc.) self-skip unless + `PGDELTA_NEXT_SUPABASE_TESTS=1`; CI runs the stock-alpine path only. + ### pg-topo tests Use `bun:test` with testcontainers for PostgreSQL validation. Located in `packages/pg-topo/test/`. @@ -136,7 +131,7 @@ Use the changeset CLI to generate one: bunx changeset ``` -This will prompt you to select affected packages and choose the version bump type (`patch` for fixes, `minor` for new features, `major` for breaking changes). Commit the generated `.changeset/*.md` file alongside your code changes. Changesets automate versioning and releases on merge to main. +This will prompt you to select affected packages and choose the version bump type (`patch` for fixes, `minor` for new features, `major` for breaking changes). Commit the generated `.changeset/*.md` file alongside your code changes. Changesets automate versioning and releases on merge to main. The repo is in changesets **pre/alpha mode** (`.changeset/pre.json`): a `major`/`minor`/`patch` bump increments the `-alpha.N` counter rather than the base version. ## Conventional Commits @@ -162,49 +157,46 @@ The `Lint Pull Request` CI check (see `.github/workflows/lint-pull-request.yml`) ## CI -- GitHub Actions with `dorny/paths-filter` detects which packages changed -- Only affected packages are tested -- pg-delta integration tests are sharded across 15 runners x 3 PG versions -- Changesets automate releases on merge to main +- GitHub Actions with `dorny/paths-filter` detects which packages changed (`.github/actions/detect-changes`). Only affected packages are tested. +- pg-delta test jobs in `.github/workflows/tests.yml`: + - `pg-delta-unit` — `bun test src/`. + - `pg-delta-corpus` — the proof loop (`tests/engine.test.ts`), matrix of **PG 14–18 × 4 shards** (`PGDELTA_TEST_IMAGE` + `PGDELTA_NEXT_SHARD`). + - `pg-delta-integration` — everything except the corpus loop, matrix of **PG 14–18**. + - `pg-delta-integration-pg15-compat` / `pg-delta-integration-pg17-compat` — stable status-check names (for branch protection) that aggregate the corpus + integration matrices. +- `check-types` and `format-and-lint` build `@supabase/pg-topo` first, because pg-delta type-checks its optional peer through pg-topo's gitignored `dist/*.d.ts`. +- Changesets automate releases on merge to main; `release-preview` publishes a `pkg-pr-new` preview of both packages. When changing shard count or PG versions, update all of these locations: -- `.github/workflows/tests.yml` — `shard_index`, `shard_total` in the matrix; the `pg-delta-build-test-images` matrix (`postgres_version`, `alpine_tag`, `pg_branch`) **must** stay in sync with `ALPINE_TAG_FOR_PG_MAJOR` in `packages/pg-delta/tests/alpine-tags.ts` -- `scripts/coverage.ts` — default `--shards` value (doc comment + code) -- This file (`AGENTS.md` / `CLAUDE.md`) — both the CI section and the Testing Discipline section - -### Prebuilt `dummy_seclabel` test image - -The `pg-delta-test:` postgres image (which preloads the -`dummy_seclabel` contrib so integration tests can exercise -`SECURITY LABEL`) is **prebuilt once per PG version on GHCR** rather than -rebuilt by every shard. The flow: - -1. `pg-delta-test-image-hash` job hashes - `packages/pg-delta/tests/dummy-seclabel.Dockerfile` + - `packages/pg-delta/tests/alpine-tags.ts` and decides whether the - run can push (same-repo) or must fall back to inline builds (forked PR). -2. `pg-delta-build-test-images` (matrix on PG version) probes - `ghcr.io//pg-delta-test:-` with - `docker manifest inspect`; if missing, it builds with `buildx` - (GitHub Actions cache) and pushes. -3. Each `pg-delta-integration` shard logs into GHCR, pulls the prebuilt - image, and retags it locally as `pg-delta-test:`. The - `image.exists(...)` short-circuit in - `packages/pg-delta/tests/postgres-alpine.ts::buildPostgresTestImage` - then skips the docker build entirely. -4. On forked PRs the prebuild is skipped and `buildPostgresTestImage` - builds inline at test time (current behavior). `getBuildInvocationCount()` - in that file is exposed only so `tests/postgres-alpine.test.ts` can - verify the short-circuit doesn't regress. - -When you change `dummy-seclabel.Dockerfile` or `ALPINE_TAG_FOR_PG_MAJOR`, -the hash flips automatically and the next CI run rebuilds + republishes; -no manual cache invalidation is needed. If you add a new PG version, -update **all three** of: `ALPINE_TAG_FOR_PG_MAJOR` in `tests/alpine-tags.ts`, the -`pg-delta-build-test-images` matrix in `tests.yml`, and the -`postgres_version` list in `pg-delta-integration` / `pg-delta-unit` / -the compat aggregator jobs. +- `.github/workflows/tests.yml` — the `postgres_version` list and `shard` list in `pg-delta-corpus`, and the `postgres_version` list in `pg-delta-integration`. +- This file (`AGENTS.md` / `CLAUDE.md`) — both the CI section and the Testing Discipline section. + +### Coverage + +Local coverage is produced by the `@supabase/bun-istanbul-coverage` preload, +which instruments the source globs in `.nycrc.json` (both packages' `src/`) and +writes per-process istanbul JSON to `NYC_OUTPUT_DIR`. Each package's +`scripts/run-tests.ts` injects that preload **only when `BUN_COVERAGE=1`** and is +otherwise a transparent passthrough to `bun test` (so CI, which calls `bun test` +directly, is unaffected). + +```bash +bun run coverage # pg-topo + pg-delta (unit + integration + corpus), then nyc report +bun run coverage --unit-only # skip pg-delta's slow integration + corpus (pg-topo still runs; Docker required) +bun run coverage --pg-image postgres:17-alpine # pin the PG image for pg-delta integration/corpus +bun run coverage --skip-tests # regenerate the report from an existing .nyc_output +``` + +Reports land in `.coverage-artifacts/` (HTML/lcov/json-summary). Docker is +required — pg-topo and pg-delta integration/corpus use testcontainers. + +CI uploads **pg-topo coverage only** (`pg-delta-*` jobs run without +`BUN_COVERAGE`); pg-delta coverage is a local-on-demand tool by choice, because +instrumenting the corpus PG-version × shard matrix in CI is disproportionately +costly. To restore pg-delta coverage in CI, set `BUN_COVERAGE=1` + +`NYC_OUTPUT_DIR` on the `pg-delta-*` jobs and upload their `.nyc_output` as a +`coverage-*` artifact (the aggregation job already merges everything matching +`coverage-*`). ## Agent Workflow @@ -231,7 +223,7 @@ Every bug fix and every feature with a well-defined acceptance criterion follows 1. **RED first.** Author the regression test(s) against the current (broken) code. Run the focused test and confirm it **fails for the right reason** — an assertion mismatch, a missing symbol, or a runtime error that matches the bug. A test that fails because of a typo or wrong import does not count. 2. **Capture the failure.** Save the assertion excerpt or test-runner summary (just the relevant lines). This goes into the follow-up commit message and/or PR description so reviewers can see the regression was real. 3. **GREEN.** Apply the production change. Re-run the same focused test and confirm it passes. -4. **No regressions.** Run the broader focused suites for the package(s) you touched (unit tests, and integration tests for the affected area when iterating locally) plus `bun run format-and-lint:fix && bun run check-types && bun run knip --fix`. +4. **No regressions.** Run the broader focused suites for the package(s) you touched (unit tests, and integration tests / the corpus for the affected area when iterating locally) plus `bun run format-and-lint:fix && bun run check-types && bun run knip --fix`. **Commit shape.** Prefer splitting the work into two commits on the working branch: @@ -250,19 +242,66 @@ If a repository policy or reviewer asks for a single squashed commit, keep the R ### Testing Discipline -pg-delta has 45+ integration test files across 3 PG versions, sharded across 15 CI runners. Never run the full suite while iterating. +pg-delta has a large integration + corpus suite across PG 14–18. Never run the full suite while iterating. **During development:** -- pg-topo: `cd packages/pg-topo && bun run test` is fine (small test suite) -- pg-delta unit tests: `cd packages/pg-delta && bun run test src/.test.ts` -- pg-delta integration tests: `cd packages/pg-delta && bun run test tests/integration/.test.ts` — one file at a time -- Run a single test within a file: `bun run test --test-name-pattern "" ` -- Limit PG versions to speed up iteration: `PGDELTA_TEST_POSTGRES_VERSIONS=17 bun run test tests/integration/` +- pg-topo: `cd packages/pg-topo && bun run test` is fine (small test suite). +- pg-delta unit tests: `cd packages/pg-delta && bun test src/.test.ts`. +- pg-delta integration tests: `cd packages/pg-delta && bun test tests/.test.ts` — one file at a time. +- Run a single test within a file: `bun test --test-name-pattern "" `. +- Pick the PG image to speed up iteration: `PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/`. **Final validation only:** -- Run `bun run test:pg-delta` (full suite) only after all changes are complete and targeted tests pass +- Run `bun run test:pg-delta` (unit) plus a full corpus run for at least one PG version after all changes are complete and targeted tests pass. + +### Test container hygiene & corpus progress + +**No leaked containers.** Integration tests use testcontainers, whose Ryuk +reaper removes a run's containers when the test process dies. **Keep Ryuk +enabled** — never set `TESTCONTAINERS_RYUK_DISABLED`. Leaks still accumulate when +the Docker daemon restarts (orphaning what Ryuk tracked) or a run is killed +before Ryuk connects, and the shared cluster singletons in +`packages/pg-delta/tests/containers.ts` are not stopped explicitly. +Reclaim orphans with: + +```bash +cd packages/pg-delta +bun run docker:clean # remove testcontainers older than 60m (safe during an active run) +bun run docker:clean --dry-run # preview only +bun run docker:clean --all # remove ALL testcontainers — only when no tests are running +``` + +It targets only the `org.testcontainers=true` label and is age-guarded, so a run +in flight is never touched. Good as a periodic / CI post-step. Check for leaks +with `docker ps` (look for many idle `postgres:1[58]-alpine` containers hours/days old). + +**Run the corpus to validate engine/planner changes — it is cheap.** The full +corpus (every scenario, both directions) is **~2–3 min per PG version** (e.g. +420 cases in ~150s on `postgres:17-alpine`). Any change to the diff / planner / +compaction / proof path should be gated on a full corpus run for at least one PG +version before you call it "no regressions" — focused suites + blast-radius +reasoning are not a substitute, because the corpus is the only thing that proves +every scenario still applies and converges. A cosmetic compaction change in +particular fires across many unrelated scenarios, so reason about it, then run +the corpus. + +```bash +PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts +``` + +**Live corpus progress.** `bun test` buffers its own reporter when stdout is a +pipe, so a piped/background corpus run prints nothing until it finishes (still a +short wait, but a background/CI run shows no interim signal). Set +`PGDELTA_NEXT_PROGRESS=1` to stream a +`corpus [done/total pct%] PASS|FAIL ` line per scenario to +stderr (a raw fd-2 write that bypasses the buffering). Off by default so an +interactive TTY run keeps bun's native reporter clean. + +```bash +PGDELTA_NEXT_PROGRESS=1 PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts +``` ### Running integration tests in a sandbox (no systemd, no Docker daemon) @@ -295,109 +334,64 @@ yourself instead of giving up and skipping integration coverage: docker info | grep -A1 "Registry Mirrors" # confirm ``` -3. **Pre-pull only the images you need.** `tests/global-setup.ts` pulls *all* - Alpine + Supabase tags listed in `tests/constants.ts` at startup. Always - limit the matrix with `PGDELTA_TEST_POSTGRES_VERSIONS=17` (or `15`) so the - preload only fetches the tags relevant to your run: - - ```bash - docker pull postgres:17.6-alpine - docker pull supabase/postgres:17.6.1.107 # only if your test uses withDbSupabase* - ``` - -4. **Skip the `dummy_seclabel` image with `PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1`.** - The default integration path requires the `pg-delta-test:` image - (stock alpine + the upstream `dummy_seclabel` test contrib so SECURITY - LABEL tests can run). CI prebuilds it and uploads to - `ghcr.io/supabase/pg-toolbelt/pg-delta-test:-`. In sandboxes - you usually cannot get it either way: - - - `pkg-containers.githubusercontent.com` (where GHCR keeps the actual - blobs) is typically *not* on the Claude Code web egress allow-list, so - `docker pull ghcr.io/supabase/pg-toolbelt/pg-delta-test:...` fails with - `403 Forbidden` even though the package is public. - - Building locally from `dummy-seclabel.Dockerfile` fetches - `https://dl-cdn.alpinelinux.org/` over TLS, which the sandbox also - intercepts (`TLS: server certificate not trusted`), so `apk add` fails - before `dummy_seclabel.so` can be compiled. - - `buildPostgresTestImage` in `packages/pg-delta/tests/postgres-alpine.ts` - honors `PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1` (or `true`) by returning the - plain `postgres:` image instead. The container constructor - already gates the `shared_preload_libraries=dummy_seclabel` flag on the - tag prefix, so stock alpine boots cleanly. The two test files that - actually need the module (`tests/integration/security-label-operations.test.ts`, - `tests/integration/security-label-filter.test.ts`) skip themselves via - `describe.skipIf(...)` when the flag is set; `tests/postgres-alpine.test.ts` - (which asserts the `pg-delta-test:` tag) does too. **Never set this flag - in CI** — security-label coverage would silently disappear. - -5. **Run integration tests as usual** — the global-setup will reuse the cached - images: +3. **Pre-pull only the image you need**, then point the tests at it: ```bash + docker pull postgres:17-alpine cd packages/pg-delta - PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 \ - PGDELTA_TEST_POSTGRES_VERSIONS=17 \ - bun run test tests/integration/.test.ts + PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/.test.ts ``` -If you cannot get Docker running (e.g. the sandbox blocks `dockerd`'s -networking even with the mirror), say so explicitly in your final report — -do not silently skip the integration step. For unit-only iteration, you can -bypass the bunfig `preload` (which loads `tests/global-setup.ts` and tries to -contact the Docker daemon) by invoking `bun test` from outside the package -directory: + Supabase-image tests self-skip unless `PGDELTA_NEXT_SUPABASE_TESTS=1`, so the + stock-alpine image is enough for the corpus and most integration files. The + `security-label-proof` test builds the `dummy-seclabel.Dockerfile` inline via + testcontainers; it self-skips (`skipSeclabelProof`) when that build can't run. -```bash -cd /tmp && bun test /home/user/pg-toolbelt/packages/pg-delta/src/... -``` - -This is a workaround for fast unit-test feedback only; integration tests -still need a working Docker daemon. +If you cannot get Docker running (e.g. the sandbox blocks `dockerd`'s +networking even with the mirror), say so explicitly in your final report — do +not silently skip the integration step. For fast unit-only feedback you can run +`bun test src/` (no Docker needed). ### Upgrading Supabase test images -When changing `packages/pg-delta/tests/constants.ts`, especially -`POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG`, treat the generated Supabase -baseline fixtures as part of the upgrade. - -- Do **not** hand-edit `packages/pg-delta/tests/integration/fixtures/supabase-base-init/*.sql`. - Regenerate them with the maintainer script. -- Regenerate all supported fixtures: - `cd packages/pg-delta && env -u PGDELTA_TEST_POSTGRES_VERSIONS bun run sync-base-images` -- Regenerate a single version while iterating: - `cd packages/pg-delta && PGDELTA_TEST_POSTGRES_VERSIONS=17 bun run sync-base-images` -- The sync script is expected to: - - create a temporary `supabase start` project pinned to the exact image tag - - diff a bare `supabase/postgres` container against the fully bootstrapped - local stack - - write `tests/integration/fixtures/supabase-base-init/_fullstack_container_init.sql` - - replay that SQL into a fresh test-style Supabase container and require a - final zero-diff validation -- `withDbSupabaseIsolated(...)` automatically replays the generated base-init - fixture. Any test that starts `SupabasePostgreSqlContainer` manually must call - `applySupabaseBaseInit(...)` from `packages/pg-delta/tests/utils.ts` before - asserting on Supabase-managed objects or applying project migrations. -- After upgrading the image tags, rerun the focused regression tests before - considering the upgrade done: - - `cd packages/pg-delta && PGDELTA_TEST_POSTGRES_VERSIONS=15,17 bun run test tests/integration/supabase-base-init.test.ts tests/integration/catalog-model.test.ts tests/integration/supabase-dsl-e2e.test.ts` - - `cd packages/pg-delta && PGDELTA_TEST_POSTGRES_VERSIONS=15 bun run test tests/integration/dbdev-roundtrip.test.ts` -- If the sync script or focused tests reveal new schemas, roles, grants, or - comments, update pg-delta’s Supabase handling (for example - `packages/pg-delta/src/core/integrations/supabase.ts` or the relevant - extraction/diff/serialization logic) instead of papering over the problem by - editing the generated SQL fixture by hand. +When changing the Supabase image pinned in `packages/pg-delta/tests/containers.ts` +(`SUPABASE_IMAGE`), treat the generated Supabase baseline fixtures as part of the +upgrade. + +- Do **not** hand-edit `packages/pg-delta/tests/fixtures/supabase-base-init/*.sql`. + Regenerate them with the maintainer script: + `cd packages/pg-delta && bun run sync-base-images`. +- The Supabase-image integration tests (`supabase-*.test.ts`, `dbdev-*.test.ts`, + `extension-intent-*.test.ts`, `profile-e2e-*.test.ts`) require + `PGDELTA_NEXT_SUPABASE_TESTS=1` and the `SUPABASE_IMAGE` pulled locally. Run + them after an image change before considering the upgrade done. +- If the sync reveals new schemas, roles, grants, or comments, update pg-delta's + Supabase handling (`src/integrations/**`, `src/policy/**`, or the relevant + extraction/diff logic) instead of hand-editing the generated SQL fixture. ### Test Coverage Expectations All code changes must be covered by tests: -- Unit tests go in `src/` next to the code (e.g., `src/core/objects/foo/foo.diff.test.ts`) -- Integration tests go in `tests/integration/` using `withDb`/`withDbIsolated` patterns -- **pg-delta:** Every fix or feat must be covered by at least one integration test that proves it works end-to-end (e.g. roundtrip or diff applied against a real DB). -- Prefer `roundtripFidelityTest` for pg-delta integration coverage instead of hand-rolled `createPlan` + apply assertions. Use custom plan assertions only when validating planner internals that roundtrip utilities cannot express. -- Follow existing test patterns in the codebase +- Unit tests go in `src/` next to the code (e.g., `src/plan/rules/helpers.test.ts`). +- Integration tests go in `tests/` using the `tests/containers.ts` helpers. +- **pg-delta:** Every fix or feat must be covered end-to-end. Prefer adding or + extending a **corpus scenario** (`corpus//{a,b}.sql`) so the proof loop + exercises it in both directions, rather than a hand-rolled plan+apply assertion. + Use a focused integration test only when validating engine internals the corpus + cannot express. +- **Seed corpus tables so the data-preservation proof has teeth.** The proof + loop auto-seeds each empty kept table with `INSERT … DEFAULT VALUES`, but a + table with a NOT NULL-without-default / FK / unique / check column cannot be + seeded that way and stays EMPTY — getting zero fingerprint/count coverage. + When you add or extend a scenario whose tables can't take the default insert, + ship seed files: `corpus//seed.sql` (INSERTs against `a.sql`, applied in + the FORWARD direction) and `corpus//seed-b.sql` (against `b.sql`, applied + in REVERSE) with one minimal row per such table, so the proof actually + fingerprints real data. Adding an `autoseed-allowlist.ts` entry is the + **fallback** for tables that genuinely cannot or should not be seeded (e.g. a + BEFORE INSERT trigger that suppresses the row, or a scenario whose whole point + is a constraint interplay) — not the default. - Author tests **before** the production change per **Test-Driven Fixes** above — a new test that has never failed does not prove the regression was real. ### Snapshot Assertions @@ -410,7 +404,7 @@ expect(result.sql).toMatchInlineSnapshot(` `); ``` -Start with an empty inline snapshot assertion, run the test once so Bun fills in the expected value automatically, and update snapshots intentionally with `bun run test -u -- "pattern"`. +Start with an empty inline snapshot assertion, run the test once so Bun fills in the expected value automatically, and update snapshots intentionally with `bun test -u `. ### Kaizen (Continuous Improvement) @@ -419,3 +413,4 @@ Whenever you are told you made a mistake — whether in commands, coding style, ### Common Issues - Lint errors can usually be detected and auto-fixed by running `bun run format-and-lint:fix && bun run check-types && bun run knip --fix`. Run this after you finish code changes to ensure you don't introduce lint errors into the project. +- **Never revert `oxfmt` / `oxlint --fix` output to keep a diff scoped.** The `Format and lint` CI check runs `oxfmt --check` over the whole repo, so any formatting drift the auto-fixer touched — even on lines you didn't author — fails CI once the branch merges. If the formatter reformats unrelated/pre-existing lines, keep those changes; if you want to isolate them, commit the formatting-only changes as a separate `chore`/`style` commit rather than reverting them. (Real incident: an implementer reverted an `oxfmt` ternary rewrap "to keep the diff scoped"; the drift shipped in the squash-merge and failed `Format and lint` on the downstream PR.) diff --git a/.github/scripts/fixtures/deno-golden-path.ts b/.github/scripts/fixtures/deno-golden-path.ts index 82f30823b..5ea0c46cd 100644 --- a/.github/scripts/fixtures/deno-golden-path.ts +++ b/.github/scripts/fixtures/deno-golden-path.ts @@ -1,4 +1,4 @@ -import { formatSqlStatements } from "@supabase/pg-delta"; +import { formatSqlStatements } from "@supabase/pg-delta/sql-format"; import { analyzeAndSort } from "@supabase/pg-topo"; function assert(condition: boolean, message: string): void { diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 377e5ea17..1dac262d5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -109,8 +109,18 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup uses: ./.github/actions/setup + # pg-delta type-checks its optional @supabase/pg-topo peer through the + # `types` field (dist/*.d.ts), which is gitignored and not produced by + # install. Build it so the sql-order reorder assist type-checks. + - name: Build @supabase/pg-topo (provides .d.ts for the type check) + if: needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true' + working-directory: packages/pg-topo + run: bun run build - name: Check types (pg-delta) - if: needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.root == 'true' + # pg-delta imports @supabase/pg-topo at runtime, so a pg-topo change + # (e.g. an exported-type or ordering-contract change) must re-run + # pg-delta's suites even when no packages/pg-delta/** file changed. + if: needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true' run: bun run --filter '@supabase/pg-delta' check-types - name: Check types (pg-topo) if: needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true' @@ -128,6 +138,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup uses: ./.github/actions/setup + # pg-delta type-checks its optional @supabase/pg-topo peer through the + # `types` field (dist/*.d.ts), which is gitignored and not produced by + # install. oxlint-tsgolint does real type-checking, so it needs the same + # build the check-types job requires. + - name: Build @supabase/pg-topo (provides .d.ts for oxlint-tsgolint) + working-directory: packages/pg-topo + run: bun run build - name: Format and lint run: bun run format-and-lint @@ -154,10 +171,11 @@ jobs: if: >- needs.detect-changes.outputs.is-release-pr != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && - (needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.root == 'true') + (needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true') needs: detect-changes name: "pg-delta: Unit tests" runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -165,251 +183,92 @@ jobs: uses: ./.github/actions/setup - name: Run unit tests working-directory: packages/pg-delta - env: - BUN_COVERAGE: "1" - NYC_OUTPUT_DIR: ${{ github.workspace }}/packages/pg-delta/.nyc_output - run: bun run test:unit - - name: Upload coverage - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: coverage-pg-delta-unit - path: packages/pg-delta/.nyc_output/ - retention-days: 1 - include-hidden-files: true + run: bun test src/ - pg-delta-test-image-hash: + pg-delta-corpus: if: >- needs.detect-changes.outputs.is-release-pr != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && - (needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.root == 'true') + (needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true') needs: detect-changes - name: "pg-delta: Compute test image hash" + name: "pg-delta: Corpus (PG ${{ matrix.postgres_version }}, shard ${{ matrix.shard }})" runs-on: ubuntu-latest - outputs: - hash: ${{ steps.hash.outputs.hash }} - can_push: ${{ steps.context.outputs.can_push }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Compute content hash - id: hash - # Hash both the Dockerfile and the file owning ALPINE_TAG_FOR_PG_MAJOR - # so a change to either produces a fresh image tag without colliding - # with previously-published ones. (Alpine tags live in alpine-tags.ts - # alone so unrelated edits to postgres-alpine.ts do not bump the hash.) - run: | - HASH=$(cat \ - packages/pg-delta/tests/dummy-seclabel.Dockerfile \ - packages/pg-delta/tests/alpine-tags.ts \ - | sha256sum | cut -c1-12) - echo "hash=$HASH" >> "$GITHUB_OUTPUT" - - name: Resolve build/push capability - id: context - # Forked PRs cannot push to GHCR (no write token), so they fall back - # to building the image inline inside `buildPostgresTestImage`. - run: | - if [ "${{ github.event_name }}" = "pull_request" ] && \ - [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then - echo "can_push=false" >> "$GITHUB_OUTPUT" - else - echo "can_push=true" >> "$GITHUB_OUTPUT" - fi - - pg-delta-build-test-images: - if: >- - needs.pg-delta-test-image-hash.outputs.can_push == 'true' - needs: - - detect-changes - - pg-delta-test-image-hash - name: "pg-delta: Build test image (PG ${{ matrix.postgres_version }})" - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - packages: write + timeout-minutes: 30 strategy: fail-fast: false matrix: - # Keep `alpine_tag` in sync with `ALPINE_TAG_FOR_PG_MAJOR` in - # `packages/pg-delta/tests/alpine-tags.ts`. The hash job picks - # up changes to Dockerfile or alpine-tags so a forgotten update - # produces a fresh tag rather than reusing a stale image. - include: - - postgres_version: 15 - alpine_tag: "3.19" - pg_branch: REL_15_STABLE - - postgres_version: 17 - alpine_tag: "3.23" - pg_branch: REL_17_STABLE - - postgres_version: 18 - alpine_tag: "3.23" - pg_branch: REL_18_STABLE + postgres_version: ["14", "15", "16", "17", "18"] + shard: ["0/4", "1/4", "2/4", "3/4"] steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Login to GHCR - uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 - with: - timeout_minutes: 2 - max_attempts: 3 - retry_wait_seconds: 5 - # Defensive against the same GHCR auth flake that bites the - # shard runners — see `pg-delta-integration` below. - command: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Resolve image reference - id: ref - run: | - REPO_LC=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') - IMAGE="ghcr.io/${REPO_LC}/pg-delta-test:${{ matrix.postgres_version }}-${{ needs.pg-delta-test-image-hash.outputs.hash }}" - echo "image=$IMAGE" >> "$GITHUB_OUTPUT" - - name: Check if image already published - id: probe - # `docker manifest inspect` is read-only and avoids re-running the - # build (and its `apk add alpine-sdk` + source compile) when the - # Dockerfile hasn't changed since the last main-branch run. - run: | - if docker manifest inspect "${{ steps.ref.outputs.image }}" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - name: Set up Docker Buildx - if: steps.probe.outputs.exists != 'true' - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Build and push - if: steps.probe.outputs.exists != 'true' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 - with: - context: packages/pg-delta/tests - file: packages/pg-delta/tests/dummy-seclabel.Dockerfile - push: true - tags: ${{ steps.ref.outputs.image }} - build-args: | - PG_MAJOR=${{ matrix.postgres_version }} - PG_BRANCH=${{ matrix.pg_branch }} - ALPINE_TAG=${{ matrix.alpine_tag }} - cache-from: type=gha,scope=pg-delta-test-${{ matrix.postgres_version }} - cache-to: type=gha,mode=max,scope=pg-delta-test-${{ matrix.postgres_version }} + - name: Setup + uses: ./.github/actions/setup + - name: Corpus proof loop (both directions) + working-directory: packages/pg-delta + env: + PGDELTA_TEST_IMAGE: postgres:${{ matrix.postgres_version }}-alpine + PGDELTA_NEXT_SHARD: ${{ matrix.shard }} + run: bun test tests/engine.test.ts pg-delta-integration: if: >- needs.detect-changes.outputs.is-release-pr != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && - (needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.root == 'true') && - !cancelled() && - ( - needs.pg-delta-build-test-images.result == 'success' || - ( - needs.pg-delta-build-test-images.result == 'skipped' && - needs.pg-delta-test-image-hash.outputs.can_push != 'true' - ) - ) - needs: - - detect-changes - - pg-delta-test-image-hash - - pg-delta-build-test-images - name: "pg-delta: Integration (PG ${{ matrix.postgres_version }}, shard ${{ matrix.shard_index }}/${{ matrix.shard_total }})" + (needs.detect-changes.outputs.pg-delta == 'true' || needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true') + needs: detect-changes + name: "pg-delta: Integration (PG ${{ matrix.postgres_version }})" runs-on: ubuntu-latest - timeout-minutes: 14 - permissions: - contents: read - packages: read + timeout-minutes: 30 strategy: fail-fast: false matrix: - postgres_version: [15, 17, 18] - shard_index: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] - shard_total: [15] + postgres_version: ["14", "15", "16", "17", "18"] steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup uses: ./.github/actions/setup - - name: Login to GHCR - if: needs.pg-delta-test-image-hash.outputs.can_push == 'true' - uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 - with: - timeout_minutes: 2 - max_attempts: 3 - retry_wait_seconds: 5 - # GHCR's API is occasionally flaky for anonymous-token logins - # (`context deadline exceeded` on `ghcr.io/v2/`). The shard had - # nothing else to do at this point, so retry the auth round-trip - # instead of failing the whole job. - command: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Pull prebuilt test image - # On forked PRs `can_push=false` and we let `buildPostgresTestImage` - # build the image inline at test time (current behavior). On main - # and same-repo PRs we pull the prebuilt image and retag it as - # `pg-delta-test:` so the existing test code (which keys - # off that tag prefix) finds it via `image.exists` and skips the - # docker build entirely. - if: needs.pg-delta-test-image-hash.outputs.can_push == 'true' - uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 - with: - timeout_minutes: 3 - max_attempts: 3 - retry_wait_seconds: 5 - # Same GHCR flake category as the login step above — `docker pull` - # can hit `Client.Timeout exceeded while awaiting headers` from - # `pkg-containers.githubusercontent.com`. Retry rather than fail. - command: | - REPO_LC=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') - REMOTE="ghcr.io/${REPO_LC}/pg-delta-test:${{ matrix.postgres_version }}-${{ needs.pg-delta-test-image-hash.outputs.hash }}" - LOCAL="pg-delta-test:${{ matrix.postgres_version }}" - docker pull "$REMOTE" - docker tag "$REMOTE" "$LOCAL" - - name: Run integration tests (shard) - uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 - with: - timeout_minutes: 10 - max_attempts: 3 - command: | - cd packages/pg-delta - ALL_FILES=$(find tests/ -name "*.test.ts" | sort) - TOTAL=${{ matrix.shard_total }} - INDEX=$(( ${{ matrix.shard_index }} - 1 )) - SHARD_FILES=$(echo "$ALL_FILES" | awk "NR % $TOTAL == $INDEX") - if [ -z "$SHARD_FILES" ]; then - echo "No test files for this shard, skipping" - exit 0 - fi - echo "Running shard ${{ matrix.shard_index }}/$TOTAL with files:" - echo "$SHARD_FILES" - echo "$SHARD_FILES" | xargs bun run test + - name: Run integration tests (everything except the corpus proof loop) + working-directory: packages/pg-delta env: - PGDELTA_TEST_POSTGRES_VERSIONS: ${{ matrix.postgres_version }} - BUN_COVERAGE: "1" - NYC_OUTPUT_DIR: ${{ github.workspace }}/packages/pg-delta/.nyc_output - - name: Upload coverage - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: coverage-integration-pg${{ matrix.postgres_version }}-shard-${{ matrix.shard_index }} - path: packages/pg-delta/.nyc_output/ - retention-days: 1 - include-hidden-files: true + PGDELTA_TEST_IMAGE: postgres:${{ matrix.postgres_version }}-alpine + run: | + # The corpus proof loop (engine.test.ts) runs in its own sharded job. + find tests -maxdepth 1 -name '*.test.ts' ! -name 'engine.test.ts' \ + | sort | xargs bun test + # Stable status-check names for branch protection. The integration/corpus + # matrices produce per-version/per-shard job names that are unstable to + # require directly, so these aggregators expose a fixed pass/fail signal. pg-delta-integration-pg15-compat: if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) needs: - - pg-delta-build-test-images + # detect-changes must be a direct dependency so this aggregator can tell + # "no pg-delta changes → matrices legitimately skipped" (stay green) apart + # from "detect-changes itself FAILED → matrices skipped through unmet needs" + # (must fail). Without it the always() gate below accepts the skipped + # matrices as success and the aggregator goes green on a broken run. + - detect-changes + - pg-delta-corpus - pg-delta-integration name: "pg-delta: Integration (PostgreSQL 15)" runs-on: ubuntu-latest steps: - - name: Validate prebuilt test image status - # A failed prebuild silently skips the integration matrix, which - # would otherwise look indistinguishable from "no pg-delta changes - # to test". Surface the failure here so the aggregator turns red. + - name: Validate detect-changes result + # A failed/cancelled detect-changes skips the matrices through unmet + # needs; that must fail the aggregator rather than pass on the skips + # below. A successful/skipped detect-changes (no pg-delta changes) is a + # legitimate skip and stays green. + run: | + if [ "${{ needs.detect-changes.result }}" == "failure" ] || [ "${{ needs.detect-changes.result }}" == "cancelled" ]; then + echo "detect-changes result: ${{ needs.detect-changes.result }}" + exit 1 + fi + - name: Validate corpus matrix status run: | - if [ "${{ needs.pg-delta-build-test-images.result }}" != "success" ] && [ "${{ needs.pg-delta-build-test-images.result }}" != "skipped" ]; then - echo "Prebuilt test image status: ${{ needs.pg-delta-build-test-images.result }}" + if [ "${{ needs.pg-delta-corpus.result }}" != "success" ] && [ "${{ needs.pg-delta-corpus.result }}" != "skipped" ]; then + echo "Corpus matrix status: ${{ needs.pg-delta-corpus.result }}" exit 1 fi - name: Validate integration matrix status @@ -422,15 +281,31 @@ jobs: pg-delta-integration-pg17-compat: if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) needs: - - pg-delta-build-test-images + # detect-changes must be a direct dependency so this aggregator can tell + # "no pg-delta changes → matrices legitimately skipped" (stay green) apart + # from "detect-changes itself FAILED → matrices skipped through unmet needs" + # (must fail). Without it the always() gate below accepts the skipped + # matrices as success and the aggregator goes green on a broken run. + - detect-changes + - pg-delta-corpus - pg-delta-integration name: "pg-delta: Integration (PostgreSQL 17)" runs-on: ubuntu-latest steps: - - name: Validate prebuilt test image status + - name: Validate detect-changes result + # A failed/cancelled detect-changes skips the matrices through unmet + # needs; that must fail the aggregator rather than pass on the skips + # below. A successful/skipped detect-changes (no pg-delta changes) is a + # legitimate skip and stays green. + run: | + if [ "${{ needs.detect-changes.result }}" == "failure" ] || [ "${{ needs.detect-changes.result }}" == "cancelled" ]; then + echo "detect-changes result: ${{ needs.detect-changes.result }}" + exit 1 + fi + - name: Validate corpus matrix status run: | - if [ "${{ needs.pg-delta-build-test-images.result }}" != "success" ] && [ "${{ needs.pg-delta-build-test-images.result }}" != "skipped" ]; then - echo "Prebuilt test image status: ${{ needs.pg-delta-build-test-images.result }}" + if [ "${{ needs.pg-delta-corpus.result }}" != "success" ] && [ "${{ needs.pg-delta-corpus.result }}" != "skipped" ]; then + echo "Corpus matrix status: ${{ needs.pg-delta-corpus.result }}" exit 1 fi - name: Validate integration matrix status @@ -440,14 +315,41 @@ jobs: exit 1 fi + pg-topo-tests: + if: >- + needs.detect-changes.outputs.is-release-pr != 'true' && + (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && + (needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true') + needs: detect-changes + name: "pg-topo: Tests" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup + uses: ./.github/actions/setup + - name: Run tests + working-directory: packages/pg-topo + env: + BUN_COVERAGE: "1" + NYC_OUTPUT_DIR: ${{ github.workspace }}/packages/pg-topo/.nyc_output + run: bun run test + - name: Upload coverage + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: coverage-pg-topo + path: packages/pg-topo/.nyc_output/ + retention-days: 1 + include-hidden-files: true + coverage: if: >- !cancelled() && - (needs.pg-delta-unit.result != 'skipped' || needs.pg-delta-integration.result != 'skipped' || needs.pg-topo-tests.result != 'skipped') + needs.pg-topo-tests.result != 'skipped' continue-on-error: true needs: - - pg-delta-unit - - pg-delta-integration - pg-topo-tests name: "Coverage report" runs-on: ubuntu-latest @@ -512,35 +414,6 @@ jobs: retention-days: 30 include-hidden-files: true - pg-topo-tests: - if: >- - needs.detect-changes.outputs.is-release-pr != 'true' && - (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && - (needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true') - needs: detect-changes - name: "pg-topo: Tests" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup - uses: ./.github/actions/setup - - name: Run tests - working-directory: packages/pg-topo - env: - BUN_COVERAGE: "1" - NYC_OUTPUT_DIR: ${{ github.workspace }}/packages/pg-topo/.nyc_output - run: bun run test - - name: Upload coverage - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: coverage-pg-topo - path: packages/pg-topo/.nyc_output/ - retention-days: 1 - include-hidden-files: true - deno2-library-e2e: if: >- needs.detect-changes.outputs.is-release-pr != 'true' && diff --git a/.gitignore b/.gitignore index 23cbaef15..fdfdaf1bb 100644 --- a/.gitignore +++ b/.gitignore @@ -181,4 +181,7 @@ docs/api-reference/ .verdaccio/storage/ .verdaccio/htpasswd .verdaccio/plugins/ -verdaccio/.verdaccio/ \ No newline at end of file +verdaccio/.verdaccio/ + +# Generated by `bun run audit:porting` (pg-delta-next port-parity report) +packages/pg-delta-next/tests/porting-audit.json \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md index ccf63a9f3..63b11abc9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,6 +36,22 @@ _Avoid_: Publication drop cycle, dropped-table publication membership cycle Cycle-breaking change injection that creates explicit foreign-key constraint drops and makes table drops stop claiming those constraint stable identifiers. _Avoid_: Relaxed publication requirement, when resolving dropped-table publication membership cycles +**Migration-plan topological ordering**: +The exact, trusted sort of the diff's typed **Changes**, computed from catalog dependency edges. This is the ordering that produces the apply. +_Avoid_: "Ordering" unqualified; topological sort, when the context is loading raw SQL into the shadow + +**Shadow-load ordering**: +The best-effort, fail-safe sequencing of raw SQL **statements** into the shadow database. Advisory and approximate: it can only fail to build the shadow (a visible error before extraction), never corrupt the extracted desired state, because Postgres is the elaborator. +_Avoid_: Topological sort; "ordering" unqualified, when the migration-plan ordering is meant + +**Statement reordering assist**: +The optional pg-topo pre-sort that produces a shadow-load ordering — it splits files into one-statement units and topologically pre-sorts them. Advisory and degradable: correctness comes from the split plus the shadow's retry rounds, never from trusting the assist's order. +_Avoid_: Topological sort, when the trusted migration-plan ordering is meant; "the sorter", when ambiguous with the plan sort + +**Shadow-load cycle**: +A raw-SQL cycle (e.g. inline mutual foreign key) that stops the shadow from converging. Distinct from a **Dependency cycle**, which is a cycle in the migration-plan graph of **Changes**. +_Avoid_: Dependency cycle, when the cycle is in raw SQL rather than the plan graph + ## Relationships - A **Migration plan** contains one or more **Changes**. @@ -47,6 +63,9 @@ _Avoid_: Relaxed publication requirement, when resolving dropped-table publicati - In a **Publication FK-chain constraint-drop cycle**, the terminal referenced-constraint drop table must be part of the publication membership being removed. - **FK constraint-drop injection** for a **Publication FK-chain constraint-drop cycle** is cycle-local: inject only FK drops that point to a dropped table in the cycle or to the terminal referenced constraint being dropped. - **FK constraint-drop injection** can be shared by multiple cycle breakers; each breaker still owns its own matcher and safety checks. +- **Migration-plan topological ordering** is trusted and operates on **Changes**; **Shadow-load ordering** is advisory and operates on raw SQL **statements**. They are different orderings at different stages. +- A **Statement reordering assist** produces a **Shadow-load ordering**; it never feeds the **Migration-plan topological ordering**. +- A **Shadow-load cycle** stops the shadow load and is surfaced as an advisory hint on the (authoritative) Postgres error; a **Dependency cycle** is resolved by **Cycle-breaking change injection** in the plan. ## Example dialogue @@ -58,3 +77,4 @@ _Avoid_: Relaxed publication requirement, when resolving dropped-table publicati - "Whole-plan interaction" was used for both **Structural normalization** and **Cycle-breaking change injection**. Resolved: deterministic rewrites of the final change list are structural normalization; rewrites triggered by a concrete unbreakable dependency cycle are cycle-breaking change injection. - `AlterTableDropConstraint` was first described as optional in the publication/table drop cycle. Resolved: the observed production cycle is a **Publication FK-chain constraint-drop cycle**, so a separately emitted referenced-constraint drop is part of that specific matcher. - Rebuilding `AlterPublicationDropTables` with relaxed requirements was considered for **Publication FK-chain constraint-drop cycles**. Resolved: keep publication membership changes unchanged and break the foreign-key chain with **FK constraint-drop injection**. +- "Ordering" / "topological sort" was used for both the trusted plan sort and the best-effort shadow load. Resolved: the trusted sort of typed **Changes** is **Migration-plan topological ordering**; the best-effort sequencing of raw SQL **statements** into the shadow is **Shadow-load ordering**, produced by the advisory **Statement reordering assist**. A cycle in raw SQL is a **Shadow-load cycle**, not a **Dependency cycle**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7acbf6fba..b6839db21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,7 @@ bun run build bun run check-types bun run format-and-lint bun run test +bun run coverage ``` Always use `bun run test`, not bare `bun test`, so the repository's test wrapper and flags are preserved. @@ -44,7 +45,7 @@ Always use `bun run test`, not bare `bun test`, so the repository's test wrapper ## Contribution expectations - Keep changes focused and scoped to the approved issue. -- Add or update tests for code changes. +- Add or update tests for code changes. **New code is expected to come with test coverage** — cover the lines and branches your change introduces. Check locally with `bun run coverage` (HTML report in `.coverage-artifacts/`; use `--unit-only` for a quick pass that skips pg-delta's slow integration + corpus suites). - Add a changeset for user-facing fixes or features. - Prefer targeted package tests while iterating, then run broader validation before finishing. diff --git a/README.md b/README.md index 38b3f7f2f..0a4dcaf89 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,28 @@ bun run build # Build all packages bun run test # Test all packages bun run test:pg-delta # Test pg-delta only bun run test:pg-topo # Test pg-topo only +bun run coverage # Test coverage report (all packages) bun run check-types # Type check all packages bun run format-and-lint # Format and lint all code ``` +### Test coverage + +`bun run coverage` runs both packages' suites with Istanbul instrumentation and +writes an HTML/lcov report to `.coverage-artifacts/` (open +`.coverage-artifacts/index.html`). Docker is required (the suites use +testcontainers). + +```bash +bun run coverage # everything (unit + integration + corpus) +bun run coverage --unit-only # skip pg-delta's slow integration + corpus suites +bun run coverage --pg-image postgres:17-alpine # pin the PostgreSQL image for pg-delta +bun run coverage --skip-tests # regenerate the report from the last run +``` + +New code is expected to come with test coverage — see +[CONTRIBUTING.md](./CONTRIBUTING.md). + ### Working with individual packages ```bash diff --git a/bun.lock b/bun.lock index e2e4ba6fd..f9940da56 100644 --- a/bun.lock +++ b/bun.lock @@ -32,39 +32,35 @@ }, "packages/pg-delta": { "name": "@supabase/pg-delta", - "version": "1.0.0-alpha.25", + "version": "1.0.0-alpha.31", "bin": { - "pgdelta": "./dist/cli/bin/cli.js", + "pgdelta": "./dist/cli/main.js", }, "dependencies": { - "@stricli/core": "^1.2.4", - "@supabase/pg-topo": "^1.0.0-alpha.1", - "@ts-safeql/sql-tag": "^0.2.0", - "chalk": "^5.6.2", "debug": "^4.3.7", "pg": "^8.17.2", - "picomatch": "^4.0.3", - "zod": "^4.2.1", }, "devDependencies": { "@supabase/bun-istanbul-coverage": "workspace:*", - "@tsconfig/node-ts": "^23.6.2", - "@tsconfig/node24": "^24.0.3", + "@supabase/pg-topo": "^1.0.0-alpha.2", "@types/bun": "^1.3.9", "@types/debug": "^4.1.12", "@types/node": "^24.10.4", "@types/pg": "^8.11.10", - "@types/picomatch": "^4.0.2", - "dedent": "^1.7.1", "knip": "^5.75.2", "testcontainers": "^11.10.0", - "typedoc": "^0.28.17", "typescript": "^5.9.3", }, + "peerDependencies": { + "@supabase/pg-topo": "^1.0.0-alpha.2", + }, + "optionalPeers": [ + "@supabase/pg-topo", + ], }, "packages/pg-topo": { "name": "@supabase/pg-topo", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.3", "dependencies": { "@pgsql/traverse": "^17.2.4", "plpgsql-parser": "^0.5.4", @@ -161,8 +157,6 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], @@ -395,20 +389,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - - "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - - "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - - "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - - "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], - "@stricli/core": ["@stricli/core@1.2.7", "", {}, "sha512-a0HxA/cSWjqHj/9GM+cfc/zGNmBdxVTQQpHIEvY1AbDEgo4ZU84cTCbtywYEQOHw2wIc6Vu+PKv+ZQoqZwkHnQ=="], - "@supabase/bun-istanbul-coverage": ["@supabase/bun-istanbul-coverage@workspace:packages/bun-istanbul-coverage"], "@supabase/pg-delta": ["@supabase/pg-delta@workspace:packages/pg-delta"], @@ -419,12 +401,6 @@ "@testcontainers/postgresql": ["@testcontainers/postgresql@11.14.0", "", { "dependencies": { "testcontainers": "^11.14.0" } }, "sha512-wYbJn8GRTj8qfqzfVubxioYWlHJU/ImIjuzPwyy9C5Qfo6g3GLduPZAj+BifvqTZjgT3gd4gFVLCPhBji7dc1w=="], - "@ts-safeql/sql-tag": ["@ts-safeql/sql-tag@0.2.2", "", {}, "sha512-zC3AjWuA4FD2GgA0hpcS5Zvt2x1/sgSxuK5p2OMyCBljZ8MaDC4SdDOTBgwA2wPSVt7wd1SPfWNEx5XJFXKXTQ=="], - - "@tsconfig/node-ts": ["@tsconfig/node-ts@23.6.4", "", {}, "sha512-37BMJvNQZ+vTgd1xG2TGBkJ6ENeT4eO4Wh2CHrnn0IwH7ybLFCzh4Uc//kc7UIvqiRac4uGdIc1meKOjMSlKzw=="], - - "@tsconfig/node24": ["@tsconfig/node24@24.0.4", "", {}, "sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/babel-generator": ["@types/babel-generator@6.25.8", "", { "dependencies": { "@types/babel-types": "*" } }, "sha512-f5l89J0UpYhTE6TFCxy3X+8pJVru1eig1fcvF9qHmOk9h1VxZimd+++tu5GShntCOdhE/MoZZ0SlpGTyh4XrKg=="], @@ -439,8 +415,6 @@ "@types/dockerode": ["@types/dockerode@4.0.1", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q=="], - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-instrument": ["@types/istanbul-lib-instrument@1.7.8", "", { "dependencies": { "@types/babel-generator": "*", "@types/babel-types": "*", "@types/istanbul-lib-coverage": "*", "source-map": "^0.6.1" } }, "sha512-y8t6GUkn5bTXry7Zu/2HjTakxLNtvKbIQnAiGR2M3orrdZF+zp1J9ZAKfj3VM1k3sJodkjEcWfdCJ0bEAKp6CA=="], @@ -451,16 +425,12 @@ "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], - "@types/picomatch": ["@types/picomatch@4.0.3", "", {}, "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ=="], - "@types/responselike": ["@types/responselike@1.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA=="], "@types/ssh2": ["@types/ssh2@0.5.52", "", { "dependencies": { "@types/node": "*", "@types/ssh2-streams": "*" } }, "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg=="], "@types/ssh2-streams": ["@types/ssh2-streams@0.1.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA=="], - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@verdaccio/auth": ["@verdaccio/auth@8.0.1", "", { "dependencies": { "@verdaccio/config": "8.1.0", "@verdaccio/core": "8.1.0", "@verdaccio/loaders": "8.0.1", "@verdaccio/signature": "8.0.1", "debug": "4.4.3", "lodash": "4.18.1", "verdaccio-htpasswd": "13.0.1" } }, "sha512-jq3bg0YyU5GciWMRjS22OUuVvpoV+ftlAuF49K9ctFN0PYlrJipIPu4aMd3OC4dfYaC210QEApdSM2q49/VsJg=="], "@verdaccio/config": ["@verdaccio/config@8.1.0", "", { "dependencies": { "@verdaccio/core": "8.1.0", "debug": "4.4.3", "js-yaml": "4.1.1", "lodash": "4.18.1" } }, "sha512-wMTffzodvyLa/Ob/Zd6SFRhyKQpTzBRPypvYwqLouLO+AMpUoZQ6KN197r8Xt+vclVa7ysUnGqscnKx94l2Rpg=="], @@ -527,7 +497,7 @@ "archy": ["archy@1.0.0", "", {}, "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], @@ -621,8 +591,6 @@ "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], @@ -681,8 +649,6 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "default-require-extensions": ["default-require-extensions@3.0.1", "", { "dependencies": { "strip-bom": "^4.0.0" } }, "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw=="], @@ -727,8 +693,6 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "envinfo": ["envinfo@7.21.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -961,8 +925,6 @@ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], - "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], - "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lockfile": ["lockfile@1.0.4", "", { "dependencies": { "signal-exit": "^3.0.2" } }, "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA=="], @@ -997,16 +959,10 @@ "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], - "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="], - "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], - "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], @@ -1185,8 +1141,6 @@ "pumpify": ["pumpify@1.5.1", "", { "dependencies": { "duplexify": "^3.6.0", "inherits": "^2.0.3", "pump": "^2.0.0" } }, "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ=="], - "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], - "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -1365,12 +1319,8 @@ "typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="], - "typedoc": ["typedoc@0.28.19", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.5", "yaml": "^2.8.3" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], - "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], "unbash": ["unbash@2.2.0", "", {}, "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w=="], @@ -1527,8 +1477,6 @@ "istanbul-lib-report/make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], - "js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lowdb/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], @@ -1573,6 +1521,8 @@ "verdaccio-htpasswd/@verdaccio/file-locking": ["@verdaccio/file-locking@13.0.0", "", { "dependencies": { "lockfile": "1.0.4" } }, "sha512-wC7HzhKDC+5PDZqCmh5griU9O/PnxS0pKHDRUAKHvcaoWANPUP8PR3ONmZ9prJUIHRPexX+jQn3ATqF5ZetJcA=="], + "@changesets/parse/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@grpc/grpc-js/@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -1587,6 +1537,8 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@verdaccio/config/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@verdaccio/core/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "@verdaccio/local-storage-legacy/@verdaccio/core/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..8ebf53b6c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,61 @@ +# pg-delta-next docs + +`pg-delta-next` compares two PostgreSQL schemas and emits a migration to turn one +into the other — then **proves** that migration converges, with your data intact, +before you trust it. It's a clean-room rebuild of `pg-delta` on one idea: *let +PostgreSQL be the only thing that understands PostgreSQL.* + +**Pick the path that fits you:** + +## 🚀 I want to use it + +- **[getting-started.md](getting-started.md)** — the CLI and the programmatic API, + with copy-pasteable examples for the two workflows (diff two databases, or keep + your schema as `.sql` files). +- **[../packages/pg-delta-next/COVERAGE.md](../packages/pg-delta-next/COVERAGE.md)** + — exactly what the engine models and what it deliberately excludes. + +## 🧭 I want to understand it + +- **[overview.md](overview.md)** — *why* the engine was rebuilt: the old engine's + problems, the two principles, old-vs-new with verified numbers. +- **[architecture/README.md](architecture/README.md)** — *how* it works, + concept-first, for a newcomer. Links out to the deep designs below. + +## 🔧 I want to work on it + +- **[architecture/onboarding.md](architecture/onboarding.md)** — the contributor + map: where each pipeline stage lives, and how to add a new object kind. +- **[architecture/target-architecture.md](architecture/target-architecture.md)** — + the north star: the full design, principles, and guardrails. +- **[architecture/managed-view-architecture.md](architecture/managed-view-architecture.md)** + — how scope, ownership, and applier capability enter the engine. +- **[architecture/extension-intent.md](architecture/extension-intent.md)** — how + stateful extensions (pgmq, pg_cron, pg_partman) are diffed without losing data. + +## 📋 What's done and what's next + +- **[build-log.md](build-log.md)** — a light record of how the engine was built, + hardened, and reviewed (the decision trail). +- **[roadmap/](roadmap/)** — the path to v1 ([v1.md](roadmap/v1.md)) and the + post-v1 backlog ([post-v1.md](roadmap/post-v1.md)). + +--- + +## Map + +``` +docs/ + getting-started.md ← use it (CLI + API) + overview.md ← why we rebuilt the engine + architecture/ ← how it works + README.md ← concept-first intro (start here) + target-architecture.md / managed-view-architecture.md / extension-intent.md + onboarding.md ← contributor map + build-log.md ← how it was built (history) + roadmap/ ← what's left (v1, then post-v1) +``` + +`architecture/` is authoritative and current. `build-log.md` and `roadmap/` are, +respectively, the past and the future — trust the code and `architecture/` where +anything disagrees. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 000000000..67fdf5079 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,159 @@ +# How pg-delta-next works + +A gentle, concept-first tour of the engine for someone seeing it for the first +time. It explains the *ideas*; the documents it links to explain each idea in +depth. + +- New to the project? Read [overview.md](../overview.md) first — *why* the engine + was rebuilt. +- Want to *use* it? See [getting-started.md](../getting-started.md). +- About to touch the code? Pair this with [onboarding.md](onboarding.md), the + contributor map of where each stage lives. + +--- + +## The one idea + +A schema-diff tool has to "know" PostgreSQL: what an object is, what depends on +what, how to write the DDL, in what order. The hard-won lesson of the old engine +was that *re-implementing* that knowledge — in extractors, in a SQL parser, in a +retry loop — is where the bugs live. + +So pg-delta-next makes one bet: + +> **PostgreSQL is the only thing that understands PostgreSQL.** + +The engine never parses SQL to understand it. Every input state is resolved by a +*real* PostgreSQL instance — your live database, or a scratch "shadow" database +the engine populates from your `.sql` files — and then **read back out of the +catalog**. The engine's job is reduced to two things: turn a catalog into +**facts**, and turn a **change in facts** into DDL. + +(The full rationale, including the second principle — that PostgreSQL knowledge +lives in exactly *two* forms — is in +[target-architecture.md](target-architecture.md) §2.) + +--- + +## The pipeline + +``` + ┌─ live DB ────────────┐ + │ ▼ +.sql files ─┴─► shadow DB ──► extract ──► fact base + │ + diff (vs the other side) + │ + ▼ + deltas ──► plan ──► actions + │ + ┌──────────┴───────────┐ + ▼ ▼ + apply prove + (the target) (a clone) +``` + +Five steps, each its own document when you want the depth: + +### 1. Extract — a database becomes facts + +`extract()` reads a database in **one consistent snapshot** (a single +`REPEATABLE READ` transaction, so the catalog can't shift under it) and produces +a **fact base**. A *fact* is one addressable thing — a table, a column, a +constraint, an index, a policy, an ACL grant, an ownership edge — captured as a +content-addressed `{ id, payload }`. Identity is structured (schema + name + +kind), never a fragile attnum or OID. DDL text is whatever +PostgreSQL's own `pg_get_*def()` reports, so it's already canonical. + +The fact base is a Merkle tree: every fact has a hash, and parents roll up their +children's hashes. That makes comparison and change-detection cheap. + +### 2. Diff — two fact bases become deltas + +Because everything is a fact at the same grain, `diff()` is **generic** — a +single descent that compares hashes and emits `add` / `remove` / `set` / `link` / +`unlink` deltas. There is *zero per-object-type code* in the diff. A new kind of +object adds no `if` here. + +### 3. Plan — deltas become ordered actions + +`plan()` turns each delta into an atomic **action** (a `CREATE` / `ALTER` / +`DROP`) using a **rule table** — data, not a hundred hand-written change classes. +It then builds **one dependency graph** mixing all the edges and runs **one +deterministic topological sort**. + +The payoff of working at fact grain: **dependency cycles structurally cannot +form**, so there are no cycle-breakers, no repair loop, no second-pass +normalization — the things that made the old ordering code fragile simply don't +exist here. + +### 4. Prove — a plan earns trust on a clone + +This is the safety net the old engine never had. `provePlan()` applies the plan +to a **throwaway clone**, re-extracts it, and checks two things: + +- **State proof** — the result's fact hashes equal the desired state (zero drift). +- **Data preservation** — rows in kept ordinary heap tables survive (and a table + rewritten without declaring it fails the proof). + +Because re-extraction yields the same kind of facts, "did the migration work?" +becomes a hash comparison, run automatically in CI — not a production incident. + +### 5. Apply — run it for real + +`apply()` executes the plan against the target in lock-aware **segments** +(grouping transactional actions, isolating ones that can't run in a transaction +like `CREATE INDEX CONCURRENTLY`). It re-extracts the target first and refuses to +run if the target drifted from what the plan was built against (the +**fingerprint gate**). + +--- + +## The three cross-cutting ideas + +Three concerns don't belong to any single step — they shape the whole pipeline: + +### Identity and ACLs — names are not OIDs + +`StableId` is a declarative, name-based address; PostgreSQL carries runtime +references by OID. Role renames, ownership, memberships, grants, and +non-semantic extraction hints all sit on that boundary. The identity/ACL +invariants document separates today's post-diff carry from the planned +canonical-normalization model and records what ACL equality actually means. + +→ [identity-and-acl.md](identity-and-acl.md) + +### The managed view — "what do we even manage?" + +Real databases contain things you don't own: platform-managed roles and schemas, +objects created by an extension, operations your applier role can't perform. +Rather than scatter `skipSchema` / `skipAuthorization` flags through the code, +the engine projects a **managed view** of the fact base — once — and applies it +**identically before planning and before proving**. Ownership is an *edge*; +hiding a role just prunes the edge. Scope, ownership, and applier-capability all +collapse into one definition. + +→ [managed-view-architecture.md](managed-view-architecture.md) + +### Extension intent — keeping stateful extensions' data + +pgmq, pg_cron, and pg_partman create objects no `.sql` file declares (queue +tables, schedule rows, partition children). The old engine saw them as "extra" +and dropped them — **data loss**. The new engine attaches a `managedBy` +provenance edge at extract time and filters them from the diff, with no +per-extension special-casing in the core. + +→ [extension-intent.md](extension-intent.md) + +--- + +## Go deeper + +| Document | What it covers | +|---|---| +| [target-architecture.md](target-architecture.md) | The north star: the five sub-problems, the two principles, the fact model, the rule table, the one graph, the proof loop — the full design and its guardrails. | +| [identity-and-acl.md](identity-and-acl.md) | StableId addressability vs PostgreSQL OIDs, current and target rename flow, explicit per-grantee ACL equality, underscore metadata, and proof implications. | +| [managed-view-architecture.md](managed-view-architecture.md) | How scope, ownership, and applier capability enter the engine through one `resolveView`, closed under the proof loop. | +| [extension-intent.md](extension-intent.md) | How stateful extensions are diffed without destroying their data. | +| [onboarding.md](onboarding.md) | The contributor map: which file holds each stage, and how to add a new object kind. | +| [../build-log.md](../build-log.md) | How the engine was built and reviewed (the record of decisions). | diff --git a/docs/architecture/extension-intent.md b/docs/architecture/extension-intent.md new file mode 100644 index 000000000..0513263ef --- /dev/null +++ b/docs/architecture/extension-intent.md @@ -0,0 +1,550 @@ +# Extension intent: diffing stateful extensions (pgmq, pg_cron, pg_partman) + +- **Status**: Feature design — extends `target-architecture.md`; does **not** + amend its invariants. Implements the substrate that the Linear triage (see + [the build log](../build-log.md)) identified as the one genuine design gap. +- **Date**: 2026-06-13 (rev. b — optimized to a single fact base; see §10c) +- **Relates to**: CLI-1385 (parent RFC), CLI-1591 / CLI-1555 (pg_partman), + CLI-341 (pg_cron), CLI-1430 (per-extension intent matrix), CLI-1431 + (declarative source format), CLI-1434 (vault), CLI-1435 (pg_cron ownership), + CLI-1433 (pg_net). +- **Baseline**: `pg-delta-next` @ `feat/pg-delta-next`, commit `2a91580`. + +> **One sentence.** Stateful extensions own catalog state the schema diff must +> neither destroy nor re-derive — pgmq queues, pg_cron schedules, pg_partman +> parents. This design captures that state as **ordinary facts in the one fact +> base** (produced by the integration layer, not core; provenance-tagged so the +> core schema contract stays pure), filters the operational objects the +> extension created out of the diff, and replays intent through the **extension's +> own API** ordered by the **same one graph and proven by the same proof loop** +> as schema actions — adding no second pipeline and amending none of the +> north-star invariants. + +--- + +## 1. The problem, and the boundary it must respect + +`supabase db schema declarative sync` (and branch rebuilds) emit `DROP TABLE` +for every child partition pg_partman created, drop pgmq queue tables, and never +recreate cron schedules — because the schema diff sees operationally-created +objects as drift and sees nothing to recreate them from. The three use cases: + +- **pgmq** — `pgmq.create('jobs')` builds `pgmq.q_jobs` / `pgmq.a_jobs` tables + + indexes at runtime. A diff drops them (loses queued messages); a rebuild + yields a project with no queues. +- **pg_cron** — `cron.schedule('nightly','0 0 * * *','...')` inserts a `cron.job` + row. A diff never captures it (the `cron` schema is filtered as managed); a + rebuilt branch has no scheduled jobs. +- **pg_partman** — `partman.create_parent('public.events', ...)` registers + `part_config` and premakes children. A diff drops the children (data loss); a + rebuild yields a bare parent with no partitioning and no config. + +### The hard line: intent, not data + +`target-architecture.md` §1 makes data migration (DML) **permanently out of +scope**, and that does not change. The unit captured is **intent** — the +*structural declaration* the user made — never the operational payload: + +| Extension | **Intent** (captured) | **Data / runtime** (never captured) | +|---|---|---| +| pgmq | the set of queues; each queue's type (standard / unlogged / partitioned) + partition settings | queue messages (`q_*` rows), archive (`a_*` rows), visibility/read counters | +| pg_cron | the set of jobs: `jobname`, `schedule`, `command`, `database`, `username`, `active` | `cron.job_run_details` (run history) | +| pg_partman | `part_config` reduced to its **intent columns** (control, partition type/interval, premake, retention, template, automatic_maintenance, …) + `part_config_sub` | which children currently exist, `last_partition`, rows inside partitions | +| supabase_vault | **presence only** (extension enabled?) | `vault.secrets` — never read (CLI-1434) | + +Three consequences, load-bearing for the rest of the design: + +1. **Operational objects are filtered, not diffed.** partman children, pgmq + `q_*`/`a_*` tables — the extension owns their lifecycle, so the schema diff + excludes them (Deliverable A, §4.3). +2. **Intent is replayed through the extension's own API**, not as + reverse-engineered DDL. We emit `pgmq.create(...)`, `cron.schedule(...)`, + `partman.create_parent(...)` — the declarations. Synthesizing the raw + `CREATE TABLE pgmq.q_jobs (...)` the extension produces would **reimplement + the extension**, which is version-fragile and violates P1 (§2). +3. **Row data is preserved, never moved.** The data-preservation proof (§6) + asserts messages and partition rows survive any plan that does not *intend* + to drop the queue/parent. We never copy rows; we only refuse to destroy them. + +--- + +## 2. Principles (the north-star principles, applied to intent) + +No new philosophy — the existing two principles applied to a second kind of +state. + +### P1 applied — capture and replay, never parse, never reimplement + +The deepest bug source is reimplementing PostgreSQL (or extension) semantics. +Intent is no exception, in **both** directions: + +- **Reading**: intent is *captured* from the extension's own catalogs + (`part_config`, `cron.job`, pgmq's `meta`) exactly as schema is read from + `pg_catalog`. We never parse a `cron.command`, an install script, or a + migration to *infer* intent. A `cron.command` we cannot introspect is treated + like a PL/pgSQL body — opaque, late-bound, ordered conservatively (§4.5). +- **Writing**: intent is *replayed* through the extension's own function. The + extension is the authoritative elaborator of its own objects — so replaying + `pgmq.create` is the same P1 move as letting Postgres elaborate a `CREATE + TABLE`, while emitting hand-built DDL for `pgmq.q_jobs` would be a private, + version-coupled reimplementation. + +### P2 applied — intent knowledge in exactly two forms + +Per-extension knowledge lives in exactly two forms, mirroring the rule table: + +1. **Capture queries** — extension catalog → normalized facts + edges. +2. **Intent rules** — intent-deltas → replay actions, with `consumes` / + `produces` / safety metadata, registered into the **one** rule table. + +Everything between (diff, ordering, proof, rename, safety) is the **same generic +machinery** the schema path uses. Supporting a new stateful extension means +writing one **handler** (a data package) and authoring zero engine code. + +### One fact base — "sidecar" is a contract, not a second pipeline + +Two things must both hold, and they are **not** a trade-off (the earlier draft +treated them as one — §10c): + +- **The core schema contract stays pure.** `src/core` extracts `pg_catalog` and + nothing else (P1, project `CLAUDE.md`); it never learns what a queue is. +- **There is exactly one fact model, one diff, one graph, one proof.** A + *second* fact relation with its own capture/diff/proof would reintroduce the + grain translation that §8.7 exists to eliminate (guardrail 8). + +The resolution: intent facts are **ordinary facts in the one fact base** — same +`Fact` shape, identity-free content hash, Merkle rollup, generic `Delta`, +one-graph sort, proof loop, rename machinery — but **produced by the integration +layer, not by core extraction**, and carrying **provenance** that excludes them +from the core schema contract. "Sidecar" means *who produces the fact and which +contract governs it*, not a separate structure. Core extraction reads +`pg_catalog`; an **integration-aware extract** additionally runs the registered +handler captures **under the same exported snapshot**; both fact streams land in +one fact base. Nothing downstream is duplicated, and §1/P1 hold because *core* +still produces only schema. + +--- + +## 3. What is captured per extension (the intent matrix, v1) + +An intent fact is identity-keyed and content-hashed exactly like a schema fact +(`target-architecture.md` §3.1): identity-free payload, so hash-equality drives +diff and rename. Identity uses a **single generic core kind**, +`extensionIntent`, parameterized by `ext` + `intentKind` + key — so the core +StableId codec gains *one* kind, not one per extension (guardrail 1 stays a +single codec). + +```ts +// canonical id: extensionIntent:{ ext, intentKind, key } +// e.g. extensionIntent:{ ext:"pgmq", intentKind:"queue", key:"jobs" } +``` + +### 3.1 pgmq + +```ts +{ ext:"pgmq", intentKind:"queue", key:, + payload: { type:"standard"|"unlogged"|"partitioned", + partition_interval?:string, retention_interval?:string } } +``` + +- **Capture**: read pgmq's queue registry (`.meta` / + `pgmq.list_queues()`), schema resolved dynamically via + `pg_extension`/`pg_namespace`. +- **Create**: `select pgmq.create('')` / `create_unlogged` / + `create_partitioned`. **Drop**: `pgmq.drop_queue('')` — + `dataLoss:"destructive"`. +- **Operational objects** (filtered, §4.3): `pgmq.q_`, `pgmq.a_`, + their indexes/sequences. +- **consumes**: none. **produces**: the queue intent fact + (via `managedBy` + edges) its operational objects. + +### 3.2 pg_cron + +```ts +{ ext:"pg_cron", intentKind:"job", key:, + payload: { schedule:string, command:string /* opaque */, + database:string, username:string, active:boolean } } +``` + +- **Capture**: read `cron.job`. **Ownership normalization (CLI-1435)**: jobs + created before the patch were owned by `supabase_read_only_user`; normalize + `username` to `postgres` on capture so a rebuild doesn't reproduce the legacy + owner. Declared once, in the handler. +- **Create**: `select cron.schedule('','','')` (+ + `schedule_in_database` when `database` differs). +- **Change** (`schedule`/`command`/`active`/`database`): **`unschedule` + + re-`schedule` by name** — fully static and deterministic; no apply-time + `job_id` resolution. Run history isn't contracted, so recreate is lossless + w.r.t. our contract. (`cron.alter_job` is avoided precisely because it needs a + runtime id.) +- **Drop**: `cron.unschedule('')` — `dataLoss:"none"`. +- **consumes**: the `command` may reference user objects but is **opaque** (P1: + not parsed) and **late-bound at run time** — ordered conservatively last + (§4.5). **produces**: the job intent fact. + +### 3.3 pg_partman + +```ts +{ ext:"pg_partman", intentKind:"parent", key:, + payload: { control:string, partition_type:"range"|"list"|"hash"|"native", + partition_interval:string, premake:number, retention?:string, + retention_keep_table?:boolean, template_table?:string, + automatic_maintenance?:"on"|"off" } } +// + extensionIntent:{ ext:"pg_partman", intentKind:"parent_sub", ... } from part_config_sub +``` + +- **Capture**: resolve partman's schema dynamically, read `part_config` (+ + `part_config_sub`), reduce to the **intent subset** of its ~40 columns + (CLI-1430 owns the authoritative column→intent mapping; the sketch is the v1 + cut). Runtime columns (`last_partition`, premade-children state) are **excluded + from the hashed payload** — they drift legitimately, like extension `version` + (§3.1 per-kind equality surface). +- **Create**: `select partman.create_parent(p_parent_table=>'...', + p_control=>'...', p_interval=>'...', ...)` + a `set_part_config`-style + follow-up for retention/template/maintenance. **Drop**: + `partman.undo_partition(...)` / unregister — `dataLoss` per + `retention_keep_table` (conservatively `destructive` unless retention + guarantees survival). +- **Operational objects** (filtered, §4.3): children whose `pg_inherits` parent + ∈ `part_config` (incl. `*_default` and premade). **This is the authoritative + signal**; `relispartition` and `pg_depend` are both insufficient — a partman + child carries no extension dependency and is indistinguishable from a + user-declared `PARTITION OF` by `relispartition` alone (CLI-1591). +- **consumes**: the parent table `public.events` (a normal schema fact) → ordered + after `CREATE TABLE` + columns/PK. **produces**: the parent intent fact + (via + `managedBy`) its operational children. + +### 3.4 supabase_vault / pg_net (boundary cases) + +- **vault**: **presence-only** handler. Captures "enabled"; never reads + `vault.secrets`. If the source has vault-encrypted columns but the target + lacks the extension, the plan emits a **clear blocking error** (CLI-1434). +- **pg_net**: environment-specific URLs/secrets in function bodies are a + **rewrite/templating** concern (CLI-1433), distinct from intent capture. Out of + scope here; the handler interface (§4.1) reserves an optional `rewrite` hook. + +--- + +## 4. Architecture + +```text + live DB ──────────────┐ core extract: pg_catalog → schema facts + SQL files ─ shadow ───┤ handler capture: ext catalogs → intent facts + managedBy edges + snapshot ─────────────┘ (one exported snapshot; integration-aware extract) + │ + ▼ + ONE FACT BASE schema facts + intent facts + edges + │ ( managedBy edges mark operationally-created objects ) + │ resolveView: managedBy provenance → project out (Deliverable A) + ▼ + generic hash diff → deltas (schema + intent, one Delta type, O(changed)) + ▼ + ONE rule table (schema rules + registered handler intent rules) → actions + ▼ + ONE graph → one deterministic sort + (replay actions are ordinary nodes, wired by consumes / produces) + ▼ + PROOF: apply to clone → re-extract (core + handlers) → hash-compare both; + data-preservation seeds messages / partition rows + ▼ + apply (segmented, transactionality-aware) +``` + +### 4.1 The handler — a generic engine extension point + +The handler mechanism (interface, capture-into-the-fact-base, intent-rule +registration) is a **generic capability of `pg-delta-next`**, *not* +Supabase-specific: pgmq/cron/partman are general extensions, usable outside +Supabase. Handlers register into the engine like rules; the Supabase integration +**composes** a chosen set of handlers with its managed-schema/role policy and +baseline (§3.9). A non-Supabase consumer can register the pgmq handler alone. + +A handler is data with functions in narrow slots only (rule-table discipline, +guardrail 3). It needs to read non-`pg_catalog` tables, so it lives **above +core** (`src/policy/extensions/.ts` for the bundled Supabase set; the +interface itself is engine-level). + +```ts +interface ExtensionHandler { + extension: string; // "pgmq" | "pg_cron" | "pg_partman" + versions?: string; // semver range supported + + /** resolve install + schema dynamically; null ⇒ not installed */ + detect(ctx: CaptureCtx): { schema: string } | null; + + /** P1 — read the extension's OWN catalogs and EMIT into the shared fact base: + * intent facts (extensionIntent:…) AND `managedBy` edges on the operational + * objects the extension created. The single intent reader for all frontends. */ + capture(ctx: CaptureCtx): { facts: Fact[]; edges: DependencyEdge[] }; + + /** P2 — register rules for this handler's intent kinds into the one rule + * table; the generic planner dispatches by kind exactly as for schema. */ + intentKinds: Record; + + /** OPTIONAL authoring sugar (deferred — §8); reduces to capture (§4.3). */ + declarativeSchema?: ZodSchema; + materialize?(block: unknown, shadow: Conn): Promise; + + /** reserved for pg_net-class templating (out of v1 scope) */ + rewrite?(fact: Fact, env: Env): Fact; +} + +interface IntentKindRule { + create(f: Fact, view: FactView): ReplayAction; + drop(f: Fact, view: FactView): ReplayAction; + alter?: Record; // changed attr → in-place replay + consumes(f: Fact, view: FactView): StableId[];// schema facts the replay references + produces(f: Fact): StableId[]; // intent id + owned operational ids + dataLoss(a: ReplayAction): "none" | "destructive"; + transactionality(a: ReplayAction): "transactional" | "non" | "either"; +} +``` + +`ReplayAction` is an ordinary `Action` (`target-architecture.md` §3.5) whose SQL +is a `select .(...)` call. It carries the same metadata as any action, +so planner, sort, safety report, and executor treat it uniformly. There is **no +`captureIntent → IntentFact[]`** and **no `ownedObjects → StableId[]`** — both +collapse into `capture` emitting facts + edges, because *provenance is data* +(§3.1): the `managedBy` edge is the filter signal (§4.3), the intent rule's +`produces`, and the data-preservation seed target — one concept, three readers. + +> **Implementation note (pg_cron slice, shipped).** The concrete `IntentKindRule` +> in `src/plan/rules.ts` collapses the sketch's separate `consumes` / `produces` / +> `dataLoss` / `transactionality` slots into the SAME `ActionSpec` the schema +> rules already return (a `create`/`drop` returning `ActionSpec[]` carries those +> as per-spec fields) — one action-metadata grammar, not two. `alter?` is kept, +> but a rule instead declares `payloadAttrs: string[]`; every listed attribute is +> treated as `"replace"` (extension intent has no in-place ALTER), and a *changed* +> attribute NOT listed trips the "extend the rule vocabulary" guard, so payload +> evolution fails loudly. And "register … into the one rule table" is realized as +> a per-plan **resolver** (`buildRuleResolver(intentRules)`), NOT a mutation of the +> global `RULES`: the resolved profile supplies its handlers' `intentKinds` as +> `PlanOptions.intentRules`, and `rules.ts` stays the single lookup seam — this +> keeps `plan()` pure (no global state) across the corpus's many plans per process. + +### 4.2 Diff, rule table, graph, proof — all unchanged + +Because intent facts are facts, the generic differ already emits intent deltas +(`add queue`, `set partman.parent.retention`, `remove job`) alongside schema +deltas, as one `Delta` stream. The rule table dispatches by `kind`; handler +intent kinds are simply more registered kinds. The one-graph sort orders replay +actions among schema actions by their `consumes`/`produces`. The proof loop +re-extracts (core + handlers) and hash-compares. Rename, drift, snapshot +round-trip, and the safety report all extend to intent **for free** — the payoff +of one fact base over a parallel relation. + +### 4.3 Provenance filtering (Deliverable A — closes CLI-1555, the no-data-loss half) + +`capture` attaches a `managedBy()` edge to every operationally-created +object (partman children, pgmq `q_*`/`a_*` tables) — provenance as data (§3.1). +`resolveView` (`src/policy/policy.ts`) then projects out every `managedBy`-tagged +fact **and its descendant subtree** (the child table's columns/constraints), +pruning edges with a removed endpoint — exactly as it already does for +`memberOfExtension`. It is the **single projection point**: `managedBy` and +`memberOfExtension` are two provenance cases of the one `excludeByProvenance` +primitive (`src/policy/view.ts`; `excludeManaged` is the thin named wrapper). The +projection mirrors `subtractBaseline`. + +**Exclusion is at the FACT level, not the delta level — and this matters for +the proof.** A tempting alternative is a policy *filter rule* over deltas +(`{ edgeTo: { edgeKind: "managedBy" } } → exclude`). It is wrong here: a +delta-only filter would make the proof **drift** — the clone keeps the children, +`desired` lacks them — so the invariant "the plan you prove == the plan you run +== the data-preserving plan" (§6) only holds when the facts are removed on **both +sides + the proof re-extract**. That is why managedBy exclusion is a built-in +provenance projection in `resolveView` (universal, not Supabase-specific), not a +policy rule. (The DSL *does* now support edge-kind predicates — `edgeTo: { +edgeKind }` matches the edge's `EdgeKind`, used by policy scope rules — but the +data-loss-class managed exclusion must apply to everyone, so it lives in the +view projection, not behind a policy.) + +A **user-declared** `PARTITION OF` carries no `managedBy` edge, so its intended +drop still fires — the #5491 false-suppression regression cannot recur by +construction (regression-tested in `src/policy/managed.test.ts`). The signal is +sourced exactly where CLI-1591 requires it — the extension's own catalog, in +the integration layer, never `src/core`. + +> **Implemented (Phase A — wired into the default path).** +> `EdgeKind += "managedBy"` (`src/core/fact.ts`); the `ExtensionHandler` contract +> (`src/extract/handler.ts`) whose `capture(ctx, current)` runs on the SAME +> snapshot-bound transaction as core extraction — `extract(pool, { handlers })` +> (`src/extract/extract.ts`) runs them before COMMIT, so handler `managedBy` edges +> describe the same moment in DB time as the core facts. The **pg_partman** +> handler reads `part_config` + a recursive `pg_inherits` walk +> (`src/policy/extensions/pg-partman.ts`). `resolveView` projects out `managedBy` +> (`src/policy/policy.ts`) so the managed view is correct at every entry point. +> The **integration profile** (`src/integrations/`; `supabaseProfile`) composes +> handlers + policy + baseline + capability and is wired into `plan`, `prove`, +> `apply`, and the CLI (`--profile supabase`); `apply`'s fingerprint gate and +> `provePlan`'s re-extract both re-extract handler-aware via the profile, so the +> live proof loop stays consistent. Proven end-to-end against a real pg_partman +> DB on the Supabase image (`tests/extension-intent-partman.test.ts`): the raw +> diff drops the children; the profile path stops it and preserves the parent. +> **Remaining for production:** Phase B (intent replay — `create_parent` etc.). + +### 4.4 One intent reader, three doors (the hybrid sourcing, made uniform) + +There is exactly **one** intent reader — `capture` — mirroring P1's "one +elaborator": + +- **Live DB**: `capture` reads the source's extension catalogs directly, under + the same exported snapshot as schema extraction (consistency — §3.2). +- **SQL files (shadow)**: the files contain the replay calls (`select + pgmq.create('jobs');` …). The shadow loader runs them; `pgmq.create` has DDL + side-effects but writes **no user-table rows**, so it passes the loader's + parser-free DML rejection. After loading, `capture` reads the **shadow's** + extension catalogs. Same reader. +- **Declarative block (deferred sugar)**: a typed `[extensions.*]` block that + `handler.materialize` would expand into the same replay calls against the + shadow before capture. **Deferred to B+** (§8): it is a *second representation* + of intent and the files path already works without it. + +Net: intent enters the system exactly one way — by being read from a Postgres +that actually holds it (live source, or a shadow built from the replay calls). +This is the same move P1 makes for schema, and it answers RFC open question #2 +(CLI-1431): the declarative source "format" is *the replay calls themselves*, +read back by capture — not a parallel semantics engine. + +### 4.5 The opaque-command strategy (the P1 honest cost, reused) + +`cron.command` and partman maintenance calls are opaque strings we refuse to +parse — the same blind spot `pg_depend` has for PL/pgSQL bodies +(`target-architecture.md` §7). Same layered defense: (1) order intent replay +conservatively late (a late tie-break weight in the one sort); (2) rely on +run-time late binding (a cron command resolves when it fires, not at apply); +(3) the **proof loop catches a genuine ordering gap before production** — a +replay call that fails against the clone fails the gate. No body parsing in the +trusted path. + +--- + +## 5. Phasing + +Deliverable A and B are sequential (B needs A's detection), and A ships alone +with immediate value (stops data loss). + +| Phase | Content | Closes | Gate | +|---|---|---|---| +| **A — Filter** | handler `detect` + `capture` emitting `managedBy` edges; Supabase policy exclude rule (capture read-only/diagnostic at this stage) | CLI-1555, the no-data-loss half of CLI-1591 | Corpus: managed objects produce **no** schema delta; a native `PARTITION OF` drop **still** fires; data-preservation proof on seeded partition rows | +| **B — Replay** | `intentKinds` rules; intent deltas flow through the one diff/graph/proof; replay wired by `consumes`/`produces` | CLI-1591 rebuild fidelity, CLI-341 (cron), schema-side of CLI-1385 | Corpus: from-empty rebuild recreates queues/jobs/parents; intent round-trips both directions; data-preservation across the rebuild | +| **B+ — Authoring sugar** | `declarativeSchema` + `materialize`; the typed block | CLI-1431 (only if raw replay calls prove error-prone) | Block → shadow → capture ≡ live capture | + +Per-extension rollout within B follows CLI-1430: pgmq (no `consumes`, simplest) → +pg_cron (opaque command, late-order) → pg_partman (consumes parent, richest +config). + +--- + +## 6. Proof (intent is proven, not just reported) + +The proof loop is the arbiter (guardrail 9), extended to intent because intent +is just facts: + +1. **State proof (schema)** — unchanged: apply, re-extract, hash-compare. + Operational objects are excluded on both sides (provenance), so they never + cause a false diff. +2. **Intent proof** — re-capture intent via the handlers and hash-compare against + desired. Zero intent diff = the replay reproduced the declared + queues/jobs/parents (and implicitly, their operational objects exist). +3. **Data-preservation proof** — the keystone. Distinguish two cases, because + they preserve data differently and the proof must check the right one: + - **Incremental apply** (target already has the managed objects): the filter + keeps them untouched, so seeded **queue messages** and **partition rows** + survive. Asserted directly. + - **Rebuild from empty** (branch creation): replay premakes *empty* children / + queues — correct under the schema-only contract (rows are seeded + separately, never moved by us). The proof asserts the *structure* is + reproduced, not that rows appear. + +The generative engine extends naturally: generate queues/jobs/parents, mutate +intent, roundtrip through the proof loop including data preservation. + +--- + +## 7. Plan artifact & safety surface + +Replay actions appear in the plan artifact like any action, carrying +`dataLoss`/`lockClass`/`transactionality`. So **Risk 2.0** (CLI-1459) covers +them for free: `pgmq.drop_queue` and a destructive `undo_partition` surface as +hazards in the same proof-verified safety report. No separate risk path. + +--- + +## 8. Honest costs & open questions + +- **Capture reads non-`pg_catalog` tables.** Strictly an integration-layer + capability (core forbids it). Intended — intent is vendor knowledge. +- **Shadow/clone must carry the extension binaries.** capture-from-shadow and + proof both require the extension installed in the ephemeral Postgres. Supabase + images carry them; a generic shadow without the extension **degrades to + filter-only (Phase A) with a clear notice**, never to a wrong replay. Same + class as the §7 extension-parity cost. +- **The declarative block is deferred (not cut).** Because the files path + reduces to capture-after-running-the-replay-calls, a typed `[extensions.*]` + block is a *second representation* of the same intent — a divergence surface + (P2's warning). v1 ships one representation (replay calls in the schema files); + the block lands in B+ only if field evidence shows the raw calls are too + error-prone. +- **Extension-version skew.** Intent shape can change across versions (pgmq + `meta`, partman `part_config`). Handlers are version-keyed; unsupported + versions degrade to filter-only with a notice, never a wrong replay. +- **Opaque commands** (cron, maintenance) are ordered conservatively and trusted + to late-bind; a real gap surfaces in proof, not production (§4.5). +- **Idempotency / plan-to-target.** Replay is diff-driven (emit `create` only + when the intent is absent on the target — both sides are captured), so we never + blind-run `pgmq.create` against an existing queue; where a create fn isn't + idempotent, the rule guards on existence. +- **partman maintenance scheduling** (pg_partman_bgw / pg_cron `run_maintenance`) + — intent or operational? v1: out of intent scope; revisit per CLI-1430. +- **Cross-extension edges.** partman uses pg_cron for maintenance; the one-graph + model supports cross-handler `consumes`/`produces` edges, but the matrix + (CLI-1430) must enumerate them. + +--- + +## 9. Issue mapping + +| Issue | This design | +|---|---| +| CLI-1385 (parent) | Schema/intent half designed here; pure data-diffing (messages, run history, rows) stays **out of scope** (§1). | +| CLI-1555 (partman drops) | Phase A: `capture` `managedBy` edges + policy filter. | +| CLI-1591 (partman intent) | Phase A (drops) + Phase B (`create_parent` replay via intent rules). | +| CLI-341 (cron not listed) | Phase B: pg_cron handler captures + replays jobs. | +| CLI-1430 (intent matrix) | Authoritative column→intent spec the handlers consume; §3 is the v1 cut. | +| CLI-1431 (declarative source format) | §4.4: the replay calls *are* the format, read by capture; typed block deferred to B+. | +| CLI-1434 (vault) | §3.4: presence-only + blocking error. | +| CLI-1435 (cron ownership) | §3.2: `username` normalization on capture. | +| CLI-1433 (pg_net templating) | Reserved `rewrite` hook (§4.1); full design deferred. | +| CLI-1432 (cross-schema triggers) | Orthogonal — already handled by the Supabase policy (assessment §3). | + +--- + +## 10. Decision log + +- **2026-06-13 (a)** — Feature scoped: **filter + intent replay**, phased + (A→B→B+); **hybrid sourcing**; **sidecar by contract**. DML stays out of scope + (§1); P1 (Postgres/the extension is the only elaborator) preserved. +- **2026-06-13 (b)** — Boundary fixed: capture **intent**, never payload data. + Messages, run history, partition rows, vault secrets are never read; rows and + messages are *preserved* (data-preservation proof), never moved. Operational + objects are filtered via `managedBy` provenance edges sourced from the + extension's own catalog in the integration layer. +- **2026-06-13 (c)** — **Optimization pass** (maintainer: "seek the technical + optimum"). The rev-a draft modeled intent as a **separate fact relation with + its own diff/proof** — duplicated machinery and a second granularity (§8.7, + guardrail 8). Corrected to a **single fact base**: intent facts are + integration-produced, provenance-tagged facts flowing through the **identical** + generic diff / graph / proof / rename / safety machinery. "Sidecar" is + redefined as a *production-and-contract* boundary, not a separate structure — + the earlier sidecar-vs-first-class framing was a false dichotomy; the optimum + yields the boundary **and** machinery unification, snapshot/drift/rename + coverage for free. Follow-on corrections: the handler mechanism is a **generic + engine registry** (not Supabase-coupled), Supabase merely composes handlers; + operational filtering is a **`managedBy` edge** emitted by `capture` (not an + `ownedObjects` list); cron schedule/command changes use **unschedule + + reschedule by name** (static) over `alter_job` (runtime id); core gains **one + generic `extensionIntent` StableId kind** (not one per extension); the typed + declarative block is **deferred** to B+ (one representation in v1). +- **Open** (to CLI-1430): exact `part_config` intent-column subset; whether + partman maintenance scheduling is intent; cross-extension ordering edges. +``` diff --git a/docs/architecture/identity-and-acl.md b/docs/architecture/identity-and-acl.md new file mode 100644 index 000000000..2966dbcd5 --- /dev/null +++ b/docs/architecture/identity-and-acl.md @@ -0,0 +1,231 @@ +# Identity and ACL invariants + +Read this before changing a `StableId`, adding a role-bearing field, touching +ACL extraction, or changing rename behavior. PostgreSQL catalogs use OIDs to +connect objects at runtime; pg-delta uses declarative, name-based addresses to +describe the state that DDL can reproduce. Confusing those two identities is +the source of most rename and ACL edge cases. + +This document distinguishes the **current implementation** from the **I1 +target**. I1 has not landed yet: current planning still uses post-diff role +carry and a narrow role-rename ordering carve-out. + +## The invariants at a glance + +1. A `StableId` is a structured declarative address, not a PostgreSQL OID. +2. A fact's address belongs in `id`; semantic state belongs in `payload`. +3. `encodeId()` is the only string codec. Do not recover structured fields by + parsing or regex outside that codec. +4. PostgreSQL carries OID references through a rename, but name-keyed facts do + not follow automatically inside pg-delta. +5. ACL identity is target + grantee + optional column. ACL payload is the + explicit per-grantee privilege set; grantor provenance is deliberately not + modeled. +6. Payload keys beginning with `_` are operational hints. They may guide + rendering, ordering, or safety, but never participate in equality. +7. State proof compares post-apply declarative identities. Data proof remaps + accepted ordinary heap-table renames only. + +## StableId means declarative addressability + +[`StableId`](../../packages/pg-delta/src/core/stable-id.ts) is a discriminated +union of the fields PostgreSQL DDL needs to address an object: + +- a table is `{ kind: "table", schema, name }`; +- a routine also includes its argument-type list; +- a policy includes schema, table, and policy name; +- a membership includes the granted role and member; +- an ACL includes its target, grantee, and optional column. + +These are durable *names within a captured state*, not durable database +identities. PostgreSQL may preserve one OID while `ALTER ... RENAME` changes the +name. pg-delta will then extract a different `StableId` for the same physical +object. + +Keep identity structured end-to-end. Extraction returns identity parts and +TypeScript constructs the union. `encodeId()` and `parseId()` are the only +canonical string boundary. Encoded ids currently serve as in-memory map and +graph keys as well as persisted artifact fields and logs; callers must still +inspect the structured union rather than reverse-engineering the string. +Snapshot and plan formats have versions, but the StableId codec itself has no +independent version tag today. + +Identity parts are not duplicated as structured payload attributes. Canonical +`pg_get_*def()` text can still embed an object's own or referenced names; that +known limitation can turn a would-be rename into a reported near-miss. Where +payload content remains identity-free, its hash can remain equal across a leaf +rename. Named Merkle rollups still fold encoded child ids and dependency +endpoints, so identity changes propagate to normal state equality. Rename +proposal uses a separate structural rollup that omits fact/edge identities to +compare whole subtrees. + +## Names carried by PostgreSQL OIDs + +`ALTER ROLE old RENAME TO new` changes the catalog name while preserving the +role OID. Ownership, grants, memberships, user mappings, and policy role lists +continue to reference that OID. Re-extraction reports `new` everywhere even +though pg-delta's pre-rename facts were keyed by `old`. + +The current StableId role-name registry is +[`ROLE_NAME_BEARING_KINDS`](../../packages/pg-delta/src/plan/role-rename-carry.ts): + +| Kind | Role-bearing identity fields | +|---|---| +| `role` | `name` | +| `membership` | `role`, `member` | +| `userMapping` | `role` | +| `defaultPrivilege` | `role`, `grantee` | +| `acl` | `grantee`; recursively, a role-bearing `target` | +| `comment` | recursively, a role-bearing `target` | +| `securityLabel` | recursively, a role-bearing `target` | + +Two important role references are outside that table: + +- an `owner` is a dependency edge whose `to` endpoint is a role, not a + StableId kind; +- `policy.roles` is a structured payload field, not identity. + +The registry guard test forces every new StableId kind to be classified, but it +cannot protect payload fields. Whenever a new catalog field contains a role +name, decide explicitly whether it belongs in identity, an edge, or structured +payload. Do not derive or repair the reference by parsing SQL text. + +## Rename flow: current and target + +### Current implementation + +Current planning performs these steps: + +1. Reconstruct the managed source and desired views. +2. Run generic diff and policy filtering; build remove/add worklists from kept + deltas. +3. Propose rename-capable roots whose identity-free structural rollups match. + Ambiguous and near-miss candidates are reported, never guessed. +4. Accept candidates according to the rename mode (`auto`/`prompt`/`off`) and + explicit confirmations; cancel their old and new subtrees from create/drop + worklists. +5. For accepted role renames, match remove/add deltas by relabeling their + role-bearing StableIds without rewriting either fact base. Cancel unchanged + pairs and matching owner unlink/link pairs; convert payload-changed pairs + into mutations against the new identity. +6. Emit the rename separately, with the old subtree in `destroys` and the new + subtree in `produces`, so dependent actions order against both names. + +This post-diff carry lives in +[`role-rename-carry.ts`](../../packages/pg-delta/src/plan/role-rename-carry.ts). +It cannot relabel `policy.roles`, because that reference is payload-carried. +The current planner therefore retains the narrow +[B1](../roadmap/agent-tracks/B1-role-rename-policy-cycle.md) ordering carve-out +for a policy mutation spanning an accepted role rename. + +### I1 target (not current) + +[I1](../roadmap/agent-tracks/I1-prediff-rename-identity.md) replaces carry with +identity normalization: + +1. A discovery diff, with policy filtering, proposes and accepts role renames. +2. Copy-on-write normalization rewrites both fact bases into the **desired-name + space**: ids, parents, edges, `referenceOnly` encoded ids, and known + structured payload references such as `policy.roles`. +3. A second canonical diff and policy filter produce the ordinary plan. +4. The existing rename-emission seam still renders SQL from the original + physical old/new facts. + +Only the source fingerprint remains tied to the physical pre-rename source. +Everything else consumes the canonical pair. When I1 lands, the role carry +module and B1 carve-out should disappear; until then, documentation and tests +must describe them as current behavior. + +## ACL identity and equality + +An ACL is a satellite fact. Its identity is: + +```ts +{ kind: "acl", target: StableId, grantee: string, column?: string } +``` + +For an object-level grant, `target` is the object and `column` is absent. For a +column-level grant such as `GRANT SELECT (email) ON users TO reporter`, `target` +remains the owning relation and `column` contains `email`; the fact's parent is +the column fact. Keeping the optional column in the codec and every identity +rewrite is essential—dropping it aliases a column grant with an object grant. + +The semantic payload contains sorted `privileges` and `grantable` sets. +Extraction models the grantee's **explicit ACL privileges**, not which grantor +supplied them. `aclexplode()` can return the same privilege once per grantor; +the extractor groups by grantee and de-duplicates privilege and grant-option +values. It does not resolve role memberships, owner-implied privileges, or +access inherited through another role. Consequently: + +- two databases with the same explicit ACL privileges for a grantee compare + equal even if grantor provenance differs; +- removing one of several equivalent grantors is intentionally invisible while + the explicit ACL set remains; +- export and replay preserve explicit grantee ACL state, not exact GRANT + provenance or the complete authorization closure. + +Ordinary object ACL extraction normalizes a null ACL through `acldefault()` and +synthesizes empty owner/PUBLIC entries when a built-in default was revoked. An +extension member instead records only differences from `pg_init_privs` (or the +object default), because recreating the extension restores the installed ACL. + +## Non-semantic underscore payload fields + +[`canonicalize()`](../../packages/pg-delta/src/core/hash.ts) recursively omits +every object key beginning with `_`. Those fields therefore cannot change fact +hashes, diff deltas, fingerprints, or state-proof equality. + +They are not dead data. They can guide a plan whose semantic need is already +established by hashed state. The current inventory is: + +| Field | Purpose and consumer | +|---|---| +| `_ownerDefault` | Version-correct owner defaults from `acldefault()`; lets default-ACL compaction remove only grants PostgreSQL supplies on create. | +| `_initPrivs` | Extension member's installed ACL; lets ACL removal restore install state instead of blindly revoking everything. | +| `_revokedDefault` | Records a removed built-in default privilege so default-privilege rendering can restore it. | +| `_position` | Preserves declared column/composite-attribute creation order without making order-only drift semantic. | +| `_serverMajor` | Selects only subscription mutations supported by the captured PostgreSQL version. | +| `_configGucs` | Keeps assumed-schema seeding from replaying routines that require unsafe configuration. | + +The rule for adding one is strict: changing an underscore field alone must not +represent desired-state drift, because generic diff will never see it. Use an +underscore field only for extraction provenance, rendering context, ordering, +or safety. If changing the value must produce DDL by itself, it is semantic and +must not begin with `_`. + +## Proof implications + +State proof applies the finished plan, re-extracts the clone, reconstructs the +same managed view, and compares it with the projected desired view. The +post-apply catalog must therefore use the desired declarative ids and names; +underscore hints cannot rescue a state mismatch because proof does not include +them in equality. + +Data proof has narrower coverage: row statistics include only ordinary heap +tables (`pg_class.relkind = 'r'`). For an accepted heap-table rename, proof maps +the old relation key used before apply to the new key used afterward. That +prevents a data-preserving rename from being misclassified as drop/recreate. +Materialized views and partitioned table roots receive state-proof coverage but +do not enter these row statistics; role and other object renames do not use the +row-key map either. + +Under the I1 target, the proof algorithm remains unchanged, but fingerprint +routing matters: apply must compare the physical pre-rename source with the +physical source fingerprint, while canonical ids feed diff, planning, and the +desired state proof. + +## Checklist for contributors + +Before adding or changing identity, ACL, or role-bearing state: + +- Is the value part of the DDL address (`id`), semantic state (`payload`), or a + dependency/provenance relationship (`edge`)? +- If a StableId embeds a role name, is its kind classified and relabeled in the + role-name-bearing registry and guard test? +- If a payload embeds a role name, is the current carry limitation documented + and the I1 normalizer inventory updated? +- Does an ACL change preserve `target`, `grantee`, and optional `column`? +- Are privilege arrays sorted and grantor duplicates removed intentionally? +- If a field begins with `_`, is it safe for equality and proof to ignore it? +- Does rename behavior have both plan-ordering coverage and, for relations, + data-proof coverage? diff --git a/docs/architecture/managed-view-architecture.md b/docs/architecture/managed-view-architecture.md new file mode 100644 index 000000000..fad798567 --- /dev/null +++ b/docs/architecture/managed-view-architecture.md @@ -0,0 +1,370 @@ +# The managed-view architecture (canonical) + +- **Status**: Canonical design. **Supersedes** the scope/serialize portions of + [`target-architecture.md`](target-architecture.md) §3.9, and folds the former + object-filtering-flags / service-migration-baselines stubs (now in the + [post-v1 backlog](../roadmap/post-v1.md)) into a single model. +- **Scope**: `packages/pg-delta-next` only. This is a from-scratch design of how + *context* (scope, ownership, applier identity) enters the engine — not a + migration of the existing scope/serialize code. +- **Date**: 2026-06-14. + +## The one idea + +> **The engine never diffs raw catalogs. It diffs a policy-defined, +> applier-capability-restricted _view_ of the managed universe — and a view is +> closed under the proof loop.** + +Everything previously handled by three inconsistent mechanisms — fact-level +projection (`excludeManaged`/`excludeExtensionMembers`), delta-level filtering +(`filterDeltas`), and serialize escape-hatch params (`skipAuthorization`, +`skipSchema`) — collapses into one: **define the view, diff the view, prove the +view.** + +``` + raw source ─┐ ┌─ raw desired + ▼ ▼ + view(facts, policy, capability) ◀── ONE definition + │ │ + effectiveSource ──── diff ──── effectiveDesired + │ + deltas ── rules ── actions ── graph ── sort ── apply + │ + provePlan (re-extract is run through the SAME view) +``` + +## Why this is forced by the constraints (not a preference) + +| Constraint (from target-architecture) | What it forces | +|---|---| +| **Proof honesty: `plan == prove == run`** | A single **fact grain**. Filtering *after* the diff breaks honesty (the plan target ≠ the desired fact base), which is why the old design needed `projectTarget` to rebuild a synthetic target and the missing-requirement guard's *"a filter may be hiding its creation"* branch to catch stranded references. A view defined *before* the diff is honest by construction — nothing to repair. The constraint doesn't permit single-grain, it **selects** it. | +| **§3.1 "provenance is data — an edge, not a flag"** | Ownership and scope are **edges and facts**, never serialize toggles. | +| **One graph / no per-kind code in the planner body** | The view *decisions* are generic; per-kind *rendering* (e.g. `AUTHORIZATION` vs `OWNER TO`) stays in the rule table, where it is allowed. | +| **"Source it from the catalog, don't invent a second source of truth"** | A render flag that describes the object (`skipSchema`) is a **missing fact field** (`pg_extension.extrelocatable`), not a policy constant. | + +## The four structural moves + +### 1. Ownership becomes an edge (`ownedBy`), not a payload string + +Today `schema.create` reads a payload `owner`, emits `AUTHORIZATION ` + +`consumes: role`, and `skipAuthorization` (rules.ts:584) suppresses the clause. + +**New model:** ownership is an edge `object --ownedBy--> role`, emitted by the +extractor wherever `pg_catalog` has an owner column (`nspowner`, `relowner`, +`proowner`, `typowner`, …). Consequences, all structural: + +- Projecting a role out of the view **prunes the `ownedBy` edges to it + automatically** — the projection already "prunes edges with removed + endpoints." A surviving object whose owner is out of scope simply has no + in-scope `ownedBy` edge. +- A create/alter rule renders owner **iff an in-scope `ownedBy` edge exists**. + No edge → no clause. **`skipAuthorization` ceases to exist** as a structural + consequence of edge pruning — it is not "fixed," it is gone. +- Owner *changes* become edge deltas (`unlink`/`link` → `ALTER … OWNER TO`), + and owner becomes **rename-aware**, uniformly, for every ownable kind. +- Per-kind owner SQL (`AUTHORIZATION` inline for schema; `OWNER TO` follow-up + action for most kinds, compactable into the create like column folds) lives in + the rule table. The planner holds no owner logic. + +> **Known non-goal — do NOT elide third-party GRANTs on co-created objects to +> shrink the diff.** The apply model is **creates-as-applier + `ALTER … OWNER +> TO`**: an object is created by the connected applier (e.g. `test`), then +> ownership is transferred by a follow-up ALTER. There is **no** `SET ROLE` / +> `SET SESSION AUTHORIZATION` in the apply path, and `pg_default_acl` defaults +> fire on the role that *creates* the object (`defaclrole`). So a +> `defaultPrivilege FOR ROLE owner` does **not** fire on an applier-created +> object — the grantee would be left with **no privilege at all** if the explicit +> `GRANT` were dropped. This is exactly why the proposed "whole REVOKE+GRANT +> group" elision (historically "Item 2a") was rejected as a convergence bug, and +> why the dbdev fixture's extra GRANTs over the old engine (~12) are +> **load-bearing correctness, not cosmetic noise**. The compaction passes only +> trim the *cosmetic leading `REVOKE ALL`* (`elideCoCreateRevokeBeforeGrant`, +> guarded by a strict-superset check) and fold ownership +> (`foldCoCreateOwnership`) — they never drop a GRANT. +> +> The only way those defaults could fire (making the extra GRANTs genuinely +> redundant) is to change the create model so objects are created **as their +> owner** (`SET ROLE`/session-authorization, or owner-scoped `CREATE … +> AUTHORIZATION` applied as that role). That is a deep change with its own +> capability and ordering risks; it is **out of scope** and must not be +> back-doored via a compaction pass. Revisit the create model first, with its own +> proof, before any attempt to remove these GRANTs. + +### 2. Object-intrinsic render flags become fact fields, from the catalog + +`skipSchema` (rules.ts:604) encodes a property of the *extension* +(`pg_extension.extrelocatable`) that was never extracted, so the policy +hardcodes the answer (`["pgmq","pgsodium","pgtle"]`). + +**New model:** extract `extrelocatable` onto the `extension` fact. The extension +rule emits `SCHEMA` **iff the extension is relocatable**. No param, no name list, +**not Supabase-specific** — correct for PostGIS, uuid-ossp, anyone. (A +non-relocatable extension creates its own schema; a relocatable one is the only +kind that honours a `SCHEMA` clause.) + +*Principle: a serialize param that describes the object is a missing fact field.* + +### 3. Applier capability becomes a probed fact; the view is capability-restricted + +This is the irreducible residue, and the genuinely new idea. `skipAuthorization`'s +*real* cause is not the schema — it is that **the applier cannot become that +role**. The FDW-ACL exclusion (Supabase Rule 9) has the identical shape: **the +applier is not superuser**, so `GRANT ON FOREIGN DATA WRAPPER` is unexecutable. +Neither is derivable from the object catalog or from scope — both are properties +of **who is applying**. + +**New model:** probe the applier's capabilities at connect time and represent +them as facts — exactly as the schema is extracted: + +```ts +interface ApplierCapability { + role: string; + isSuperuser: boolean; + memberOf: ReadonlySet; // roles the applier can SET ROLE to + // derived helpers: + canActAs(role: string): boolean; // isSuperuser || memberOf.has(role) + canGrantOn(kind: FactKind): boolean; // superuser-gated kinds (fdw, …) +} +``` + +The view is then `view(facts, policy, capability)`. Capability-blocked +operations are handled by their *shape*, because not all of them can be skipped +cleanly: + +- **FDW ACLs** (`GRANT/REVOKE ON FOREIGN DATA WRAPPER` is superuser-only) are a + **leaf fact** — projecting them out of the view converges with no ripple. So a + non-superuser's FDW ACLs are projected out (`capabilityExcludedRoots`). This + derives the exclusion the Supabase policy hard-codes as Rule 9. +- **Ownership** (`ALTER … OWNER TO R` needs superuser or membership in R) **can + NOT** be silently skipped: leaving an object applier-owned ripples into its + acldefault-normalized ACL (owner-relative), so the state would not converge. + So an owner action the applier can't run is a **fail-fast at plan time** + (`canSetOwner` → a clear, actionable error), surfaced before any apply. + +For a pure file-to-file diff with no applier connection, capability defaults to +"unrestricted" — the corpus/CI path is a no-op. + +### 4. Filtering collapses to one grain + +The policy DSL **survives unchanged as composable data**; only its *compilation +target* changes — it compiles to a view transformation, not a post-diff filter. + +- **Scope rules (no `verb`)** — "exclude system schemas / system-role-owned / + satellites on managed objects" (Supabase Rules 4,5,6,7,10) — become + **fact-level projections** on both sides. Satellites and owner-edges then + follow their target out *by pruning*, so Rules 5/7/10 evaporate (a satellite + rides out with its parent automatically). **Stranding is impossible here.** +- **Operation rules (`verb`-bearing)** — "never DROP this", "create-only" — are + the only rules that need the diff, and each reduces to a **pre-diff adjustment + of the desired view relative to source**: `don't-drop` = copy the fact into + desired; `don't-create` = remove it from desired; `don't-alter` = pin source's + value. This is precisely what `projectTarget` computes — now run on an + already-scope-projected base, so it can strand **only** a genuine policy + conflict (you pinned a dependent but excluded its dependency). + +The missing-requirement guard stays, but its meaning **sharpens** from "the +mechanism stranded a reference" to "your policy is internally inconsistent" — +which is exactly when it should fire. + +## The durable principle (prevents regression) + +> A serialize param is legitimate **iff** it encodes an *apply-time strategy*. +> - Describes the **object** → it is a **fact field** (sourced from the catalog). +> - Describes **what is in scope** → it is an **edge + projection**. +> - Describes **who is applying** → it is **probed capability**. + +By that test, `concurrentIndexes` is the **only** legitimate serialize param. +`KNOWN_PARAMS` ends at `{ "concurrentIndexes" }`. + +## What collapses + +| Today | New | Why it disappears | +|---|---|---| +| `skipSchema` param + 3-name list | `extrelocatable` fact field | object-intrinsic → field | +| `skipAuthorization` param | `ownedBy` edge pruning + capability | scope/capability, not a flag | +| FDW-ACL exclude (Supabase Rule 9) | probed-capability projection | same class as skipAuthorization | +| schema / owner / satellite excludes (Rules 4,5,6,7,10) | fact-level projection + edge pruning | scope is a view; satellites follow parents | +| `excludeManaged` + `excludeExtensionMembers` + `filterDeltas` (3 entry points) | one `resolveView(...)` | one grain, one definition | +| `projectTarget` for scope rules | gone (scope is pre-diff) | honesty by construction | +| guard's "a filter may be hiding its creation" branch | gone | scope can't strand; only real conflicts remain | + +## The new pipeline (concrete) + +```ts +// One definition, used by plan() and the proof re-extract identically. +function resolveView( + raw: FactBase, + policy: Policy | undefined, + capability: ApplierCapability | undefined, +): FactBase; // pure existence/identity/provenance/capability projection (no verb) + +// Operation (verb) rules act on the diff of two resolved views: +function applyOperationRules( + deltas: Delta[], + source: FactBase, // resolved + desired: FactBase, // resolved + policy: Policy | undefined, +): { kept: Delta[]; reverted: Delta[] }; // == old projectTarget, minimized + +function plan(rawSource, rawDesired, options) { + const cap = options?.capability; + const source = resolveView(rawSource, options?.policy, cap); + const desired = resolveView(rawDesired, options?.policy, cap); + const all = diff(source, desired); + const { kept, reverted } = applyOperationRules(all, source, desired, options?.policy); + const target = revertInto(desired, reverted); // plan target == proof target + // … rules → actions → graph → sort, as today +} +``` + +- `excludeManaged` / `excludeExtensionMembers` become two *cases* inside + `resolveView` (they are scope projections by the `managedBy` / `memberOfExtension` + edges). The Supabase scope rules are more cases. One code path. +- The proof loop’s `reextract` is wrapped in the same `resolveView` so + `plan == prove == run` holds with no special-casing. +- **Reconstruction under management scope** (plan / prove / apply / schema + export) goes through one internal helper — + `policy/reconstruct.ts` → `reconstructManagedView` — which seals the order + `resolveView` then `projectManagementScope`. Diff and seed paths may still + call bare `resolveView` (no scope projection). The helper is not on the + package index; `ResolvedProfile` remains the public composition surface. + +## Costs (kept honest) + +- **+1 edge per ownable object.** Ownership-as-edge grows the edge set + (≈ one per table/view/sequence/type/function/schema). Cheap per edge; buys + uniform owner-diff + owner-rename. Real, accepted. +- **Capability probing is new extraction** (role membership + superuser) and a + new optional *input*. Absent (file-only diff), default to unrestricted. +- **Operation (verb) rules still need a post-diff revert** — `projectTarget` + shrinks to this minority; it does not vanish entirely. Stated, not oversold. +- **`idField` + glob** (membership / pgmq-table matcher) survives as the one + stringly-typed predicate; the target for future structural replacement. + +## Build sequence (each step RED→GREEN, no old/new coexistence) + +This is a build order, not a migration — the old scope/serialize code is +replaced in place. + +> **Reordering note (discovered during implementation).** Owner-as-edge's +> *skipAuthorization elimination* relies on **fact-level role projection pruning +> the owner edge**, which is the view foundation; and turning an `owner` edge +> link/unlink into an `ALTER … OWNER TO` action is **brand-new core machinery** +> (today `link`/`unlink` deltas are emitted by `diff` but ignored by the planner +> — edges only drive ordering). So the view foundation is built **before** +> owner-as-edge. The ordering below reflects that. + +1. ✅ **`extrelocatable` → derive `SCHEMA`; delete `skipSchema`.** Extractor + field + extension-rule conditional. (shipped `6b74b7d`) +2. ✅ **Projection primitive** — `excludeByProvenance` / `excludeFactsAndDescendants` + in `policy/view.ts`; `excludeManaged` + `excludeExtensionMembers` collapse + into it. The fact-level foundation. (shipped `d1883e9`) +3. ✅ **`resolveView`**: non-`verb` policy rules → fact-level projection on both + sides + the proof reextract; `verb` rules left to the delta-level filter. + First-match-wins with over-projection safety (an operation-`include` protects + a fact a later scope-`exclude` would remove). (shipped `96fe441`, corpus 418/418) +4. ✅ **`owner` edge + edge→action**: extractor emits an `owner` edge and drops + the payload `owner`; the planner emits `ALTER … OWNER TO` from `owner`-edge + link deltas (`KindRules.ownerAlterPrefix`); `diff.emitSubtree` emits edge + deltas for created/dropped subtrees; the `{ owner }` predicate resolves via + the edge; `skipAuthorization` deleted. (shipped `d840768`, corpus 418/418) +5. ✅ **`applyOperationRules` — SUBSUMED by 3 + 4.** No separate change is + correct. After 3, `projectTarget` already handles only the `verb`-filtered + deltas (scope rules produce no filtered deltas — they're projected), and the + missing-requirement guard is already **conflict-only** (scope can't strand; + only a `verb` rule, e.g. don't-create a kept object's dependency, fires it — + pinned by policy.test.ts test 3). A clean "verb-only" delta pass is **not** + achievable: `resolveView` conservatively KEEPS facts protected by an + operation-`include`, so their non-protected deltas still need the full policy + at the delta level. The current `resolveView` + full `filterDeltas` + + minimized `projectTarget` IS the end state. +6. ✅ **`ApplierCapability`**: probe role / superuser / memberships + (`probeApplierCapability`); thread `capability` through plan()/prove(). FDW + ACLs (leaf) are projected out for a non-superuser; ownership (rippling) is a + plan-time **fail-fast** (`canSetOwner`). Additive + gated (default + unrestricted → corpus no-op). (shipped `5f6841d` + follow-up 1) +7. **Cleanup** (remaining): `KNOWN_PARAMS = { concurrentIndexes }` ✅ (move 4); + update `COVERAGE.md` + the Supabase policy comment block. NOTE: Rules 5 + (schema-name), 7 (role-name) and 10 (satellite-on-system-target) are **genuine + scope rules that stay** — they are now applied fact-level via `resolveView`, + not deleted. Only Old-13 (skipAuthorization) and Old-14 (skipSchema) were + removed. + +End state: the policy DSL describes a view; the engine diffs `view(source)` +against `view(desired)`; the proof re-extracts through the same view; serialize +params hold only apply-strategy; and there is **zero Supabase-specific knowledge +in core.** + +## Readiness (2026-06-16 — integration profile) + +The single-view contract is now reachable from every entry point, not just from +hand-composed helpers. Status by concern: + +- **Current (implemented + wired into the default path).** + - `resolveView` is the **single projection point**: baseline → `memberOfExtension` + → applier capability → `managedBy` → policy scope. `managedBy` exclusion is + unconditional (a no-op without handler edges), so `plan`/`prove`/`apply` all + drop operationally-managed objects without any caller-side `excludeManaged`. + - Extension handlers run **inside the extraction transaction** + (`extract(pool, { handlers })`, `src/extract/handler.ts`) — same snapshot as + core facts. + - One **`IntegrationProfile`** (`src/integrations/`) owns handlers + policy + + baseline + capability; `resolveProfile` resolves them once and bakes them into + plan/prove/apply option bundles (shared by reference → `plan == prove == + apply`). `apply` gained a `reextract` option so its fingerprint gate + re-extracts handler-aware. + - **CLI** `--profile ` on `plan`/`apply`/`prove`; the public + surface is reachable from the package root and the `./integrations` subpath. + - `loadSqlFiles` DML detection reuses the extraction scope predicate and excludes + extension-owned relations (so an extension's seeded config table is not + mistaken for user DML). +- **v1 required (not yet committed).** A committed Supabase **baseline snapshot** + (`supabasePolicy.baseline` stays intentionally unset until then; `resolveBaseline` + fail-fasts loudly if a policy declares a baseline with no snapshot). +- **Phase B (extension intent).** Handlers emit only `managedBy` filter edges + today; intent **replay** (`create_parent`, pg_cron jobs) is future work + (docs/architecture/extension-intent.md §4.4). +- **Deferred roadmap.** Explicit CLI `--policy ` / `--baseline ` + escape hatches (the profile covers the safe path); parallel snapshot extraction. + +## Follow-ups (post-move-7) + +### Follow-up 1 — owner residue → fail-fast (shipped `2179e37`) + +`ALTER … OWNER TO R` needs superuser or membership in R. Unlike an FDW ACL (a +leaf that projects cleanly), an owner can't be silently skipped — leaving the +object applier-owned ripples into its acldefault-normalized ACL, so the state +won't converge (a synthetic skip produced 58 drift deltas). So when a +capability is supplied and the applier can't set an owner an owner-link delta +requires, `plan()` fail-fasts with an actionable error (`canSetOwner`) before +any apply. Gated (only fires for a non-superuser capability) → corpus no-op. + +### Follow-up 2 — capability vs Supabase Rule 9: parity finding (Rule 9 KEPT) + +The question was whether the applier-capability mechanism lets us *retire* +Supabase Rule 9 (`{ acl, target fdw } → exclude`). The analysis — and the +empirical check that **a non-superuser cannot GRANT on a FDW** +(`tests/capability.test.ts`) — says: **keep Rule 9.** + +- Capability (move 6) derives Rule 9's *stated rationale* precisely: it drops + FDW ACLs exactly when the applier is non-superuser (so they're unappliable). +- The two are **at parity for the real Supabase flow** (local + Cloud `postgres` + are both non-superuser → capability excludes, matching Rule 9). +- They **diverge for a superuser applier**: capability would (correctly) *manage* + the FDW ACL; Rule 9 excludes *unconditionally*. +- Rule 9 is a **self-contained policy guarantee** — it holds even if a caller + forgets to probe + pass capability. Capability depends on every caller + supplying it. Retiring Rule 9 would weaken an unconditional guarantee into a + caller obligation (forget it → CLI-1469 returns). So **retiring is a + regression in robustness, not an improvement.** + +Conclusion: capability is the **general** mechanism (any non-superuser applier, +any policy, + the owner residue); Rule 9 is the Supabase policy's **unconditional +belt-and-suspenders**. They are complementary, not redundant — Rule 9 stays. + +**Remaining productization (separable, not blocking):** wire the CLI to +`probeApplierCapability` and thread it through `plan`/`diff`/`apply`/`prove`. For +the multi-step `plan → apply/prove` flow this needs **capability persisted in the +Plan artifact** (like `policy` is), so a separate `prove`/`apply` invocation +recovers the same view (`ApplierCapability.memberOf` serialized as an array). +The engine mechanism + the parity conclusion above stand without it. diff --git a/docs/architecture/onboarding.md b/docs/architecture/onboarding.md new file mode 100644 index 000000000..6c559f8c6 --- /dev/null +++ b/docs/architecture/onboarding.md @@ -0,0 +1,62 @@ +# pg-delta-next: contributor onboarding map + +A one-page orientation for someone touching the engine for the first time. +Pairs with [overview.md](../overview.md) (the why), [README.md](README.md) (the +concept-first intro), and [target-architecture.md](target-architecture.md) (the +full design). + +## The pipeline, and where each stage lives + +```mermaid +flowchart TD + Extract["extract(pool)\nsrc/extract/extract.ts"] --> FB["FactBase\nsrc/core/fact.ts\n(facts + dependency edges)"] + SqlFiles["SQL-file frontend\nsrc/frontends/load-sql-files.ts"] --> FB + FB --> View["resolveView\nsrc/policy/policy.ts\n(policy · capability · baseline)"] + View --> Diff["diff\nsrc/core/diff.ts\n(generic, zero per-kind code)"] + Diff --> Plan["plan\nsrc/plan/plan.ts + rules.ts\n(rule table → one action graph)"] + Plan --> Apply["apply\nsrc/apply/apply.ts"] + Plan --> Prove["provePlan\nsrc/proof/prove.ts\n(apply to a clone, re-extract, compare)"] + Corpus["corpus/ scenarios\n(a.sql / b.sql)"] --> Prove +``` + +Answers to the five questions a newcomer asks: + +| Question | Where | +|---|---| +| Where do facts come from? | `src/extract/extract.ts` orchestrates per-family extractors (`src/extract/*.ts` — e.g. `relations.ts`, `foreign.ts`, `types.ts`; shared `pg_depend` resolver in `dependencies.ts`) over a live DB; `src/frontends/load-sql-files.ts` does `.sql` → shadow DB → extract. Both produce a `FactBase`. | +| What is a fact? | `src/core/fact.ts` — a content-addressed `{ id: StableId, parent?, payload }`. Identity lives in `src/core/stable-id.ts`; hashing in `src/core/hash.ts`. | +| How is ordering decided? | `src/plan/plan.ts` builds atomic actions from the rule registry (`src/plan/rules.ts`, with per-family rules in `src/plan/rules/*.ts`) and orders them with one topological sort over the dependency graph (`src/plan/internal.ts`). At fact grain there are no cycles to break. | +| What proves a change safe? | `src/proof/prove.ts` — applies the plan to a throwaway clone, re-extracts, and checks the fact hashes match (state) and seeded rows survive (data). | +| Where does product-specific scope live? | `src/policy/` — `resolveView(facts, policy, capability, baseline)` projects the managed view; `src/policy/supabase.ts` is the Supabase package. Never in core diff/plan. | + +## Adding a new object kind + +1. **Identity** — add the kind to the `StableId` union + codec in `src/core/stable-id.ts`. +2. **Extract** — query its facts in the matching family extractor under + `src/extract/` (e.g. `relations.ts`, `foreign.ts`, `types.ts`, `policies.ts`); + `src/extract/extract.ts` only orchestrates them, the shared `pg_depend` + resolver for dependency edges lives in `src/extract/dependencies.ts`, and + shared scope helpers in `src/extract/scope.ts`. Emit identity PARTS as + columns, never build id strings in SQL (the library codec does that). +3. **Rules** — add the kind's entry to the matching family file under + `src/plan/rules/` (e.g. `tables.ts`, `types.ts`, `views.ts`), reusing the + shared rendering helpers in `src/plan/rules/helpers.ts`. `src/plan/rules.ts` + is the registry that composes them and stays the planner's single interface + (`create`/`drop`/`alter`/attribute rules + flags like `weight`, + `rebuildable`, `cascadesToChildren`). +4. **Unit test** — a focused serialization test next to the rule if useful. +5. **Corpus scenario** — add `corpus/-operations--/{a,b}.sql`; it runs + both directions through the full proof loop (state + data). +6. **Coverage** — update `packages/pg-delta-next/COVERAGE.md`. + +The diff, sort, and proof layers are generic — steps 1–3 are usually all that a +new kind needs; the engine never grows a per-kind `if`. + +## Running tests fast (local) + +- Unit (no Docker): from outside the package dir, `bun test `. +- One corpus scenario: `PGDELTA_NEXT_ONLY= PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts`. +- Whole corpus, parallel: `PGDELTA_NEXT_CONCURRENCY=8 PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts` (~3× faster; role/cluster scenarios run serially automatically). +- Live progress on a piped run: add `PGDELTA_NEXT_PROGRESS=1`. + +See `.github/agents/pg-toolbelt.md` for the full testing discipline. diff --git a/docs/architecture/target-architecture.md b/docs/architecture/target-architecture.md new file mode 100644 index 000000000..4a0abfa8d --- /dev/null +++ b/docs/architecture/target-architecture.md @@ -0,0 +1,1040 @@ +# North Star Architecture: pg-delta & pg-topo + +- **Status**: North star — the project's target design, judged on technical + merit alone, deliberately ignoring migration cost and past choices +- **Date**: 2026-06-12 +- **Baseline for all code citations**: commit `115dde8` + (`pg-delta@1.0.0-alpha.28`, `pg-topo@1.0.0-alpha.1`) + +This document defines where the project is going. It is not a critique of how +it got here: the current design is the strongest in its class (it is the only +tool in the migra/pg-schema-diff family that treats `pg_depend` as the source +of ordering truth), and its accumulated PostgreSQL knowledge is the asset the +north star is built from. But the north star is derived from the problem, not +from the codebase — and it is a **clean-room library**: the old engine +donates assets (§5) and serves as a differential oracle (§9), and nothing +else. No format, API, or mechanism carries over for compatibility's sake. +§9 defines the build-and-cutover plan. When a local decision conflicts with +this document, this document wins or this document gets amended. + +--- + +## 1. The problem, stated purely + +Given two PostgreSQL schema states — each obtainable from a live database, +from SQL files, or from a serialized snapshot — produce a **minimal, +correctly ordered, reviewable, safety-classified** DDL script that transforms +one state into the other, **deterministically**, and be able to **prove** it +did. + +Five sub-problems follow from this statement, and any architecture in this +space must answer all five: state capture, comparison, action synthesis, +ordering, execution. Every serious tool (pg_dump/pg_restore itself, migra, +pg-schema-diff, atlas, skeema) converges on this staged shape; the +architectural freedom — and where this design differs from the current +codebase — is *within* the stages: how state is represented, where semantic +knowledge lives, who is trusted to elaborate PostgreSQL semantics, how cycles +are handled, and how correctness is established. + +Out of scope, permanently: data migrations (DML). The tool's contract is +schema state, not data movement. + +--- + +## 2. The two principles + +Everything in this design follows from two principles. + +### P1 — Postgres is the only elaborator + +The single deepest source of bugs in this tool class is reimplementing +PostgreSQL semantics: name resolution, type elaboration, dependency +inference, default normalization. The current codebase half-commits to this +principle (dependency edges come from `pg_depend`, never from parsing — the +right call) but still runs **three** semantic engines: + +1. catalog extraction (exact), +2. pg-topo's libpg-query static analysis (approximate — quoted-identifier + normalization, signature inference, builtin-type filtering are all + heuristic recoveries of what Postgres already knows), +3. declarative apply's round-based retry, which is "ask Postgres for + forgiveness, one error message at a time" + ([round-apply.ts:252-281](../../packages/pg-delta/src/core/declarative-apply/round-apply.ts), + worst-case O(n²) statement executions). + +The north star has **one**: every input state is resolved by an actual +Postgres instance. Live databases trivially; SQL files by applying them to an +ephemeral shadow database and extracting the result; the system's own output +by applying it to a scratch clone and re-extracting (§3.7). No static SQL +parser exists anywhere in the trusted path. Static analysis survives only as +a developer-experience layer (§4.4). + +### P2 — PostgreSQL knowledge exists in exactly two forms + +The per-type knowledge — what is alterable, what forces replacement, what +cascades implicitly, what locks, what rewrites — is the irreducible core of +the problem. Today it is smeared across **eight forms**: extractor SQL, Zod +models, per-type diff functions, 106 change classes, serializers, custom +sort constraints, cycle breakers, and post-diff normalization passes. Every +new PostgreSQL version or object type touches most of them. + +The north star has **two**: + +1. **Extraction queries** — catalog → normalized facts (§3.1). The existing + extractor SQL corpus, the most valuable code in the repository, survives + nearly verbatim as the fact producer. +2. **The rule table** — fact-deltas → actions (§3.4). + +Everything between — diffing, ordering, planning, verification — is generic +machinery that never changes when Postgres evolves. Supporting PostgreSQL 19 +means updating extraction queries and adding rules. **That is the +scalability that matters most over a decade: scaling with PostgreSQL's +evolution, not only with schema size.** + +A corollary that the rest of the design depends on: the two forms can only +stay two if state, diff, dependencies, and actions all share **one +granularity** — the fact. The moment state is stored at a coarser grain than +dependencies point at, hand-written translation code reappears (§8.7 shows +this is exactly what happened). The fact base below is therefore not a +storage detail; it is what makes P2 hold. + +--- + +## 3. The architecture + +```text +frontends: live DB ──(single-snapshot extract)──┐ + SQL files ──(shadow-DB elaboration)──┼──▶ FACT BASE + snapshot JSON ───────────────────────┘ normalized fact rows + parent & + dependency edges; content hash per + fact, Merkle rollups along parents + │ + generic diff: rollup-guided descent → fact-level DELTAS + (hash compare — zero per-type code, O(changed)) + │ + RULE TABLE ◀── the only per-type logic in the system + │ + ATOMIC ACTIONS (≈ 1:1 with deltas) + each declares produces / consumes / destroys, lock class, + rewrite risk, data-loss class, transactionality + │ + ONE dependency graph → one deterministic topological sort + (a cycle is a rule bug caught by CI, not a runtime repair job) + │ + optional COMPACTION: merge adjacent actions into idiomatic DDL + by joining facts along parent relations, only where no edge + crosses the merge (cosmetics; off for machines) + │ + PLAN = ordered deltas + fingerprints + safety report + │ + ┌── PROOF: apply to scratch clone, re-extract, hash-compare ──┐ + └──────────── apply (lock-aware segmented txns) ──────────────┘ +``` + +### 3.1 The fact base: a normalized, content-addressed relation + +**Normalized, not nested.** PostgreSQL's own catalog is relational — +`pg_attribute`, `pg_constraint`, `pg_attrdef` are rows referencing their +parents, not sub-documents of `pg_class`. The optimal state representation +mirrors that shape instead of re-imposing a document hierarchy on it: every +addressable thing — table, column, constraint, default, index, trigger, +policy, ACL entry, comment, security label, role membership, extension +membership — is its **own fact**: + +```ts +interface Fact { + id: StableId; // typed union: column:, constraint:, default:, acl:, comment: … + parent?: StableId; // hierarchy as a relation, not as containment + payload: NormalizedAttrs; // cooked attributes (names not attnums, canonical pg_get_*def) + hash: ContentHash; // content hash of the normalized payload +} + +interface FactBase { + facts: Map; + edges: Set; // pg_depend-derived + ACL/membership/ownership, as data + rollup: Map; // Merkle: parent hash folds children's hashes +} +``` + +For the operational invariants behind StableId addressability, PostgreSQL OID +carry, ACL equality, underscore metadata, and current-vs-target rename behavior, +see [identity-and-acl.md](identity-and-acl.md). + +Six properties define it: + +- **Typed identity, structured end-to-end.** IDs are a discriminated union + (`{kind: "procedure", schema, name, args}` …) everywhere — including at + the SQL boundary: extraction queries return identity *parts* (kind, + schema, name, args, parent) as structured columns and never synthesize + identity strings, so the encoding exists in exactly **one codec**, on the + library side. (The current system maintains the format twice — TS helpers + plus `format()` literals inside the dependency mega-query — a divergence + risk the structured boundary removes outright.) A canonical string + encoding — one escaping rule, one version tag — is derived from the + structure and appears only where persistence demands it: snapshots, plan + artifacts, fingerprints. In memory, graph keys are interned. Structure is + accessed by field, never recovered by regex. **No format is frozen**: + persisted artifacts carry a format version, making compatibility a + versioning concern instead of a design constraint. +- **One granularity everywhere.** Facts, dependency edges, diff deltas, and + actions all live at the same grain. A `pg_depend` edge that points at a + column points at a fact that *exists*; nothing maps between coarse state + and fine dependencies, because there is no coarse state. +- **Content addressing with Merkle rollups.** Every fact hashes its + normalized payload; every parent's rollup hash folds its children's + hashes **and its outgoing edge set**, so edge-only changes (an object + gaining or losing extension ownership, a shifted dependency) are visible + to hash comparison, not just payload changes. Two consequences: equality + at any granularity is one comparison, and diffing is **O(changed)** — + subtrees whose rollups match are skipped wholesale. Fingerprints, no-op + detection, drift detection, and caching are the same mechanism. Because + hash equality is the **sole** equality gate (a deep-compare fallback on + matches would reinstate the full-compare cost on the unchanged majority), + the digest must be collision-resistant — ≥128 bits over the canonical + payload encoding (e.g. BLAKE3 or truncated SHA-256), computed once at + extraction where it amortizes into I/O wait. **Hashes are computed over + identity-free payloads**: a fact's own name and its parent's name live in + its `id`, never in the hashed payload — this is what makes rename + detection (§4.1) possible. +- **Hierarchy is a view, not the storage.** Renderers and the compactor that + need "a table with its columns and inlineable constraints" *join* facts + along the parent relation at render time. The document shape exists where + documents are useful — output — and nowhere else. +- **Cross-cutting metadata is not special.** A comment is a fact whose + target is another fact's ID; so is an ACL entry, a security label, a + membership. There is no "scope" dimension in the system — one global rule + per metadata kind (§3.4) replaces per-object-type reimplementation. +- **Provenance is data.** "Owned by extension X" is an edge fact, not an + extraction-time filter. Downstream policy (integrations, vendor filtering) + decides what to do with provenance instead of extraction deciding what to + hide. + +The payload normalization doctrine carries over from today unchanged +(logical names instead of physical attnums; canonical `pg_get_*def()` output +as the comparison form where Postgres provides it): it answers *what we +know* correctly. The fact model changes *how it is keyed* — which is where +the current design pays (§8.7). One sharpening: **which attributes +participate in equality is itself per-kind knowledge** and lives in the +payload definition, not in diff logic. Example: extension versions drift +legitimately across environments, and today's diff deliberately ignores +them +([extension.diff.ts:53-62](../../packages/pg-delta/src/core/objects/extension/extension.diff.ts)) +even though `version` sits in the equality fields — under the fact model +that tolerance is declared once, by excluding `version` from the hashed +payload (or marking its attribute rule a no-op), instead of being +re-implemented inside an imperative diff. + +### 3.2 Frontends: one elaborator, three doors + +- **Live database**: all extraction queries run against one exported + snapshot (`pg_export_snapshot()` + `SET TRANSACTION SNAPSHOT` on N worker + connections — the pg_dump-parallel model). Parallel *and* consistent. + Today's extraction has neither property: ~28 queries race over a 5-connection + pool with no shared transaction + ([catalog.model.ts:352-381](../../packages/pg-delta/src/core/catalog.model.ts), + [postgres-config.ts:108](../../packages/pg-delta/src/core/postgres-config.ts)), + so the catalog and its dependency rows can disagree under concurrent DDL — + masked by the `unknown:` filter in + [graph-builder.ts:26-33](../../packages/pg-delta/src/core/sort/graph-builder.ts). +- **SQL files**: applied to an ephemeral shadow database, then extracted. + The desired state is whatever Postgres actually builds — no fuzzy + reference matching in the trusted path. This *is* the declarative + workflow. Four specifics the shadow loader owns: + - **Ordering is best-effort and fail-safe.** Files may not arrive + apply-ordered; the loader may pre-sort with the dev-layer static + analyzer and/or retry deferred statements in bounded rounds *against + the shadow*. This does not violate P1: ordering assistance can only + fail to build the shadow — a visible error before anything is + extracted — never corrupt the desired state, because Postgres remains + the elaborator. (The objection to round-retry was as a *production + apply engine* against live targets; on a throwaway shadow it is + harmless.) The pre-sort is the **statement reordering assist** — an + opt-in, degradable layer above the parser-free loader; see §4.4.1. + - **Body validation is restored before extraction.** Loading runs with + `check_function_bodies = off`; accepting the catalog without + re-checking would admit a typo'd routine body into the desired state — + and the proof loop would vacuously agree, since it applies the same + invalid body. After loading, the loader re-validates routine bodies + with checks on — the same final pass the current declarative engine + performs + ([round-apply.ts:445-448](../../packages/pg-delta/src/core/declarative-apply/round-apply.ts)). + - **Shared objects need cluster isolation.** Roles and memberships are + cluster-level: in a same-cluster scratch database, `CREATE ROLE` leaks + out of the shadow and can collide with existing roles. When declarative + files manage shared objects, the shadow must be an isolated ephemeral + cluster (throwaway instance/container); a same-cluster scratch database + is only safe for database-local schemas, and the loader enforces the + distinction. + - **Data statements are rejected, parser-free.** DML would succeed in the + shadow and then silently vanish from the schema-only plan. After + loading, the loader checks for observable data — any user table with + rows fails the run ("declarative files must not contain data + statements"). Detection by effect, not by parsing. +- **Snapshots**: the serialized fact base round-trips losslessly and is the + contract for offline diffing, fixtures, and caching. A flat fact relation + serializes, filters, and streams trivially — properties a nested document + model resists. + +### 3.3 Generic diff: rollup-guided descent to fact-level deltas + +Comparison is hash algebra, top-down: compare rollups; where they match, +skip the entire subtree; where they differ, descend and compare fact hashes; +where a fact differs, compare payload attributes. The output is the system's +central data type: + +```ts +type Delta = + | { verb: "add"; fact: Fact } + | { verb: "remove"; fact: Fact } + | { verb: "set"; id: StableId; attr: string; from: unknown; to: unknown } + | { verb: "link"; edge: DependencyEdge } // edge-only changes are deltas too: + | { verb: "unlink"; edge: DependencyEdge }; // provenance/ownership shifts (§3.9) +``` + +**Zero per-type diff code — structurally, not aspirationally.** The differ +never knows what a table is. Because state is normalized at fact grain, the +generic differ already produces "the default of `column:public.users.email` +changed" — there is no nested document for per-type code to re-walk. (In a +document model, "generic diff" can only say *this table changed somehow*, +and a second, hand-written diff engine per type must rediscover what — that +is precisely the 1,034 lines of +[table.diff.ts](../../packages/pg-delta/src/core/objects/table/table.diff.ts) +today, see §8.7.) + +Deltas — not statements, not class instances — are also what plans persist: +a delta list is diffable, replayable, and storable by construction. + +### 3.4 The rule table + +The only per-type logic in the system: structured data mapping deltas to +actions. + +```ts +// sketch — rules are data with functions in narrow slots only +interface KindRules

{ + kind: FactKind; + identitySql(p: P): string; + createTemplate(p: P, view: FactView): string; // bare object only (§3.5) + attributes: Record>; // per changed attribute: + // { alter: (from, to, view) => string } // in-place ALTER + // | { replace: true } // forces drop+create + // | { replace: (from, to) => boolean } // conditional (e.g. column type) + implicitlyRemoves(p: P, view: FactView): StableId[]; // cascade knowledge + lockClass(a: Action): LockClass; + rewriteRisk(a: Action): boolean; + dataLossClass(a: Action): DataLoss; +} +``` + +Three structural consequences of operating on fact deltas: + +- **Cross-cutting kinds get one rule, globally.** `comment(target) = text` + is a single rule for the entire system — not 21 per-object-type comment + implementations. Same for ACL entries, security labels, memberships. The + rule count tracks PostgreSQL's *concepts*, not the cross product of + concepts × object types. +- **Rules receive fact views, not documents.** A rule that renders + `CREATE TABLE` asks the view API for the children it may inline; the + hierarchy it needs is computed, not stored. +- **Multi-fact semantic atoms are delta-set rules.** `ALTER COLUMN … TYPE` + touches the column fact, possibly its default fact, and invalidates + dependent index/view facts. A rule may match a *set* of related deltas and + emit the composite action with the correct teardown/rebuild edges — the + declarative form of the knowledge that today lives in the `invalidates` + side channel and the expand-replace pass. + +Hard cases remain *conditional rules with more structure*, never imperative +escape hatches — escape hatches are how today's eight-forms situation +happened. The discipline that keeps rules honest: functions confined to +predicate/template slots, and the proof loop (§3.7) as a lie detector — a +rule that misdeclares cascades or alterability produces a state mismatch in +CI the day it is written. + +This replaces, outright: 21 per-type diff functions, 106 change classes, the +five per-`objectType` dispatch switches +([catalog.diff.ts:47](../../packages/pg-delta/src/core/catalog.diff.ts), +[change-utils.ts:10](../../packages/pg-delta/src/core/change-utils.ts), +[fingerprint.ts:88](../../packages/pg-delta/src/core/fingerprint.ts), +[change.types.ts:65](../../packages/pg-delta/src/core/change.types.ts), +[file-mapper.ts:59](../../packages/pg-delta/src/core/export/file-mapper.ts)), +and the per-type privilege/comment/security-label wrappers — today 256 +files / 31,162 LOC of source in `objects/` alone. + +### 3.5 Atomic actions: decomposition mirrors normalization + +Actions are ≈1:1 with deltas: bare `CREATE TABLE`; every constraint, +default, FK, index, grant, comment, ownership change as its own action. Each +action declares `produces` / `consumes` / `destroys` (fact IDs) plus the +safety metadata from its rule. **Maximal decomposition in the action space +is the same principle as normalization in the state space — one idea seen +from two sides.** A normalized state diffed at fact grain *naturally* yields +atomic actions; the impedance mismatch of decomposed actions over +document-shaped state cannot arise. + +This is also pg_dump's deep trick, adopted wholesale: **pg_dump has no +cycle-breaker module because at this granularity, cycles structurally cannot +form** for entire categories of dependencies (mutual FKs, FK-vs-drop +interleavings). Compound semantics stop being implicit: `DROP TABLE`'s +cascade becomes explicit fact-removals related by edges, ordered by the +graph like everything else. + +Failure-mode analysis is the argument for avoidance over repair: with +repair, a new cycle class means a wrong or unsortable plan in production and +a new hand-written breaker (the current registry — +[cycle-breakers.ts](../../packages/pg-delta/src/core/sort/cycle-breakers.ts): +`tryBreakFkCycle`, `tryBreakPublicationColumnCycle`, +`tryBreakPublicationFkConstraintDropCycle` — each added after a +field-discovered cycle; the codebase even fights itself, with post-diff +normalization *pruning* constraint drops for compactness that the drop-phase +breaker then *re-injects*). With avoidance, the worst case is a more verbose +script. Verbosity is recoverable; wrongness is not. + +### 3.6 One graph, one sort, then cosmetic compaction + +A single dependency graph over all atomic actions — drops, creates, alters +together. Edges come from three sources: the old state's edge facts +(teardown ordering: an action destroying X follows everything that consumes +X), the new state's edge facts (build ordering: an action producing Y +precedes everything consuming Y), and identity conflicts (remove of `X` +before add of new `X`). One deterministic Kahn pass (heap-based ready queue, +tie-break by kind weight → canonical identity) replaces a two-phase sort, an +`invalidates` side channel, and a repair loop: an in-place mutation is simply +an action that destroys the old fact and produces the new one, and the mixed +graph orders its dependents' teardown and rebuild around it naturally. + +**A cycle is a rule bug.** Cycle detection remains as an assertion with a +high-quality diagnostic, and as a property-test target — never as a runtime +repair subsystem. + +**Compaction** is the final, optional stage: merge adjacent actions into +idiomatic compound DDL (constraints inlined into `CREATE TABLE`, column +clauses folded) by joining facts along parent relations, only when no graph +edge crosses the merge boundary. It is a peephole optimization on an +already-correct sorted script — it can produce ugliness, never wrongness. On +by default for humans, off for machines. + +### 3.7 Plan and proof + +A plan is: ordered deltas (with their rendered actions) + source/target +fingerprints — which are now just fact-base rollup hashes, the same +machinery as equality — + the safety report aggregated from per-action +metadata (locks, rewrites, data loss). + +**The proof loop is the architecture's keystone.** Because any state can be +materialized and re-extracted, the planner can certify its own output. +Materialization has two forms: **template-cloning** (a cheap file copy — +right for CI, scratch sources, and quiesced databases) and **re-creation +from the extracted fact base** (render the fact base to DDL and apply it to +an empty scratch). The second form exists because +`CREATE DATABASE … TEMPLATE` requires the template database to have no +other active connections — unavailable against a live production source. +Proof against live targets therefore clones the *model* of the source, not +its files; what that certifies is "the plan correctly transforms the state +as extracted," which is the exact claim the proof makes anyway (extractor +blind spots are a separate risk with a separate defense, §4.3). + +The proof has two checks, because schema convergence alone has a blind +spot: + +1. **State proof.** Apply the plan to a clone of the source, extract, + hash-compare against the desired fact base. Zero diff = the plan produces + the right schema. +2. **Data-preservation proof.** A plan that drop+creates a table instead of + altering it converges to an *identical schema* — and destroys every row. + State proof cannot see this. So the clone is seeded with rows before + applying, and the proof asserts they survive wherever the plan claims + `dataLoss: none`. The data-loss column of the safety report stops being + a report and becomes a **verified claim**. + +One failure class remains invisible to any state-based proof: the +**convergent-but-non-minimal** plan (rebuilding an index unnecessarily loses +nothing and converges fine — it is merely catastrophic on a 2 TB table). +Minimality is asserted at the plan level — semantic assertions on action +kinds and budgets, never on SQL bytes — in the test architecture (§4.3). + +The proof loop inverts the correctness economy of the whole project: + +- **In CI**: it is the universal oracle behind both the scenario corpus and + the generative engine (§4.3). +- **In production**: proof-on-shadow is an optional pre-apply step for + high-stakes targets. +- **For the rule table**: rules that misdeclare cascades, alterability, or + data-loss classes are caught as proof failures the day they are written, + not as field bugs. + +Two safety fields need verification of their own, because state proof +cannot see them: **rewrite risk** is checked observationally on the clone +(a table whose `relfilenode` changed under an action that claimed no +rewrite is a failed proof), and **lock classes** are not provable by +outcome at all — they come from a vetted static table (PostgreSQL's +documented lock levels per DDL form) with targeted assertions, and the plan +presents them as *reported*, not certified. + +### 3.8 Execution + +Sequential, lock-aware, segmented: actions self-declare transactionality, so +the executor groups maximal transactional runs and isolates the exceptions +(`CREATE INDEX CONCURRENTLY`, …) instead of today's all-or-nothing single +concatenated script +([apply.ts:125-131](../../packages/pg-delta/src/core/plan/apply.ts), with its +own `TODO` admitting the gap at +[apply.ts:122](../../packages/pg-delta/src/core/plan/apply.ts)). Parallel DDL +execution stays rejected — `ACCESS EXCLUSIVE` locks make it a deadlock +machine, and the transaction is the atomicity contract. Per-statement error +attribution replaces the joined-string megaquery. + +### 3.9 Integrations: a policy layer over deltas + +Vendor-specific behavior (the Supabase integration today) is policy, not +engine: it decides *which* deltas a user sees and *how* actions render — +never how state is captured, diffed, or ordered. The policy layer has three +instruments, all data: + +- **Filtering = predicates over deltas and facts.** A filter rule matches + delta fields (kind, verb, identity) and fact context — including the + provenance edges of §3.1. "Hide everything owned by extension X" or + "never touch schema `auth`" become provenance/identity predicates instead + of extraction-time suppression: the engine sees everything; policy decides + visibility. The DSL is designed fresh for the fact model — typed + predicates over fact kinds, identities, provenance edges, and delta + verbs. What carries over from the current DSL is its proven *evaluation + model* (declarative rules, first-match-wins), not its pattern syntax. +- **Serialization options = rule parameterization.** Options like + `skipAuthorization` stop being per-change-class plumbing and become named + parameters that a serialize rule passes into the rule table's templates. +- **Baselines = fact-base subtraction.** An "empty catalog" for a managed + platform (what a fresh Supabase project already contains) is just a fact + base; "diff against the platform baseline" is set subtraction before + planning, replacing hand-maintained empty-catalog special cases. + +A vendor integration is therefore a data package: predicates + rule +parameters + a baseline snapshot. It is versioned, authored without touching +engine code, and tested with the same proof harness (policy scenarios in the +corpus, §4.3). + +--- + +## 4. Capabilities the design unlocks + +These are not cleanups — they are features the current architecture cannot +express: + +### 4.1 Rename detection, down to sub-entities + +Content addressing makes renames visible at every grain: a fact removed on +one side and added on the other **with the same content hash** is a rename +candidate. At object grain that means `ALTER TABLE … RENAME TO` +(data-preserving) instead of drop+create (data-destroying). At fact grain it +extends to the case that destroys data in practice: **column renames** — +same payload hash, same parent, different name. Governed by policy +(`auto` / `prompt` / `off`), since hash-equality is necessary but not +sufficient evidence of intent. Every tool in this class punts on renames; +here they fall out of the state representation. + +The mechanics depend on two §3.1 properties. Payload hashes are +identity-free (a fact's own name lives in its `id`, not in what is hashed), +so renaming a leaf changes its ID but not its hash. For *container* renames +— a renamed table changes every child's stable ID — matching uses the +identity-free **structural rollup**: payload hashes folded over the subtree +without any names, so the whole renamed subtree still matches. Honest +limit: payloads that reference *other* objects by name (an FK constraint +naming its referenced table) break hash-equality transitively, so detection +is strongest at the leaf and degrades gracefully — an undetected rename +falls back to today's drop+create behavior, never the reverse. + +### 4.2 Proof-certified plans + +"This plan was applied to a clone, produced a hash-identical desired state, +and preserved seeded data everywhere it claimed to" is a product claim no +comparable tool makes. It also gives drift +detection for free: comparing any environment against any snapshot is a +rollup-hash walk. + +### 4.3 Tests as data: one harness, a seed corpus, a generative engine + +The test architecture is P2 applied to testing itself: **one proof harness +(machinery) + scenarios (data)**. + +- **The seed corpus.** Every scenario is a named + `(DDL_A, DDL_B[, seed rows][, plan assertions])` fixture run through the + proof harness. The existing integration suite ports into this corpus + nearly mechanically — its dominant pattern (set up two databases, plan, + apply, assert convergence) already *is* the proof loop, hand-rolled per + test. What the corpus preserves is not the old assertions but the + **problem corpus**: every field-discovered edge case and PostgreSQL + semantic the project has been burned by — publication/FK-cycle drops, + policy recreation chains, attnum drift, partition juggling. After the + extractor SQL, it is the second most valuable asset in the repository, + and it is exactly what a greenfield engine would otherwise silently + regress on. The RED→GREEN discipline carries over: every new field bug + becomes a corpus entry that fails before the fix and is pinned forever + after. +- **The generative engine.** Property-based testing explores beyond the + corpus: generate schemas, generate mutations, roundtrip through the proof + loop — including the data-preservation check (§3.7) — and assert + fixpoint. Coverage grows with compute, not with test-authoring effort; + the corpus pins old ground so generation never re-loses it (the fuzzing + model: seed corpus + exploration). +- **Semantic plan assertions.** Where a scenario's point is *how* the goal + is reached — minimality, in-place alteration, risk class — the fixture + asserts action kinds and budgets ("this delta yields an `alter`-class + action, not a replace"; "≤ N actions"), never SQL bytes. Byte snapshots + die with the old engine: they assert emission shape, which decomposition + (§3.5) intentionally changes and the compactor may change again. +- **Differential testing until cutover.** Until retired, the old engine + is itself an oracle: run both engines over the corpus and assert + state-equivalent plans. Every divergence is a bug in one of them — and + either finding is valuable. +- **An independent extractor ring.** The proof loop reads both sides + through the same extractor, so an extractor blind spot (an unmodeled + reloption, a missed catalog field) passes proof **vacuously** — both + catalogs are equally blind. Two defenses: extraction fixture tests that + pin specific catalog facts per PG version (these survive from today), + and **pg_dump as an independent observer** — in CI proof runs, the + schema dumps of two states the proof calls equal must also be equal; a + divergence is an extractor gap found by a tool this project does not + maintain. + +What this replaces: the hand-written per-type matrix as the primary safety +net (127 unit-test files / 18,505 LOC in `objects/` die with the structures +they assert; 63 integration files × 3 PG versions × 15 CI shards = 45 jobs +collapse into corpus entries behind one harness). What survives unchanged: +extraction tests and catalog baselines (the extractor corpus survives, so +its tests do), and the Supabase integration tests (they assert filtering +policy, not engine behavior). + +### 4.4 An honest role for static analysis + +pg-topo exits the trusted path and becomes the developer-experience layer it +is genuinely good at: instant editor feedback, file ordering for humans +authoring declarative schemas, lint, cycle diagnostics. Its approximate +`ObjectRef` identity stops needing to agree with the exact engine, because +nothing downstream depends on it. (Consequence: the libpg-query WASM +dependency leaves the core install — +[package.json:80-89](../../packages/pg-delta/package.json) currently forces it +on every consumer.) The dev layer is today's pg-topo continuing as-is; its +evolution is deliberately **outside the staged build** (§9) — stage 7 treats +it as an optional, degradable assist, never a dependency. + +#### 4.4.1 Statement reordering assist for shadow loading + +**Context.** The shadow loader (§3.2) is parser-free by design: it sequences +*files* into the shadow in bounded retry rounds (a whole file is one +transaction, applied in textual order). That tolerates **cross-file** disorder +— a file is retried after the files it depends on build — but it cannot reorder +statements *within* a file. So `CREATE VIEW v …;` authored before +`CREATE TABLE t …;` **in the same file** never converges (the file is one +transaction and always fails the same way), and inline mutual foreign keys +across files need a manual `ALTER TABLE … ADD CONSTRAINT` split. The old +`pg-delta declarative-apply` avoided this by flattening every file into +statements and topologically sorting them — exactly the DX the declarative +workflow exists to provide, and a regression in the rewrite. + +**Decision.** Restore "author in any internal order, any split, it still loads" +**without** putting a parser back in the trusted path: + +- The fix is the **split**, not the sort. Splitting a file into one-statement + units already removes the intra-file regression, because the loader's + defer-and-retry rounds float a `CREATE VIEW` after its `CREATE TABLE` either + way. The topological pre-sort is only an optimization on top: fewer rounds, a + deterministic first pass, and cycle diagnostics. +- The assist returns **one statement per `SqlFile`** with a zero-padded ordinal + name prefix (`0007__schema/users.sql`). Fed that, the existing loader becomes + statement-granular with **zero core change** — its rounds, transaction-control + rejection, non-transactional handling, and mutual-FK hint all carry over, and + its per-round lexicographic name sort reproduces the assist's order. The + loader cannot tell it was fed sorted statements. +- Every input statement is preserved **exactly once** — including statements the + analyzer cannot classify and statements trapped in a cycle (the analyzer's + ordered output is a *total* order; cycle members land at a best-effort + position rather than being dropped). Statement text is carried **verbatim**; + output is **deterministic** for the same input. + +**Trust posture.** The assist is the developer-experience layer of §4.4, not a +trusted component. pg-topo is loaded through a guarded dynamic `import()` and +declared an *optional peer dependency*; merely importing the core never pulls +the libpg-query WASM parser — only calling the `@supabase/pg-delta/sql-order` +subpath does. Reorder is always-on in pg-delta-next's own CLI (`schema apply`, +with `--no-reorder` to reproduce raw file granularity); other consumers +(supabase/cli) opt in by adding pg-topo themselves and calling the subpath. +When the peer is absent the subpath throws a typed `ReorderUnavailableError` +with an install hint (and `canReorder()` lets a caller probe instead of catch). + +**Failure UX.** On a non-converging load the shadow's real Postgres errors are +authoritative. When the assist ran and flagged a **shadow-load cycle**, its +members are attached as a clearly-labeled, advisory static-analysis hint +(`suspected cycle a → b → a`) on top of the PG error — the assist annotates a +failure Postgres already produced, it never decides the load failed. A separate +`schema lint` command surfaces the analyzer's diagnostics for proactive +authoring, deliberately kept **out of the apply path** so apply stays +Postgres-truth. + +**Consequences.** Correctness lives in the split plus the shadow's rounds; the +assist can degrade to nothing (uninstalled, or `--no-reorder`) and the loader +still works at file granularity. The only state the design must guard against — +the subpath imported without the peer declared — is covered by the guarded +import and the typed error. + +**Alternatives rejected.** A `preserveOrder` flag on the loader (rejected: keep +the core blind to whether input was sorted; the ordinal name convention carries +order instead). A separate package that structurally guarantees pg-topo +(rejected: one guarded subpath plus a typed error already covers the only bad +state). Re-parsing `pg_get_expr()`-style output to recover references in the +trusted path (rejected by P1; the assist is dev-layer only). + +### 4.5 Packaging that falls out instead of being debated + +The architecture induces the package shape: a lean core (`pg`, `zod`-class +deps only) exposing the layers — fact base / diff / plan / proof / apply — +as independently usable entry points; pg-topo as a dev tool; the CLI as a +consumer of the public API. No further package engineering is required to +get a WASM-free, embeddable core. + +Two clarifications so the dependency story is airtight: the `pgdelta` +binary ships from the CLI package, **not** from the core library — making +pg-topo optional can never strand a CLI user, because no install that +provides the binary lacks its dependencies. And the north-star declarative +path needs no pg-topo at all (the shadow frontend replaced the +static-analysis engine, §3.2); only explicitly dev-facing commands (lint, +file-ordering help) touch it, and those degrade with a clear install hint +rather than failing obscurely. + +The new library ships as a new major (or a new package name — a product +decision, not an architectural one). The existing library enters +maintenance and is retired at the cutover bar (§9). + +--- + +## 5. Imported assets: what the new library takes from the old + +This is a clean-room build, but not amnesia. The old system's *knowledge* +is imported as data and SQL; none of its *mechanisms* — and none of its +*formats* — constrain the new design: + +- **The extractor SQL corpus** (`.model.ts` queries) — years of + accumulated `pg_catalog` knowledge across PG versions; the single most + valuable asset in the repository. It becomes the fact producer; what + changes is the keying of its output (fact rows instead of nested + documents), not its content. +- **The integration scenario corpus** — the distilled record of every + field-discovered failure, and the second most valuable asset after the + extractor SQL. The scenarios survive as seed-corpus fixtures for the + proof harness (§4.3); only their implementation-coupled assertions and + byte-level SQL snapshots are retired. +- **The `pg_depend` doctrine** — deepened from "the diff path's source of + truth" to "the only semantic engine, period" (P1). +- **The normalization knowledge** in `stableSnapshot()` overrides (physical + attnums vs logical names, canonical `pg_get_*def()` comparison forms) — + it becomes the fact payload normalization, i.e. the content-hash surface. +- **Stable identity** as a concept — redesigned, not carried: structured + end-to-end, with a version-tagged canonical encoding used only in + persisted artifacts (§3.1). Nothing about today's string format is + retained. +- **The plan + fingerprint product contract** — the *concept* is kept + (plans as reviewable, version-controllable artifacts with drift + detection); the format is new: ordered deltas plus rollup-hash + fingerprints, version-tagged from v1. +- **Safety/risk classification** — generalized into per-action metadata + supplied by the rule table. +- **Policy-as-data for integrations** — the filter/serialize DSL's proven + ideas (declarative rules, first-match-wins evaluation) are kept; the + surface itself is redesigned for facts and deltas with no compatibility + with the old pattern syntax; specified in §3.9. + +## 6. What is retired + +| Retired | Replaced by | +|---|---| +| Document-shaped catalog models (columns/constraints/privileges/labels nested in `dataFields`, [table.model.ts:211-230](../../packages/pg-delta/src/core/objects/table/table.model.ts)) | normalized fact rows with parent relations + Merkle rollups (§3.1) | +| 21 per-type diff functions + 106 change classes (256 files / 31,162 LOC) | rollup-guided generic diff + rule table (§3.3–3.4) | +| Per-type comment/privilege/security-label implementations (the "scope" axis) | one global rule per metadata kind over target-referencing facts (§3.4) | +| Two-phase sort + `invalidates` side channel + cycle breakers + dependency filter + post-diff normalization-as-repair | one mixed graph, decomposition by construction, cosmetic compaction (§3.5–3.6) | +| Round-based declarative apply ([round-apply.ts](../../packages/pg-delta/src/core/declarative-apply/round-apply.ts)) | shadow-DB elaboration through the one plan path (§3.2) | +| pg-topo in the apply path | pg-topo as dev-experience layer (§4.4) | +| String stable-ID re-parsing ([expand-replace-dependencies.ts:419-425](../../packages/pg-delta/src/core/expand-replace-dependencies.ts)) | typed `StableId` (§3.1) | +| `JSON.stringify` equality + triple extraction per apply | content hashes + single-snapshot extraction + proof-as-opt-in (§3.1, §3.2, §3.7) | +| Hand-written per-type test matrix as primary safety net; byte-level SQL snapshots | one proof harness over the seed scenario corpus + generative exploration + semantic plan assertions (§4.3) | + +## 7. Honest costs + +- **Shadow DB requirement.** The SQL-file frontend and the proof loop need a + reachable Postgres. Template-cloned databases make it cheap; embedded + options (PGlite) may eventually make it serverless, but extension and + version parity rule it out of the trusted path today. Environments that + cannot reach any scratch database lose the declarative frontend — that is + a real constraint and is accepted. +- **Fact-count growth.** Normalization multiplies object count ~10–30× (a + 10k-table schema becomes a few hundred thousand facts). Memory impact is + trivial; diff cost tracks *changes*, not facts, because of rollup + skipping. The real cost is conceptual: contributors think in facts and + views instead of convenient pre-joined documents. +- **Rule-table expressiveness risk.** The gnarliest ALTER semantics resist + tabularization; rules can degenerate into code-in-disguise. Mitigations: + functions confined to predicate/template slots, delta-set rules for + multi-fact atoms, and the proof loop as a lie detector. If a kind + genuinely cannot be expressed, the honest response is a structured + sub-rule vocabulary, not an imperative escape hatch — escape hatches are + how the current eight-forms situation happened. +- **Verbosity when compaction is conservative.** Cosmetic by construction; + the compactor can improve forever without correctness risk. +- **Proof costs an extra apply + extract.** Optional per environment; cheap + with templates and hashes. +- **`pg_depend` does not see routine bodies.** PL/pgSQL and string-literal + `LANGUAGE SQL` bodies are opaque to Postgres's dependency tracking (only + SQL-standard `BEGIN ATOMIC` bodies get edges), so the graph cannot order + a routine after a table its body references. The strategy is layered: + plans run with `check_function_bodies = off` (the diff path already + emits this — + [create.ts:350](../../packages/pg-delta/src/core/plan/create.ts)); PL/pgSQL + is late-bound at runtime, so missing edges rarely matter for it; + SQL-language ordering gaps surface in the proof loop as a failed clone + apply — before production, not in it; and the dev layer (§4.4) can lint + bodies for ordering hints. What is explicitly not done: re-parsing + bodies in the trusted path to synthesize edges (P1). +- **Consumers migrate once.** A clean-room library means a new major: new + API, new plan/snapshot formats (version-tagged), new SQL output shape. + There is no in-place compatibility layer — that is the point. The cost + is bounded by the cutover bar (§9): the old library keeps working, in + maintenance, until the new one has proven itself against the corpus, the + differential oracle, and the generative soak. **State equivalence — + never byte equivalence — is the invariant throughout.** + +## 8. Why the current design cannot simply be tuned into this + +The findings below (all verified at `115dde8`) are not isolated bugs — each +is a direct consequence of an architectural commitment the north star +removes. (Findings are cited elsewhere in this document as §8.1–§8.7.) + +1. **No shared extraction snapshot** (consistency bug) — consequence of + extraction being a query fan-out rather than a state capture. §3.2. +2. **O(n²)-flavored equality and sort costs** + ([base.model.ts:70-75](../../packages/pg-delta/src/core/objects/base.model.ts), + [graph-builder.ts:26](../../packages/pg-delta/src/core/sort/graph-builder.ts), + [topological-sort.ts:33-52](../../packages/pg-delta/src/core/sort/topological-sort.ts)) + — consequence of state not being content-addressed and the graph being + rebuilt as repair. §3.1, §3.6. +3. **31K LOC of structurally identical per-type code** — consequence of + knowledge-in-eight-forms; every object type re-states the same machinery. + §3.4 / P2. +4. **A growing cycle-breaker registry, each entry a field-discovered bug** — + consequence of compact-by-default emission + repair-on-detection. §3.5. +5. **Three semantic engines that must agree but cannot** (exact catalog, + approximate parser, retry loop) — consequence of not committing to P1. + §3.2, §4.4. +6. **45-job CI matrix defending hand-written cases** — consequence of + lacking a proof loop; correctness must be asserted per-case because it + cannot be checked per-run. §3.7. +7. **Three granularities that don't agree.** Equality lives at the document + level (`dataFields` nests columns, constraints, privileges, labels — + [table.model.ts:211-230](../../packages/pg-delta/src/core/objects/table/table.model.ts)), + dependencies live at the sub-entity level (`pg_depend` targets columns + and constraints), and actions live at the statement level. Most + hand-written code is translation between the three: + [table.create.ts:36-43](../../packages/pg-delta/src/core/objects/table/changes/table.create.ts) + re-enumerates column IDs out of the nested array so the graph can see + them; the graph builder maintains reverse multimaps to map IDs back onto + changes; and the 1,034 lines of + [table.diff.ts](../../packages/pg-delta/src/core/objects/table/table.diff.ts) + exist to re-discover *which nested part* of a "changed" document actually + changed — a second, per-type diff engine inside each document. The fact + base removes the translation by removing the disagreement: one + granularity for state, dependencies, deltas, and actions. §3.1, §3.3. + +## 9. The path: build clean, prove against old, cut over + +The new library is built **clean-room beside the old one**. No engine code +is shared. The old engine has exactly two roles: *asset donor* (extractor +SQL, normalization knowledge, scenario corpus, safety tables — §5) and +*differential oracle* (it runs unmodified next to the new engine until +cutover). There are no byte-compatibility gates anywhere — every stage +ships behind proof-based gates only. Repository discipline (changesets, +RED→GREEN regressions) applies as usual. + +Each stage's build — deliverables, old-codebase mining maps, pitfalls, and the +gate in checkable form — is recorded in [the build log](../build-log.md). + +| # | Stage | Builds | Gate | +|---|---|---|---| +| 0 | **The red test suite, first**: scenario corpus ported from the existing integration tests; target API as typed stubs; fixture-validity layer green from day one; engine tests red on `NotImplementedError`, pinned by an explicit list; old-engine baselines captured | §4.3 | Fixture validity green on all PG versions; 100% of old integration files accounted for; every red explained | +| 1 | Identity codec + fact-base core: typed IDs, identity-free payload hashing, Merkle rollups (facts + edges), snapshot format v1 (version-tagged) | §3.1 | Property tests: codec round-trip, rollup algebra, hash stability | +| 2 | Extractor port: re-key the extractor SQL corpus to return structured identity parts and fact rows + edges; snapshot-consistent parallel capture | §3.1–3.2 | Extractor fixture ring per PG version; pg_dump observer; content cross-check against old-engine catalogs | +| 3 | Proof harness + corpus: `provePlan` (state + data-preservation checks; template-clone materialization — the render-from-fact-base form lands in stage 6); port the integration scenarios as the seed corpus; stand up the differential runner against the old engine | §3.7, §4.3 | Harness proves extract → clone → re-extract fidelity and snapshot round-trips over the corpus | +| 4 | Generic diff: rollup-guided descent emitting fact and edge deltas | §3.3 | Fixture diffs; `diff(A, A) = ∅` generatively | +| 5 | The planner: rule table, atomic actions, one-graph sort, compaction | §3.4–3.6 | Corpus green under proof; differential vs old engine (state-equivalent plans, divergences triaged); generative soak; zero cycles | +| 6 | Execution + plan artifact v1: ordered deltas, rollup-hash fingerprints, safety report (proven / observed / vetted tiers) | §3.7–3.8 | End-to-end proof on the corpus, including segmented non-transactional actions | +| 7 | Frontends: shadow-DB SQL loader (fail-safe ordering, body validation, cluster isolation, DML rejection); snapshot frontend | §3.2 | Declarative scenarios in the corpus; loader rejection tests | +| 8 | Policy layer: DSL v2 over facts/deltas; Supabase integration as a data package with platform baseline | §3.9 | Policy scenarios; baseline-subtraction proof against a real platform image | +| 9 | Renames (leaf + structural-rollup matching, policy-gated); declarative export; drift detection surfaced; public API and CLI finalized | §4.1, §4.2, §4.5 | Rename corpus; export round-trip `load(export(fb)) ≡ fb`; API review | +| 10 | **Cutover at the parity bar**: full corpus green; differential clean-or-explained; generative soak quota met; extractor ring green on all supported PG versions. Old library enters maintenance; consumer migration guide ships | — | The parity bar itself | + +**Supported PostgreSQL versions.** The build targets the set the old +engine's CI matrix tests today: **15, 17, 18** (16 is absent because the +current product never supported it; that is inherited, not re-decided). +Adding or retiring a version is a product decision recorded in this decision +log; mechanically it is P2's promise — extraction-query updates, rules, and +a new fixture-ring lane (stage 2 owns the lanes; stage 10's bar runs on the +then-current set). + +Stage 0 builds the definition of done before any engine code exists — the +corpus is the contract, and "all red" is sound because red is pinned to +mean *engine missing*, never *fixture broken* (the fixture layer is green +from day one). Stages 1–4 are bottom-up construction with no user-visible +surface; 5–6 are the engine; 7–9 are the product; 10 is the switch. The old +engine is never modified beyond what the differential runner needs. +Consumers migrate once, at cutover, to a new major — there is no in-place +compatibility layer. + +## 10. Guardrails for implementers + +This document will be executed stage by stage, likely by different people +and different agents, each holding only part of the context. The invariants +below are absolute. When one seems to block progress, the correct move is to +amend this document (with a decision-log entry) — never to make a local +exception: + +1. **Identity has exactly one codec.** Extraction SQL returns structured + identity parts — it never synthesizes identity strings; only the + library-side codec produces the canonical encoding, and every persisted + artifact (snapshot, plan, fingerprint) carries a format version. + Hand-building an identity string anywhere, TS or SQL, reintroduces the + §8.7 disease. Formats are never frozen — they are versioned; freezing + was the old library's constraint, not this one's. +2. **No static SQL parsing in the trusted path** — extraction, diffing, + planning, proof, apply. If a feature seems to need a parser, it belongs + in the dev-experience layer (§4.4) or the design is wrong (P1). +3. **No imperative escape hatches in the rule table.** If a kind cannot be + expressed, extend the rule vocabulary with a structured sub-rule form and + record it in the decision log. One escape hatch is how eight forms of + knowledge happen again (P2). +4. **A cycle is always a rule or emission bug.** Fix the rule or decompose + the emission. Adding a cycle breaker — any runtime repair of the graph — + is forbidden (§3.5–3.6). +5. **No planner work before the proof harness exists** (stage 3 lands + before stage 5 starts). The proof loop is the safety net; building the + trapeze act first is not faster. +6. **Never assert SQL bytes in new tests.** Assert state (proof), data + survival (proof), or action kinds/budgets (semantic plan assertions, + §4.3). Byte assertions re-couple tests to emission shape. +7. **Gates are non-negotiable.** Every stage ships behind its §9 gate — + proof, differential, fixture ring; never byte equivalence. Cutover + happens only at the parity bar. Every behavior change carries a + changeset and a RED→GREEN regression per repository policy. +8. **Granularity is one.** State, dependencies, deltas, and actions all live + at fact grain. Any new structure that nests sub-entities back into + documents — or any map that translates between grains — is §8.7 + returning. +9. **The proof loop is the arbiter.** A change that passes state + + data-preservation proof over the corpus and the generative engine is + presumed correct; a change that cannot be proven that way is presumed + wrong, however plausible it looks. + +This document wins conflicts. If implementation contact shows an invariant +is genuinely wrong, amend the document first — decision-log entry, then +code. + +## 11. Decision log + +- **2026-06-12 (a)** — This document supersedes the earlier + incremental-roadmap framing of itself. The maintainer's direction: define + the **technical optimum without regard to past choices**; it is the + project's north star. Earlier scoping decisions made under the incremental + framing (e.g. "moderate packaging") are superseded where this document + derives a different answer; the shadow-DB convergence decision is + unchanged and now central (P1). +- **2026-06-12 (b)** — Following maintainer review ("are the change-based + data structures and captured data the optimum?"), the fact base is + specified as **fully normalized with Merkle rollup hashing** (§3.1), the + diff output as fact-level deltas (§3.3), and cross-cutting metadata as + ordinary target-referencing facts. This unifies state, dependency, delta, + and action granularity — the property that makes P2's "two forms of + knowledge" structurally true (§8.7) — and extends rename detection to + sub-entities (§4.1). The captured *data* (extractor SQL, normalization + doctrine) is affirmed as correct; the reshaping concerns its keying, plus + provenance (extension membership) captured as edge facts instead of + extraction-time filtering. +- **2026-06-12 (c)** — Following maintainer review ("should we keep the + current tests in some form?"), the test architecture is specified (§4.3): + the integration suite's *scenarios* are kept as the seed corpus of the + proof harness; their implementation-coupled assertions and byte-level SQL + snapshots are not ported. The proof loop gains a **data-preservation + check** (§3.7), closing the convergent-but-destructive blind spot of + schema-state proof; convergent-but-non-minimal plans are covered by + semantic plan assertions. The old engine serves as a differential oracle + until retired. +- **2026-06-12 (d)** — Pre-handoff review: added the integration policy + layer specification (§3.9) and the implementer guardrails (§10), in + preparation for implementation planning being delegated phase by phase. + The document is considered ready; further refinement happens through + implementation contact and decision-log amendments. +- **2026-06-12 (e)** — External review (PR #297, 13 findings — all + verified against the codebase; every *finding* accepted, one suggested + *mitigation* replaced with a different fix, noted at the end): + collision-resistant identity-free hashing with edges folded into rollups + and `link`/`unlink` deltas (§3.1, §3.3); shadow-loader specifics — + fail-safe ordering, restored body validation, cluster isolation for + shared objects, parser-free DML rejection (§3.2); fact-base re-creation + as the proof materialization for live sources, since `TEMPLATE` cloning + requires a connection-free source (§3.7); narrowed safety-certification + claims — data loss proven, rewrite risk observed via `relfilenode`, lock + classes vetted-not-proven (§3.7); independent extractor ring with + pg_dump as outside observer against vacuous proof (§4.3); rename + mechanics via identity-free structural rollups with the + cross-reference caveat (§4.1); CLI packaging clarification (§4.5); + per-kind equality-surface policy with the extension-version example + (§3.1); `pg_depend` routine-body blind-spot strategy (§7). One reviewer + suggestion was rejected on technical grounds: a deep-compare fallback on + hash matches would reinstate the full-compare cost on the unchanged + majority — the accepted fix is a collision-resistant digest instead. +- **2026-06-12 (f)** — Maintainer direction: full breaking changes allowed; + this is a **brand-new library**, not an evolution of the current one. + Revised accordingly: the stable-ID wire format is no longer frozen — + identity is structured end-to-end with a single library-side codec and + version-tagged canonical encoding, and extraction SQL returns identity + parts instead of synthesizing strings (§3.1, guardrail 1); the policy DSL + is designed fresh for facts/deltas rather than carrying the old pattern + syntax (§3.9); plan and snapshot formats are new and version-tagged + (§5); §9 is rewritten from in-place migration phases to a clean-room + build-and-cutover plan — byte-identical SQL gates are gone entirely, + replaced by proof/differential/fixture gates; consumers migrate once, at + the cutover parity bar (§7). Entries (a)–(e) above predate this and any + in-place-migration framing in them is superseded. +- **2026-06-12 (g)** — Per-stage implementation documents authored + (since consolidated into [the build log](../build-log.md)), and the + path gained **stage 0** at the maintainer's suggestion: the test suite is + built first — corpus ported from the existing integration tests, target + API stubs, old-engine baselines — with one refinement: red must mean + *engine missing* (pinned `NotImplementedError` list), never *fixture + broken* (the fixture-validity layer is green from day one). +- **2026-06-12 (h)** — Final pre-handoff review (two independent audit + passes over all 12 documents). Consistency fixes: §3.1 property count; + §3.6 tie-break aligned with stage 5 (kind weight → canonical identity — + no phase tier in a one-graph sort); §9 stage-3 row no longer overstates + materialization (render-from-fact-base is stage 6); §9 stage-9 row gains + export + drift; stage-doc dependency headers and wording corrected. + Ownership gaps closed: drift detection (stage 9), the + dangling-requirement-from-policy check (stage 5, graph build), the vetted + lock-class table (stage 5), the ≥10k benchmark fixture (stage 5), + generator-coverage growth (stage 5 + stage 10 bar), the new package's CI + lane (stage 0), plan-artifact version rejection (stage 6), shared + diagnostic type (stage 1), and the supported-PG-versions policy (§9). + Rename-policy default is no longer open — stage 9 sets `prompt` (CLI) / + `off` (library), revisit after field experience. +- Open questions intentionally left to their stages: compaction's default + aggressiveness (stage 5), how aggressively to prune the ported corpus + once generative coverage matures (stage 3), new-major vs new-package-name + (stage 10, product decision). + +--- + +## Appendix: baseline metrics (commit `115dde8`) + +| Metric | Value | +|---|---| +| pg-delta source (src/) | ~88K LOC, ~511 files | +| `src/core/objects/` | 256 source files / 31,162 LOC; 127 test files / 18,505 LOC | +| Object-type directories / concrete change classes | 21 / 106 | +| `depend.ts` | 1,895 LOC, 2 functions, 30+ CTEs | +| `sort/` | ~4.6K LOC across 15 files (incl. 3 cycle breakers) | +| Integration tests | 63 files × 3 PG versions, 15 CI shards (45 jobs) | +| pg-topo source | ~4.4K LOC, 18 modules; 38 statement classes, 28 object kinds | +| Library hard deps | `pg`, `zod`, `@ts-safeql/sql-tag`, `picomatch`, `debug`, `@stricli/core`, `chalk`, `@supabase/pg-topo` (→ libpg-query WASM) | diff --git a/docs/build-log.md b/docs/build-log.md new file mode 100644 index 000000000..56e2ade2e --- /dev/null +++ b/docs/build-log.md @@ -0,0 +1,149 @@ +# Build log — how pg-delta-next was built + +A light record of the clean-room rebuild: the stages it was built in, the +hardening and review passes it went through, and the decisions made along the +way. It is a **history**, not a description of the present — for how the engine +works today, trust the code and [architecture/](architecture/). + +> This consolidates what used to be ~20 separate stage/review files. The detailed +> originals live in git history if you need to dig; this is the map. + +--- + +## Built in stages, test-first + +The rebuild was executed as a sequence of gated stages — the test corpus and +proof harness were stood up *before* the engine, so correctness was measured from +day one rather than asserted after the fact. + +| Stage | What it delivered | +|---|---| +| 0 — test suite | Scenario corpus, proof-harness contract, and differential baselines — red by design until the engine existed. | +| 1 — fact core | The typed data layer: stable-id codec, facts, edges, content hashing, Merkle rollups, snapshot format. | +| 2 — extractors | Catalog → fact base, captured in one consistent snapshot, with `pg_depend`-sourced dependency edges. | +| 3 — proof harness | The safety net: `provePlan`, the data-preservation and rewrite checks, the differential runner. | +| 4 — diff | The generic, rollup-guided, zero-per-kind diff emitting fact deltas. | +| 5 — planner | The rule table, atomic actions, the one mixed graph, the deterministic sort, and compaction. | +| 6 — execution | Plan artifact v1 (versioned, round-trippable) and the segmented, lock-aware, fingerprint-gated executor. | +| 7 — frontends | The shadow-DB `.sql` loader (bounded-round ordering) and the snapshot frontend. | +| 8 — policy | Policy DSL v2 (typed predicates, filter/serialize rules, baseline subtraction) and the Supabase package. | +| 9 — renames & API | Rename detection over structural rollups, the reviewed public API, and the CLI. | +| 10 — cutover | *Forward-looking* — the parity bar that gates switching consumers over (see [roadmap/post-v1.md](roadmap/post-v1.md)). | + +Result (rewrite-era snapshot at first engine-complete cut): **−79% source LOC, +one rule table instead of ~100 change classes, and a correctness guarantee the +old engine never had.** Current measured size (three budgets + corpus count) +lives in [overview.md](overview.md) §4 — do not treat the rewrite-era −79% as +today’s package total. + +--- + +## Hardened against the north star + +After the first end-to-end engine, an 8-item hardening pass closed the gaps +between the initial implementation and the target design — **all shipped**: + +1. **Explicit projection** — plan/prove fingerprints derive from the *projected* + desired state, not the raw one. +2. **Proof coverage** — proof reports per-table content modes + (`fingerprint`/`count`/`none`) and deterministic fingerprints on seeded tables. +3. **Typed predicates** — `edgeKind` on edge predicates; `validatePolicy` rejects + unknown id-fields. +4. **Satellite consistency + provenance flip** — a filtered object's satellites + (comments, ACLs) filter with it; extension members are observed as facts with + `memberOfExtension` edges and projected out by default (sub-entity and rare + member kinds remain documented deferrals). +5. **Enum boundary** — `commitBoundaryAfter` unconditionally closes a segment + (`ALTER TYPE … ADD VALUE` before its first consumer). +6. **SQL-file robustness** — per-file transactional wrapping with a + non-transactional fallback. +7. **Planner split** — graph build, tie-break, compaction, and safety-report into + their own module. +8. **Docs normalization** — coverage sorted into implemented / simplification / + excluded buckets. + +--- + +## Measured against the old engine + +A triage of the **134 tracked issues** in the *database diffing 2.0* project +against the new engine found that the architecture *dissolves whole classes of +bug* rather than requiring per-issue fixes: + +- **~90 resolved by construction, corpus, or policy** — the fact model, the + one-graph sort, the single-snapshot extractor, the missing-requirement guard, + and the proof loop close most field bugs structurally; the Supabase policy DSL + handles the platform-specific cluster. +- **~13 substrate-ready** — the engine provides the mechanism; the consumer/CLI + surface is the remaining work (now tracked in [roadmap/post-v1.md](roadmap/post-v1.md)). +- **One genuine design gap** — stateful-extension *intent*. Phase A (stop + dropping extension-managed data) shipped; Phase B (replay on rebuild) is a + scoped, blocked follow-up + ([roadmap/extension-intent-phase-b.md](roadmap/extension-intent-phase-b.md)). + +--- + +## Reviewed, repeatedly + +The branch went through an independent **v1-readiness review** plus a rapid series +of **branch and follow-up reviews** over 2026-06-13 → 2026-06-16. The reviews were +adversarial and found real issues; **all correctness findings have shipped.** The +notable ones: + +- **Unmodeled-kind detection** (the one P0 from the readiness review) — the + extractor used to silently omit user objects in kinds it doesn't model (casts, + operators, text-search, statistics, languages, transforms). Now a + provenance-aware **catalog completeness check** reports them as `unmodeled_kind` + diagnostics, and `--strict-coverage` refuses to act while they exist. *Shipped.* +- **Diagnostics surfaced + `Policy.baseline` fail-loud** — extraction diagnostics + now print on every CLI command; a declared-but-unresolved baseline throws + instead of silently no-op'ing. *Shipped.* +- **Projected-target planning** — action emission used the unprojected target and + could reference filtered-out dependencies; emission now runs against the + projected view. *Shipped.* +- **Rename + ownership correctness** — several edge cases where an accepted rename + combined with an owner/role change dropped a role too early or formed a cycle; + role-rename identity is now carried through owned objects. *Shipped.* +- **SQL loader hardening** — rejects self-managed transactions + (`BEGIN`/`COMMIT`/`SAVEPOINT`, `BEGIN ATOMIC` bodies excepted) and fails loudly + on round-budget exhaustion rather than loading partially. *Shipped.* + +Remaining review items were optimizations (fewer DDL statements around role +renames) and deeper integration coverage, not correctness blockers — folded into +[roadmap/post-v1.md](roadmap/post-v1.md). + +--- + +## Two architectural refinements that landed during review + +- **The managed view** — scope, ownership, and applier capability were unified + into one `resolveView` applied identically before plan and prove. This replaced + the `skipSchema` / `skipAuthorization` escape-hatch parameters with catalog + facts and ownership-as-edge. + → [architecture/managed-view-architecture.md](architecture/managed-view-architecture.md) +- **Integration profiles** — `IntegrationProfile` / `ResolvedProfile` made "what + the engine manages" a first-class object, threaded through extract → plan → + prove → apply so the invariant *plan == prove == apply* holds by construction + (and the plan artifact records the profile that produced it). *Shipped.* + +--- + +## The first performance pass + +Correctness was v1's gate, but profiling the extractor turned up a clean win: a +single correlated `pg_depend` resolver query was **86% of extraction time**. +Rewriting it set-based made the query **7× faster** and extraction **4.2× faster** +overall, with byte-identical output (gated by an edge-set oracle + the full +corpus on PG 14–18). Parallel snapshot extraction was *re-profiled and +deferred* — the resolver is now one unsplittable query that caps the parallel +ceiling below 2×. Details and the memory roadmap: +[roadmap/post-v1.md](roadmap/post-v1.md). + +--- + +## Where things stand + +The engine and its correctness machinery are **v1-ready**; what remains is +running the validation gates to green *at scale* and publishing the scope +statement. See [roadmap/v1.md](roadmap/v1.md) for the cut plan and +[roadmap/v1-evidence.md](roadmap/v1-evidence.md) for the evidence record to fill. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..65d1d9934 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,300 @@ +# Getting started with pgdelta + +`pgdelta` turns one PostgreSQL schema into another. You give it a **source** +(where you are) and a **desired** state (where you want to be); it produces an +ordered DDL migration, **proves** that migration converges with your data intact, +and applies it. + +You can drive it two ways: + +- **The CLI** — `pgdelta ` for diffing, planning, proving, applying, + and a declarative `.sql`-files workflow. +- **The library** — import the pipeline functions (`extract`, `plan`, `apply`, + `provePlan`, …) and compose them yourself. + +> **Status:** `@supabase/pg-delta` is this clean-room engine, published as a +> **breaking-change alpha** (`1.0.0-alpha.x`). It replaced the legacy engine +> outright — the CLI, the public API, and the persisted artifact formats are +> all new; nothing carries over. See [overview.md](overview.md) for the why. + +--- + +## The mental model + +Everything flows through one pipeline: + +``` +extract read a database into a "fact base" (one fact per object: table, + column, constraint, policy, grant, … — all content-addressed) + ↓ +diff compare two fact bases → a list of deltas (add / remove / set / link) + ↓ +plan turn deltas into ordered, atomic DDL actions (one dependency graph, + one deterministic sort — no cycle-breakers) + ↓ +prove apply the plan to a throwaway clone, re-extract, and check the result + equals the desired state AND seeded rows survived ← the safety net + ↓ +apply run the plan against the real target (fingerprint-gated) +``` + +The desired state can come from **another live database** or from your +**`.sql` files** (loaded into a scratch "shadow" database first — PostgreSQL, +not a SQL parser, elaborates them). Either way the same pipeline runs. + +--- + +## CLI + +### Install / run + +From the monorepo (Bun): + +```bash +bun install +cd packages/pg-delta +bun run src/cli/main.ts [flags] +# or, if linked on your PATH: +pgdelta [flags] +``` + +`pgdelta help` prints the command list. Exit codes: **0** success, +**1** runtime failure (or drift detected), **2** bad arguments. + +### Flow 1 — migrate one database to match another + +```bash +# 1. See what would change (human-readable) +pgdelta diff --source "$SOURCE_URL" --desired "$DESIRED_URL" + +# 2. Produce a plan artifact (JSON) +pgdelta plan --source "$SOURCE_URL" --desired "$DESIRED_URL" --out plan.json + +# 3. (recommended) Prove it on a sacrificial clone of the source +pgdelta snapshot --source "$DESIRED_URL" --out desired.snapshot +pgdelta prove --plan plan.json --clone "$CLONE_URL" --desired-snapshot desired.snapshot + +# 4. Apply to the real target (fingerprint-gated against the source it was planned from) +pgdelta apply --plan plan.json --target "$SOURCE_URL" +``` + +`plan` writes the JSON plan to stdout (or `--out `) and a summary +(action count, filtered deltas, safety report, rename candidates) to stderr. + +### Flow 2 — declarative: keep your schema as `.sql` files + +Author your schema as ordinary `.sql` files in a directory; order doesn't matter +(the loader resolves dependencies across files in bounded rounds). `schema apply` +loads them into a **shadow** database, extracts that as the desired state, and +migrates the target to match: + +```bash +pgdelta schema apply \ + --dir ./schema \ + --shadow "$SHADOW_URL" \ # a fresh, empty database the files are loaded into + --target "$TARGET_URL" # the database to migrate +``` + +Export the inverse — a live database back out to `.sql` files: + +```bash +pgdelta schema export --source "$SOURCE_URL" --out-dir ./schema +# --layout by-object (default) groups by schema/kind; --layout ordered emits a +# single load order with the load(export(db)) ≡ db guarantee +# +# --layout grouped restores the old engine's "nice" export: files ordered by +# semantic category, statements sorted within a file for readability, plus +# opt-in grouping: +# --grouping-mode single-file|subdirectory (default subdirectory) +# --group-patterns '[{"pattern":"^auth_","name":"auth"}]' (first match wins) +# --flat-schemas partman,audit (one file per category) +# --no-group-partitions (keep partition children separate) +# +# --format-options pretty-prints the exported SQL (any layout; off by default): +# --format-options '{"keywordCase":"upper","maxWidth":180}' +# It is cosmetic — the load(export(db)) ≡ db guarantee still holds. The same +# formatter is available as a library helper at @supabase/pg-delta/sql-format +# (formatSqlStatements). +``` + +> The shadow database must be a fresh, empty Postgres. Auto-provisioning an +> ephemeral shadow (so `--shadow` becomes optional) is a designed-but-deferred +> feature — see [roadmap/ephemeral-shadow-design.md](roadmap/ephemeral-shadow-design.md). + +### Detect drift from a saved snapshot + +```bash +pgdelta snapshot --source "$PROD_URL" --out prod.snapshot # capture once +pgdelta drift --env "$PROD_URL" --snapshot prod.snapshot # later: did it change? +``` + +`drift` exits **0** when the environment still matches the snapshot, **1** when it +has drifted (and prints the deltas) — handy in CI. + +### Command reference + +| Command | What it does | Key flags | +|---|---|---| +| `diff` | Print the deltas between two live DBs | `--source` `--desired` `[--strict-coverage]` | +| `plan` | Produce a plan artifact (JSON) | `--source` `--desired` `[--out]` `[--profile]` `[--renames]` `[--no-compact]` `[--accept-rename]` `[--restrict-to-applier]` `[--strict-coverage]` | +| `apply` | Apply a plan to a target | `--plan` `--target` `[--profile]` `[--force]` | +| `prove` | Apply a plan to a clone and verify convergence + data preservation | `--plan` `--clone` `--desired-snapshot` `[--profile]` | +| `snapshot` | Save a database's fact base to a file | `--source` `--out` `[--strict-coverage]` | +| `drift` | Compare a live DB against a saved snapshot | `--env` `--snapshot` `[--strict-coverage]` | +| `schema export` | Export a live DB to `.sql` files | `--source` `--out-dir` `[--layout]` `[--profile]` `[--strict-coverage]` | +| `schema apply` | Load `.sql` files via a shadow DB and migrate a target | `--dir` `--shadow` `--target` `[--renames]` `[--accept-rename]` `[--force]` `[--profile]` `[--restrict-to-applier]` `[--strict-coverage]` | + +Common flags, explained: + +- **`--profile raw\|supabase`** — selects an *integration profile* (default `raw`). + `supabase` knows about Supabase-managed roles/schemas/extensions and excludes + them from the diff. See [Profiles](#profiles) below. +- **`--strict-coverage`** — refuse to act while user objects exist in a kind the + engine doesn't model yet (instead of silently ignoring them). +- **`--renames auto\|prompt\|off`** — `plan`/`schema apply` default to `prompt`, + which lists rename candidates you confirm with `--accept-rename =`. +- **`--force`** — disables the fingerprint gate on `apply` (see + [Safety](#safety-features)). Use sparingly. + +--- + +## Programmatic API + +The package ships TypeScript source via subpath exports. The everything-entry is +`@supabase/pg-delta`; each stage is also importable on its own +(`/extract`, `/plan`, `/apply`, `/proof`, `/frontends`, `/core`, `/policy`, +`/integrations`). + +It takes [`pg`](https://node-postgres.com/) `Pool`s as input. + +### Flow 1 — DB to DB + +```ts +import { Pool } from "pg"; +import { extract } from "@supabase/pg-delta/extract"; +import { plan } from "@supabase/pg-delta/plan"; +import { apply } from "@supabase/pg-delta/apply"; + +const source = new Pool({ connectionString: SOURCE_URL }); +const desired = new Pool({ connectionString: DESIRED_URL }); + +// 1. extract both sides into fact bases +const { factBase: sourceFb } = await extract(source); +const { factBase: desiredFb } = await extract(desired); + +// 2. plan the migration source → desired +const thePlan = plan(sourceFb, desiredFb); +for (const action of thePlan.actions) console.log(action.sql); +console.log(thePlan.safetyReport); // destructive / rewrite / lock summary + +// 3. apply to the target (re-extracts and checks the fingerprint first) +const report = await apply(thePlan, source); +if (report.status !== "applied") throw new Error(report.error?.message); +``` + +### Prove before you trust it + +```ts +import { provePlan } from "@supabase/pg-delta/proof"; + +// clonePool is a throwaway copy of the source; it WILL be mutated +const verdict = await provePlan(thePlan, clonePool, desiredFb); +if (!verdict.ok) { + console.error(verdict.driftDeltas); // what didn't converge + console.error(verdict.dataViolations); // rows that vanished + console.error(verdict.coverage); // per-table: how it was checked +} +``` + +### Flow 2 — declarative `.sql` files + +```ts +import { loadSqlFiles, exportSqlFiles } from "@supabase/pg-delta/frontends"; + +// load .sql files into a fresh shadow DB → desired fact base +const { factBase: desiredFb, rounds, diagnostics } = await loadSqlFiles( + [{ name: "01_tables.sql", sql: "create table t (id int primary key);" }], + shadowPool, +); + +// ...then plan/prove/apply exactly as in Flow 1. + +// the inverse: fact base → .sql files +const files = exportSqlFiles(sourceFb, { layout: "by-object" }); +``` + +### Persisting plans and snapshots + +```ts +import { serializePlan, parsePlan } from "@supabase/pg-delta/plan"; +import { saveSnapshot, loadSnapshot } from "@supabase/pg-delta/frontends"; + +const json = serializePlan(thePlan); // plan ↔ JSON round-trips losslessly +const restored = parsePlan(json); + +saveSnapshot(sourceFb, "17", "prod.snapshot"); +const { factBase } = loadSnapshot("prod.snapshot"); +``` + +### Key types at a glance + +- **`FactBase`** — an immutable, content-addressed set of facts + dependency + edges. Compared by hash. +- **`Plan`** — `{ actions, deltas, filteredDeltas, safetyReport, source, target, + renameCandidates, … }`. `Action` carries `sql`, `verb`, `transactionality`, + `lockClass`, `dataLoss`, `rewriteRisk`, and produces/consumes/destroys edges. +- **`ApplyReport`** — `{ status, appliedActions, actionStatuses, error? }`. +- **`ProofVerdict`** — `{ ok, driftDeltas, dataViolations, rewriteViolations, + coverage, applyError? }`. + +--- + +## Profiles + +An **integration profile** bundles "what state the engine manages": which objects +to extract, what to filter, the platform baseline to subtract, and what the +applier can actually execute. The same profile is threaded through extract → plan +→ prove → apply, so *what you prove is exactly what you run*. + +```ts +import { resolveProfile, supabaseProfile } from "@supabase/pg-delta/integrations"; + +const ctx = await resolveProfile(targetPool, supabaseProfile); +const { factBase } = await ctx.extract(targetPool); +const thePlan = plan(sourceFb, factBase, ctx.planOptions); +await apply(thePlan, targetPool, ctx.applyOptions); +``` + +- **`raw`** (default) — no handlers, no policy: diff everything. +- **`supabase`** — excludes Supabase-managed roles, schemas, and extensions, and + captures stateful-extension objects (e.g. pg_partman children) so they aren't + dropped. + +On the CLI this is just `--profile supabase`. + +--- + +## Safety features + +- **Proof loop** — `prove` (CLI) / `provePlan` (API) is the keystone: a migration + is only trusted once it has been applied to a clone, re-extracted, and shown to + converge with data intact. +- **Fingerprint gate** — `apply` re-extracts the target and refuses to run if it + no longer matches the source the plan was built from (catches drift between + plan and apply). `--force` disables it. +- **`--strict-coverage`** — fail loudly rather than silently skip objects in + kinds the engine doesn't model. +- **Honest filtering** — anything a policy filtered out is reported in + `plan.filteredDeltas`, never silently dropped. + +--- + +## Where to go next + +| You want… | Read | +|---|---| +| Why the engine was rebuilt | [overview.md](overview.md) | +| How it works, conceptually | [architecture/README.md](architecture/README.md) | +| The full design (the north star) | [architecture/target-architecture.md](architecture/target-architecture.md) | +| What it models / deliberately excludes | [../packages/pg-delta/COVERAGE.md](../packages/pg-delta/COVERAGE.md) | +| What's left before v1 | [roadmap/v1.md](roadmap/v1.md) | diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 000000000..e07067ca2 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,366 @@ +# pg-delta: why we rebuilt the schema-diff engine + +> **TL;DR** — `@supabase/pg-delta` compares two PostgreSQL databases and emits a +> migration to turn one into the other. The original engine was correct but had +> grown to **~54,000 lines** in which PostgreSQL's semantics were re-implemented +> in **eight** different places, with **no way to prove a migration actually +> works** before shipping it. `pg-delta` is a clean-room rebuild on a single +> idea — *let PostgreSQL be the only thing that understands PostgreSQL* — and a +> single safety net: **every migration is applied to a throwaway clone and +> proven to converge, with your data intact, before you trust it.** At rewrite +> cutover the engine was **~11,500 lines (79% smaller)** — a **rewrite-era +> snapshot**. As of **2026-07-21** the published package is **~27.1k non-test +> LOC / 107 files** (engine slice ~16.2k; product surface ~10.8k including +> ~3.8k `sql-format`; see §4), with one rule table instead of ~100 hand-written +> change classes, a correctness guarantee the old engine never had, and — as of +> the first performance pass — **4.2× faster extraction**. + +- **Audience**: engineers, reviewers, and decision-makers evaluating the rewrite. +- **Status**: engine code-complete and proven on a **316-scenario corpus (632 + cases, both directions)** across PostgreSQL 14–18 — all green (one + `EXPECTED_RED` pin on PG16+ for a known teardown); cutting v1 on correctness. + See [roadmap/v1.md](roadmap/v1.md). +- **Deep design**: [architecture/target-architecture.md](architecture/target-architecture.md) + (the north star) and [architecture/managed-view-architecture.md](architecture/managed-view-architecture.md). + +--- + +## 1. The problem with the old engine + +A schema-diff tool lives or dies on one question: **does the migration it +generates actually produce the target schema, without destroying data?** The old +`pg-delta` answered this with human review and a large integration suite — never +with a machine check. And the reason it *couldn't* cheaply add one is the deeper +problem: it re-implemented PostgreSQL's own rules, over and over. + +### Knowledge was smeared across eight forms + +To diff two databases the old engine had to "know" PostgreSQL semantics, and that +knowledge lived in eight different shapes that all had to agree: + +```mermaid +flowchart TB + subgraph OLD["OLD pg-delta — PostgreSQL knowledge in 8 places"] + direction LR + A1["1. Extractor SQL"] + A2["2. Zod models"] + A3["3. Per-type diff fns (×21)"] + A4["4. ~100 change classes"] + A5["5. Serializers"] + A6["6. Custom sort constraints"] + A7["7. Cycle breakers"] + A8["8. Post-diff normalization"] + end + A1 -. "must stay consistent with" .- A4 + A3 -. "must stay consistent with" .- A5 + A6 -. "must stay consistent with" .- A7 +``` + +Every new object type or edge case meant touching several of these in lockstep. +Worse, three of them are *independent semantic engines* that each re-derive what +PostgreSQL already knows: + +| Re-implemented engine | What it did | Failure mode | +|---|---|---| +| Catalog extraction | Read `pg_catalog` into models | Drifts from the real catalog under concurrent DDL | +| libpg-query static analysis (`pg-topo`, WASM) | Parse SQL to infer types/identifiers | Approximate — heuristics disagree with the server | +| Round-based apply | Retry statements until they stick | Worst-case **O(n²)** statement executions | + +The single deepest source of bugs in this class of tool is **re-implementing +PostgreSQL semantics**. The old engine did it three times. + +### These weren't theoretical — here is what users hit + +| Symptom | Structural cause | Status in new engine | +|---|---|---| +| **Data loss**: a schema sync could `DROP` pg_partman partition children and pgmq queue tables | The engine had no notion of "this object is managed by an extension" — it saw rows the desired state lacked and dropped them | **Cannot happen** — `managedBy` provenance edges filter extension-managed objects out of the diff (extension-intent Phase A, shipped) | +| **Inconsistent reads**: `cache lookup failed` aborts mid-run | ~28 extractors ran on a connection pool with *no shared snapshot*, so the catalog and `pg_depend` could disagree under concurrent DDL | **Cannot happen** — extraction is one `REPEATABLE READ` snapshot, consistent by construction | +| **Wrong function signatures** | libpg-query inferred types from SQL text (temp-schema artifacts, normalization gaps) | **Cannot happen** — PostgreSQL reports the canonical signature | +| **Ordering bugs that fought their own fix** | A cycle-breaker registry that grew one entry per field-discovered cycle; post-diff normalization re-injected drops the breaker had removed | **Cannot happen** — at fact grain there are no cycles to break | + +### There was no proof loop + +The old engine generated a plan and applied it. Nothing re-extracted the result +and checked it equalled the target; nothing seeded rows and checked they +survived. Correctness was discovered in the field, one bug report at a time. + +### It had grown enormous + +The verified shape of the old codebase today (source only, excluding tests): + +```mermaid +pie title Old pg-delta source: 53,933 LOC across 341 files + "objects/ — per-type change classes (256 files)" : 31162 + "everything else: extract, depend, diff, sort, plan, apply, serialize, integrations" : 22771 +``` + +**The per-type object layer alone is 31,162 LOC across 256 files — 58% of the +source, 75% of the files** — and roughly two-thirds of it is structurally +identical create/alter/drop/privilege/comment/security-label handling, repeated +once per object type. That single directory is **~2.7× the rewrite-era new +engine (~11.5k)** and still **~1.9× today's engine slice (~16.2k)**. + +--- + +## 2. The core bet: two principles + +`pg-delta` is built on two inversions (full rationale in +[architecture/target-architecture.md](architecture/target-architecture.md) §2): + +**P1 — PostgreSQL is the only elaborator.** Every input state is resolved by an +actual PostgreSQL instance (the live DB, or a shadow DB the engine populates from +your `.sql` files). The engine never parses SQL to *understand* it. There is one +semantic engine, not three. (Static analysis survives only as a dev-time +convenience, never in the trusted path.) + +**P2 — On the trusted diff path, PostgreSQL knowledge lives in two forms:** (1) +the **extraction queries** that turn a catalog into facts, and (2) the **rule +table** that turns a fact-level change into DDL. Eight forms collapse to two +*there* — generic diff is kind-free. The **planner phases** that consume the +rule table remain per-kind orchestration (not a third semantic engine). Do not +read this as a package-wide claim that all PostgreSQL knowledge lives in only +those two forms. + +--- + +## 3. The new architecture in one picture + +```mermaid +flowchart LR + DB[(source DB)] --> EX + SQL["desired .sql files"] --> SH[(shadow DB)] --> EX + EX["extract
(1 consistent txn)"] --> FB["fact base
content-addressed,
Merkle rollups"] + FB --> VIEW["resolveView
(policy + capability)"] + VIEW --> DIFF["generic diff
(zero per-kind code)"] + DIFF --> RULES["rule table →
atomic actions"] + RULES --> GRAPH["one dependency graph →
one deterministic sort"] + GRAPH --> APPLY["apply
(single txn,
per-statement attribution)"] + GRAPH --> PROVE{{"PROVE on a clone:
state == target?
data preserved?"}} + PROVE -->|yes| TRUST["trusted migration"] + PROVE -->|drift| REJECT["rejected in CI"] +``` + +Everything flows at **one granularity — the fact.** A table, column, constraint, +index, policy, ACL entry, ownership edge, extension membership: each is its own +content-addressed fact. State, diff, dependencies, and actions all live at that +same grain, so: + +- **diff is generic** — a rollup-guided descent emitting `add`/`remove`/`set`/ + `link`/`unlink` deltas, with *zero per-type code*; +- **ordering needs no cycle-breakers** — at fact grain, dependency cycles + structurally cannot form (the trick `pg_dump` uses), so one deterministic + topological pass replaces the old two-phase sort + `invalidates` side-channel + + repair loop + three hand-written cycle breakers; +- **the proof loop is cheap** — because re-extraction produces the same facts, a + migration is *proven* by applying it to a clone, re-extracting, and checking + the fact hashes match (state proof) and seeded rows survive (data proof). + +`resolveView` sits between the fact base and the diff, and is applied +**identically** before `plan()` and before `prove()` — so the plan you prove is +exactly the plan you run. + +--- + +## 4. Old vs new, by the numbers + +### Rewrite-era snapshot + +Figures at first engine-complete cut (source only, excluding `*.test.ts`). Keep +these as the historical “79% smaller” claim — they are **not** today’s package +size. + +| Dimension | OLD engine | NEW engine (rewrite-era) | Change | +|---|---:|---:|---| +| Source LOC (non-test) | 53,933 | 11,471 | **−79%** | +| Source files | 341 | 46 | **−87%** | +| `objects/` per-type code | 256 files / 31,162 LOC | one rule table / 2,183 LOC | **−93% LOC** | +| Semantic engines | 3 (catalog + libpg-query + round-retry) | 1 (PostgreSQL itself) | **−2** | +| Forms of PG knowledge (diff path) | 8 | 2 (extract + rule table; planner phases remain) | **−6 on the diff path** | +| Per-type diff functions | 21 | 0 (generic diff) | **eliminated** | +| Hand-written change classes | ~100 | 0 (data-driven rules) | **eliminated** | +| Cycle-breaker code | 3 hand-written breakers | 0 (cycles can't form) | **eliminated** | +| Apply model | round-based retry, worst-case O(n²) | ordered single pass | **asymptotically faster** | +| Migration proof | none | state + data-preservation proof on a clone | **new guarantee** | +| Serialize escape-hatch params | many (`skipSchema`, `skipAuthorization`, …) | 1 (`concurrentIndexes`) | **collapsed** | +| libpg-query / WASM in trusted path | yes (hard dependency) | no (dev-time only) | **removed** | +| Extract latency (~12k-object catalog) | ~1.88 s | ~0.45 s | **−76% (4.2×)** | + +### Size today (measured 2026-07-21) + +Non-test `packages/pg-delta/src` (`find … ! -name '*.test.ts' | xargs wc -l`): + +| Budget | Scope | LOC | +|---|---|---:| +| **Engine slice** | `core` + `extract` + `plan` + `proof` + `apply` + `policy` + `integrations` | **16,186** | +| **Product surface** | `frontends` + `cli` | **10,768** | +| **Published package total** | all non-test `src/` (incl. root `index.ts`) | **27,095** / **107** files | + +**Engine LOC** excludes `frontends/sql-format` (**3,842** LOC), which sits in +the product-surface budget. Boundary: `./sql-format` is a package subpath export +(`package.json`); the root index re-exports no sql-format **symbols**, but root +consumers **do** load the formatter transitively via `exportSqlFiles` → +`formatSqlStatements`. Only focused-subpath consumers (`/core`, `/plan`, …) +reliably avoid loading it — do not claim that root imports “never load the +formatter.” + +**Corpus today:** **316** scenarios × 2 directions (**632** cases) under +`packages/pg-delta/corpus`. + +```mermaid +flowchart LR + subgraph O["OLD: 3 engines re-deriving PG"] + direction TB + oc["catalog extraction"] + ol["libpg-query analysis"] + orr["round-based retry"] + end + subgraph N["NEW: PostgreSQL is the elaborator"] + direction TB + pg[("a real PostgreSQL
instance")] + end + O -->|"clean-room rebuild"| N +``` + +### Why the test suite shrank too + +The old engine carried **~34,000 lines of tests** — largely per-type unit tests +asserting exact SQL strings. The new engine proves correctness *behaviourally* +instead: a **316-scenario corpus, run in both directions (build and teardown) +under the full proof loop, on PostgreSQL 14–18** (632 cases per version — +all green aside from one pinned `EXPECTED_RED` teardown on PG16+; measured +2026-07-21), plus a **differential harness** and a **generative soak** +(below). Correctness is demonstrated by "apply it and re-extract — does it +match?", not by pinning byte strings. + +- **Differential harness** — runs the new and the old engine over the same corpus + and treats any case where the new engine fails while the old one converges as a + **hard regression**; every accepted difference carries a documented reason. +- **Generative soak** — property-tests the full proof loop: generate a schema, + generate a mutation, roundtrip through apply → re-extract → data-preservation + check, assert fixpoint. Kind-coverage is enforced by a checklist, so coverage + grows with compute time, not test-authoring effort. + +--- + +## 5. What it does better — and differently + +**Correctness is mechanical, not aspirational.** The proof loop is the keystone: +a rule that emits wrong DDL is caught in CI when the clone fails to converge or a +seeded row vanishes — not by a user in production. This *inverts the correctness +economy* of the whole project. + +**The managed view is one definition, not three mechanisms.** Scope filtering, +ownership, and "what can this applier actually execute" used to be three +inconsistent code paths (`excludeManaged`, `excludeExtensionMembers`, post-diff +`filterDeltas`) plus serialize escape-hatch params. Concretely: ownership used to +be a `skipAuthorization` boolean threaded through every serializer; now **ownership +is an edge**, and projecting a role out of the view simply prunes that edge — the +parameter ceases to exist *structurally*, not as a workaround. The same move +turned `skipSchema` into the catalog fact `extrelocatable`. All of it collapses +into one `resolveView(facts, policy, capability)` applied identically before +`plan()` and `prove()`. See +[architecture/managed-view-architecture.md](architecture/managed-view-architecture.md). + +**Stateful extensions keep their data.** pgmq, pg_cron and pg_partman create +objects (queue tables, partition children, schedule rows) that no `.sql` file +declares. The old engine saw them as "extra" and dropped them. The new engine +attaches a `managedBy` provenance edge at extract time and filters them from the +diff — **no per-extension special-casing in the core**, and the same generic +proof loop verifies the data survives. See +[architecture/extension-intent.md](architecture/extension-intent.md). + +**It never silently misses your schema.** If you created an object in a kind the +engine doesn't model (a custom cast, operator class, text-search config, …), the +old engine would simply not see it. The new engine runs a provenance-aware +*catalog completeness check* and reports it as an `unmodeled_kind` diagnostic; +`--strict-coverage` refuses to plan while unmanaged user objects exist. Honest by +construction: it manages X, or it tells you it doesn't. + +**Ordering is correct by construction.** No cycle-breaker registry that grows by +one entry per field-discovered cycle — at fact grain there are no cycles to break. + +**The core library is lean.** `createPlan` consumers no longer pull a WASM SQL +parser into the trusted path. PostgreSQL does the elaboration. + +**It resolves most known issues by design.** Of **134 tracked issues** in the +diffing-2.0 project, roughly **90 are resolved by construction, by the corpus, or +by policy** rather than by porting individual fixes — the architecture dissolves +whole classes of bug. See [build-log.md](build-log.md). +(An independent readiness review flagged five further gaps; all five have since +shipped — see [build-log.md](build-log.md).) + +--- + +## 6. Performance and memory, measured + +Correctness was v1's gate, but the first performance pass has already landed a +large win — and it is a good illustration of the engineering discipline. + +**Profile first.** The roadmap *assumed* the big extraction win would be parallel +snapshot extraction. Profiling said otherwise: **one query — the `pg_depend` +dependency resolver — was 86% of extraction time**, because it ran a 160-line +correlated `CASE` subquery twice for every dependency row. Rewriting it +set-based (resolve each catalog once, hash-join to the dependency set) made it +**7× faster**, and extraction **4.2× faster** overall, with byte-identical +output (gated by an edge-set oracle + the full corpus on PG 14–18): + +| Stage | Before | After | Speedup | +|---|---:|---:|---:| +| `pg_depend` resolver query | 1,434 ms | 204 ms | **7.0×** | +| `extract` (cold, ~12k objects) | 1,881 ms | 453 ms | **4.2×** | +| `extract` (re-extract, warm) | 1,523 ms | 323 ms | 4.7× | + +Parallel snapshot extraction was then *re-profiled and deferred*: after the +rewrite the resolver is one unsplittable query that caps the parallel ceiling +below 2×, not worth a consistency-critical refactor. + +**Memory.** The content-addressed fact base is lean — measured at **~660 bytes +per fact**; two full catalogs plus the diff and plan for a ~12k-object database +hold only ~15 MB of live objects. Peak resident memory (~200 MB at that scale) is +dominated by the PostgreSQL driver buffering result sets, and is **comparable to +the old engine** (the old engine peaked ~185 MB on the same catalog). Both +materialize catalogs fully, so both scale roughly linearly; a streaming, +*O(changes)* diff is the next memory item on the roadmap +([roadmap/post-v1.md](roadmap/post-v1.md)). A +new `extract()` statement-timeout budget already turns a runaway query on a +pathological schema into an actionable diagnostic instead of a hang. + +--- + +## 7. What is deliberately the same — and out of scope + +Honest boundaries matter as much as the wins: + +- **Same 7-stage pipeline shape and the `creates/drops/requires` change + contract** — this was the old engine's genuinely good idea; the rebuild keeps + it and makes the layers generic. +- **Data diffing (DML) is permanently out of scope** — this is a schema tool. + +What v1 does **not** yet do (each documented, regression-free, with a trigger to +revisit): + +| Not yet | Why it's safe | Where | +|---|---|---| +| *Model* rare kinds (casts, operators, text-search, statistics, languages, transforms) | They are **detected and reported**, never silently dropped; modeling is demand-driven | [COVERAGE.md](../packages/pg-delta/COVERAGE.md), [roadmap/post-v1.md](roadmap/post-v1.md) | +| Extension-intent **Phase B** (replay extension objects on rebuild) | Phase A (don't-drop) ships; replay is blocked on a declarative-format decision | [roadmap/extension-intent-phase-b.md](roadmap/extension-intent-phase-b.md) | +| Parallel snapshot extraction | Re-profiled: < 2× win for high refactor risk | [roadmap/post-v1.md](roadmap/post-v1.md) | +| Stage-10 cutover (naming, deprecation, migration guide) | Sequenced after v1 + performance | [roadmap/post-v1.md](roadmap/post-v1.md) | + +Consumers migrate once, at the cutover parity bar: the public surface stays the +`createPlan` / `applyPlan` facade, on a new major, with a migration guide. + +--- + +## 8. Where to go next + +| You want… | Read | +|---|---| +| To use it (CLI + programmatic API) | [getting-started.md](getting-started.md) | +| How it works, concept-first | [architecture/README.md](architecture/README.md) | +| The full design rationale (the north star) | [architecture/target-architecture.md](architecture/target-architecture.md) | +| How scope / ownership / capability enter the engine | [architecture/managed-view-architecture.md](architecture/managed-view-architecture.md) | +| How stateful extensions (pgmq, pg_cron, pg_partman) are handled | [architecture/extension-intent.md](architecture/extension-intent.md) | +| The performance work (shipped) and memory roadmap | [build-log.md](build-log.md), [roadmap/post-v1.md](roadmap/post-v1.md) | +| What's left before cutting v1 | [roadmap/v1.md](roadmap/v1.md) and [roadmap/README.md](roadmap/README.md) | +| What the engine models / deliberately excludes | [../packages/pg-delta/COVERAGE.md](../packages/pg-delta/COVERAGE.md) | +| How it was built and reviewed | [build-log.md](build-log.md) | diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md new file mode 100644 index 000000000..d4d6ba25f --- /dev/null +++ b/docs/roadmap/README.md @@ -0,0 +1,37 @@ +# Roadmap + +The forward-looking plan: cut **v1 on correctness**, then performance, then DX +and cutover. + +## What's here + +- **[v1.md](v1.md)** — the one-page v1 plan: what's already done and proven, and + what blocks a correctness-first cut. +- **[v1-evidence.md](v1-evidence.md)** — the reproducible evidence record to fill + when the v1 validation gates are run at scale (template; v1 isn't cut until it's + filled). +- **[post-v1.md](post-v1.md)** — the consolidated backlog after v1: the + performance milestone, the DX & cutover milestone, and the deliberate deferrals. +- **[extension-intent-phase-b.md](extension-intent-phase-b.md)** — full design for + replaying stateful-extension intent on a from-scratch rebuild (blocked on a + format decision). +- **[ephemeral-shadow-design.md](ephemeral-shadow-design.md)** — full design for + auto-provisioning an ephemeral shadow database (deferred). +- **[pg-delta-next-follow-ups.md](pg-delta-next-follow-ups.md)** — known pitfalls + and follow-ups captured from the PR #315 review, ranked P1–P3. +- **[agent-tracks/](agent-tracks/)** — delegation-ready briefs for architecture + follow-ups (managed view seal, rename identity, proof quality, compaction), + grouped for parallel agents without file collisions. Reviewable tour: + [agent-tracks/OVERVIEW.md](agent-tracks/OVERVIEW.md). + +## How the engine got here + +For the build stages, hardening, review passes, and shipped work, see +[../build-log.md](../build-log.md). + +## Conventions + +- Code citations are workspace-relative (`packages/pg-delta-next/src/...`). +- Linear IDs map to the *pg-delta: database diffing 2.0* project. +- Every planned change follows **Test-Driven Fixes**: author the RED test before + the production change. diff --git a/docs/roadmap/agent-tracks/B1-role-rename-policy-cycle.md b/docs/roadmap/agent-tracks/B1-role-rename-policy-cycle.md new file mode 100644 index 000000000..666508edf --- /dev/null +++ b/docs/roadmap/agent-tracks/B1-role-rename-policy-cycle.md @@ -0,0 +1,91 @@ +# B1 — Fix role-rename + policy dependency cycle (bug) + +**Priority:** Urgent (crash-class planner bug on main) · **Wave:** 1 (parallel with V1) · +**Ship:** one PR, one agent · **Blocks:** I1 (normalization later subsumes this fix) · +**Conflicts with:** I1, anyone on `internal.ts` ordering or `rules/policies.ts` + +> **Contract:** focused `renames: "auto"` RED first (corpus can't express +> renames); fix = release-edge carve-out restricted to accepted role old→new +> pairs, with an over-skip negative test; I1b deletes the carve-out. + +## Bug + +An accepted role rename plus an RLS policy whose `roles` payload references the +renamed role **fails to plan**. Reproduced 2026-07-20 on `feat/pg-delta-next` +with an in-memory FactBase (source: role `role_a` + policy `roles: ["role_a"]`; +desired: renamed to `role_b`), `plan({ renames: "auto", compact: false })`: + +```text +dependency cycle among 2 actions — this is a rule/emission bug, fix the rule (guardrail 4): + ALTER ROLE "role_a" RENAME TO "role_b" + ALTER POLICY "docs_read" ON "app"."t" TO "role_b" +``` + +## Mechanism (verified) + +- `rules/policies.ts` `roles.alter` (~48–72) sets `consumes = [newRole]`, + `releases = [oldRole]` — plain string-set diff, no rename awareness. +- `internal.ts`: `consumes` → produce-before-consume edge `[rename, policy]` + (~133–147); `releases` → **unconditional** release-before-destroy edge + `[policy, rename]` (~126–132). The rename action `produces` the new role + subtree and `destroys` the old, so both edges target the same action → 2-cycle. +- The existing rename carve-out (`internal.ts` ~191, ~246) covers only + `owner`-kind edges during subtree traversal — not an action's own + `consumes`/`releases`. +- `policy` is deliberately absent from `ROLE_NAME_BEARING_KINDS` (role name is + **payload**-carried, `extract/policies.ts:39`), so carry never sees it. + +## Coverage gap + +No corpus scenario or integration test combines a role rename with a policy +referencing the renamed role (`rls-operations--policy-roles-swap` is +drop+create, not rename). That is why nothing caught this. + +## RED first (TDD, mandatory) + +**The corpus cannot express this bug today**: the harness plans with no +`renames` option (`tests/engine.test.ts:50`), which defaults to `"off"` +(`plan.ts:154-155`) — rename acceptance is never active in the proof loop. +Therefore: + +1. **Primary RED:** a focused test with `renames: "auto"` — the in-memory unit + repro (model on `src/plan/role-rename-carry.test.ts`: role + table + policy + `TO` that role, renamed on the desired side) plus an integration test that + plans/proves it end-to-end. Confirm the cycle error verbatim. +2. Capture the failure output for the fix commit message. +3. A corpus scenario for this class lands only once corpus scenarios can opt + into rename acceptance — that work item is owned by I1b (see I1). Do not + block B1 on it. + +## Fix options (pick one, justify in PR) + +1. **Preferred:** rename-aware carve-out in `internal.ts` — when a + releases-target's destroyer is a rename action that also **produces** the + corresponding renamed id, skip the release-before-destroy edge (mirrors the + owner-edge carve-out). **Restrict it to accepted role old→new pairs + specifically** — do not skip release-before-destroy edges generally — and + add a **negative over-skip test**: a policy releasing a role that is + genuinely dropped (not renamed) must still be ordered before the drop. +2. Make `rules/policies.ts` `roles.alter` rename-aware (consult accepted + renames; don't consume/release ids carried by a rename). +3. Extend carry/relabel to payload role refs — **least preferred**: it grows + the folklore I1 exists to delete. + +Option 1 is smallest and local to ordering; I1's payload normalization later +makes the whole policy delta vanish, at which point the carve-out becomes +dead-but-harmless and is removed with carry. + +## Acceptance criteria + +- [ ] RED repro captured, then green after fix +- [ ] Focused unit + integration tests committed (`renames: "auto"`) so this + class stays covered; over-skip negative test included +- [ ] Full corpus green on `postgres:17-alpine` (regression safety — the + corpus itself cannot exercise renames yet) +- [ ] Changeset: `patch` +- [ ] Note added to I1 that normalization subsumes this carve-out + +## Done when + +Role rename + dependent policy plans and proves; I1 inherits the scenario as a +pin for payload-role normalization. diff --git a/docs/roadmap/agent-tracks/C1-compaction-split.md b/docs/roadmap/agent-tracks/C1-compaction-split.md new file mode 100644 index 000000000..4c2aaff3a --- /dev/null +++ b/docs/roadmap/agent-tracks/C1-compaction-split.md @@ -0,0 +1,121 @@ +# C1 — Prove compaction is not required for convergence + +**Priority:** Medium–High · **Wave:** 3 (any time after V1) · **Ship:** one PR · **Depends on:** V1 preferred (prove.ts touch-points) · **Coordinate with:** P1/P3 on `tests/engine.test.ts` · **Conflicts with:** C2, H1 (`internal.ts`); V1/P2 only if `prove.ts` API is touched + +> **Contract:** harness-only. Corpus builds **two plan artifacts** per +> scenario × direction (compact on/off) and proves/applies each end-to-end, +> with full per-mode teardown (drop DBs → `dropRolesExcept` → replay) on the +> serial lane. No default flips in this PR. + +> **Scheduling note:** the old “after I1” dependency was inherited from the +> defaults-flipping design, which edited `plan.ts`/`prove.ts` gates. Dual-prove +> is harness-only, so it can — and preferably should — land **before I1**: I1’s +> mandatory full-corpus gate then validates rename scenarios under both compact +> modes, catching rename×compaction interactions when they are most likely to +> be introduced. Cost: I1’s corpus run goes from ~2.5 to ~5 min. Worth it. + +## Goal + +**Enforce** “compaction must not be required for convergence” in the corpus by +**dual-proving every scenario compact and uncompact**. Treat any default flip +for library/CLI as a **secondary** product decision, not the correctness fix. + +## Why this track exists + +`plan/internal.ts` (~992 LOC) runs multi-pass elision (`compact !== false` in +`plan/plan.ts`). Reviews: “cosmetic” ACL/ADP/policy elisions encode create-model +semantics; `--no-compact` vs compact can diverge in review while both claim to +prove. Compaction is a second planner on the hot path. + +**Why not “just default uncompact for prove”?** If CLI users still emit and apply +compacted plans, and prove uses the same compact setting as the plan under test, +compaction remains on the user-facing correctness path. Flipping library/prove +defaults only changes which variant CI exercises — and can *reduce* coverage of +the path humans actually run. Dual-prove fixes the stated problem directly. + +## Out of scope + +- Rewriting every elision into rules (that’s C2) +- Identity normalize (I1) +- sql-format (K1) + +## Owned files (write) + +| Area | Paths | +|---|---| +| Corpus harness | `packages/pg-delta/tests/engine.test.ts` — dual prove loop | +| Plan options | `plan/plan.ts` only as needed to make compact on/off explicit and testable | +| Prove | `proof/prove.ts` only if harness needs a clean “prove this plan artifact” API | +| Docs | README / corpus note: both shapes must converge | + +**Not in this PR:** CLI flags, library defaults, any product-behavior change. +Default flips are a **follow-up decision** taken after dual-prove is green — +this track is genuinely harness-only. + +## Design requirements + +### Primary (required) + +1. **Corpus dual-prove:** for every scenario × direction, build **two plan + artifacts** — `plan(…, { compact: true })` and `plan(…, { compact: false })` + — and apply/prove **each artifact** end-to-end. Both must converge (state + proof; data proof per existing coverage rules). Note: `compact` exists only + at `plan()` time; `provePlan`/`apply` take the finished artifact + (`prove.ts:379-384`, `apply.ts:112-116`) — there is no downstream compact + setting to keep in sync, only "prove the artifact you apply." +2. Failure message must name scenario, direction, and which compact mode failed. +3. Cost: expect ~2× corpus wall time per PG version (~2–3 min → ~5 min on + `postgres:17-alpine`). Acceptable; do not “optimize” by sampling unless CI + matrix forces a documented shard strategy. +4. Each mode proves and applies **its own artifact** (no cross-wiring a compact + plan with an uncompact proof or vice versa). +5. **Per-mode isolation for cluster-global state (roles).** The shared cluster + has **no automatic role cleanup** (`tests/containers.ts:1-16`); role-DDL + scenarios rely on a serial lane (`engine.test.ts` `mustRunSerially`, + ~249-257) and never re-run today. Running the same scenario twice back-to-back + collides on leftover role state. Between modes, do a **full teardown and + replay**, in this order: drop the scenario's clone/shadow **databases first** + (a role owning objects or holding grants in any live DB cannot be dropped), + then `dropRolesExcept(baseline)` (`containers.ts:163-176`), then replay the + scenario from its SQL for the second mode. Role-DDL scenarios stay on the + serial lane. Resetting roles while scenario databases are still alive is + **not** sufficient. + +### Follow-up only (not this PR) + +6. Product default for human CLI `plan` output may stay compacted; library + embedders may choose either. Decide and document in a separate PR after + dual-prove is green. +7. Whatever is decided, do **not** land a default flip that removes compact + from CI coverage. + +## RED → GREEN + +1. **RED:** Harness runs dual-prove; if any scenario fails uncompact (or compact) + today, that failure is the pin — fix planner/compaction or skip with issue + link only if environmental. +2. **GREEN:** Dual-prove green on PG17 full corpus. +3. Run: + ```bash + cd packages/pg-delta + bun test src/plan/internal.test.ts + PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts + ``` + +## Acceptance criteria + +- [ ] Every corpus scenario proves under `compact: true` and `compact: false` +- [ ] Docs state compaction is a pretty-printer, not a correctness dependency +- [ ] Each mode's artifact proved and applied as-built (no cross-wiring) +- [ ] Changeset: none if harness/tests only; `patch` if `plan.ts`/`prove.ts` + needed touching + +## Conflicts / do not touch + +- `role-rename-carry` / identity normalize +- Deep rewrite of individual elisions (C2) + +## Done when + +CI will fail if a future elision makes uncompacted (or compacted) plans diverge +from convergence; C2 can shrink elisions with a real safety net. diff --git a/docs/roadmap/agent-tracks/C2-compaction-shrink.md b/docs/roadmap/agent-tracks/C2-compaction-shrink.md new file mode 100644 index 000000000..9d07d686c --- /dev/null +++ b/docs/roadmap/agent-tracks/C2-compaction-shrink.md @@ -0,0 +1,70 @@ +# C2 — Shrink load-bearing compaction elisions + +**Priority:** Medium–Low · **Wave:** 4 (after C1) · **Ship:** alone · **Depends on:** C1 (dual-prove safety net) · **Conflicts with:** H1 (same `internal.ts`) + +> **Contract:** evidence-gated (a dual-prove divergence or an implicated bug). +> Per-fact suppressions may become rules; cross-action folding stays +> pretty-only — it cannot be a per-kind rule. + +## Goal + +With C1's dual-prove enforcing that compaction is never *required* for +convergence (C1 does **not** flip any default), **delete or move** elisions +that encode ADP/default-ACL/create-as-applier semantics into the rule table +(or leave them only on the pretty path with explicit tests). + +## Why this track exists + +Even as a pretty-printer, `internal.ts` kind-switches (policy drops, PUBLIC +defaults, ACL revoke/grant folding). Reviews: anything needing “is this +load-bearing?” should consult payload+edges in one helper or not elide. + +## Out of scope + +- Default compact flip (not happening anywhere — C1 is harness-only; any flip + is a separate product decision) +- Full declarative rule IR (H2) +- Identity (I1) + +## Owned files (write) + +- `packages/pg-delta/src/plan/internal.ts` (+ `internal.test.ts`) +- Possibly `plan/rules/metadata.ts` if an elision becomes a rule suppress +- Docs: comment pass list in README compact section + +## Evidence gate + +Prefer opening this track only when C1's dual-prove has surfaced a concrete +compact/uncompact divergence, or a specific elision is implicated in a bug. +A purely aesthetic LOC-reduction pass on `internal.ts` is not worth the risk. + +## Method + +1. Inventory compaction passes (README already lists ~5). +2. Classify each on **two axes**: (a) pure cosmetic vs encodes PG + default/create model; (b) **per-fact suppression vs cross-action folding**. +3. Only **per-fact** suppressions are candidates to move into `KindRules` — + cross-action folding (revoke/grant pairs, cascade subsumption) is + plan-global by nature and **cannot** be expressed as a per-kind rule; those + passes stay pretty-only with a named export + unit tests proving + equivalence on fixtures. +4. Prefer fewer passes over cleverer passes. + +## RED → GREEN + +Per elision removed/moved: a unit test that pins before/after SQL or action +lists on a minimal fact fixture (TDD). + +## Acceptance criteria + +- [ ] `internal.ts` LOC trending down (target: meaningful cut, not drive-by) +- [ ] No elision that ignores payload refs the way + `elideCascadeSubsumedPolicyDrops` historically could +- [ ] Pretty path still useful for common ACL noise +- [ ] Changeset: `patch` if `--compact` output changes; none for pure + internal moves + +## Done when + +Compaction is honestly a peephole pretty-printer; H1 lint can ban new kind +switches in `internal.ts` without a huge allowlist. diff --git a/docs/roadmap/agent-tracks/D0-docs-metrics.md b/docs/roadmap/agent-tracks/D0-docs-metrics.md new file mode 100644 index 000000000..d5ee596a1 --- /dev/null +++ b/docs/roadmap/agent-tracks/D0-docs-metrics.md @@ -0,0 +1,86 @@ +# D0 — Fix size / corpus narrative + +**Priority:** High (trust tax) · **Wave:** 0 · **Ship:** alone · **Parallel with:** anything + +> **Contract:** re-measured three-budget size story (engine slice / product +> surface / package total), dated; precise sql-format boundary wording +> (subpaths avoid it; root loads it transitively). + +## Goal + +Stop marketing an obsolete size story. Reviewers and agents currently optimize +against `docs/overview.md` claiming ~11.5k LOC / 46 files / 210 scenarios while +the package is ~27k non-test LOC / ~107 source files / ~316+ corpus scenarios +(**re-measure at PR time** — do not copy these figures blindly; `ls corpus | wc -l` +was 316 on 2026-07-20). + +## Why this track exists + +Architecture reviews repeatedly flag docs drift as a maintainability risk: it +mis-sets expectations for “lean core,” understates frontends growth, and makes +P2’s “two forms of knowledge” read as a package-wide claim rather than a +diff-path claim. + +## Out of scope + +- No production code changes. +- Do not rewrite architecture north-star docs for identity/compaction (those are + I2 / C1). +- Do not move packages or split sql-format (K1). + +## Owned files (write) + +- `docs/overview.md` (primary) +- `docs/roadmap/v1.md` (corpus counts) +- `docs/build-log.md` only if it still asserts the same stale numbers +- This folder’s README metrics if you refresh measured numbers in the intro + +## Read-only references + +- Measure with: + ```bash + find packages/pg-delta/src -name '*.ts' ! -name '*.test.ts' -type f | xargs wc -l + # per-dir: core extract plan proof policy integrations frontends apply cli + ls packages/pg-delta/corpus | wc -l + ``` +- `docs/architecture/target-architecture.md` (for accurate “two forms” wording — + clarify that generic diff is kind-free; planner/rules remain per-kind) + +## Acceptance criteria + +1. Overview TL;DR and status lines use **current measured** LOC, file count, and + corpus scenario count (date the measurement in a footnote or parenthetical). +2. Distinguish at least three budgets: + - **Engine slice** — `core + extract + plan + proof + apply + policy + integrations` + - **Product surface** — `frontends + cli` + - **Published package total** — all non-test `src/` + + Include the sql-format wording absorbed from retired + [K1](K1-sql-format-boundary.md): state that “engine LOC” excludes + `frontends/sql-format` (~3.8k). Be precise about the boundary: + `./sql-format` is a package subpath export (`package.json:78-83`) and the + root index re-exports no sql-format **symbols** — but the root **does** + transitively load the formatter (`index.ts:70-72` exports `exportSqlFiles`, + which imports `formatSqlStatements`, `export-sql-files.ts:22`). Only + focused-subpath consumers (`/core`, `/plan`, …) reliably avoid loading it. + Do not claim root consumers “never load the formatter.” +3. Where “79% smaller / ~11.5k” appears as historical rewrite result, either: + - keep it clearly labeled as **rewrite-era snapshot**, or + - replace with current numbers and move the historical claim to build-log. +4. `v1.md` corpus count matches `packages/pg-delta/corpus` (scenarios, and + “×2 directions” if still claimed). +5. Do not claim “PostgreSQL knowledge lives only in two forms” for the whole + package without noting rule table + planner phases. + +## Test plan + +- None (docs only). Spot-check links still resolve. +- Optional: `rg '11,?500|~11\\.5k|210 scenarios|46 files' docs` + +## Changeset + +- Not required (docs-only). + +## Done when + +PR updates the stale metrics; index in `agent-tracks/README.md` still accurate. diff --git a/docs/roadmap/agent-tracks/H1-planner-kind-lint.md b/docs/roadmap/agent-tracks/H1-planner-kind-lint.md new file mode 100644 index 000000000..80d43f2e9 --- /dev/null +++ b/docs/roadmap/agent-tracks/H1-planner-kind-lint.md @@ -0,0 +1,64 @@ +# H1 — Planner-body kind-switch lint + +**Priority:** Low–Medium · **Wave:** 5 · **Ship:** alone · **Depends on:** C1 (prefer after C2) · **Conflicts with:** I1, C1, C2 while those edit `internal.ts` + +> **Contract:** per-file kind-check **count ratchet** (baseline table, fails +> on growth, shrinks by baseline update); `plan/rules/**` exempt by design. + +## Goal + +Add a guard test (like `diff.guard.test.ts`) that fails when new per-kind +switches appear in planner **body** modules outside the rule table / approved +allowlist — so Guardrail 3 doesn’t rot into folklore. + +## Why this track exists + +Generic diff is kind-free; `plan/` still has many kind checks (~127 historically). +`internal.ts` knows schema/acl/owner/role. Without a lint, the next PG feature +lands as another late pass. + +## Out of scope + +- Large behavioral refactors (do those in C2/H2 first) +- Touching `core/diff.ts` guard (already exists) + +## Owned files (write) + +- New: `packages/pg-delta/src/plan/plan.guard.test.ts` (name flexible) +- A **per-file baseline count table** (see design requirements) — `plan/rules/**` + exempt by design (the rule table is where kind knowledge belongs) +- Optional: tiny cleanups **only** if needed to make the baseline lint pass + +## Design requirements + +1. Mirror the spirit of `diff.guard.test.ts` (grep for kind string literals or + `kind ===` in disallowed paths). +2. **Per-file count ratchet, not a file allowlist.** `plan/` has ~79 existing + kind-checks; a file-level allowlist that blesses `internal.ts` wholesale is + toothless (new switches in an allowlisted file pass silently). Instead: + record today’s count per file as the baseline; the guard fails if any + file’s count **exceeds** its baseline; shrinking is allowed and should be + committed as a baseline update in the same PR that removes the checks. +3. `plan/rules/**` is exempt (kind knowledge belongs in the rule table); + everything else in `plan/` gets a baseline entry. +4. PR description lists the baseline table and which tracks are expected to + shrink which entries. + +## RED → GREEN + +1. Write guard in failing mode against a known bad pattern, then record the + per-file baseline counts so CI is green. +2. Optionally add one intentional violation in a test fixture file to prove the + guard catches regressions. + +## Acceptance criteria + +- [ ] Guard runs in `bun test src/` +- [ ] Per-file baseline count table documented +- [ ] No unrelated refactors +- [ ] Changeset: none (tests only) + +## Done when + +New kind switches in `plan/plan.ts` / `internal.ts` fail CI unless explicitly +allowlisted. diff --git a/docs/roadmap/agent-tracks/H2-declarative-rule-ir.md b/docs/roadmap/agent-tracks/H2-declarative-rule-ir.md new file mode 100644 index 000000000..55a16394b --- /dev/null +++ b/docs/roadmap/agent-tracks/H2-declarative-rule-ir.md @@ -0,0 +1,70 @@ +# H2 — Declarative replace/rebuild IR (**evidence-gated — not scheduled**) + +**Priority:** None until gated · **Wave:** — · **Ship:** not scheduled · **Status:** park indefinitely pending evidence + +## Status + +**Do not delegate this track** unless the evidence gate below is met. + +TS callbacks on a rule table (`replaceWhen`, `rebuildsDependents`) already *are* +the declarative boundary relative to 106 change classes. Encoding those +predicates as “data” means inventing an expression language for +`replacement-expansion` to interpret — trading type-checked TypeScript for a +homegrown DSL that is harder to debug and unlikely to be smaller. +`rules/helpers.ts` is **~830 LOC**; that alone is not a bug. + +Wave-5 / Priority-Low in the original plan was an admission of this. Made +explicit: **aesthetics dressed as architecture until evidence appears.** + +## Evidence gate (revisit only if) + +Open this track only when **at least one** is true: + +1. A shipped bug is traced to callback sprawl / inconsistent `replaceWhen` + duplication across kinds (cite issue + failing test), **or** +2. A concrete kind family cannot be expressed with typed callbacks without + copy-paste that has already caused a divergence, **or** +3. Maintainers agree in writing that a minimal typed IR (not a string DSL) would + unlock a specific user-visible fix. + +Until then: prefer fixing the bug in `plan/rules/.ts` + expander tests. + +## Goal (if unparked) + +Reduce *demonstrably harmful* imperative replace/rebuild knowledge by moving +**one** kind family into data consumed by `replacement-expansion`, with a typed +IR — not an open-ended expression language. + +## Out of scope (even if unparked) + +- Compaction (C1/C2) +- Identity (I1) +- “Migrate all kinds” rewrites +- Inventing a general-purpose rule DSL + +## Owned files (write) — only after gate + +| Area | Paths | +|---|---| +| Rule IR | `plan/rules.ts`, selected `plan/rules/*.ts` | +| Expander | `plan/phases/replacement-expansion.ts` | +| Helpers | `plan/rules/helpers.ts` only when deleting dead paths | +| Evidence | Link issue + RED test in PR body | + +## Method (if unparked) + +1. Cite the evidence gate item. +2. Migrate **one** kind family end-to-end. +3. Stop. No drive-by “while we’re here” IR expansion. + +## Acceptance criteria (if unparked) + +- [ ] Evidence cited (issue + test) +- [ ] One kind family data-driven; unrelated kinds untouched +- [ ] No new stringly DSL +- [ ] Changeset `refactor` or `fix` + +## Done when + +Not applicable while parked. When unparked: a pattern exists for the next family +*and* the motivating bug is fixed. diff --git a/docs/roadmap/agent-tracks/I1-prediff-rename-identity.md b/docs/roadmap/agent-tracks/I1-prediff-rename-identity.md new file mode 100644 index 000000000..f1fe6d4ec --- /dev/null +++ b/docs/roadmap/agent-tracks/I1-prediff-rename-identity.md @@ -0,0 +1,259 @@ +# I1 — Pre-diff rename identity normalization + +**Priority:** Highest strategic · **Wave:** 2 · **Ship:** **two PRs** — I1a (pure normalizer, no pipeline change) then I1b (pipeline integration + carry deletion) · **Depends on:** V1 merged; **B1 merged** (cycle fix + scenario become I1's pin); C1 dual-prove preferred first (corpus gate then covers both compact modes) · **Conflicts with:** B1, C1, H1, anyone on carry/emitter + +> **Contract:** role renames only, normalized pre-diff into **desired**-name +> space (ids + edges + payload role refs). Pipeline: discovery diff → filter → +> propose from kept → normalize → canonical diff → filter → plan. Only +> `source.fingerprint` uses the physical (un-rewritten) view; rename emission +> stays in the existing action-emitter seam; carry (and B1's carve-out) deleted; +> I1b adds the corpus `renames` opt-in. + +## Goal + +Treat accepted renames (especially **role renames**) as a **rewriting of both +fact bases into a canonical StableId space**, then run ordinary diff/plan with +**zero cancel/carry folklore**. Shrink or delete `role-rename-carry` as a +planner feature. + +## Why this track exists + +Stable ids embed role **names** (`acl.grantee`, `membership`, `userMapping`, +`defaultPrivilege`, owner edges). Postgres carries refs by OID, so +`ALTER ROLE … RENAME` produces remove/add churn that +`plan/role-rename-carry.ts` (225 LOC) cancels after the fact. Column ACL was +already a regression in that seam (`relabel` dropping fields). Carry will keep +growing with every new role-bearing field. + +Target end state: + +> Structural matching **proposes** renames; identity normalization **applies** +> them to both sides; diff sees continuity; carry modules become a bug. + +## Out of scope + +- Compaction policy (C1/C2) +- Action budgets (P1) +- sql-format / frontends packaging (K1) +- Changing which renames are *accepted* (keep `plan/renames.ts` matching policy + unless required for correctness) + +## Owned files (write) + +| Area | Paths | +|---|---| +| New normalizer | Prefer `plan/identity-normalize.ts` (or `core/` if pure + reusable) | +| Rename emission | **Existing seam** in `plan/phases/action-emitter.ts` (~lines 180–194) — keep it; see **Design decisions §2** | +| Integration point | After managed view reconstruction, **before** `diff()` — `plan/phases/change-set.ts` | +| Shrink/remove | `plan/role-rename-carry.ts`, call sites in `change-set.ts` / `plan/phases/action-emitter.ts` | +| Proof | **No changes expected** — `prove.ts` `renamedTables` is table/matview rename machinery (role renames are filtered out, ~prove.ts:411–414); out of scope, P2 owns `prove.ts` | +| Tests | normalize unit tests; `tests/role-rename-column-grant-carry.test.ts`; `tests/renames.test.ts`; corpus rename scenarios | + +## Read-only references + +- `plan/role-rename-carry.ts` (current Depth Module — inventory of kinds) +- `core/stable-id.ts` — codec, column-qualified ACL ids +- `plan/renames.ts` — accepted rename proposal +- `docs/architecture/target-architecture.md` +- V1 helper: call `reconstructManagedView` then normalize — do not open-code view +- Live backlog cross-refs: [#332](https://github.com/supabase/pg-toolbelt/issues/332), + [#333](https://github.com/supabase/pg-toolbelt/issues/333) — pin rename scenarios + that match real fidelity gaps when available + +## Design decisions (do not rediscover mid-PR) + +These are load-bearing. Challenge them in the PR description if wrong; do not +leave them implicit. + +### 1. Canonical direction = **desired (new) names** + +Rewrite **both** fact bases so every role-name-bearing StableId uses the +**post-rename** name (the desired / `to` side of each accepted role rename). + +Rationale: rename actions must sort **before** dependent DDL so subsequent +statements render against post-rename names. Canonicalizing to old names would +force the rest of the plan to speak pre-rename identifiers. + +### 2. Rename emission already exists — keep the seam, do not rebuild it + +Rename actions are **already synthesized outside generic diff**: the action +emitter iterates `acceptedRenames` and invokes each kind’s `rename` rule +(`plan/phases/action-emitter.ts` ~lines 180–194), emitting the action with +`produces` = new subtree / `destroys` = old subtree. Diff never emits renames +today, and `role-rename-carry.ts` only cancels churn — it never emits the +rename itself. **Do not build a second emission path.** + +What normalization changes *around* that existing seam: + +- **Capture `acceptedRenames` before the id rewrite.** The rename rule renders + from the original `from` fact (`ALTER ROLE old RENAME TO new`); the emitter + must keep receiving pre-rewrite from-facts, not normalized ones. +- **Ordering pin:** post-normalization, dependent facts reference **new**-name + ids, so the existing `produces` = new-subtree edge is what sorts the rename + before its dependents. Add a regression test for that ordering — today it + also leans on old ids existing on the source side. + +```text +discovery diff(source, desired) # EXISTING: proposals need its + → filterDeltas(allDeltas, policy, …) # EXISTING: policy keeps/filters + → propose + accept renames from KEPT deltas # remove/add maps are built from + (original from-facts captured) # filtered deltas — change-set.ts:141-162 + → record physical source fingerprint (§6) BEFORE any rewrite + → rewrite both FBs to desired names (ids + edges + payload role refs, §3) + → canonical diff(source′, desired′) # sees continuity; no churn + → filterDeltas AGAIN on canonical deltas # filtering is delta-level + → action emitter synthesizes rename actions from acceptedRenames (existing code) + → emit remaining actions (no carry canceler) +``` + +**Two diffs, by design.** `matchRenameCandidates` consumes the remove/add maps +of an initial diff — and today those maps are built from the **policy-kept** +deltas (`filterDeltas` at `change-set.ts:141-143`, maps at `:152`), not from +`allDeltas`. Preserve that: proposals come from kept deltas on both passes. +Diff is an in-memory Merkle compare; running it twice is cheap. Do not try to +collapse this into one pass, and do **not** reintroduce post-diff remove/add +cancellation to “find” renames. + +**What feeds what.** Everything downstream of normalization — canonical deltas, +their filtering, replacement expansion, emission, the dependency graph, and the +**desired-side** fingerprint (canonical desired names *are* the physical +post-apply names) — consumes the canonical pair. **Only `source.fingerprint` +uses `physicalSource`** (§6). + +**Scope: role renames only.** Normalization rewrites role-name-bearing ids, +edges, and payload role refs. Non-role object renames (tables, views, …) keep +today’s mechanism unchanged: matched remove/add pairs leave the worklists and +the rename rule emits the action — that existing subtree cancellation is not +carry folklore and is not this track’s target. + +### 3. What the rewrite must touch + +Symmetric rewrite on source and desired for every kind in +`ROLE_NAME_BEARING_KINDS`, plus: + +- **Dependency / owner edges** whose endpoints embed role names +- Any hash-adjacent structures derived from those ids (recompute rollups after + rewrite; do not leave stale Merkle nodes) +- Column-qualified ACL keys (`acl:(…).grantee.column`) — full codec round-trip +- **`FactBase.referenceOnly`** — a `ReadonlySet` of **encoded ids** + (`core/fact.ts:73`); remap it alongside facts and edges, or reference-only + tracking silently detaches from the rewritten ids +- **Structured role-bearing payloads** — role names that live in fact + *payloads*, not ids. Known inventory today: `policy.roles` + (`extract/policies.ts:39`). This is **in scope**, not residual: unhandled, it + produced the B1 dependency cycle (policy `consumes`/`releases` vs the rename + action — see [B1](B1-role-rename-policy-cycle.md)), and “zero carry folklore” + is false if payload refs still need a special-case carve-out. After + normalization the policy delta vanishes entirely (correct: `polroles` is + OID-carried, Postgres renames it for free) and B1's carve-out is deleted + along with carry. Inventory payload role-ref fields explicitly in the PR; + `policy.roles` is the only known case. + +Prefer **copy-on-write** fact bases; do not mutate extract outputs shared with +other commands unless proven safe. + +### 4. Owner edges + dual renames + +Today’s emitter zip/projection must either fall out of normalization or be +re-proven with an explicit regression test. Dual object+role renames are in +scope for that pin. + +### 5. Carry retirement + +Default goal: **delete** `role-rename-carry` cancel logic. If a residual remains, +document the exact kinds and why; do not keep the full Depth Module “just in +case.” + +### 6. Physical vs canonical source — the fingerprint gate + +`plan.source.fingerprint` is recorded from the managed view (`plan.ts:516`) and +`apply()` re-extracts the **physical** target — which still has pre-rename +names — and compares (`apply/apply.ts:158-186`). Today no mismatch exists +because nothing rewrites `source`. Under I1, a naive “normalize, then plan” +would fingerprint post-rename ids and the gate would **always fail** against +the real database. + +Therefore keep both: + +- **`physicalSource`** — the un-rewritten managed view; sole input for + `source.fingerprint` and the apply gate. +- **`canonicalSource` / `canonicalDesired`** — the rewritten pair; used only + for the canonical diff and planning. + +Add a regression test: plan with an accepted role rename, assert the recorded +fingerprint equals the physical managed view's root hash (not the canonical +one), and that apply's gate passes against a pre-rename extraction. + +## Two-PR split + +- **I1a — pure normalizer.** `plan/identity-normalize.ts` (+ unit tests): given + a fact base and an accepted-rename map, return the rewritten copy (ids, + edges, payload role refs, recomputed rollups). No pipeline changes; carry + untouched; ships dark. +- **I1b — pipeline integration.** Wire the normalizer into `change-set.ts` + (discovery diff → filter → normalize → canonical diff → filter), record + physical source fingerprint per §6, delete carry (and B1's carve-out), + migrate tests, full corpus gate. + + **I1b also owns the corpus rename opt-in.** The corpus currently plans with + `renames` defaulting to `"off"` (`tests/engine.test.ts:50`), so **no corpus + scenario exercises rename acceptance at all** — “corpus green” does not pin + rename behavior. Add scenario-level metadata (e.g. `meta.renames: "auto"`) + so B1's and I1's rename scenarios run inside the proof loop, and convert + B1's focused scenario into a corpus case. + +## Design requirements (checklist) + +1. Pipeline order as in decision §2. +2. Guard against new role-name-bearing kinds moves with the normalizer (same + spirit as today’s `ROLE_NAME_BEARING_KINDS` ↔ `ALL_FACT_KINDS` partition test). +3. Column-qualified ACL + role rename integration test stays green **without** + hand-maintained field spreads in a carry relabeler. + +## RED → GREEN + +**Mandate this RED (behavior, not implementation absence):** + +1. Unit test: after normalization, `diff(source′, desired′)` has **no** + remove/add (or unlink/link) pairs that are pure role-name relabels for + ACL / membership / owner / defaultPrivilege / userMapping. +2. Plan-level test: accepted role rename + column ACL → plan contains the + rename action(s) and does **not** contain REVOKE/GRANT churn for the rename. +3. Do **not** use “assert carry module is unimported” as the primary pin. + +**GREEN:** Implement normalizer (rename emission stays in the existing +action-emitter seam — decision §2); remove carry; re-run: + +```bash +cd packages/pg-delta +bun test src/plan/identity-*.test.ts src/plan/renames*.test.ts +bun test tests/role-rename-column-grant-carry.test.ts tests/renames.test.ts +PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts # required +``` + +## Acceptance criteria + +- [ ] Canonical direction = desired names (documented in code) +- [ ] Rename emission stays in the existing action-emitter seam, fed + pre-rewrite from-facts; not recovered via carry; ordering pinned by test +- [ ] Role-name relabel churn absent from post-normalize diff +- [ ] Column ACL + role rename regression green +- [ ] Corpus green on at least PG 17 full run, **including** rename scenarios + running under the new `meta.renames` opt-in (I1b) +- [ ] B1's carve-out removed or proven dead (I1b — folklore must not survive + under a new name) +- [ ] Changeset: I1a **none** (ships dark — internal module, no behavior + change); I1b `minor` if the plan result surface changes, else `patch` +- [ ] Tombstone/doc for the new seam (and deleted carry) + +## Conflicts / do not touch + +- `plan/internal.ts` compaction (C1) +- Policy/Supabase rule bodies +- Extract SQL except if a bug blocks normalization (escalate; don’t expand) + +## Done when + +Carry is gone or trivially thin (including B1's carve-out); I2 can document the +invariant. C1's dual-prove is expected to be in place already (see scheduling) — +I1's corpus gate then covers both compact modes. diff --git a/docs/roadmap/agent-tracks/I2-identity-invariants-docs.md b/docs/roadmap/agent-tracks/I2-identity-invariants-docs.md new file mode 100644 index 000000000..514db1df5 --- /dev/null +++ b/docs/roadmap/agent-tracks/I2-identity-invariants-docs.md @@ -0,0 +1,51 @@ +# I2 — Document identity / ACL invariants + +**Priority:** Medium · **Wave:** 2 (optional) · **Ship:** alone · **Parallel with:** I1 if different files; D0; K1 + +## Goal + +Write down the identity model so the next role-bearing field does not rediscover +carry/normalize bugs via production regressions. + +## Why this track exists + +Reviews found: name-keyed StableIds vs OID-carried Postgres refs; non-hashed +`_` payload attrs (`_ownerDefault`, `_initPrivs`); grantor-blind ACL extraction; +column-qualified ACL codec. Knowledge lives in code comments across +`stable-id.ts`, `role-rename-carry.ts`, extract ACL, hash.ts. + +## Out of scope + +- No planner behavior changes (that’s I1). +- No extract logic changes unless documenting an intentional current behavior + that is already tested. + +## Owned files (write) + +- New: `docs/architecture/identity-and-acl.md` (recommended) +- Light cross-links from: + - `docs/architecture/README.md` + - `docs/architecture/target-architecture.md` (short pointer only) + - Header comments in `core/stable-id.ts` and extract ACL module pointing to the doc + +## Content outline + +1. **StableId = declarative addressability**, not OID identity +2. Which kinds embed role names (table matching `ROLE_NAME_BEARING_KINDS`) +3. Rename story: structural propose → (post-I1) normalize → diff +4. ACL model: grantee/column keys; grantor handling (document current extract + choice and why) +5. Non-hashed `_` fields: what they are for; why they must not silently affect + Merkle equality; planner consumers +6. Proof implications: rename-aware data proof / canonical ids + +## Acceptance criteria + +- [ ] One architecture doc a new contributor can read before touching ACL/rename +- [ ] Links from architecture README +- [ ] No false claims that carry is gone until I1 merges (use “current” vs + “target” sections if I1 not done) + +## Test plan / changeset + +- Docs only; no changeset. diff --git a/docs/roadmap/agent-tracks/K1-sql-format-boundary.md b/docs/roadmap/agent-tracks/K1-sql-format-boundary.md new file mode 100644 index 000000000..1435e142d --- /dev/null +++ b/docs/roadmap/agent-tracks/K1-sql-format-boundary.md @@ -0,0 +1,25 @@ +# K1 — sql-format boundary (**retired — folded into D0**) + +**Status:** Retired 2026-07-20. Do not delegate. + +## Why retired + +Fact-check against the package showed the boundary **already exists**: + +- `packages/pg-delta/package.json:78-83` exports `./sql-format` (and + `./extract`, `./plan`, `./apply`, `./proof`, `./core`, `./policy`, …) as + subpaths. +- The root package (`src/index.ts`) re-exports no sql-format **symbols** — + though it does load the formatter transitively via `exportSqlFiles` + (`index.ts:70-72` → `export-sql-files.ts:22`); only focused subpaths + (`/core`, `/plan`, …) avoid it. D0 owns stating this precisely. + +Tier B (package/subpath boundary) is therefore done, and Tier A (docs wording: +“engine LOC excludes sql-format”, import guidance for embedders) is a +paragraph, not a track. That paragraph is now **owned by +[D0](D0-docs-metrics.md)** as part of its three-budget split (engine slice / +product surface / package total). + +If a physical package split (`@supabase/pg-delta-sql-format`) is ever wanted, +that is a product/publishing decision — open a fresh issue with the use case; +this brief does not cover it. diff --git a/docs/roadmap/agent-tracks/OVERVIEW.md b/docs/roadmap/agent-tracks/OVERVIEW.md new file mode 100644 index 000000000..43322c913 --- /dev/null +++ b/docs/roadmap/agent-tracks/OVERVIEW.md @@ -0,0 +1,480 @@ +# Architecture follow-ups — reviewable overview + +A plain-language tour of what the [agent tracks](README.md) will change, why, +and what “broken → fixed” looks like after each big step. + +**Audience:** reviewers, tech leads, agents about to pick up a track. +**Deep briefs:** one markdown file per track in this folder. +**Not scheduled:** H2 (parked), K1 (retired into D0). + +--- + +## The story in one picture + +pg-delta already does the hard thing well: ask Postgres what the schema *is*, +diff two fact bases, emit a plan, prove it on a clone. The follow-ups are about +**trust seams** — places where the engine is correct *sometimes*, or green for +the wrong reason, or crashes on a rename Postgres itself handles fine. + +```mermaid +flowchart TB + subgraph today [Today: sound core, leaky seams] + E[Extract / FactBase] --> V[Managed view - open-coded x4] + V --> D[Diff] + D --> P[Plan + carry folklore + compaction] + P --> R[Prove convergence] + R -.->|sometimes hides| X[Policy bugs / empty-table data proof / rename cycles] + end + + R --> BRIDGE[B1 V1 C1 I1 P-tracks] + + subgraph after [After the tracks: same core, sealed seams] + E2[Extract / FactBase] --> V2[reconstructManagedView - one helper] + V2 --> N[Rename normalize - canonical ids] + N --> D2[Diff] + D2 --> P2[Plan no carry + optional pretty compact] + P2 --> R2[Prove both shapes + budgets + audit + seeds] + end + + BRIDGE --> E2 +``` + +--- + +## Ship order (what lands when) + +```mermaid +flowchart LR + subgraph w01 [Wave 0-1] + D0n[D0 docs] + B1n[B1 crash fix] + V1n[V1 seal view] + P3n[P3 autoSeed] + end + + subgraph w2 [After V1] + C1n[C1 dual-prove] + P1n[P1 budgets] + P2an[P2a attribution] + end + + subgraph w3 [Identity] + I1an[I1a normalizer] + I1bn[I1b integrate] + end + + subgraph w4 [Later] + P2bn[P2b prove/CLI] + C2H1[C2 / H1 polish] + end + + V1n --> C1n + C1n --> P1n + V1n --> I1an + B1n --> I1bn + C1n --> I1bn + I1an --> I1bn + P2an --> P2bn + I1bn --> C2H1 +``` + +| Step | Track(s) | One-line win | +|---|---|---| +| 0 | **D0** | Stop advertising a size story the package outgrew | +| 1a | **B1** | Role rename + RLS policy stops crashing the planner | +| 1b | **V1** | One function builds the managed view everywhere | +| 1c | **P3** | CI actually notices when seed inserts fail | +| 2 | **C1** | Compaction can’t secretly be required for “green” | +| 3 | **P1** | Convergent-but-catastrophic plans fail the corpus | +| 4 | **I1a→I1b** | Renames become identity math, not cancel folklore | +| 5 | **P2a→P2b** | “Managed ok” can still show *suspicious* exclusions | + +--- + +## Mental model (ELI5 → ELI10) + +### ELI5 — what is pg-delta? + +You have two Lego castles (databases). pg-delta looks at both, writes a +**recipe** to turn castle A into castle B, then **builds the recipe on a spare +table** to check it really works — without wrecking your real castle. + +### ELI10 — where the seams are + +| Idea | Plain meaning | +|---|---| +| **Fact base** | A fingerprint of “what Postgres thinks exists,” as structured facts + edges | +| **Managed view** | The subset we’re allowed to care about (minus platform noise, baselines, …) | +| **Diff** | Generic: add / remove / set / link / unlink — no per-object change classes | +| **Plan** | Turn deltas into ordered SQL actions using a rule table | +| **Carry** | After-the-fact canceler for “this churn is just a rename” | +| **Compact** | Pretty-printer that folds noisy GRANT/REVOKE pairs | +| **Prove** | Apply plan on a clone; re-extract; hashes (and maybe data) must match | + +The architecture bet stays: **Postgres elaborates; we don’t re-parse SQL.** +These tracks fix how we **project, rename, pretty-print, and prove** — not that bet. + +--- + +## Step-by-step: broken → fixed + +### Step 1a — B1: role rename x policy cycle + +**ELI5:** Renaming a user while a door-pass still has their old name written on +it makes the recipe writer freeze (“do the rename first / do the pass first”). + +**ELI10:** `ALTER POLICY … TO new_role` emits `consumes(new)` + `releases(old)`. +The rename action both *produces* `new` and *destroys* `old` → a 2-cycle in the +dependency graph. Carry never sees `policy.roles` (payload, not StableId). + +```mermaid +flowchart LR + subgraph broken [BROKEN today] + R1["ALTER ROLE a RENAME TO b
produces b, destroys a"] + P1["ALTER POLICY TO b
consumes b, releases a"] + R1 -->|must before| P1 + P1 -->|must before| R1 + end + + subgraph fixed [FIXED after B1] + R2["ALTER ROLE a RENAME TO b"] + P2["ALTER POLICY TO b"] + R2 -->|rename first - carve-out| P2 + end +``` + +**Concrete example** + +```sql +-- source +CREATE ROLE app_reader; +CREATE TABLE app.docs (...); +CREATE POLICY docs_read ON app.docs TO app_reader USING (true); + +-- desired: same, but role renamed +ALTER ROLE app_reader RENAME TO docs_reader; +-- policy still TO docs_reader (Postgres OID-carries this) +``` + +| | Behavior | +|---|---| +| **Today** | `plan({ renames: "auto" })` → **dependency cycle** among rename + `ALTER POLICY` | +| **After B1** | Plans: rename, then policy (or policy delta elided later by I1). Integration test pins it | +| **After I1b** | Policy delta usually **vanishes** (payload normalized); B1 carve-out deleted | + +--- + +### Step 1b — V1: one managed-view helper + +**ELI5:** Four people were assembling the same sandwich with the ingredients in +different orders. One of them once put the pickle on after the bread was closed. +We print a single recipe card. + +**ELI10:** `resolveView` then `projectManagementScope` is order-sensitive (owner +edges before role prune under database scope). Today that composition is +open-coded in plan / prove / apply / export. + +```mermaid +flowchart TB + subgraph before_v1 [BROKEN: four copies] + CS[change-set.ts] + PR[prove.ts] + AP[apply.ts] + EX[schema-export.ts] + CS --> RV1[resolveView then scope] + PR --> RV2[resolveView then scope] + AP --> RV3[resolveView then scope] + EX --> RV4[resolveView then scope] + end + + subgraph after_v1 [FIXED: one helper] + H[reconstructManagedView] + CS2[change-set] --> H + PR2[prove] --> H + AP2[apply] --> H + EX2[export] --> H + end +``` + +**Concrete example** + +| | Behavior | +|---|---| +| **Today** | Export, plan, and prove can disagree if someone changes call order in one site | +| **After V1** | Same helper, same order, guard test fails if a fifth site open-codes both imports | + +User-visible SQL usually **unchanged** — this is a correctness/maintainability seal. + +--- + +### Step 1c — P3: honest autoSeed + +**ELI5:** We say “we checked your toys are still in the box,” but sometimes the +box was empty and we nodded anyway. Also if a toy wouldn’t go in, we quietly +ignored the jam. + +**ELI10:** Empty tables often get `contentMode: "none"`. `autoSeed` can insert +rows for a real data proof, but failures can be swallowed; CI doesn’t force the +stronger path. + +```mermaid +flowchart LR + subgraph before_p3 [BROKEN: weak data proof] + T1[Empty table] --> M1[contentMode none] + M1 --> OK1[Prove ok] + S1[Seed INSERT fails] --> C1[empty catch] + C1 --> OK1 + end + + subgraph after_p3 [FIXED: observable seeds] + T2[Empty table] --> SEED[autoSeed in CI] + SEED --> M2[fingerprint or count] + FAIL[INSERT fails] --> TAX[skipped vs failed - SQLSTATE allowlist] + TAX --> REPORT[Prove reports it] + end +``` + +**Concrete example** + +| | Behavior | +|---|---| +| **Today** | Scenario with empty `public.items` can “prove” data safety without ever inserting a row; a bad seed can look like success | +| **After P3** | Corpus enables autoSeed; per-table outcomes; unknown SQLSTATEs fail closed (allowlist for known skips) | + +--- + +### Step 2 — C1: dual-prove compact and uncompact + +**ELI5:** There are two ways to write the recipe — short (pretty) and long +(explicit). We only checked the short one. Maybe the long one is the only one +that actually works, or vice versa. + +**ELI10:** Compaction elides GRANT/REVOKE noise. Convergence with `compact: true` +does not prove the uncompacted action list also converges (and users may apply +either shape depending on flags/defaults). + +```mermaid +flowchart TB + FB[Same source and desired] --> Ptrue[plan compact true] + FB --> Pfalse[plan compact false] + Ptrue --> A1[apply and prove] + Pfalse --> A2[apply and prove] + A1 --> G{both green?} + A2 --> G + G -->|yes| PASS[Compaction is optional pretty] + G -->|no| FAIL[Harness fails - elision was load-bearing] +``` + +**Concrete example** + +```text +Uncompacted (honest): + REVOKE … FROM x; + GRANT … TO x; + … + +Compacted (pretty): + — elide no-op revoke/grant pairs — +``` + +| | Behavior | +|---|---| +| **Today** | Corpus proves only the **compact** artifact — an elision that breaks it fails now, but compaction can **mask a broken uncompacted plan**, and `--no-compact` users apply a never-proven shape | +| **After C1** | Every scenario × direction builds **two** plans and proves/applies each (with role teardown between modes) | + +--- + +### Step 3 — P1: action-shape budgets + +**ELI5:** The recipe can rebuild the whole castle brick-by-brick and still “work.” +We want a sticker that says “you’re only allowed to repaint the door.” + +**ELI10:** Proof = convergence, not minimality. Budgets assert semantic shape on +the **uncompacted** artifact using derived predicates (`replacement(K)`, +`rename(K)`) over `create|alter|drop` (there is no `replace` verb). + +```mermaid +flowchart LR + PLAN[Uncompacted plan] --> PRED{replacement or rename?} + PRED -->|forbidden by budget| RED[Corpus FAIL] + PRED -->|allowed or absent| GREEN[OK] +``` + +**Concrete example** + +```json +// corpus/some-column-type-change/budget.json (illustrative) +{ "forbid": ["replacement:view"], "require": ["alter:column"] } +``` + +| | Behavior | +|---|---| +| **Today** | DROP VIEW + CREATE VIEW + … can prove green when an ALTER path was expected | +| **After P1** | Opt-in budgets fail that scenario until the planner emits the intended shape | + +--- + +### Step 4 — I1: rename identity normalization + +**ELI5:** Postgres remembers people by a secret ID. Our notebook uses names. +When someone changes their name, the notebook thinks they left and a stranger +arrived — so we scribble out half the page (carry). Instead: rewrite the +notebook to the new names *first*, then compare. + +**ELI10:** Role names live in StableIds and some payloads (`policy.roles`). +Diff sees remove/add churn; `role-rename-carry` cancels it. I1 rewrites both +fact bases into **desired-name** space after rename discovery, keeps physical +source for fingerprint/apply, deletes carry. + +```mermaid +sequenceDiagram + participant S as Physical source FB + participant D as Desired FB + participant N as Normalizer + participant Diff as Canonical diff + participant Em as Action emitter + + S->>Diff: discovery diff plus filter + D->>Diff: discovery diff plus filter + Diff->>Em: acceptedRenames keep pre-rewrite from-facts + Note over S,N: physicalSource fingerprint recorded HERE + S->>N: rewrite ids edges payloads to new names + D->>N: rewrite to new names + N->>Diff: canonical diff continuity no churn + Diff->>Em: remaining actions + Em->>Em: synthesize RENAME from acceptedRenames +``` + +**Concrete example** + +```sql +-- source: role analyst + GRANT SELECT ON t TO analyst + policy TO analyst +-- desired: same grants/policy, role renamed to reporter +``` + +| | Today | After I1b | +|---|---|---| +| Diff noise | remove `acl:…analyst` + add `acl:…reporter` (and friends) | continuity after normalize | +| Plan | rename + carry cancels churn (or B1 cycle on policies) | `ALTER ROLE … RENAME`; grants/policy usually untouched | +| Folklore | `role-rename-carry.ts` (~225 LOC) + B1 carve-out | deleted | +| Fingerprint | luckily OK (no rewrite) | `source.fingerprint` from **physical** view only | +| Corpus | renames off in harness | opt-in `meta.renames` runs real rename scenarios | + +--- + +### Step 5 — P2: attributed projection audit + +**ELI5:** We cleaned the room by hiding toys under the bed, then said “room’s +clean.” Now we list what we hid and *why* — and flag surprises (“user’s toy +hidden by a vague rule”). + +**ELI10:** Unattributed second diffs are perpetually noisy. Audit records +**suppressed deltas/state** with stage + stable reason code + +acknowledged/suspicious, computed at **plan** time (prove has no raw FB). + +```mermaid +flowchart TB + RAW[Post-extract catalog] --> PROJ[Projection stages] + PROJ --> MV[Managed view] + PROJ --> AUD[Suppression records: stage, reasonCode, viaDescendantOf] + MV --> PROOF[Managed proof] + AUD --> CLASS{acknowledged or suspicious?} + CLASS -->|acknowledged| QUIET[Expected platform noise] + CLASS -->|suspicious| ALERT[Surface in prove/CLI] +``` + +**Concrete example** + +| | Behavior | +|---|---| +| **Today** | Policy/scope drops a user table from the view → managed proof green; operator sees nothing | +| **After P2** | Audit: `suspicious` — `public.my_table` suppressed by rule X / stage Y; `--strictAudit` can fail CI | + +--- + +## Before / after for a single user story + +**Story:** Rename role `analyst` → `reporter` while an RLS policy and column +GRANT still name that role. Apply with Supabase profile. Trust the proof. + +```mermaid +flowchart TB + subgraph T0 [Today] + A0[Plan] -->|often| CRASH[Cycle or noisy REVOKE/GRANT] + A0 -->|if lucky| PROVE0[Prove green] + PROVE0 --> Q0[Did we seed? Did policy hide something?] + end + + Q0 --> A1 + + subgraph T1 [After B1 and V1] + A1[Plan] --> OK1[Ordered plan, shared view helper] + OK1 --> PROVE1[Prove] + end + + PROVE1 --> A2 + + subgraph T2 [After C1 P3 P1] + A2[Two artifacts proved] + SEED[Seeds observed] + BUD[Shape budgets optional] + end + + A2 --> A3 + + subgraph T3 [After I1 and P2] + A3[Clean rename plan - no carry] + AUD2[Projection audit] + end +``` + +| Milestone | What the user/dev feels | +|---|---| +| B1 | Rename+policy **stops crashing** | +| V1 | Invisible; fewer “export ≠ apply” landmines | +| C1 | CI fails if pretty-print was load-bearing | +| P3 | CI fails if data proof was theater | +| P1 | CI fails if we DROP+CREATE when we meant ALTER | +| I1 | Rename migrations look like renames, not grant churn | +| P2 | “Managed ok” comes with a suspicious-exclusion report | + +--- + +## What we are *not* changing + +```mermaid +flowchart TB + ROOT[Unchanged bets] + ROOT --> A[Postgres elaborates] + A --> A1[live + shadow extract] + A --> A2[no AST in diff path] + ROOT --> B[Fact grain] + B --> B1[one granularity] + B --> B2[edges from pg_depend] + ROOT --> C[Generic diff] + C --> C1[add remove set link unlink] + ROOT --> D[Rule table] + D --> D1[per-kind emission knowledge] + ROOT --> E[Proof harness] + E --> E1[corpus remains the oracle] +``` + +--- + +## Track map (for reviewers) + +| Track | User-visible? | Risk | Parallel with | +|---|---|---|---| +| [D0](D0-docs-metrics.md) | Docs only | None | Anything | +| [B1](B1-role-rename-policy-cycle.md) | Fixes crash | Medium | V1, D0 | +| [V1](V1-reconstruct-managed-view.md) | Usually none | Low | B1, D0 | +| [P3](P3-autoseed-ci.md) | Stricter CI | Low–Med | Before/after C1 harness owner | +| [C1](C1-compaction-split.md) | Stricter CI | Med | Sole `engine.test.ts` owner | +| [P1](P1-action-shape-budgets.md) | Stricter CI | Low | After C1 or stacked | +| [I1](I1-prediff-rename-identity.md) | Cleaner rename SQL | **High** | After B1+V1+C1 | +| [P2](P2-unfiltered-drift.md) | New audit output | Med | After V1; P2a then P2b | +| [C2](C2-compaction-shrink.md) / [H1](H1-planner-kind-lint.md) | Polish | Low | After C1 | +| [H2](H2-declarative-rule-ir.md) | — | — | **Not scheduled** | +| [K1](K1-sql-format-boundary.md) | — | — | **Retired** | + +Delegation briefs and conflict matrix: [README.md](README.md). diff --git a/docs/roadmap/agent-tracks/P1-action-shape-budgets.md b/docs/roadmap/agent-tracks/P1-action-shape-budgets.md new file mode 100644 index 000000000..040fc1176 --- /dev/null +++ b/docs/roadmap/agent-tracks/P1-action-shape-budgets.md @@ -0,0 +1,115 @@ +# P1 — Action-shape budgets in corpus + +**Priority:** High (proof quality) · **Wave:** 3 · **Ship:** alone · **Depends on:** V1 preferred · **Serialize with:** C1/P3 on `tests/engine.test.ts` (single owner — land after C1 or stack on its branch); no P2 conflict (P1 never touches `prove.ts`) + +> **Contract:** opt-in per-scenario, **per-direction** semantic budgets, +> evaluated against the **uncompacted** plan artifact, speaking the derived +> vocabulary (replacement/rename predicates over `create|alter|drop` actions) — +> never raw counts as primary. + +## Goal + +Make the corpus catch **convergent but catastrophic** plans (e.g. DROP+CREATE +where in-place ALTER is required) via **action-shape budgets** / semantic +assertions — not only hash convergence. + +## Why this track exists + +Proof answers “does the managed view converge?” Drop+create almost always proves +green. Reviews called this out: proof is a backstop, not a synthesis oracle. +Without budgets, maintainers optimize for green proofs over idiomatic DDL. + +## Out of scope + +- Unfiltered drift mode (P2) +- Changing autoSeed defaults (P3) — coordinate if you share `engine.test.ts` +- Compaction defaults (C1) +- Rewriting the rule table for every noisy scenario — only pin high-risk kinds + +## Owned files (write) + +| Area | Paths | +|---|---| +| Budget helper | Test-only helper under `tests/` (preferred — keeps the shipped `proof/` surface clean) **or** `src/proof/budgets.ts` if a real library use case exists | +| Harness | `packages/pg-delta/tests/engine.test.ts` (minimal hook) | +| Fixtures | Opt-in per-scenario files under `packages/pg-delta/corpus//` e.g. `budget.json` or `expect.yaml` | +| Unit tests | `src/proof/budgets.test.ts` | + +**Do not touch `proof/prove.ts` at all** — the budget helper reads the plan +artifact’s action list directly (it is already accessible); no exported hook, +no `summarizeActions` on the public surface. P2 owns prove API changes. + +## Design requirements + +1. Budgets are **opt-in per scenario** (don’t break 300+ scenarios on day one), + and **per direction**: the corpus proves a→b and b→a, whose plan shapes + legitimately differ. The fixture format must let a budget target one + direction or both explicitly (e.g. `{"a-to-b": …, "b-to-a": …}`); a + direction-blind budget will false-positive on the reverse run. +2. Start with a small allowlist of high-risk scenarios (replace-vs-alter for + tables/columns, views/policies rebuild storms, extension drops). +3. Budget dimensions — **lead with semantic assertions** (stable, express intent): + - forbid `drop`/`create` of kind K when alter is expected + - forbid *replacement* of kind K when `alter` expected (see vocabulary below) + - **Avoid** raw `max actions total` as a primary budget — it rots into + snapshot-churn on every unrelated planner improvement. Use counts only as + a last resort for a known pathological storm, with a comment explaining why. + + **Action vocabulary (pinned — the raw plan cannot express these directly).** + `Action.verb` is only `"create" | "alter" | "drop"` (`plan.ts:36-60`) — + there is **no `replace` verb**, no subject field (actions carry + `produces`/`destroys` StableId sets), and renames emit as `alter`. The + budget helper must therefore define derived predicates over the action + list: **replacement(K)** = a `drop` and a `create` whose `destroys`/ + `produces` contain the **same StableId by exact `encodeId(...)` equality** + — identity fields beyond names (routine signatures, ACL columns) are part + of the id, and name-path matching would misclassify a dropped overload + + added overload as a replacement; + **rename(K)** = an `alter` whose `produces` and `destroys` are both + non-empty subtrees. Document the derivation in the helper — budget fixtures + speak this derived vocabulary, never raw verbs. + + **Budgets evaluate the uncompacted plan form, pinned.** Compaction folds + revoke/grant pairs and elides actions, which distorts shape assertions; + with C1's dual-prove both artifacts exist, so assert against + `compact: false` output. +4. Failure message must show **actual vs budget** and scenario name. +5. Document fixture schema in `corpus/README` or `tests/README` if one exists; + otherwise a short section in this track’s PR description + comment on helper. +6. Cross-link known-bad shapes to live issues when possible: + [#332](https://github.com/supabase/pg-toolbelt/issues/332), + [#333](https://github.com/supabase/pg-toolbelt/issues/333) — prefer pinning + real backlog bugs over purely synthetic fixtures. + +## RED → GREEN + +1. Pick one known noisy-but-wrong-shape scenario (or craft a tiny corpus case) + that today converges with too many DROP+CREATE. +2. **RED:** Add `budget.json` that fails on current plan shape. +3. **GREEN:** Only if the track includes a planner fix — **this track’s default + is harness-only**. If the first scenario fails for a real planner bug, either: + - land harness + known-failing budget as `test.skip` / expected-fail list, or + - split: P1 lands harness + budgets for scenarios that already pass; file + follow-up issues for failing ones. +4. Prefer: land 3–5 budgets that **pass on current main**, proving the harness, + plus one skipped RED documenting a known bad shape. + +## Acceptance criteria + +- [ ] Fixture format + loader documented +- [ ] Engine harness enforces budgets when present +- [ ] ≥3 live scenarios with passing budgets +- [ ] ≥1 documented known-bad shape (skip or issue link) if no planner fix in-PR +- [ ] Changeset: none if tests-only; `minor` if public prove helpers exported + +## Conflicts + +- **C1/P3:** `engine.test.ts` single owner — land after C1 or stack on its + branch. +- **P2:** no conflict — P1 never touches `prove.ts`; P2 owns the prove API. +- **I1:** avoid rename corpus churn while I1 open; pick non-rename scenarios. + +## Done when + +Corpus can express “this migration must look like X,” unblocking later planner +tightening without expanding prove’s convergence contract. diff --git a/docs/roadmap/agent-tracks/P2-unfiltered-drift.md b/docs/roadmap/agent-tracks/P2-unfiltered-drift.md new file mode 100644 index 000000000..cb19c1d42 --- /dev/null +++ b/docs/roadmap/agent-tracks/P2-unfiltered-drift.md @@ -0,0 +1,154 @@ +# P2 — Attributed projection audit + +**Priority:** Medium–High · **Wave:** 3 · **Ship:** **two PRs** — P2a +(attribution plumbing in `policy/`, the heavy half) then P2b (prove/CLI +surfacing, thin) · **Depends on:** V1 (attribution flows through the sealed +helper) · **Serialize with:** P1 on `prove.ts`; P3's `autoSeedEmptyTables` +touch + +> **Contract:** attribution over **suppressed deltas/state** (not dropped +> facts), with stable reason codes, descendant attribution, the full stage +> enum (incl. `managedBy`, reference-only), acknowledged/suspicious defaults +> per stage — computed at **plan time**, attached to the plan artifact. P2a = +> plumbing in `policy/`, P2b = prove/CLI surfacing. + +## Goal + +Report, alongside the managed proof, an **attributed projection audit**: every +fact the projection excluded from the managed view, tagged with **which stage +and rule excluded it** and whether it **still differs** between source and +desired — classified **acknowledged** (expected for this profile) vs +**suspicious** (user-namespace object eaten by a generic rule). So +policy/baseline/scope bugs cannot hide behind a green managed proof. + +## Why this track exists + +Prove reconstructs the projected desired view (`resolveView` / scope / baseline / +capability). Wrong view wiring fails as drift — or greens a plan that never +managed what the user thought. The failure mode this track exists for: + +> Policy (or scope) accidentally dropped a **user** object from the managed view +> while it still differs in the catalog. + +## Out of scope + +- Action budgets (P1) +- autoSeed default flip (P3) +- Identity normalization (I1) +- Changing what `resolveView` filters — only **observability** of both layers + +## Owned files (write) + +| PR | Area | Paths | +|---|---|---| +| **P2a** | Attribution plumbing | `policy/policy.ts` (`resolveView`), `policy/view.ts` (`projectManagementScope`), the V1 reconstruction helper (attribution threads through it) | +| **P2a** | Reason codes / flags | Policy rule types + rule data (per-rule classification overrides) | +| **P2a** | Plan artifact | `plan/plan.ts` + the plan artifact/result type (audit attached at plan time) | +| **P2a** | Tests | Projection/policy unit tests; plan artifact tests; public-API surface tests | +| **P2b** | Prove result | `proof/prove.ts`, `proof/prove.test.ts` — surface the plan-attached audit | +| **P2b** | CLI | `cli/commands/prove.ts`, `cli/commands/drift.ts` — additive fields only; focused cases in `tests/cli.test.ts` | +| **P2b** | Docs | Short note in README prove section or `managed-view-architecture.md` | + +## Audit model (pinned — challenge in PR if wrong) + +Two earlier designs were rejected for the same reason — **an unattributed +second drift diff is perpetually noisy**: + +- *Raw-extract drift*: platform/extension noise always red-lights. +- *Baseline-subtracted drift* (and its “projection residue” refinement): + intentionally excluded objects that legitimately differ (e.g. Supabase + platform schemas excluded by policy scope rules, cluster objects under + database scope) are also “excluded from the view and still different.” + Operators learn to ignore the signal, which defeats it. + +The signal must carry **attribution**: + +**The unit of attribution is the suppressed delta/state, not the dropped +fact.** Projection does more than drop facts: it keeps facts as +**reference-only** while suppressing their payload and edge deltas +(`core/diff.ts:32`), it prunes **edges** independently of their endpoint +facts, and it hard-projects **`managedBy` provenance** unconditionally +(`policy/policy.ts:850`). An audit that only tracks “excluded facts” is blind +to all three. + +| Piece | Definition | +|---|---| +| **Managed drift** | Today’s prove compare, after `reconstructManagedView` — unchanged. | +| **Suppression record** | For each delta/state the projection suppressed relative to the pre-projection catalog: `{ subject (factId or edge), stage, reasonCode, viaDescendantOf? }`. Stage enum (complete): baseline · policy scope rule · capability · management scope · reference-only projection · **managedBy provenance**. Reason codes are **stable identifiers** (data, not prose) so tooling can allowlist them. Facts pruned as descendants of an excluded root carry `viaDescendantOf: rootId` — attribution points at the decision, not the collateral. | +| **Audit entry** | Suppression record joined with “does this subject differ between source and desired?” — only differing suppressed subjects are reported. | +| **Classification** | **acknowledged** vs **suspicious**, per the pinned defaults table below; per-rule flags override. The rubric is data, not a hardcoded schema list. | + +**Pinned classification defaults (challenge in PR if wrong, don't improvise):** + +| Stage | Default | Rationale | +|---|---|---| +| `managedBy` provenance | acknowledged | explicit ownership marker | +| Reference-only projection (extension members, assumed schemas) | acknowledged | structural, named mechanism | +| Capability restriction | acknowledged | server capability is a fact, not a choice | +| Management scope | acknowledged | explicit user/profile choice (`scope: database` etc.) | +| Policy scope rule | per-rule flag; **default suspicious for generic/wildcard matchers, acknowledged for named-object rules** | a wildcard eating a user object is exactly the bug class | +| Baseline | **acknowledged but always visible** — baseline-suppressed *differing* subjects are reported as their own count in the audit summary, never silently folded; strict mode escalates them | the goal says baseline bugs cannot hide: a baseline that captured user state must be detectable, but flat-suspicious would red-light every image upgrade | + +**Where the audit runs: plan time, pinned.** `provePlan` takes a finished +`Plan` and has no raw pre-projection source FactBase (`prove.ts:379-384`), so +the audit is computed during planning — where both raw fact bases are in +hand — and attached to the plan artifact; prove/CLI only surface it. Do not +add a pre-apply extraction to prove for this. + +This requires `resolveView` / `projectManagementScope` (via V1’s helper) to +**emit suppression attribution** — an additive optional output, off the hot +path when not requested. That is **P2a**, the core engineering of this track +(it touches the projection path V1 just sealed — attribution must flow through +the helper, not around it). **P2b** is the prove-result/CLI surfacing on top. + +## Design requirements + +1. **Additive API:** existing `ok` / managed proof semantics unchanged unless + documented as intentional. The audit is informational by default. +2. Code comments restate the audit model above at the attribution site. +3. CLI: human-readable section + machine-readable field; don’t fail CI corpus + on audit findings unless opted in (`strictAudit` or similar — and then only + on **suspicious** entries, never acknowledged ones). +4. Unit tests: managed green / audit red (synthetic fact bases where a generic + policy rule filters out a differing user fact → one **suspicious** entry + with the correct stage + rule attribution). + +## RED → GREEN + +1. **RED:** A policy rule filters a user fact out of the managed view while it + still differs — managed proof ignores it; the audit must surface it as + **suspicious** with stage/rule attribution. Companion case: a + platform-schema fact excluded by a named rule differs → **acknowledged**, + not suspicious. +2. **GREEN:** Attribution plumbing + audit join; wire CLI optionally. +3. Focused: + ```bash + cd packages/pg-delta + bun test src/proof/prove.test.ts + bun test tests/cli.test.ts # if CLI surfaced + ``` + +## Acceptance criteria + +- [ ] Prove result includes the projection-audit summary (even if empty) +- [ ] Every audit entry carries stage + rule attribution and a + suspicious/acknowledged classification +- [ ] Audit model matches the table above (or PR documents a justified amendment) +- [ ] Managed path uses sealed reconstruct helper (V1); attribution flows + through it, not around it +- [ ] Tests cover suspicious vs acknowledged vs clean, including a + reference-only payload-suppression case and a descendant-attribution case +- [ ] Audit computed at plan time and attached to the plan artifact; prove/CLI + only surface it +- [ ] Changeset: `minor` (new plan-artifact/prove-result surface) +- [ ] No corpus mass-failure (opt-in strictness, suspicious-only) + +## Conflicts + +- Sole owner of `prove.ts` while this track is open +- Do not parallel with V1 or heavy I1 prove rename edits + +## Done when + +Operators can see “managed ok, but the projection excluded N differing facts — +M suspicious (rule X ate schema Y), rest acknowledged.” diff --git a/docs/roadmap/agent-tracks/P3-autoseed-ci.md b/docs/roadmap/agent-tracks/P3-autoseed-ci.md new file mode 100644 index 000000000..a13682895 --- /dev/null +++ b/docs/roadmap/agent-tracks/P3-autoseed-ci.md @@ -0,0 +1,112 @@ +# P3 — autoSeed on by default in CI corpus + +**Priority:** Medium · **Wave:** 3 · **Ship:** alone (tiny) · **Parallel with:** P1 (if P1 avoids prove defaults), D0, V1 · **Depends on:** nothing (V1 not required) + +> **Contract:** first make seeding observable (per-table outcome, SQLSTATE +> taxonomy, no empty catch), then enable autoSeed at the harness level with a +> keyed skip-allowlist and strict-on-unknown; library default unchanged. + +## Goal + +Turn on **data-proof seeding** (`autoSeed`) for the corpus / CI path so empty +tables don’t silently get `contentMode: "none"` and green-wash data safety. + +## Why this track exists + +`prove.ts` already supports coverage modes and opt-in `autoSeed`. Reviews noted +empty tables → weak data proof. Productizing honesty means CI exercises the +stronger path. + +## Out of scope + +- Budgets (P1), unfiltered drift (P2) +- Changing fingerprint algorithms +- Seeding strategy redesign beyond enabling existing autoSeed + +## Owned files (write) + +| Area | Paths | +|---|---| +| Corpus harness | `packages/pg-delta/tests/engine.test.ts` | +| CI | `.github/workflows/tests.yml` only if an env flag is required | +| Seeder observability | `proof/prove.ts` — **only** the `autoSeedEmptyTables` function and the seed-outcome plumbing into the result type (requirement 4); serialize with P2, which owns the rest of `prove.ts` | +| Prove defaults (careful) | Prefer harness-level `autoSeed: true` over changing global library default | +| Docs | One line in `packages/pg-delta/README.md` prove section or corpus docs | + +## Design requirements + +1. Prefer **test/CI opt-in** over changing library default for all `provePlan` + callers (avoid surprising embedders). +2. If some scenarios cannot seed (extensions, exotic types), allowlist skips with + reason — don’t weaken the global default for everyone. +3. Failures must be actionable (which scenario, which table, coverage mode). +4. **Fix seeder observability first — flipping the flag alone proves nothing.** + `autoSeedEmptyTables` currently swallows insert failures with an empty catch + (`proof/prove.ts:257-263`), so `contentMode: "none"` conflates “genuinely + unseedable” with “failed for an unexpected reason nobody saw.” Required: + record a per-table seed outcome — `seeded | skipped(reason) | failed(error)` + — and surface it in the prove result. Then enforce a **coverage contract** + in the corpus: a table ending at `"none"` must carry an allowlisted reason; + an unexpected failure class fails the scenario (or reports loudly under a + non-strict default — pick one and document it). +5. **Pinned taxonomy — do not improvise.** `skipped` vs `failed` is decided by + **SQLSTATE class**, not string matching. The seeder inserts + `DEFAULT VALUES` (`prove.ts:259`), so the reachable expected-unseedable + errors are exactly **class 23 (integrity constraint violations)** — `23502` + not-null without default, `23503` FK, `23505` unique, `23514` check → + `skipped(reason=SQLSTATE)`. (Generated/identity `428C9` errors are + unreachable via `DEFAULT VALUES` — do not allowlist what cannot occur.) + **One documented exception to "SQLSTATE only":** a `DEFAULT VALUES` insert + can *resolve* yet leave no row in the final pre-apply snapshot — a BEFORE + INSERT trigger returning NULL, a DO INSTEAD rule, or an AFTER INSERT trigger + deleting the row (possibly while seeding a later table). rowCount is the + command tag, not persisted state, so persistence is judged by reconciling + provisional seeds against that one snapshot, not a per-insert probe. That is + also `skipped`, with the **synthetic sentinel `reason=no_row`** — the one + non-SQLSTATE skip code. Everything else + (connection, syntax, permission, raised exceptions, unknown states) → + `failed(error)`; only class-23 and `no_row` are skips. The skip-allowlist is + keyed by `{ scenario, direction, table, reasonCode }` — no bare table names. + Strict behavior: any `failed` outcome, or a `skipped` with a non-allowlisted + key, fails the scenario. + Data on tables that were already populated stays anchored to the pre-seed + snapshot; only originally-empty tables take their baseline from the final + post-seed snapshot. This prevents seed-trigger side effects on existing data + from being silently accepted as the proof baseline. Populated kept tables are + also compared directly before and after seeding, before the plan runs, so a + later schema change cannot suppress detection of an equal-row-count mutation. + A schema-signature change caused by seeding itself fails the proof immediately. + Allowlist entries are checked in both directions: new skips fail, and declared + skips that are no longer observed fail as stale exemptions that must be removed. + +## RED → GREEN + +1. Enable autoSeed on corpus; run: + ```bash + cd packages/pg-delta + PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts + ``` +2. Fix or skip scenarios that fail for environmental reasons; do not disable + autoSeed globally to silence them. + +## Acceptance criteria + +- [ ] Corpus CI path runs with autoSeed enabled +- [ ] Per-table seed outcome (`seeded | skipped(reason) | failed(error)`) + recorded and surfaced; no empty-catch swallowing remains +- [ ] Coverage contract enforced: `contentMode: "none"` only with an + allowlisted reason; unexpected failure classes are loud +- [ ] Library default for ad-hoc `provePlan` unchanged (or documented if changed) +- [ ] Skip list (if any) documented, keyed per the pinned taxonomy +- [ ] Changeset: `patch` (seed-outcome reporting in the prove result); the + harness/CI part needs none + +## Conflicts + +- Light touch on `engine.test.ts` — coordinate with P1 if both land the same week +- `prove.ts`: only the seeding function + outcome plumbing; everything else is + P2’s turf — serialize with P2 rather than sharing the file + +## Done when + +CI data proof is meaningfully stronger than fingerprint-on-empty-tables. diff --git a/docs/roadmap/agent-tracks/README.md b/docs/roadmap/agent-tracks/README.md new file mode 100644 index 000000000..c32a85294 --- /dev/null +++ b/docs/roadmap/agent-tracks/README.md @@ -0,0 +1,243 @@ +# Agent tracks — architecture follow-ups + +Detailed, delegation-ready briefs for the architecture work prioritized after +the Jul 2026 reviews. Each track is one agent / one PR unless the brief says +otherwise. + +**Start here for review:** [OVERVIEW.md](OVERVIEW.md) — ELI5/ELI10, before/after +examples, and schematics for each big step. + +**Commit this folder before delegating** — untracked briefs are invisible to +agents in fresh worktrees. + +## Cross-links to live correctness backlog + +This folder is scoped to **architecture hygiene** from the Jul reviews. It does +not replace the plan/apply fidelity backlog. When picking corpus pins or rename +scenarios, prefer concrete issues: + +- [#332](https://github.com/supabase/pg-toolbelt/issues/332) — extraction/model fidelity gaps +- [#333](https://github.com/supabase/pg-toolbelt/issues/333) — plan/apply correctness gaps + +Especially relevant to **P1** (known-bad action shapes) and **I1** (rename +identity scenarios). + +## Dependency graph + +```text +D0 (docs metrics) ─────────────────────────────────────────── anytime +P3 (autoSeed CI + seeder observability) ───────────────────── anytime (parallel with V1) +B1 (rename+policy cycle BUG) ──────────────────────────────── URGENT, parallel with V1 + (crash-class; blocks I1) + +V1 (reconstructManagedView) ──► I1a (normalizer) ──► I1b (integration) + └──► P1 / P2 / C1 may start after V1 + (respect conflict matrix) +B1 ─────────────────────────────► I1b (inherits scenario; deletes B1 carve-out) + +V1 ──► C1 (dual-prove compact and uncompact) ──► C2 / H1 + C1 is harness-only — prefer landing it BEFORE I1b so I1's corpus + gate validates both compact modes; single owner of + tests/engine.test.ts (see rule below) + +P1 parallel P3 after or beside V1; serialize P1 vs P2 on prove.ts +P2 (attributed projection audit) after V1 — two PRs: P2a attribution + plumbing in policy/, P2b prove/CLI surfacing; owns prove.ts while open + (P3 may touch only autoSeedEmptyTables — serialize, don't share) + +tests/engine.test.ts has ONE OWNER at a time: C1 first; P1 and P3 stack + on C1's branch or land strictly before/after — never three concurrent + PRs into the harness file ("coordinate" was too weak) + +H1 after C1 (file overlap with internal.ts) +H2 PARKED — evidence-gated, not scheduled +K1 RETIRED — boundary already exists; docs wording folded into D0 +I2 docs anytime (avoid colliding with I1 identity docs) +``` + +## Parallel waves + +Wave numbers are **conflict groupings, not strict chronology** — the +[Suggested first delegation](#suggested-first-delegation) section is the +authoritative order (notably: C1 preferably lands *before* I1). + +| Wave | Ship together? | Tracks | Notes | +|---|---|---|---| +| 0 | Alone | [D0](D0-docs-metrics.md) | Parallel with anything; re-measure LOC/corpus at PR time; owns retired K1's wording | +| 1 | Separate PRs | [B1](B1-role-rename-policy-cycle.md), [V1](V1-reconstruct-managed-view.md) | B1 = urgent crash-class bug, parallel with V1 (disjoint files); V1 blocks I1/P2/C1 | +| 2 | **Two PRs** (I1a→I1b) | [I1](I1-prediff-rename-identity.md), optional [I2](I2-identity-invariants-docs.md) | I1a pure normalizer, I1b integration; needs B1 + V1; prefer C1 first; I2 docs-only parallel | +| 3 | Separate PRs | [P1](P1-action-shape-budgets.md), [P2](P2-unfiltered-drift.md) (P2a→P2b), [P3](P3-autoseed-ci.md), [C1](C1-compaction-split.md) | `engine.test.ts` single-owner: C1 first, P1/P3 stack or serialize; P1 vs P2 serialize on `prove.ts`; P3's seeder touch serializes with P2; prefer C1 before I1b | +| 4 | Alone | [C2](C2-compaction-shrink.md) | After C1; evidence-gated on a dual-prove divergence | +| 5 | Lint only | [H1](H1-planner-kind-lint.md) | After C1; per-file count ratchet | +| — | **Not scheduled** | [H2](H2-declarative-rule-ir.md) | Evidence-gated; do not delegate | +| — | **Retired** | [K1](K1-sql-format-boundary.md) | Boundary already exists (`./sql-format` subpath export); folded into D0 | + +## Conflict matrix + +| Track | Do not run in parallel with | +|---|---| +| B1 | I1; anyone on `internal.ts` ordering or `rules/policies.ts` | +| V1 | I1, C1, P2 (plan / prove / apply / export) | +| I1 | B1, V1, C1, H1; anyone on `role-rename-carry`, `change-set`, `action-emitter` | +| C1 | C2, H1 (`internal.ts`); P1/P3 on `engine.test.ts` — **single owner**: C1 first, P1/P3 stack on its branch or land strictly before/after; V1/P2 only if `prove.ts` API touched | +| P1 | C1/P3 on `engine.test.ts` (single owner — after C1 or stacked); **no P2 conflict** — P1 never touches `prove.ts` | +| P3 vs P2 | P3's `autoSeedEmptyTables` touch vs P2 owning `prove.ts` — serialize | +| D0, I2 | almost nothing | + +## Suggested first delegation + +1. **Agent A → B1** (urgent bug) and **Agent B → V1**, in parallel (disjoint files) +2. **Agent C → D0** (parallel with anything); **Agent D → P3** also fine now — + but P3 must **land before C1 starts, or stack on C1's branch** + (`engine.test.ts` single owner); don't open both the same day unstacked +3. After V1 (and P3 landed or stacked): **Agent E → C1** (dual-prove); + parallel **Agent F → P1** (semantic budgets) +4. After B1 + V1: **Agent G → I1a** (pure normalizer, ships dark) +5. After C1 + I1a: **Agent H → I1b** (integration; corpus gate covers both + compact modes); **P2a → P2b** any time after V1 as sole `prove.ts` owner + +## Review amendments (2026-07-20) + +Applied after codebase fact-check: + +1. **I1** — pinned canonical direction = desired names; rename actions injected + outside generic diff; RED must be behavioral (no relabel-pair churn), not + "carry module absent." +2. **C1** — primary deliverable is corpus **dual-prove** compact and uncompact; + default flips are secondary. +3. **P2** — unfiltered drift = baseline-subtracted catalog (not raw extract). +4. **H2** — parked / evidence-gated. +5. **V1** — only four full composition sites; diff/seed stay resolveView-only. +6. **P1** — semantic budgets first; drop count budgets as primary. +7. **P3** — no V1 dependency. + +Second pass (same day), after verifying rename mechanics in code: + +8. **I1** — corrected: rename actions are **already** synthesized from + `acceptedRenames` in `plan/phases/action-emitter.ts` (~180–194); the brief + now says *keep that seam* (feed it pre-rewrite from-facts, pin the + `produces`=new-subtree ordering) instead of presenting injection as new + machinery. Dropped the erroneous `prove.ts` "renamedTables will simplify" + owned-file entry — that map is table/matview-only (roles filtered out) and + out of I1's scope. +9. **C1** — unblocked from I1 (that dependency was inherited from the old + defaults-flipping design); moved to wave 3, harness-only, prefer landing + **before** I1 so I1's corpus gate validates both compact modes. + +Third pass (same day), after adversarial cross-review + live reproduction: + +10. **B1 (new)** — role rename + RLS policy referencing that role is a + **confirmed crash-class planner bug** on main (dependency cycle between + the rename and `ALTER POLICY … TO`; reproduced with an in-memory fixture; + zero corpus/integration coverage existed). Urgent bugfix track, before I1. +11. **I1** — payload-carried role refs (`policy.roles`) moved **in scope** + (B1 proved they are not harmless residual); added discovery-diff decision + (`matchRenameCandidates` consumes diff remove/add pairs — two diffs by + design); added physical-vs-canonical source decision (fingerprint/apply + gate must use the un-rewritten view); split into I1a (normalizer) + I1b + (integration); fixed leftover "injection"/"unblocks C1" contradictions. +12. **C1** — per-mode isolation specified: full teardown (drop scenario DBs + **first**, then `dropRolesExcept`, then replay) on the serial lane; + default flips removed from the PR entirely (follow-up only). +13. **P2** — reframed as **attributed projection audit** (stage/rule + attribution + acknowledged-vs-suspicious); unattributed drift diffs + (raw, baseline-only, residue) all rejected as perpetually noisy. +14. **P3** — seeder observability mandated first: empty catch at + `prove.ts:257-263` swallows insert failures; per-table seed outcomes + + coverage contract. +15. **V1** — helper kept internal (no package-index export; `ResolvedProfile` + stays the public surface); identity assertion corrected (`resolveView` is + identity only with no extension members / `managedBy` provenance — pin is + byte-identical output, not raw-FB identity). +16. **H1** — file allowlist replaced with per-file baseline **count ratchet** + (~79 existing kind-checks make a file allowlist toothless). +17. **C2** — evidence-gated on a dual-prove divergence; cross-action folding + passes stay pretty-only (cannot be per-kind rules). +18. **K1** — retired: `./sql-format` subpath export already exists and the + root index does not re-export it; remaining docs wording folded into D0. + +Fourth pass (2026-07-21), after two further cross-reviews (all checkable +claims verified in code): + +19. **I1** — pipeline pins `filterDeltas` on **both** diff passes (rename + proposals come from policy-**kept** deltas, `change-set.ts:141-162`); + fingerprint routing pinned (only `source.fingerprint` from + `physicalSource`; canonical feeds everything else incl. desired-side + fingerprint); scope pinned to **role renames only** (object-rename + worklist cancellation stays); I1b owns a corpus `renames` opt-in — + the corpus plans with renames **off** today (`engine.test.ts:50`), so + "corpus green" pins no rename behavior; I1b acceptance now requires B1's + carve-out removed or proven dead. +20. **P2** — split P2a (attribution plumbing) / P2b (surfacing); unit of + attribution is the **suppressed delta/state**, not the dropped fact + (reference-only payload suppression, independent edge pruning, and the + previously missing `managedBy` stage); stable reason codes + descendant + attribution; audit computed at **plan time** (`provePlan` has no raw + source FB). +21. **P1** — action vocabulary pinned: verbs are only `create|alter|drop` + (no `replace`; renames emit as `alter`), so budgets speak derived + replacement/rename predicates and evaluate the **uncompacted** artifact. +22. **P3** — SQLSTATE-based skipped/failed taxonomy; allowlist keyed + `{scenario, direction, table, reasonCode}`; strict on unknown errors. +23. **B1** — primary RED is a focused `renames: "auto"` test (corpus cannot + express renames); carve-out restricted to accepted role old→new pairs + plus an over-skip negative test. +24. **V1** — guard switched to import/call-based per module (the literal + nested-call grep misses schema-export's intermediate-variable + composition); leftover "exported API" acceptance line fixed. +25. **C1** — reworded to two plan **artifacts** proved/applied as built + (`provePlan`/`apply` take finished plans; no downstream compact setting). +26. **Changesets** — terminology corrected across all briefs to bump types + (`patch|minor|none`), not commit types. +27. **D0/K1** — corrected an over-claim introduced in pass 3: the root + package **does** transitively load sql-format (`index.ts:70-72` → + `export-sql-files.ts:22`); only focused subpaths avoid it. +28. **Ops** — `engine.test.ts` single-owner rule replaces "coordinate"; + every active brief now opens with a one-line **Contract** so delegates + get the pinned decisions without reading this log. + +Fifth pass (2026-07-21), editorial residue from pass 4 + one design forcing: + +29. **P2** — owned-files table rewritten for the P2a/P2b split (P2a touches + `policy/policy.ts`, `policy/view.ts`, the V1 helper, rule types/data, + `plan/plan.ts` + artifact type); classification **defaults pinned** per + stage, including the baseline decision: acknowledged-but-always-visible + (baseline-suppressed differing subjects get their own summary count; + strict escalates) — flat-acknowledged would contradict "baseline bugs + cannot hide," flat-suspicious would red-light every image upgrade. +30. **I1** — normalizer must also remap `FactBase.referenceOnly` (a + `ReadonlySet` of encoded ids, `core/fact.ts:73`); I1a changeset corrected + to **none** (ships dark). +31. **Leftover sweeps** — V1: no policy-barrel re-export (`./policy` is a + public subpath); C1: stale "optional default flips" acceptance line + removed; P1: `summarizeActions` public escape hatch removed (budgets read + the plan artifact directly, `prove.ts` untouched); P3: taxonomy tightened + to SQLSTATE **class 23** (the `DEFAULT VALUES` seeder cannot hit + generated/identity `428C9` — don't allowlist the unreachable); C2: + "already C1" default-flip leftover fixed; H1: residual "allowlist" wording + replaced with baseline-count language; conflict matrix + delegation now + match the `engine.test.ts` single-owner rule. +32. **Rejected** — reviewer claim that `tests/cli.test.ts` doesn't exist: + false, the file exists (59.7K). First fully wrong checkable claim across + five review rounds; everything else was verified before adoption. + (Reviewer later confirmed the correction: truncated search output.) + +Sixth pass (2026-07-21) — final editorial round; **review closed, delegate**: + +33. **P1** — replacement predicate pinned to exact `encodeId(...)` equality + (name-path matching would misclassify routine overloads); stale + "serialize with P2 on `prove.ts`" clauses removed everywhere — P1 never + touches `prove.ts` (matrix updated to match). +34. **OVERVIEW** — ship-order diagram now orders P1 after C1 (matching the + single-owner rule); C1's "Today" row corrected: the compact artifact IS + proven today — the uncovered risk is compaction masking a broken + **uncompacted** plan and `--no-compact` users applying a never-proven + shape. + +## Conventions for every agent + +- Repo: `packages/pg-delta` under pg-toolbelt. Follow `AGENTS.md` (TDD for + fix/feat, changeset for behavior changes, focused tests only while iterating). +- Do **not** expand scope into another track's owned files. +- End with: summary of what changed, tests run, residual risks, and whether a + follow-up track is unblocked. diff --git a/docs/roadmap/agent-tracks/V1-reconstruct-managed-view.md b/docs/roadmap/agent-tracks/V1-reconstruct-managed-view.md new file mode 100644 index 000000000..ff0a80875 --- /dev/null +++ b/docs/roadmap/agent-tracks/V1-reconstruct-managed-view.md @@ -0,0 +1,139 @@ +# V1 — Seal `reconstructManagedView` + +**Priority:** Highest · **Wave:** 1 · **Ship:** one PR, one agent · **Blocks:** I1; prefer before P2/C1 + +> **Contract:** one **internal** helper seals `resolveView` → +> `projectManagementScope` for the four full call sites; pin is byte-identical +> output vs today (not raw-FB identity); guard is import/call-based per module. + +## Goal + +Replace the duplicated `resolveView` → `projectManagementScope` composition with +**one helper** used by plan, prove, apply, export (and any other reconstruction +path). Order bugs already happened once; convention + comments are not enough. + +## Why this track exists + +Today the load-bearing order is documented in +`plan/phases/change-set.ts` (~lines 101–138): + +```ts +projectManagementScope(resolveView(fb, policy, capability, baseline), scope, scopeOpts) +``` + +The same **full** composition (`resolveView` then `projectManagementScope`) is +open-coded in exactly four sites today: + +- `plan/phases/change-set.ts` +- `proof/prove.ts` +- `apply/apply.ts` +- `frontends/schema-export.ts` + +`ResolvedProfile` shares *options*, but not a single reconstruction function. + +**Not full managed-view sites:** `cli/commands/diff.ts` and +`frontends/seed-assumed-schemas.ts` call `resolveView` **without** +`projectManagementScope`. Do **not** force them through scope projection. +Either leave them on `resolveView` only, or give the helper an explicit +`scope: "none" | omit` / `projectScope: false` mode that is resolveView-only — +do not change their semantics by accident. + +## Out of scope + +- Do **not** implement pre-diff rename identity (I1). +- Do **not** change compaction defaults (C1). +- Do **not** add proof budgets or autoSeed (P*). +- Do **not** redesign policy/Supabase rules. + +## Owned files (write) + +| Area | Paths | +|---|---| +| New helper | `packages/pg-delta/src/policy/view.ts` **or** new `policy/reconstruct.ts`. Do **not** re-export from the policy barrel — `./policy` is a public subpath (`package.json` exports), and this helper is internal-only | +| Call sites (required) | `plan/phases/change-set.ts`, `proof/prove.ts`, `apply/apply.ts`, `frontends/schema-export.ts` | +| Optional / resolveView-only | `cli/commands/diff.ts`, `frontends/seed-assumed-schemas.ts` — see note above; do not add scope projection | +| Profile (only if needed) | `integrations/profile.ts` — wire helper into documented “how to rebuild the view” without changing profile semantics | +| Tests | `policy/view.test.ts` / new `policy/reconstruct.test.ts`; update call-site tests if snapshots of structure change; add a **guard** that greps call sites | + +## Read-only references + +- `docs/architecture/managed-view-architecture.md` +- `integrations/profile.ts` header comment (`plan == prove == apply` by option identity) +- `policy/policy.ts` — `resolveView` +- `policy/view.ts` — `projectManagementScope` +- Owner-exclusion vs database-scope comments in `change-set.ts` + +## Design requirements + +1. **Single function**, something like: + ```ts + reconstructManagedView( + fb: FactBase, + opts: { + policy?: Policy; + capability?: …; + baseline?: …; + scope?: ManagementScope; // default "cluster" + defaultOwner?: string; + }, + ): FactBase + ``` + **Keep it internal.** Do not export from the package index (`src/index.ts`) + — the invariant is internal, and `ResolvedProfile` remains the public + safe-composition surface. Export later only if a concrete embedder use case + appears. +2. **Order is fixed inside the helper:** `resolveView` then `projectManagementScope`. + Document why (owner edges needed before role prune under database scope). +3. **Behavior pin = byte-identical output vs today at every call site** — not + “identity on the raw fact base.” `resolveView` with no policy/baseline/ + capability is true identity **only** when the fact base has no extension + members and no `managedBy` provenance: `extensionMemberReferenceOnly` and + `excludeByProvenance(base, "managedBy")` run unconditionally + (`policy/policy.ts:838-850`; early return at `:887`). Do not write a test + asserting raw-FB identity in the general case — it is false. +4. Call sites pass the same knobs they pass today — behavior-preserving refactor. +5. Add a unit/guard test — **guard on imports/calls per module, not a literal + nested-call grep**. `schema-export.ts` composes via an intermediate variable + (`resolveView` at ~:78, `projectManagementScope` at ~:122), so grepping for + `projectManagementScope(resolveView(` passes today while the duplication + stands. Instead: fail if any module outside the helper imports (or calls) + **both** `resolveView` and `projectManagementScope`. Bare `resolveView` + alone may remain in diff/seed paths. + +## RED → GREEN + +1. **RED:** Add the guard test expecting a single reconstruction entry point + (will fail while duplication exists), **or** a behavioral test that documents + current order (owner-exclusion + database scope) and will be the regression + pin after the move. +2. **GREEN:** Introduce helper, migrate call sites, make guard pass. +3. Run focused tests: + ```bash + cd packages/pg-delta + bun test src/policy/ + bun test src/plan/phases/ src/proof/prove.test.ts + bun test tests/schema-frontends.test.ts # if export path touched + ``` +4. If behavior could drift: one PG17 corpus shard is enough only if you changed + semantics; for pure move, unit/integration above is enough. + +## Acceptance criteria + +- [ ] One **internal** reconstruction helper (not on the package index); all + four plan/prove/apply/export call sites use it +- [ ] Guard prevents reintroduction of open-coded `resolveView`+scope composition +- [ ] No intentional behavior change; existing view/scope tests still pass +- [ ] Short note in `managed-view-architecture.md` pointing at the helper +- [ ] Changeset: none (internal refactor, no behavior change, no public + surface change); `patch` only if any public export does move + +## Conflicts / do not touch + +- `plan/role-rename-carry.ts`, `plan/internal.ts` compaction passes +- Rule tables, extractors +- Corpus SQL fixtures + +## Done when + +V1 merged → unblocks I1 (identity normalize against one view) and makes P2/C1 +safer to land without re-breaking view order. diff --git a/docs/roadmap/ephemeral-shadow-design.md b/docs/roadmap/ephemeral-shadow-design.md new file mode 100644 index 000000000..c80b57281 --- /dev/null +++ b/docs/roadmap/ephemeral-shadow-design.md @@ -0,0 +1,178 @@ +# Design: ephemeral / auto-provisioned shadow for `schema apply` + +Status: **design only — not implemented** (deferred). Captures the problem, the +correctness tension we discovered, and the alternatives with trade-offs so +implementation can pick a path later. + +Scope note: the related **progress-based shadow-load round cap** (a separate, +self-contained fix) IS implemented — `loadSqlFiles` now scales `maxRounds` with +file count (`Math.max(files.length + 1, 25)`) so a deep dependency chain that is +making progress never hits an artificial ceiling. This doc is only about +auto-provisioning the shadow database itself. + +## Problem + +`schema apply` materializes the declarative `.sql` files into a **shadow** +database, extracts it, and diffs against the target (parser-free design — +"Postgres is the elaborator", see `packages/pg-delta-next/src/frontends/load-sql-files.ts`). +Today `--shadow ` is **required**, so the operator must provision and +manage a second empty Postgres database. That is the single biggest DX friction +in the declarative flow. Goal: make `--shadow` optional by auto-provisioning a +throwaway shadow, while staying **safe** and **correct**. + +## The correctness tension we found (the crux) + +A naive auto-shadow on the **target's own cluster** (a sibling temp database) is +**unsafe**: cluster-global DDL (`CREATE ROLE`, role `GRANT`s, `ALTER DEFAULT +PRIVILEGES`, `TABLESPACE`) isn't database-scoped, and the loader commits +per-file, so such statements **commit against the real target cluster** before +any leak check — and `ALTER ROLE … SET` on a pre-existing role can't be undone. +→ Rejected. + +An **isolated** shadow (its own cluster, e.g. a Docker container) is safe, but +introduces a **correctness** problem: it has its *own* cluster-global roles +(`postgres`, `pg_*`) that differ from the target's (`test`, app roles, `pg_*`). +Extraction includes role/membership/default-privilege facts, so diffing the +shadow against the target produces **spurious and dangerous** role changes +(drop the target's roles, recreate the shadow's, churn ownership) even when the +declarative files never mention roles. + +Root cause: the existing same-cluster `--shadow` flow is correct for roles only +**by accident** — roles are cluster-global and therefore visible identically +from any database on the same cluster, so they cancel in the diff. The tool has +an implicit assumption that **shadow and target share a cluster**. Any isolated +auto-shadow breaks that assumption. (This latent issue also affects a +user-supplied `--shadow` on a *different* cluster; isolation just forces it into +the open.) + +Conclusion: declarative **schema** (database-local) and cluster-global +**roles/grants** are two problems with different correctness models. An +auto-shadow naturally serves the former; the latter needs the target's cluster +context. + +## Airtight, parser-free detection of cluster-global intent + +An isolated container starts **pristine**, which gives a clean primitive: snapshot +its cluster-global catalog (`pg_roles`/`pg_auth_members`/`pg_db_role_setting`/…) +**before** loading the files and **after**. The delta is exactly what the files +declared for cluster-global state — DO-blocks, dynamic SQL, anything — because +it's the *real post-load catalog*, not a text scan. This is consistent with +"Postgres is the elaborator" and is safe (it's inside the throwaway container). +Used either to **refuse** (option B) or as the **baseline** input (option C). + +## Alternatives + +### A. Sibling temp DB on the target's cluster — REJECTED +Correct for roles (shared cluster) but unsafe: cluster-global DDL in the files +leaks/commits onto the target before detection. Not viable. + +### B. Isolated Docker container + scope cluster-global OUT of the diff +- Container = dedicated cluster ⇒ fully safe. Image `postgres:-alpine` + ⇒ version-correct extraction. +- Filter `role` / `membership` / `defaultPrivilege` (and other cluster-global) + facts out of both sides before diffing, so the bootstrap-role mismatch never + surfaces. Owner edges to scoped-out roles prune ⇒ objects created + applier-owned (no spurious `ALTER … OWNER`). +- Use the before/after snapshot to **refuse loudly** if the files manage + cluster-global state ("auto-shadow manages database-local schema only — use + `--shadow` on a cluster matching the target for roles"). +- **Pro:** simplest correct option. **Con:** auto-shadow can't manage roles at + all (not even add). + +### C. Isolated Docker container + BASELINE subtraction — RECOMMENDED +Reuse `subtractBaseline` (`packages/pg-delta-next/src/policy/baseline.ts`), which +drops facts present-and-identical (same id + payload hash) in a baseline +`FactBase` from **both** sides before diffing (`resolveView`, +`packages/pg-delta-next/src/policy/policy.ts`). + +Baseline = **(shadow container's pristine cluster-global state) ∪ (the target's +current cluster-global state)**, passed as `PlanOptions.baseline`: +- shadow bootstrap roles (`postgres`, …) are in the baseline ⇒ **not** created on + the target; +- the target's existing roles are in the baseline ⇒ **not** dropped; +- owner edges to baselined roles prune ⇒ objects created applier-owned (no owner + churn); +- a role the files genuinely **declare** is in neither baseline ⇒ survives in + desired ⇒ `CREATE`d. ✓ +- **Limitation (intrinsic to subtraction):** *modifying* a pre-existing role's + attributes can't be expressed — subtracting the target's copy makes the changed + role look brand-new (→ `CREATE ROLE` that already exists). Documented contract: + auto-shadow can add roles and leaves undeclared roles alone, but to **alter** a + pre-existing role use `--shadow` on a matching cluster. +- **Pro:** correct for the realistic cases (add / leave-undeclared / owners / + db-local schema), reuses existing machinery, one narrow documented limit. + **Con:** must construct the union baseline `FactBase` from filtered extractions. + +### D. Isolated Docker container + SEED target roles into the shadow +Replicate the target's roles/memberships/configs into the container *before* +loading the files (a mini-apply of the target's cluster-global facts via the +existing role rules). The shadow then holds `role@target-value` + the files' +changes, so the diff yields clean `ALTER` (modify), `CREATE` (add), and no drop +of undeclared roles (they're present on both sides → additive semantics). +- **Pro:** full declarative role fidelity, no limitation. **Con:** materially + more code + edge cases (superuser-name/owner alignment so the load owns objects + as the target's applier; membership/config replication; the "additive vs + authoritative" choice for undeclared roles). + +### E. Park — keep `--shadow` required +Ship nothing here; the round-cap fix already landed. Lowest effort. + +## Recommendation + +**Option C (baseline)**, with **D** as a later upgrade if altering pre-existing +roles via auto-shadow becomes a real requirement. C is the cleanest use of the +existing baseline mechanism, correct for the realistic cases, and its single +limitation is narrow and documentable. If even that is too much for a first cut, +**B** (database-local only + loud refusal) is a perfectly defensible smaller +contract that can be upgraded to C/D later without changing the user-facing +default. + +## Implementation sketch (for the recommended path) + +1. **`src/cli/docker-shadow.ts`** — drive the `docker` CLI directly (no + `testcontainers` runtime dep in `src/`): + - `dockerAvailable()` = `docker version` exits 0. + - `startDockerShadow(pgMajor, image?)`: `docker run -d --rm --label + pg-delta-next-shadow= -e POSTGRES_PASSWORD= -P + postgres:-alpine -c fsync=off -c full_page_writes=off -c + wal_level=logical`; read mapped port via `docker port`; connect-and-retry + readiness; `stop()` = `docker rm -f` (idempotent) + `SIGINT`/`SIGTERM` + handlers; age-guarded startup sweep of stale labeled containers for + `SIGKILL`-leak reclaim. + - `image` overridable via `--shadow-image` (Supabase / extension-heavy + schemas need a custom base; a stock image fails loudly on a missing + extension rather than mis-diffing). +2. **`src/cli/commands/schema.ts` — `cmdSchemaApply`**: + - `--shadow` optional; add `--shadow-image`, `--max-rounds`. + - given `--shadow` → current behavior (`databaseScratch` mode). + - omitted + Docker available → probe target major, `startDockerShadow`, + `isolatedCluster` mode; build the union baseline (pristine snapshot + + target cluster-global extraction filtered to cluster-global kinds) and pass + via `planOptions.baseline`; refuse with a clear message + pointer to + `--shadow` if the before/after snapshot shows an *altered* pre-existing role + (the case baseline can't express); teardown in `finally`. + - omitted + Docker absent → exit 2 with `--shadow` fallback instructions. +3. **Baseline construction helper** — filter a `FactBase` to cluster-global kinds + (+ their edges) and `buildFactBase` the subset; union pristine + target. + +## Open questions + +- Exact set of "cluster-global" kinds to baseline/scope (role, membership, + defaultPrivilege; tablespaces/parameters are out of model scope today). +- Whether to also align the container superuser name to the target's applier, or + rely purely on owner-edge pruning (the latter seems sufficient under C). +- Supabase profile: a stock image won't carry Supabase extensions; likely needs + the profile to declare a shadow image, or require `--shadow` for Supabase. +- `testcontainers` (already a dev dep, has the Ryuk reaper for leak cleanup) vs + the `docker` CLI shell-out (no `src/` runtime dep). Leaning shell-out for a + clean `src/` boundary. + +## Tests (when implemented) + +- Auto-shadow happy path: `schema apply` with no `--shadow` applies db-local + schema; the labeled container is gone afterward (no leak). +- Add-role via files converges; undeclared target roles are left untouched; + no spurious owner churn (option C). +- Alter-pre-existing-role is refused with a clear `--shadow` message (option C + contract) — or works (option D). +- `dockerAvailable() === false` → exit 2 with fallback instructions. diff --git a/docs/roadmap/extension-intent-phase-b.md b/docs/roadmap/extension-intent-phase-b.md new file mode 100644 index 000000000..3731794ff --- /dev/null +++ b/docs/roadmap/extension-intent-phase-b.md @@ -0,0 +1,246 @@ +# Extension intent — Phase B implementation plan (intent replay) + +- **Status**: Ready to execute. Phase A (filter / no-data-loss) is shipped and + proven; this plan covers Phase B (capture + replay intent for rebuild + fidelity). Parks the work so it can be resumed cold. +- **Date**: 2026-06-13 +- **Design**: `docs/extension-intent.md` (the *what* and *why*). This doc is the + *how* — concrete files, contracts, sequencing, and gotchas mapped against the + real code. +- **Branch / baseline**: `feat/pg-delta-next` @ `06782d8`. +- **Closes (on completion)**: CLI-1591 Deliverable B (partman `create_parent`), + CLI-341 (pg_cron jobs), the schema-side of CLI-1385; sets up CLI-1430/1431. + +> **One sentence.** Phase B makes a from-scratch rebuild recreate the queues / +> schedules / partman parents a project declared, by capturing them as +> `extensionIntent` facts and replaying them through the extension's own API +> (`pgmq.create`, `cron.schedule`, `partman.create_parent`), ordered and proven +> by the same one-graph sort + proof loop as schema — no second pipeline. + +--- + +## 0. What Phase A already built (the substrate B extends) + +> The substrate is now the **integration profile** (the post-handoff-review +> refactor). The old `excludeManaged` / `extractWithHandlers` / `extractManaged` +> recipe was removed — handlers run inside `extract`, `resolveView` projects +> `managedBy`, and `resolveProfile` composes the option bundles. Build Phase B on +> the profile, not the removed helpers. + +- `EdgeKind` includes `"managedBy"` (`src/core/fact.ts`). +- The `ExtensionHandler` contract lives in `src/extract/handler.ts`; its + `capture(ctx, current)` receives a **snapshot-bound** `HandlerContext`. + `extract(pool, { handlers })` (`src/extract/extract.ts`) runs handlers inside + the same repeatable-read transaction as core extraction. The **pg_partman** + handler emits `managedBy` edges from `part_config` + a recursive `pg_inherits` + walk (`src/policy/extensions/pg-partman.ts`). +- `resolveView(...)` (`src/policy/policy.ts`) is the **single projection point**: + it projects out `managedBy` (and `memberOfExtension`) facts + descendants, on + both sides and the proof re-extract. (`excludeByProvenance` in + `src/policy/view.ts` is the underlying primitive; there is no caller-side + `excludeManaged` step anymore.) +- `resolveProfile(pool, profile, …)` (`src/integrations/`) is the public + composition module. It returns a handler-aware `extract` plus `planOptions` / + `proveOptions` / `applyOptions` whose `reextract` re-extracts handler-aware, so + the proof re-extract and the apply fingerprint gate reconstruct the same + managed view. `provePlan` / `apply` accept that `reextract` option directly. + +Phase B adds the **intent** half: handlers also emit `extensionIntent` facts, +and the planner renders them as replay actions. + +--- + +## 1. The contracts B builds against (verified at `06782d8`) + +### StableId codec (`src/core/stable-id.ts`) +- Kind sets: `SIMPLE_KINDS`, `QUALIFIED_KINDS`, `SUBENTITY_KINDS`, + `ROUTINE_KINDS`, plus bespoke kinds (membership, userMapping, typeAttribute, + publicationRel, publicationSchema, comment, acl, securityLabel, + defaultPrivilege). +- `StableId` union (lines ~52–76); `FactKind = StableId["kind"]`. +- `encodeId(id)` switch (~97–131) and `parseAt(c)` switch (~241–313) — both must + gain a branch for any new kind. The bespoke `publicationRel` (a kind + 3 + string discriminators) is the closest existing template. + +### Rule table (`src/plan/rules.ts`) +- `ActionSpec` (lines ~23–55), full field set: + `sql; consumes?; alsoProduces?; alsoDestroys?; releases?; dataLoss?; + rewriteRisk?; lockClass?; transactionality?; compaction?; acceptsColumnFolds?`. +- `KindRules` (lines ~104–141): `create(fact, view, params?) => ActionSpec[]`, + **`drop(fact) => ActionSpec`** (no params today), optional `rename`, + `attributes`, `weight`, and graph flags (`metadata`, `cascadesToChildren`, + `rebuildable`, `suppressible`, `dropRootRedirect`, `defaclObjtype`). +- `PlanParams = Record` (line ~69); **`KNOWN_PARAMS`** is the + allow-list — `plan()` throws on any param name not in it. +- `RULES: Record` (line ~522); `rulesFor(kind)` (lines + ~2261–2269) throws on unknown kind. +- Simple template to copy: the `extension` / `schema` rule entries (both read + `params` in `create`). + +### Planner (`src/plan/plan.ts`) +- `PlanOptions` (~90–108): `params?, policy?, renames?, acceptRenames?, compact?`. +- `params` validated against `KNOWN_PARAMS` (~134–146). +- `paramsFor(fact)` (~372–387) merges policy serialize-rule params + global + `options.params` per fact (global params reach every fact). +- `emitCreate` (~389–400) calls `rulesFor(kind).create(fact, base, paramsFor(fact))`. +- **Drop call sites that need the params thread (2):** the drop loop (~490) and + the replace path (~514) call `rulesFor(kind).drop(fact)`. + +### Proof (`src/proof/prove.ts`) +- `provePlan(plan, clonePool, desired, { reextract })`; default reextract is + core `extract`. The profile supplies a handler-aware `reextract` + (`ctx.proveOptions.reextract`, i.e. `extract(pool, { handlers })`), so the + proof re-extract emits the same `managedBy` edges and `resolveView` projects + the same managed view. + +--- + +## 2. The one real design decision (settled) + +**Intent rules reach the rule table via `PlanParams`, not a global registry or a +core→handler import.** Core (`rules.ts`) must not import the pgmq/cron/partman +handlers (layering). So the integration injects its intent rules as a plan +param: + +```ts +// PlanParams carries (cast at the rule): +// intentRules: Map<`${ext}/${intentKind}`, IntentKindRule> +``` + +The `extensionIntent` rule in `rules.ts` is generic: it looks up +`params.intentRules.get(`${id.ext}/${id.intentKind}`)` and delegates +`create`/`drop`/attribute rendering. Consequence: **`KindRules.drop` must gain +an optional `params?: PlanParams`** (only `create` has it today), and the 2 +`plan.ts` drop call sites pass `paramsFor(fact)`. Existing `drop: (fact) => …` +impls are assignable unchanged (fewer params is fine) — so this is a 2-call-site +change, not a 30-rule change. Add `"intentRules"` to `KNOWN_PARAMS`. + +Rejected alternatives: a module-level mutable registry (global state, test +isolation hazard) and importing handlers into core (layering violation). + +--- + +## 3. Sequencing (each step its own RED→GREEN; commit at green) + +### Step 1 — `extensionIntent` codec (isolated, zero planner risk) +- Add `{ kind: "extensionIntent"; ext: string; intentKind: string; key: string }` + to the `StableId` union. +- Add the `encodeId` branch: `extensionIntent:${seg(ext)}.${seg(intentKind)}.${seg(key)}`. +- Add the `parseAt` branch (mirror `publicationRel`). +- **RED→GREEN**: a round-trip property test in `stable-id.test.ts` + (`parseId(encodeId(x)) === x`) for the new kind, incl. names needing quoting. +- Gate: `bun test src/core/stable-id.test.ts`; check-types. +- **Commit** (`feat(pg-delta-next): extensionIntent stable-id kind`). + +### Step 2 — intent rules in the rule table + planner thread +- Define `IntentKindRule` and `IntentRegistry` (a `Map`) — put them where the + rule table can import the *types* without importing handlers + (e.g. `src/plan/intent.ts`, types only): + ```ts + interface IntentKindRule { + create(fact: Fact, view: FactView): ActionSpec; + drop(fact: Fact): ActionSpec; + attributes?: Record; + } + type IntentRegistry = Map; // key: `${ext}/${intentKind}` + ``` +- `rules.ts`: add `"intentRules"` to `KNOWN_PARAMS`; add the `extensionIntent` + RULES entry whose `create(fact, view, params)` and `drop(fact, params)` + resolve `params.intentRules` and delegate (throw a clear error if a fact's + `ext/intentKind` has no registered rule — guardrail 3: extend the vocabulary, + don't hack the planner). Pick a `weight` (intent replays late — high weight). +- `rules.ts`: widen `KindRules.drop` to `drop(fact: Fact, params?: PlanParams)`. +- `plan.ts`: pass `paramsFor(fact)` at the 2 drop call sites. +- **RED→GREEN (unit, Docker-free)**: build a synthetic fact base with one + `extensionIntent` fact + a stub `intentRules` map; `plan(empty, desired, + { params: { intentRules } })` and assert the replay action's SQL + + `consumes`/`produces` land in the plan; an `add`→`remove` flip yields the drop + replay. Assert `rulesFor("extensionIntent").drop` with no registry throws. +- Gate: `bun test src/` (all 194+ still green — proves the `drop` signature + change + new kind didn't regress the planner). +- **Commit** (`feat(pg-delta-next): extensionIntent rule + intent-rule registry via PlanParams`). + +### Step 3 — capture + replay per extension (one handler per commit) +Order by complexity (validate the mechanism on the simplest first): + +1. **pgmq** (no `consumes` — self-contained): + - capture: read pgmq's queue registry (`.meta` / `pgmq.list_queues()`) + → `extensionIntent{ext:"pgmq",intentKind:"queue",key:}` facts (payload: + type, partition/retention). Still emit the existing `managedBy` edges on + `q_*`/`a_*` (Phase A). + - intentKind rule: `create` → `select `pgmq.create` (or `_unlogged` / `_partitioned` variants)`, + `drop` → `select pgmq.drop_queue(…)` (`dataLoss:"destructive"`), `produces` + the queue + (via managedBy) its operational tables. + - integration test: from-empty rebuild recreates the queue; drop removes it. + +2. **pg_cron** (opaque command — order late): + - capture: read `cron.job` → `extensionIntent{ext:"pg_cron",intentKind:"job",…}`; + normalize `username` (CLI-1435). + - intentKind rule: `create` → `cron.schedule(…)`; a schedule/command/active + change → **`unschedule` + re-`schedule` by name** (static; avoid + `alter_job`'s runtime id); `drop` → `cron.unschedule(…)`. + - integration test: rebuild recreates the job; change re-schedules it. + +3. **pg_partman** Deliverable B (`consumes` the parent): + - capture: read `part_config` (+ `part_config_sub`) → intent facts (the + intent-column subset — CLI-1430). Keep Phase A's child `managedBy` edges. + - intentKind rule: `create` → `partman.create_parent(…)` + a + `set_part_config`-style follow-up; `consumes` the parent table fact so it + orders after `CREATE TABLE`. + - integration test: rebuild from a bare parent recreates partman config + + premade children. +- **Commit per handler** (`feat(pg-delta-next): pgmq intent replay`, …). + +### Step 4 — intent proof + full regression +- Extend the proof: after apply, the handler-aware re-extract + (`ctx.proveOptions.reextract`) must converge on intent too (it already does — + intent facts flow through the same diff). Add a + replay-roundtrip integration test per extension asserting `verdict.ok` and + intent re-capture equality, plus data-preservation (seeded queue messages / + partition rows survive where the plan claims `dataLoss:"none"`). +- **Full corpus regression** before the final push: + `PGDELTA_TEST_POSTGRES_VERSIONS=17 bun run test tests/` (and the sharded/15 + pass if iterating) — the Step 2 planner-core change (new kind + `drop` + signature) must not regress any existing scenario. +- Gate: corpus green both PG versions; differential clean; lint/types/knip. + +--- + +## 4. Gotchas captured from Phase A + +- **Proof consistency is fact-level.** Intent + managed exclusion must be + applied symmetrically to source, desired, AND the proof `reextract` (the + profile's handler-aware re-extractor, projected through `resolveView`). A + delta-only filter drifts the proof. (Already learned; + applies to intent facts too — but intent facts are *kept*, only operational + objects are excluded.) +- **Desired must keep the extension installed.** A declarative desired that + drops `pg_partman` un-manages the children (the proof then can't exclude them). + Real declarative sources declare the extension; tests must install it on the + desired side too. (This bit the Phase A roundtrip test.) +- **`WITH RECURSIVE`** for the `pg_inherits` descendant walk (not bare `WITH`). +- **partman v5 signature**: `create_parent(p_parent_table, p_control, p_interval)` + named params; the parent must already be `PARTITION BY RANGE`. v5 is always + native (no `p_type`). +- **Opaque commands** (cron): never parse; order replay late (high `weight`); + rely on run-time late binding; the proof catches a real ordering gap. +- **Test image**: `supabase/postgres:17.6.1.135` (ships pg_partman/pgmq/pg_cron); + `supabaseCluster()` in `tests/containers.ts`. Heavy — keep extension-intent + integration tests in their own files reusing the lazy singleton. +- **Deferred (B+)**: the typed `[extensions.*]` declarative block — the files + path already reduces to capture-after-running-the-replay-calls, so the block + is a second representation (P2 divergence risk). Add only if raw calls prove + error-prone (CLI-1431). + +--- + +## 5. Done-when + +- A from-empty rebuild against a project using pgmq / pg_cron / pg_partman + recreates the declared queues / jobs / partman parents (intent replay), with + no drop of managed objects and no row loss, **proven** by the proof loop + (state + intent convergence + data-preservation) on the Supabase image. +- Full corpus green on PG 15 + 17; the planner-core change (new kind, `drop` + signature) regresses nothing. +- `docs/extension-intent.md` §5 phase table updated (B → done); per-extension + intent-column specifics reconciled with CLI-1430. diff --git a/docs/roadmap/pg-delta-next-follow-ups.md b/docs/roadmap/pg-delta-next-follow-ups.md new file mode 100644 index 000000000..fe4c48949 --- /dev/null +++ b/docs/roadmap/pg-delta-next-follow-ups.md @@ -0,0 +1,597 @@ +# pg-delta-next — known pitfalls & follow-ups + +Captured from the code review of PR #315 (orderless declarative apply, grouped +export, formatting, redaction). These are **known, accepted** at merge time — +recorded here so they aren't rediscovered. Nothing below blocks the stacked +`feat/pg-delta-next` line from landing, but each should be picked up before the +engine is promoted past preview. + +Severity legend: **P1** correctness/safety, **P2** contract/coverage gap, +**P3** cleanup/maintainability. + +## P1 — correctness & safety + +### Co-located shadow can execute cluster-global DDL against the live cluster — ✅ core hole fixed in this PR + +`packages/pg-delta-next/src/frontends/load-sql-files.ts` applies each file inside +`BEGIN`/`COMMIT`, which already blocks every cluster-global non-transactional +statement (`ALTER SYSTEM`, `CREATE/DROP DATABASE`, `CREATE/DROP TABLESPACE`, +`VACUUM`, …) with `SQLSTATE 25001`. The actual escape was the 25001 **raw +fallback**: `applyFile` re-ran the offending single statement via +`client.query(sql)` *outside* the transaction, so on a co-located shadow (which +shares the target's live cluster) those statements executed against the customer's +cluster and persisted after the shadow was dropped. + +**Fixed:** the raw fallback is now gated by `RAW_FALLBACK_ALLOWLIST` — one entry, +`CREATE INDEX CONCURRENTLY`, the only non-transactional statement a declarative +schema legitimately contains. Every other 25001-raiser is refused with a +`ShadowLoadError` (`unsupported_non_transactional`) instead of running +unsandboxed; this also closes `CREATE SUBSCRIPTION (connect = true)` opening a +live replication connection from the shadow. Deterministic loader refusals now +rethrow immediately rather than being retried until the round budget exhausts. +Regression coverage in `tests/load-sql-files-atomicity.test.ts` (a `VACUUM` file +is refused; a `CREATE DATABASE` file is refused and never creates the sibling +database; `CREATE INDEX CONCURRENTLY` still loads). + +**Deliberately not done (low likelihood — see review discussion):** the engine +never emits these statements and Supabase declarative schemas realistically never +contain them (on managed Supabase the applier isn't even superuser, so they fail +permission-denied rather than leak). So the two heavier layers from the design +were skipped: + +- Extending `CLUSTER_DDL_RULES` / flipping it to an allowlist for an *up-front* + refusal (before the shadow is provisioned) and to also catch the *transactional* + cluster-global forms (`ALTER DATABASE … SET`, `GRANT … ON DATABASE`). +- Post-load `pg_database` / `pg_tablespace` snapshot checks (mirroring the + existing role-leak snapshot) to catch dynamic-SQL-smuggled transactional forms. + +The genuinely airtight fix remains the isolated ephemeral cluster in +[ephemeral-shadow-design.md](ephemeral-shadow-design.md); the fallback allowlist +is correct and useful regardless of whether that lands. + +### pg-topo total-order change flips pg-delta declarative-apply on cycles — ✅ resolved in this PR + +`packages/pg-topo/src/analyze-and-sort.ts` now returns `ordered` as a **total +order** that appends dependency-cycle members, where it previously returned only +the acyclic-drainable prefix (cycle members surfaced separately via +`cycleGroups`). `@supabase/pg-topo` is a published package and +`packages/pg-delta/src/core/declarative-apply` consumes `ordered` directly: on a +genuine cycle its status now flips from silent-success-with-fewer-statements to +`stuck` — the correct outcome, since an unbreakable cycle cannot be applied and +should fail loudly rather than silently drop statements. + +**Resolved:** + +- Semver: the changeset (`.changeset/pg-topo-total-ordered.md`) is bumped from + patch to **minor** and its wording now spells out the consumer-observable + behavior change (declarative apply reports `stuck` on a true cycle instead of + a partial success). +- Coverage: `packages/pg-delta/tests/integration/declarative-apply.test.ts` now + has a cycle case (mutual-FK tables) asserting every input statement reaches + the applier (`totalStatements === 3`), a `CYCLE_DETECTED` diagnostic, and a + `stuck` result. Verified RED against the pre-total-order behavior + (`totalStatements` was `1` — the two cyclic tables were dropped from + `ordered`). + +### Formatter strands the action keyword on every `ALTER TABLE` under `--format` — ✅ resolved in this PR + +In `packages/pg-delta-next/src/frontends/sql-format/`, `scanTokens` drops +double-quoted identifiers, so `formatAlterTable`'s positional cursor landed on +the action keyword and `skipQualifiedName` (`tokenizer.ts`) consumed it. Because +the renderer's `qid()` **always** double-quotes, every engine-rendered +`ALTER TABLE "s"."t" …` hit this when formatting was enabled: + +``` +ALTER TABLE "public"."users" ADD + COLUMN a int +``` + +This affected **all** `ALTER TABLE` forms (`ADD COLUMN`, `ENABLE ROW LEVEL +SECURITY`, `REPLICA IDENTITY`, …) and the sibling `formatAlterGeneric` +(`ALTER MATERIALIZED VIEW`/`DOMAIN`/`FOREIGN TABLE`/…). + +**Fixed:** both `formatAlterTable` and `formatAlterGeneric` now find the name's +true end from the raw statement via `qualifiedNameEnd` (the helper the +CREATE-family formatters already use), instead of positional token indexing. +Regression coverage lives in `format-quoted-names.test.ts`. + +**Remaining follow-up (same root cause, different symptom):** +`keyword-case.ts` (`isCaseableInContext` / the ALTER handler around line 846) +still uses `skipQualifiedName` to find the action-token region, so for a +quoted-name `ALTER TABLE` the per-action keyword-casing loop starts one token +late and can skip casing the first action keyword. Lower severity (casing, not +stranding) — fix it the same way (anchor on `qualifiedNameEnd`) when the +keyword-case pass is next touched. + +## P2 — contract & coverage gaps + +### pg-delta CI does not re-run on pg-topo changes — ✅ resolved in this PR + +`.github/workflows/tests.yml` gated the pg-delta unit/integration/check-types +jobs on `packages/pg-delta/**` (or root) only. A pg-topo-only change — like this +PR's `ordered` contract change — never re-ran pg-delta's suites, even though +pg-delta imports `@supabase/pg-topo` at runtime. (The merge queue forces all +outputs true, so the gap was real at PR-review time but masked at merge time.) + +**Fixed:** the `pg-delta` check-types step, `pg-delta-unit`, +`pg-delta-test-image-hash`, and `pg-delta-integration` jobs now also fire on +`needs.detect-changes.outputs.pg-topo == 'true'`. The `detect-changes` action +already emitted a `pg-topo` output, so no filter change was needed. `knip +(pg-delta)` was intentionally left on the pg-delta-only gate — it inspects +pg-delta's own unused code, which a pg-topo change cannot affect. + +### Changesets version a private package — ✅ resolved in this PR + +55 changesets target `@supabase/pg-delta`, which is `"private": true` / +`0.0.0`. The repo is in changeset pre-release (alpha) mode and +`.changeset/pre.json`'s `initialVersions` doesn't list pg-delta-next, so +`changeset status` planned a **minor** bump for it — the release workflow would +have consumed all 55 into a `chore: release` PR that bumps the unpublishable +package and writes a large CHANGELOG (`changeset publish` skips private packages, +so this was churn, not breakage). + +**Fixed:** added `@supabase/pg-delta` to `.changeset/config.json` `ignore`. +This is safe and non-destructive — all 55 changesets are standalone (they +reference no other package) and no published package depends on pg-delta-next, so +`ignore` neither errors nor cascades. The changesets are preserved (ignored +changesets aren't consumed) and `changeset status` now plans only +`@supabase/pg-topo` (patch). When pg-delta-next is eventually published, remove +it from `ignore` to release the accumulated history. + +### `elideCascadeSubsumedPolicyDrops` ignores policy→role references + +`packages/pg-delta-next/src/plan/internal.ts` judges whether a dropped object is +load-bearing from `pg_depend` edges only. A policy's referenced roles live in +`pg_shdepend` and are carried on the fact payload (`roles`), not as graph edges, +so a role-referencing policy's `DROP POLICY` can be elided as cascade-subsumed. +The `DROP OWNED BY ; DROP ROLE ` sequence in `rules/roles.ts` covers +the general case, but the sole-role-in-policy ordering is untested +(`policy-drop-compaction.test.ts` covers only the view case). **Follow-up:** add +the role scenario to the corpus. + +### `elideCoCreateRevokeBeforeGrant` guard reads only desired facts + +The `defaultGrantsOutside` guard in `internal.ts` inspects only **desired** +`defaultPrivilege` facts, so a *source-only* `ALTER DEFAULT PRIVILEGES` being +dropped in the same plan can still fire at create time — leaving the applied ACL +a superset of desired. The proof loop diffs ACLs post-apply, so any +corpus-covered scenario is safe; an uncovered one ships undetected. +**Follow-up:** add a corpus scenario that drops a source-only ADP alongside a +co-created grant. + +### pgmq tables excluded by a name-glob bandaid + +`packages/pg-delta-next/src/policy/supabase.ts` re-includes user triggers in +managed schemas, then carves pgmq's `q_*`/`a_*` queue/archive tables back out by +name glob (scoped to the `pgmq` schema). The comment concedes this compensates +for pgmq creating its tables via `pgmq.create()` rather than `CREATE EXTENSION`, +so extract-time `pg_depend` `'e'` membership misses them. If pgmq changes its +internal naming — or a user creates a `q_*` table inside the pgmq schema — the +classification is wrong. **Deeper fix:** tag pgmq-created objects as +extension/owner-owned at extraction time so the generic ownership exclusion +covers them. + +### `$1$…$1$` digit dollar-tags mis-scanned + +`sql-scanner.ts`'s `readDollarTag` (and the regex in `load-sql-files.ts`) accept +digit-leading dollar tags, so `$1$…$1$` is treated as a dollar-quoted span even +though Postgres parses `$1` as a positional parameter. Unlikely in +engine-rendered SQL; low severity but a real divergence from Postgres +tokenization. + +## P3 — cleanup (batch into a follow-up chore PR) + +- **Three hand-rolled SQL scanners, two disagreeing.** `walkSql` + (`sql-scanner.ts`) and `protectDollarQuotes` (`protect.ts`) don't nest-count + block comments; `maskLiteralsAndComments` (`load-sql-files.ts`) does. Unify on + one scanner (this also closes the `$1$` item above). +- **`quoteIdent` re-implemented ~6×** (`plan/render.ts`, `core/stable-id.ts`, + `cli/shadow.ts`, `load-sql-files.ts` ×2, `proof/prove.ts`). Extract one + helper; runtime/CLI callers can use `escapeIdentifier` from `pg`. +- **`formatMixedItems` is a byte-for-byte duplicate** of `formatKeyValueItems` + (`format-utils.ts`). Delete it; point its call sites at the original. +- **Dead branches** in `keyword-case.ts` `isCaseableInContext` (`OR`, and + `AS`+`CREATE`) return the same value as the fall-through default. +- **`cmdSchemaApply` is a ~600-line God-function** (`cli/commands/schema.ts`): + duplicated role-name derivation, 5× copy-pasted enum-flag validation. Split + into `parseApplyFlags` / `resolveScopeAndProfile` / `prepareFiles` / + `resolveShadow` / `runApply`, add a `parseEnumFlag` helper. +- **Perf nits:** `internal.ts` teardown scans the full edge list per destroyed + id (use the existing `incomingEdgesByEncoded` reverse index, cf. + `replacement-expansion.ts`); `diff.ts` and `snapshot.ts` sort comparators + rebuild keys per comparison (Schwartzian transform); `keyword-case.ts` makes + two full passes (scanTokens + walkSql); `load-sql-files` does 3 round-trips per + file; `schema.ts` calls `mkdirSync` per exported file. +- **Test/script infra duplicated from pg-delta:** `tests/containers.ts` and + `scripts/sync-supabase-base-images.ts` re-derive helpers that already exist in + pg-delta's test/script harness (`ensureSupabaseDbMajorVersion` is already + exported). `collectSqlFiles` duplicates pg-topo's `discoverSqlFiles`. `hash.ts` + vs pg-delta `fingerprint.ts` is likely a deliberate independent equality + surface — add a comment saying so, so nobody "fixes" it by importing. +- **`protect.ts` body-protection kind list** is hand-maintained and disconnected + from the per-kind rule metadata the renderer uses; drive it from the same + metadata so the formatter and plan agree on which kinds carry an opaque body. +- **`formatters.ts` repetition:** ~18 hand-written command-prefix guards and a + copy-pasted parenthesized-list assembly → `matchesPrefix` + `formatParenBlock` + helpers. +- **`pg-delta-next.yml` uses bare `bun test`.** The repo convention is + `bun run test`; here the package `test` script carries no extra flags so it is + harmless, but align it or note the exception explicitly. + +## Documented, by-design (no action — recorded so they aren't re-flagged) + +- **`rootHash` folds reference-only facts** (`core/fact.ts`). Intentional: the + apply fingerprint gate relies on it, so a plan is only applicable against the + same baseline. Consequence: the same database fingerprinted under different + profiles yields different `rootHash`es, and `view.facts()` includes + reference-only platform/extension facts. Consumers that care must filter + `referenceOnly`; the load-bearing ones already do. +- **pg-topo README** states `ordered` "always contains every input statement + exactly once." Accurate for *statements*, but a file that fails parsing + contributes zero statement nodes and is therefore absent. pg-delta's + declarative-apply has no `PARSE_ERROR` fallback, so a parse failure yields a + silently-incomplete apply reported as success. Doc-wording + consumer-hardening + follow-up. +- **Deleted dogfood differential harness** (`differential.test.ts`, + `old-engine.ts`). Not a coverage loss: the new proof loop applies each scenario + against real Postgres and diffs the resulting facts against ground truth — a + stronger check than comparing two engines. Only the cross-engine + *diagnostic-quality* comparison is gone. + +## PR #315 review triage (Codex) + +**Fixed in this PR (P1):** + +- `export-manifest.ts` — fail closed on a malformed manifest. +- `view.ts` — preserve reference-only marks across fact projection. +- `schema.ts` — re-check executable SQL after `--skip-cluster-ddl` stripping. +- `load-sql-files.ts` non-role cluster DDL — already closed by the 25001 + fallback allowlist (see the shadow item above). +- `.changeset/pg-topo-total-ordered.md` — already bumped to minor. + +**False positive:** `dbdev-roundtrip.test.ts` "missing dbdev fixture helper" — +`scripts/lib/bootstrap-dbdev-fixture.ts` is committed and tracked. + +**Deferred P1 — owner-based policy exclusion is blind under `--scope database` +(dedicated follow-up PR):** + +`cmdSchemaApply` projects both fact bases to management scope (`schema.ts:936-937`) +*before* `plan()` applies the policy. `projectManagementScope("database")` prunes +`role`/`membership` facts and every `owner` edge (they point at roles); the policy +owner predicate (`policy.ts:~397`) resolves owner from that edge (the extractor +never populates `payload.owner`), so the Supabase `{ owner: SUPABASE_SYSTEM_ROLES }` +exclusion (`supabase.ts:322`) can't match — a platform-role-owned object in a USER +schema (e.g. a `supabase_admin`-owned table in `public`) is treated as managed and +planned for DROP/ALTER. Data loss, in the default scope. (`schema export` already +composes the correct order — `resolveView` then project — so an export omits these +objects while a subsequent apply drops them.) + +Deferred to its own PR because it changes core plan/apply/prove signatures and has +deliberate test-harness fallout. Recommended fix (Fable design): + +- Add a trailing `scope: ManagementScope = "cluster"` param to `resolveView` + (`policy.ts`) that applies `projectManagementScope` as the LAST step — owner + edges are intact when the owner rule is evaluated, and + `excludeFactsAndDescendants` already carries `referenceOnly` forward. +- Thread `scope` through `PlanOptions.scope` → `Plan.scope` (stamped like + `capability`) → both `resolveView` calls in `change-set.ts` → the `apply` + fingerprint gate (`apply.ts:145`) → both `resolveView` calls in `prove.ts:436`. +- In `schema.ts`, delete the two `projectManagementScope` calls (936-937) and the + one in the fingerprint-gate re-extract closure (1000-1004); pass `scope` in + `planOptions` instead. Export path unchanged. +- `"cluster"` (default) makes `projectManagementScope` identity, so the DB-to-DB + plan path and the corpus are byte-identical (verify with a full corpus run). +- **Test-harness landmine:** `supabaseCluster()` connects as `supabase_admin` (a + system role), so `phase2b-seed-shadow.test.ts` shadow objects become owner- + excluded and strand requirements. Fix those tests to apply as a non-system + login role (as `supabase-dsl-e2e.test.ts` already does) — do NOT weaken the fix. +- RED: a supabase-profile integration test where a `supabase_admin`-owned table in + `public` must survive `schema apply --scope database --profile supabase` (today + it is planned for DROP). Plus `resolve-view.test.ts` unit cases for the new param. + +**Deferred P2 (tracked follow-ups; not blocking this PR):** + +- `extract/routines.ts` — window functions (`prokind = 'w'`) are extracted as + facts but the dependency resolver still uses `('f','p','a')`, so a dependent + ordered before a user window function can fail shadow load. Add `w` to the + resolver's proc CTE. +- `frontends/seed-assumed-schemas.ts` — quick-mode supabase seeding filters out + platform extension members (e.g. `pg_graphql`), so a user object referencing + one fails to load in the co-located shadow. Seed the owning platform extension + or keep those members. +- `frontends/sql-order.ts` `orderForShadow` — drops parse diagnostics, so a + library caller can silently pass a partial desired state (same root as the + pg-topo PARSE_ERROR item above). Return the analysis or throw on blocking + diagnostics. +- `cli/commands/schema.ts` — co-located shadow lifecycle: `process.exit` skips + the `finally` that drops `pgdelta_shadow_*` (Bun); `--isolated-shadow` on the + co-located path skips the role-leak snapshot; dynamic (DO-block) cluster DDL + isn't contained. (Related to the deferred shadow-hardening layers above.) +- Redaction/legacy-snapshot handling: `apply.ts`, `drift.ts`, `schema.ts` — + treat legacy (unstamped) snapshots/exports as unredacted; `routines.ts` reject + snapshots missing routine metadata. +- `plan/rules/helpers.ts` / `frontends/export-sql-files.ts` — preserve built-in + defaults when dropping ADPs; avoid seeding non-ambient reference-only facts. +- Test hygiene: `redaction-output.test.ts` and `policy.test.ts` create databases + / roles on the shared cluster without dropping them (leak); the + `privilege-operations--create-grant-drop-unrelated` corpus case needs + `isolatedCluster: true` so the CREATE ROLE + GRANT ordering is actually tested. +- `plan/internal.ts` `elideCascadeSubsumedPolicyDrops` — a policy's `TO ` + refs live in the payload, not as edges, so dropping a table+role can elide the + only `DROP POLICY` that releases the role (also on our own review list). +- `frontends/load-sql-files.ts` `CLUSTER_DDL_RULES` — the role/user/group regexes + also match `CREATE/ALTER/DROP USER MAPPING` (a database-local FDW object), so + database scope wrongly refuses/strips user mappings. Exclude `USER MAPPING`. +- `frontends/load-sql-files.ts` `maskLiteralsAndComments` — treats only doubled + quotes as escapes, not `E'...\''` backslash escapes, so an `E`-string with a + `;` can mis-split and trip the cluster-DDL / statement scanners. Make the mask + E-string-aware (or reuse the sql-format scanner). +- `cli/commands/schema.ts` — a dir with `SET ROLE` / `SET SESSION AUTHORIZATION` + / `SET search_path` leaves session state on the pooled client after the load + (survives COMMIT), so validation/extraction can run under the wrong role. Reset + session state after loading or isolate it. +- `plan/phases/action-emitter.ts` — default-ACL hygiene on replaced objects keys + off the FINAL owner, but under `--restrict-to-applier` the recreate runs as the + applier, so an applier-level ADP grant can survive without a matching REVOKE. + Key the hygiene off the creator/applier role for recreated objects. +- Redaction safety: `extract/sensitive-options.ts` redacts `file_fdw`'s standard + `filename` (not in the allowlist), producing a placeholder path that applies to + a bogus file; `extract/publications.ts` keeps `enabled: true` for a redacted + subscription, so a default redacted export can activate a worker against a + placeholder conninfo. Both should preserve the non-credential value or refuse. + +## PR #299 review triage (Codex) — engine-hardening backlog + +Context: PR #299 promoted the clean-room engine to `@supabase/pg-delta` (the hard +switch). Codex re-reviews every commit and re-surfaces **pre-existing** engine gaps +— the switch itself only moved the package and added the build/CI/docs, so the +engine code is byte-identical to pre-switch. **None of these are switch +regressions**, and none were fixed in #299 (scope kept to the switch). They belong +to the engine-hardening track, alongside the extract-completeness fixes already +landed here (reloptions, aggregate/range/identity/subscription options: `f530082`, +`1fbdc69`, `528bc60`, `ed1bcdd`, `a638ecf`). Recorded with `comment_id`s for pickup. + +### Batch A — planning/extract crashes on legitimate declarative input (highest value) + +These make `plan` / `schema apply` / `export` throw on inputs a user can legitimately +author. The first two share the "nullable/`false` transition → `str()` throws → route +to **replace**" pattern already used for policy clause removal (`d2cdbf7`). + +- **constraint validated→NOT VALID** — `src/plan/rules/constraints.ts:49` (comment + `3537607442`). `validated` delta with `to === false` calls `str(to)` and aborts. + Route through replace/drop-add (mirror policy `usingExpr`/`checkExpr` → `"replace"`). +- **foreign server VERSION removal** — `src/plan/rules/foreign.ts:70` (comment + `3537607455`). `version` → `null` throws in the alter path (create already treats + null as omitted). Handle the nullable transition or mark the attribute for replace. +- **enum rebuild with enum-array column** — `src/plan/rules/types.ts:230` (comment + `3537607496`). For a `that_enum[]` column the rewrite renders scalar + `TYPE USING col::text::` instead of the column's desired array type — + plan fails / would scalarize the column. Use each dependent column fact's desired + type/cast, not the enum `relName` unconditionally. +- **reference-only export member under a managed non-public schema** — ✅ **resolved** + `src/frontends/export-sql-files.ts` (comment `3537607461`). Member seeded into + the pristine baseline without its schema parent → `buildFactBase` missing-parent + throw before any file renders. **Fix:** exclude extension members from the export + baseline (via `extensionMemberReferenceOnly`) — they never need seeding + (`CREATE EXTENSION` materializes them; the requirement guard's + `memberExtensionPresent` satisfies any consumer), and the managed install schema + is still exported so the result reloads. Regression: + `tests/export-extension-member-parent.test.ts`. +- **user mapping on a filtered extension-owned server** — `src/extract/foreign.ts:91` + (comment `3537607477`). Ext-owned servers are anti-joined out, but `pg_user_mapping` + rows still emit and parent to an absent `server` fact → missing-parent throw. Filter + the mappings consistently, or keep the server as a reference-only parent. + +### Batch B — rendering / access / library correctness + +- **zero-argument aggregate metadata** — `src/plan/render.ts:59` (comment + `3537607470`). `COMMENT ON AGGREGATE "s"."agg"()` (and the reused SECURITY LABEL + target) must be `(*)` for zero-arg aggregates; a from-empty plan creates the + aggregate then fails on the metadata statement. Use the signature renderer that + emits `*` for empty args. +- **role security labels touch `pg_authid`** — `src/extract/security-labels.ts:195` + (comment `3537607465`). If any security label exists and extraction runs as a + non-superuser, the role-label query reads `pg_authid` (superuser-only) and fails — + even when the label is on a table. Join through `pg_roles`. +- **default apply gate ignores plan redaction mode** — `src/apply/apply.ts:144` + (comment `3537607487`). A library caller applying an unredacted plan + (`redactSecrets: false`) without the CLI wrapper still re-extracts redacted, so the + fingerprint gate compares cleartext to placeholders and rejects an unchanged target. + When no custom `reextract` is supplied, pass `thePlan.redactSecrets ?? true` to + `extract` (mirror in `prove.ts` re-extraction). Ties to the display-vs-apply + redaction follow-up (comment `3428269873`). + +### Earlier open extract-completeness batch (same track) + +From the same review stream; jgoux fixed several siblings, these remain for pickup — +role membership SET/INHERIT options (`3530186654`), database-scoped role GUCs +`setdatabase<>0` (`3530186665`), unlogged sequences `relpersistence` (`3530186678`), +matview populated state `WITH [NO] DATA` (`3530186683`), user-defined base types +(`3530186693`), user-rule dependency resolution to rule facts (`3530186701`), custom +identity sequence names (`3530186709`), foreign-column FDW options `attfdwoptions` +(`3530186714`), PG18 virtual generated columns (`3530186719`), publication +`publish_generated_columns` (`3530186730`), role connection limits `rolconnlimit` +(`3530186736`), multiple-inheritance parents (`3536715681`), foreign-table partition +attachments (`3536715689`), non-relocatable extension `SET SCHEMA` (`3536715698`), +enum metadata restore after value-set rebuild (`3536715704`), partial default-privilege +reset before grants (`3536715708`), window-function dependency resolution `prokind='w'` +(`3536715714`), publication-member rebuild on table replace (`3536715717`). + +### Wave 2 (re-review of commit `ada0ab5`) — more of the same track + +Planning / export / apply fidelity: + +- **typed-table `OF` relationships** — `src/plan/rules/tables.ts:64` (comment + `3537805071`). A `CREATE TABLE ... OF composite_type` renders as an ordinary + `CREATE TABLE (...)`; the only catalog difference is a depends-edge (which emits no + DDL), so typedness-only changes no-op and from-empty plans recreate typed tables as + ordinary ones. Needs a `pg_class.reloftype` marker on the payload to drive replace. +- **by-object export FK atomicity** — `src/frontends/export-sql-files.ts:388` (comment + `3537805092`). By-object export appends every table action to one file, but + `loadSqlFiles` applies each file atomically; for mutually-dependent FKs the plan is + correctly ordered but regrouped so each file's FK references the other's not-yet- + committed table → both roll back. Keep FK alters in dependency-order runs / separate + files to preserve the by-object fidelity contract. +- **policy `TO`-role release before DROP ROLE** — `src/plan/rules/policies.ts:56` + (comment `3537805111`). When a policy's `TO` list drops a role in the same plan, the + `ALTER POLICY ... TO ...` neither releases nor consumes the old role; policy role + refs are shared deps (not `pg_depend` edges), so the graph may run `DROP ROLE` first + and fail. (Same family as the `elideCascadeSubsumedPolicyDrops` item above.) + +Extract-completeness / coverage (invisible drift): + +- **ICU collation rules** — `src/extract/types.ts:298` (comment `3537805075`). + `pg_collation.collicurules` isn't hashed, so a `RULES`-only difference compares equal + and from-empty emits `CREATE COLLATION` without the rules. +- **user-defined conversions unmodeled** — `src/extract/unmodeled.ts:62` (comment + `3537805081`). The completeness probe omits `pg_conversion`, so a `CREATE CONVERSION` + yields neither a fact nor an `unmodeled_kind` diagnostic — strict coverage silently + ignores it. +- **extension-member columns dropped from the reference view** — + `src/extract/relations.ts:138` (comment `3537805086`). The anti-join skips column + facts of an ext-owned table even when the table is kept as a member, so column + satellites (comment/seclabel) and column-level edges dangle. Keep columns as + reference-only descendants. +- **column-level privileges** — `src/extract/relations.ts:129` (comment `3537805098`). + `pg_attribute.attacl` (`GRANT SELECT (col)`) isn't emitted as an `acl` satellite, so + column-grant-only diffs are invisible and from-empty exports drop them. +- **role comments in cluster scope** — `src/extract/roles.ts:32` (comment `3537805102`). + Cluster-scope role facts never emit a comment satellite, so `COMMENT ON ROLE` drift is + invisible and from-empty cluster exports omit it (the renderer already supports it). + +### Wave 3 (re-reviews of `671a799` / `5e26a52`) — more extract-completeness / replace-cascade + +Only the findings NOT already listed above (Codex re-issues the earlier ones — role +membership options, db-scoped GUCs, unlogged sequences, matview populated, range +CANONICAL, foreign-column `attfdwoptions` — with fresh comment_ids each pass). + +Replace / apply cascade: + +- **FDW server children rebuild on replace** — ✅ **resolved** by `da2d75c1`. + Replacement emission recursively drops children across non-cascading server + boundaries, recreates surviving descendants after the server, and is pinned in + both directions by `foreign-data-wrapper-operations--replace-server-with-children`. + The original finding was comment `3537910549`. + +Extract-completeness (invisible drift / wrong from-empty replay): + +- **composite attribute order** — `src/extract/types.ts` (comment `3537910546`). ✅ **partially + resolved** (Unit A, this branch): `attnum` is now carried as non-semantic `_position` and the + from-empty composite `CREATE TYPE` renders in declared order, so reconstruction no longer + changes row layout. STILL OPEN: the order-only-diff-*detection* half — a field-order-only + difference still compares equal (position is `_`-excluded from the hash by design), so a live + reorder is not detected as a delta. Making position semantic needs type-rebuild machinery for + order-only changes and would invalidate every snapshot/baseline — deferred. +- **comments on constraint-backed indexes** — `src/extract/relations.ts:272` (comment + `3537910554`). The index anti-join drops the index fact for a PK/unique/exclusion + constraint, and the constraint extractor reads only `pg_constraint` comments, so a + `COMMENT ON INDEX` on that index is lost. +- **table tablespaces** — `src/extract/relations.ts:82` (comment `3537910560`). + `pg_class.reltablespace` isn't hashed and no create/alter renders `TABLESPACE`; + tablespace-only diffs no-op and from-empty replay uses the default tablespace. +- **table access methods** — `src/extract/relations.ts:36` (comment `3537910571`). + `pg_class.relam` (`CREATE TABLE … USING …`) isn't recorded; AM-only diffs compare + equal and from-empty replay uses the default table AM. +- **column storage metadata** — `src/extract/relations.ts:100` (comment `3537910575`). + `SET STORAGE` / `SET COMPRESSION` / `SET STATISTICS` (on `pg_attribute`) aren't + extracted or rendered; those-only diffs are invisible and from-empty replay uses + defaults. +- **role `VALID UNTIL`** — `src/extract/roles.ts:30` (comment `3537910580`). The + password-expiry attribute isn't on the role payload; a login role differing only by + expiry compares equal and from-empty cluster export drops it. +- **subscription `failover`** — `src/extract/publications.ts:135` (comment `3537910587`). + The version-gated subscription option list stops at `run_as_owner`/`origin`; a + `failover`-only difference hashes identically and from-empty replay omits it. + +### Wave 4 (re-review of `bd68f7b`) — apply-ordering / access / shadow + +New findings only (multiple-inheritance parents `3538433909` and window-function deps +`3538433920` are re-issues of `3536715681` / `3536715714` above). + +Apply ordering / cascade (plan can fail at apply): + +- **release old sequence owner before dropping it** — `src/plan/rules/sequences.ts:117` + (comment `3538433876`). Retargeting an `OWNED BY` sequence to a new column while the + old column/table is dropped in the same plan: the alter consumes only the new owner; + the extractor drops the sequence→old-column auto-dep and drops sort before alters, so + the old table (and its owned sequence) can be dropped first → the later + `ALTER SEQUENCE … OWNED BY` fails. Order via the `from` owned-by value. +- **order in-place alters after new dependencies** — `src/plan/internal.ts:184` (comment + `3538433892`). An ALTER that makes a surviving fact depend on a newly-created object + (column → new enum/domain, default → new function) only consumes the existing fact and + isn't in `produces`, so the desired dependency edge is never walked → the ALTER can + sort before the CREATE and fail. Walk desired edges for altered subjects (or make the + alter consume the new target). +- **move extensions before dropping their old schema** — `src/plan/rules/schemas.ts:81` + (comment `3538433899`). A relocatable extension moving `old`→`new` while `old` is + dropped: the alter consumes only `new` and never releases `old`, so `DROP SCHEMA old` + can run while members are still there and fail. Order the move via the `from` schema. + +Access / shadow correctness: + +- **subscription conninfo read as non-superuser** — `src/extract/publications.ts:139` + (comment `3538433886`). The unconditional `subconninfo` select fails for normal users + (PostgreSQL revokes that column) before redaction can help — a non-superuser diff + aborts in subscription extraction. Guard by privilege; diagnostic/placeholder instead. +- **shadow emptiness check misses non-relation objects** — + `src/frontends/load-sql-files.ts:459` (comment `3538433916`). The guard checks only + `pg_class`, so a shadow pre-loaded with enums/domains/routines/collations/extensions/ + default-privileges is treated as empty; the load then extracts that pre-existing state + as if it were declared, contaminating the desired fact base. Cover all managed catalogs. + +## Supabase roundtrip hardening (non-superuser Cloud fidelity) — this branch + +Driven by the Supabase roundtrip acceptance harness (`scripts/roundtrip-supabase.ts`) +applying as the production-faithful role a real Cloud project hands users: a NON-superuser +`postgres` that is a member of `supabase_privileged_role` (not the `supabase_admin` the test +harness previously stood in with — see `tests/containers.ts`). The old SUPERUSER shim masked +a series of real non-superuser failures, each fixed RED-first on this branch: + +- **composite attribute order** — Unit A (see above; ✅ render/export, detection deferred). +- **body validation blocked user applies on platform code** — ✅ Unit B: routine-body + validation failures in seeded (assumed-schema) schemas are now named warnings, not hard + errors; user-schema failures still block and now name the routine; the CLI prints the + per-routine `ShadowLoadError.details`. +- **body validation of non-sql/plpgsql routines** — ✅ Unit G + (`src/frontends/load-sql-files.ts`): the pass is scoped to `lanname IN ('sql','plpgsql')` + (the only bodies `check_function_bodies` validates). `LANGUAGE internal` range-support + functions auto-generated by `CREATE TYPE … AS RANGE` are non-superuser-uncreatable and + have no checkable body. +- **co-located seed not replayable by non-superuser** — ✅ Unit C + (`src/frontends/seed-assumed-schemas.ts`): the seed omits `defaultPrivilege` facts and + skips routines whose `pg_proc.proconfig` sets a superuser-only GUC (carried structurally + as non-semantic `_configGucs`; NO SQL-text editing — see the doctrine in + `.github/agents/pg-toolbelt.md`) plus their in-seed dependents. +- **system-role ADP exported as user state** — ✅ Unit E (`src/policy/supabase.ts` Rule 6b): + `defaultPrivilege` facts whose FOR-role (`id.role`) is a system role are excluded + (platform-managed); grantee-side ADP (the user-owned API-role default) is kept. +- **inline constraints folded over deferred columns** — ✅ Unit D + (`src/plan/internal.ts` `compactColumnFolds`): a table constraint is no longer folded + inline when a same-table column it references was deferred to a later `ADD COLUMN` + (domain-typed / generated columns) — it renders as a standalone `ADD CONSTRAINT`. +- **owner-based policy exclusion defeated under database scope** — ✅ Unit H + (`src/plan/phases/change-set.ts` + `plan.ts`/`apply.ts`/`prove.ts`): the managed view is + now `projectManagementScope(resolveView(raw, …), scope)` in one place, so owner edges + survive policy resolution and a policy owner-exclusion rule (Supabase Rule 6) correctly + excludes system-role-owned platform objects (event triggers, etc.) instead of planning a + DROP the applier cannot execute. This had been silently DROPping platform event triggers + under the SUPERUSER shim. + +### Still open + +- **P2 — local-`supabase start` vs Cloud baseline drift.** After all fixes, the roundtrip's + ONLY residual diff is `schemas/public/default_privileges.sql`: the local base-init fixture + (`tests/fixtures/supabase-base-init/*.sql`, from `supabase start`) carries + `ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" … REVOKE ALL … FROM "postgres"` entries the + Cloud source project does not. No loader/policy/engine change fixes this — it is a + baseline-DATA divergence between local and Cloud provisioning. This is the concrete case + for the versioned-baseline-sidecar work (per-stack-fingerprint baselines derived from real + Cloud state rather than a local-fixture capture). +- **P3 — bootstrapped explicit `--shadow` for the supabase profile.** A user-bootstrapped + (base-init'd) explicit `--shadow` currently trips the loader emptiness guard ("shadow + database is not empty"). Deferred deliberately: a bootstrapped shadow's platform surface + matches the installer era, not the target, so managed-scope divergences would surface as + phantom migrations — strictly more dangerous than the target-derived co-located seed, and + its one advantage (no reconstruction) is moot now that the seed is non-superuser-replayable + (Unit C). Revisit only alongside the baseline-sidecar work, which would make + bootstrap-vs-target drift detectable. diff --git a/docs/roadmap/post-v1.md b/docs/roadmap/post-v1.md new file mode 100644 index 000000000..722c99749 --- /dev/null +++ b/docs/roadmap/post-v1.md @@ -0,0 +1,128 @@ +# Post-v1 backlog + +What comes *after* the correctness-first v1 cut (see [v1.md](v1.md)), in two +milestones — **performance**, then **DX & cutover** — plus the deliberate +deferrals. This consolidates the former per-item `tier-*` files into one tight +backlog; each entry is problem · approach · status, with Linear IDs where they +exist. + +| Symbol | Meaning | +|---|---| +| ✅ | Shipped | +| 🟠 | Net-new engineering, ready to start | +| 🟡 | Substrate exists; build the consumer/surface | +| 🔴 | Net-new engineering, blocked on a decision | +| 🟢 | Validation / product / process (not new engine code) | +| ⚪ | Deliberate deferral (documented, regression-free) | + +--- + +## Milestone A — performance + +### ✅ Dependency-resolver rewrite +A single correlated `pg_depend` resolver query was 86% of extraction. Rewriting +it set-based made it **7×** faster and `extract` **4.2×** faster overall +(1,881 → 453 ms cold on ~12k objects), with byte-identical edges. *Shipped* +(CLI-1603); recorded in [../build-log.md](../build-log.md). + +### 🟠 Memory-optimal extraction & diff +The diff materializes both catalogs (held heap is lean ~660 B/fact, but the `pg` +driver buffers full result sets → transient peak is the OOM edge). Plan: make held +memory **O(changes)** via a two-pass hash-manifest → fetch-changed diff. **Phase 1** +(cursor-stream the unbounded extractors + a `maxFacts` guard) is low-risk and +independently shippable; the full manifest diff is gated on a real 250k+-object +catalog need. + +### ⚪ Parallel snapshot extraction +Originally assumed the big win; re-profiling after the resolver rewrite showed it +would gain **< 2×** for a large, consistency-critical refactor (the resolver is +one unsplittable query that caps the ceiling). Deferred. + +--- + +## Milestone B — DX & cutover + +### 🟡 Risk classification 2.0 +The engine already computes proof-verified per-action safety (`dataLoss`, +`rewriteRisk`, `lockClass`, `transactionality`). Derive stable `HazardKind` codes +from those fields, attach them to the plan artifact (additive), add an +`--allow-hazards` gate and a GitLab Code-Quality JSON reporter. CLI-1459–1464. + +### 🟡 Migration squash / repair +Collapse a chain of migration files into one consolidated migration, and emit it +across multiple files respecting segment boundaries. Substrate exists +(`loadSqlFiles`, `plan`, `segmentActions`, ordered export); build the `squash` / +`repair` commands + multi-file output. CLI-1597, CLI-1598 (CLI-1424's +public-only limitation doesn't exist here by construction). + +### 🟡 Object-filtering flags +Expose the policy DSL's filtering vocabulary as CLI flags (`--schema`, +`--exclude`). Thin consumer over existing predicates: flags → `Policy`, wire into +commands, report what was filtered. CLI-1006, CLI-1169, CLI-1432. + +### 🟡 Typed auth / connection errors +Classify connection failures into typed errors with stable codes (`auth_failed`, +`host_unreachable`, `tls_error`, `timeout`, `db_not_found`) and redact +credentials from every message (redaction already done). CLI-1607. + +### 🟡 Stripe Sync Engine reset +`db reset` fails when a local Stripe Sync Engine owns a schema. Engine lever +exists (mark the schema externally-managed: exclude via policy + drop FKs into +it); the container-sequencing half is Supabase-CLI work, out of this repo. +CLI-1582. + +### 🟡 Applier-capability CLI wiring +Persistence is shipped (`plan --restrict-to-applier`); extend the capability +projection through the rest of the flow. + +### ✅ Engine refactors (locality/allocation) +Cleanup items from the 2026-06-15 branch review (reverse-index rebuild, +`FactBase.getByEncoded`/`incomingEdges`, onboarding map, projected-emission seam, +extractor/rules split by family). The substantive items shipped; what remains is +deliberate non-decisions. Not bugs. + +### 🔴 Extension-intent Phase B +Replay extension intent (`pgmq.create`, `cron.schedule`, `partman.create_parent`) +on a from-scratch rebuild. Phase A (no data loss) shipped; Phase B is +execution-ready but **blocked on the declarative-source-format decision** +(CLI-1431) and the per-extension intent matrix (CLI-1430). Full plan: +[extension-intent-phase-b.md](extension-intent-phase-b.md). + +### 🟢 Stage-10 cutover +Switch consumers from the old engine at the **parity bar**: corpus 100% green, +zero untriaged differential divergences, generative soak at quota, extractor ring +green, performance parity, real-world shakedown, and the naming/deprecation +decision. Product/process, sequenced after v1 + perf. + +--- + +## Designs parked for later + +Full designs that are written but deliberately not yet implemented: + +- **[ephemeral-shadow-design.md](ephemeral-shadow-design.md)** — auto-provision a + throwaway shadow so `schema apply --shadow` becomes optional. Captures the + cluster-global-DDL correctness tension and the recommended approach (isolated + Docker container + baseline subtraction). The related round-cap fix already + shipped. + +--- + +## Deliberate deferrals (not blocking any milestone) + +Documented, regression-free, each with a trigger to revisit: + +- **Sub-entity & rare member-root provenance** — columns, constraints, indexes, + triggers, policies, rules, plus FDW/server/foreign-table/event-trigger/ + publication are still filtered at extraction rather than carried as + `memberOfExtension` facts. +- **Modeling rare kinds** — casts, operators (class/family), text-search, + statistics, languages, transforms are *detected and reported* (the + `unmodeled_kind` diagnostic), not modeled. Model them when a real schema needs + it (CLI-690 is the canonical add-when-needed example). +- **Security-label CI prebuild** — the `dummy_seclabel` image builds on first run; + prebuilding it in CI is an optimization. +- **PGlite in the trusted path** — not adopted; PostgreSQL remains the elaborator. + +See [../packages/pg-delta-next/COVERAGE.md](../../packages/pg-delta-next/COVERAGE.md) +for the authoritative catalog-coverage map. diff --git a/docs/roadmap/v1-evidence.md b/docs/roadmap/v1-evidence.md new file mode 100644 index 000000000..eca8ed267 --- /dev/null +++ b/docs/roadmap/v1-evidence.md @@ -0,0 +1,133 @@ +# pg-delta-next — v1 evidence record + +- **Purpose**: the recorded, reproducible proof that the v1 correctness gates + passed *at scale* — not just at CI defaults. v1 is not cut until this document + is filled and the gates are green. Parent roadmap: + [`v1.md`](v1.md) (§2, + "Validation — run the gates to green at scale"). +- **Status**: ⏳ **TEMPLATE — not yet run.** The engineering correctness items + are shipped (roadmap §1); this is the remaining validation-at-scale gate. + +> Fill every field below from a single run on one commit. If a field cannot be +> filled, say why explicitly — a blank or "N/A" with no reason is itself a v1 +> blocker. Silent omission is the exact failure mode v1 is designed to prevent. + +## Run identity + +| Field | Value | +|---|---| +| Commit SHA | `` | +| Branch | `feat/pg-delta-next` | +| Date (UTC) | `` | +| PG versions exercised | `15`, `17`, `18` | +| Runner / environment | `` | +| `EXPECTED_RED` contents | `` | + +## Gate 1 — corpus proof loop + +Every scenario × 2 directions under the full proof loop (state + +data-preservation + rewrite observation), on each PG version. + +```bash +cd packages/pg-delta-next +PGDELTA_TEST_IMAGE=postgres:15-alpine bun test tests/engine.test.ts +PGDELTA_TEST_IMAGE=postgres:17-alpine bun test tests/engine.test.ts +PGDELTA_TEST_IMAGE=postgres:18-alpine bun test tests/engine.test.ts +``` + +| PG | Scenarios | Pass | Fail | Notes | +|---|---|---|---|---| +| 15 | `` | | | | +| 17 | `` | | | | +| 18 | `` | | | | + +## Gate 2 — differential (new engine vs old `pg-delta`), full run + +```bash +PGDELTA_NEXT_DIFFERENTIAL=all PGDELTA_TEST_IMAGE=postgres:17-alpine \ + bun test tests/differential.test.ts +``` + +Bucket counts (the hard gate is **zero untriaged `new-fails-old-converges`**): + +| Bucket | Count | Notes | +|---|---|---| +| both-converge | | | +| accepted-difference | | each needs a one-line reason below | +| new-fails-old-converges | | **must be 0 untriaged** | +| old-fails-new-converges | | (new strictly better) | + +Accepted differences (reason each): + +- `` — `` + +## Gate 3 — generative soak + +Agreed quota: **``**. + +```bash +PGDELTA_NEXT_SOAK= PGDELTA_TEST_IMAGE=postgres:17-alpine \ + bun test tests/generative.test.ts +``` + +| Field | Value | +|---|---| +| Seed range | ``–`` | +| Iterations | `` | +| Proof failures / cycles / crashes | `` | +| Kind-coverage checklist satisfied | `` | + +## Gate 4 — real-world shakedown + +At least one large, anonymized, production-shaped schema through `plan` + `prove`. + +| Field | Value | +|---|---| +| Schema description (anonymized) | `` | +| Source | `` | +| plan result | `` | +| prove result | `` | +| `unmodeled_kind` diagnostics | `` | + +## Gate 5 — Supabase baseline path + +`src/policy/baselines/` holds only `.gitkeep` today; `supabasePolicy` does NOT +declare a baseline yet (a declared-but-unresolved baseline now fail-fasts — +`resolveBaseline`). v1 either (a) commits a real Supabase baseline snapshot and +exercises subtraction in CI, or (b) explicitly excludes baseline subtraction +from v1 scope (filters alone hide platform objects). + +| Field | Value | +|---|---| +| Decision | `` | +| If committed: file(s) | `src/policy/baselines/supabase-baseline-.json` | +| If committed: zero-residue check | `` | +| If deferred: rationale | `` | + +> **When committing the baseline, also wire the prove side.** `plan()` subtracts +> the baseline (via `options.baseline`); the proof loop re-derives the view from +> `plan.policy` (`resolveView(policy, capability)`) **without** a baseline, so a +> baseline-shaped plan would drift at prove time. Resolve the baseline in +> `provePlan` too (`resolveBaseline(plan.policy, { pgMajor })` → `resolveView`'s +> baseline arg) and add a corpus/integration case that a baseline plan proves +> clean. This is untestable until a fixture exists, which is why it lives here. + +## Deliberate exclusions still in effect at v1 + +(From `COVERAGE.md` + the `unmodeled_kind` completeness diagnostic — these are +the things v1 *says it does not manage*, now enforced and visible.) + +- Unmodeled kinds (detected + reported, not modeled): cast, operator, + operator class/family, text-search config/dict/parser/template, statistics + object, user language, transform. +- 4b deferred extractor families: sub-entity families (columns, constraints, + indexes, triggers, policies, rewrite rules) and rare member-root kinds (FDW, + server, foreign table, event trigger, publication) still filtered at extract. +- `` + +## Sign-off + +- [ ] All gates green on the recorded commit. +- [ ] `EXPECTED_RED` empty (or every entry justified). +- [ ] Every accepted-difference and deliberate exclusion has a reason. +- [ ] v1 scope statement published (links here). diff --git a/docs/roadmap/v1.md b/docs/roadmap/v1.md new file mode 100644 index 000000000..b75cf51e5 --- /dev/null +++ b/docs/roadmap/v1.md @@ -0,0 +1,121 @@ +# pg-delta-next: the road to a correctness-first v1 + +- **Date**: 2026-06-14 +- **Branch**: `feat/pg-delta-next` +- **Strategy**: cut **v1 on correctness** — a trustworthy engine, proven at + scale, honest about what it manages. **Performance and DX come after** (two + later milestones). This doc is the one-page roadmap; per-item detail lives + under [`the roadmap index`](README.md). + +## Baseline — what is already done and proven + +- **Engine code-complete (stages 0–9)** — identity codec + fact base, snapshot + extraction, generic diff, the rule-table planner (one graph, deterministic + sort, compaction), the proof loop (state + data-preservation + rewrite + observation), shadow-DB / snapshot frontends, policy DSL v2 + Supabase package, + renames + export + drift, the reviewed public API (`API-REVIEW.md`) + CLI. +- **Hardening plan** (8 items) and **4b** (extension-member provenance flip) + shipped; **security-label** proven end-to-end. +- **The managed-view architecture** ([managed-view-architecture.md](../architecture/managed-view-architecture.md)) + shipped — moves 1–7 + follow-ups: `skipSchema`/`skipAuthorization` eliminated + (derived from catalog facts), **ownership is an edge**, scope filtering is a + fact-level **view** (proof-honest), and **applier capability** restricts the + view (FDW-ACL projection + owner-residue fail-fast). +- **The validation harness is in CI and green** ([.github/workflows/tests.yml](../../.github/workflows/tests.yml)): + - corpus **316 scenarios × 2 directions** (632 cases) under the full proof + loop, on **PG 14–18**; `EXPECTED_RED` has one pinned PG16+ teardown + (`role-membership-dedup--multi-grantor:reverse`) — count measured + 2026-07-21; re-check `ls packages/pg-delta/corpus | wc -l` if stale; + - a **differential** harness (new engine vs the old `pg-delta`) with a hard + regression gate (`new-fails-old-converges` = error) + a triage ledger; + - a **generative soak** with an enforced kind-coverage checklist; + - the **benchmark** harness (informational). + +**So the engine and its correctness machinery are v1-ready.** The one real +engineering gap (loud detection of unmodeled kinds) and the rest of the v1 +readiness-review findings are now shipped (§1 below). What remains for a v1 cut +is running the gates to green at scale + publishing the scope. + +--- + +## What blocks a correctness-first v1 + +### 1. ✅ Engineering — the v1 correctness/trust gaps are now SHIPPED + +A 2026-06-15 external readiness review (recorded in [the build log](../build-log.md)) +confirmed the correctness-first framing and surfaced the delivery half of the +one real gap. All of its engineering findings are now implemented: + +- **Loud detection of unmodeled object kinds** (the one real correctness gap) — + a provenance-aware **catalog completeness check** at extract: every + user-created object in a kind v1 doesn't model (CAST, operator (class/family), + text-search config/dict/parser/template, statistics object, user language, + transform) surfaces as an `unmodeled_kind` diagnostic instead of being + silently omitted. `src/extract/unmodeled.ts`. v1 need not *model* these kinds + (post-v1, demand-driven) — only never *silently miss* them. Recorded in + [the build log](../build-log.md). +- **Diagnostics are surfaced** — every extracting CLI command prints extraction + diagnostics, and `--strict-coverage` refuses to produce an apply artifact + while unmodeled user objects exist (`src/cli/diagnostics.ts`). Detection that + no one can see is worthless; this is its delivery half. +- **`Policy.baseline` fail-loud** — a declared baseline is resolved + (`resolveBaseline` → `plan` `options.baseline`) and subtracted, or it + **throws**; it is never the silent no-op the review flagged. `supabasePolicy` + no longer claims a baseline it cannot honor (its filters still hide platform + objects); committing the real Supabase baseline snapshot remains the validation + item below. +- **SQL loader rejects self-managed transactions** — a declarative `.sql` file + containing `COMMIT`/`BEGIN`/`SAVEPOINT`/… is rejected (literal-/comment-aware + scan; `BEGIN ATOMIC` bodies excepted), so it can't commit partial DDL before a + later statement fails (`findTransactionControl`). +- **Comment/status drift removed** — the stale `idField`, `commitBoundaryAfter`, + and 4b notes were corrected to match the code. + +### 2. 🟢 Validation — run the gates to green *at scale* (evidence, not new engine code) + +The harness exists and is green at CI defaults; cutting v1 means recording it +green at the agreed scale: + +- **Full differential** (`PGDELTA_NEXT_DIFFERENTIAL=all`) run + triaged: zero + untriaged `new-fails-old-converges`; every `accepted-difference` has a reason. +- **Generative soak at the agreed quota** (raise `PGDELTA_NEXT_SOAK` to a + sustained run — set the quota now) with zero proof failures / cycles / crashes. +- **Real-world shakedown** — at least one large anonymized production-shaped + schema through `plan` + `prove`. +- **Commit the Supabase baseline snapshot** so baseline subtraction is *exercised + in CI*, not just generatable (`src/policy/baselines/` is `.gitkeep` only today — + see [the post-v1 backlog](post-v1.md)). + +### 3. 🟢 Docs — publish the v1 scope statement + +A user-facing statement of **what v1 manages and what it deliberately doesn't** +(derived from `COVERAGE.md` + the new completeness diagnostic). With item 1 +shipped, the exclusions are enforced + visible; this writes them down for users. + +> **Not a v1 gate:** "performance ≥ the old engine." v1 is correctness-first and +> may still trail the old engine on speed — that's the next milestone. The +> stage-10 parity bar's *correctness* conditions (corpus, differential, soak, +> extractor ring, shakedown) gate v1; its *performance* condition does not. + +--- + +## After v1 — performance, then DX & cutover + +Both later milestones and the deliberate deferrals are consolidated in the +**[post-v1 backlog](post-v1.md)**: the performance work (the shipped resolver +rewrite, the memory plan, deferred parallel extraction), the DX surface (risk +classification, squash/repair, filtering flags, typed errors, applier-capability +wiring), [extension-intent Phase B](extension-intent-phase-b.md), and the +stage-10 cutover parity bar. + +--- + +## Recommended order to cut v1 + +1. ✅ **Unmodeled-kind detection + the readiness-review findings** (item 1) — + **shipped**: the correctness floor for a trustworthy v1. +2. **Run the validation gates to green at scale** (item 2) — full differential, + the soak quota, a real-world shakedown; commit the Supabase baseline. Record + it in **[pg-delta-next-v1-evidence.md](v1-evidence.md)**. +3. **Publish the v1 scope statement** (item 3) → **cut v1.** +4. Then **performance** (milestone A), then **DX + cutover** (milestone B). diff --git a/package.json b/package.json index 190922ca4..157d17759 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,12 @@ "packages/*" ], "scripts": { - "build": "bun run --filter '*' build", + "build": "bun run --filter '@supabase/pg-topo' build && bun run --filter '!@supabase/pg-topo' build", "coverage": "bun scripts/coverage.ts", "check-types": "bun run --filter '*' check-types", "format-and-lint": "oxfmt --check . && oxlint --deny-warnings", "format-and-lint:fix": "oxfmt . && oxlint --fix", "knip": "bun run --filter '*' knip", - "docs:pg-delta": "bun run --filter '@supabase/pg-delta' docs", "test:pg-delta": "bun run --filter '@supabase/pg-delta' test", "test:pg-topo": "bun run --filter '@supabase/pg-topo' test", "verdaccio:start": "verdaccio --config ./verdaccio/config.yaml", diff --git a/packages/pg-delta/.vscode/extensions.json b/packages/pg-delta/.vscode/extensions.json deleted file mode 100644 index a297a5ec9..000000000 --- a/packages/pg-delta/.vscode/extensions.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "recommendations": ["thebearingedge.vscode-sql-lit", "biomejs.biome"], - "unwantedRecommendations": ["esbenp.prettier-vscode"] -} diff --git a/packages/pg-delta/.vscode/settings.json b/packages/pg-delta/.vscode/settings.json deleted file mode 100644 index 189f6b91e..000000000 --- a/packages/pg-delta/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit" - }, - "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnSave": true, - "typescript.tsdk": "node_modules/typescript/lib", - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - } -} diff --git a/packages/pg-delta/AGENTS.md b/packages/pg-delta/AGENTS.md deleted file mode 100644 index 0d84a754d..000000000 --- a/packages/pg-delta/AGENTS.md +++ /dev/null @@ -1,199 +0,0 @@ -# AGENTS.md -- Using pg-delta - -This file is for AI agents that need to use pg-delta as a tool to manage PostgreSQL schemas. For guidance on _developing_ pg-delta itself, see `CLAUDE.md`. - -## What pg-delta does - -pg-delta compares PostgreSQL database schemas and generates ordered DDL migration scripts. It supports two paradigms: - -- **Imperative (diff-based):** Compare two databases (or catalog snapshots), produce a migration plan, apply it. Commands: `plan`, `apply`, `sync`. -- **Declarative (file-based):** Export a schema as `.sql` files, version-control them, apply them to a database. Commands: `declarative export`, `declarative apply`. -- **Utility:** `catalog-export` snapshots a live database catalog to JSON for offline use. - -## Running pg-delta - -```bash -# If installed globally or via npx -pgdelta [flags] - -# From the pg-delta package directory (development) -bun run pgdelta [flags] -``` - -## Decision tree: which workflow to use - -| Task | Command(s) | -|------|------------| -| Generate a migration between two databases | `plan` then `apply` (or `sync` for one-shot) | -| Bootstrap/version-control a schema as SQL files | `declarative export` | -| Apply SQL schema files to a database | `declarative apply` | -| Snapshot a database for offline diffing | `catalog-export` | - -## Workflow A: Declarative schema management - -Use this when you need to export a database schema as `.sql` files, edit them, and apply them to another database. - -### Step 1 -- Export - -```bash -pgdelta declarative export \ - --target \ - --output ./declarative-schemas/ -``` - -Add `--integration supabase` for Supabase projects (filters system schemas). -Add `--force` to replace an existing output directory. -Add `--dry-run` to preview without writing files. - -The output directory structure: - -``` -declarative-schemas/ - cluster/ - roles.sql - extensions/ - pgcrypto.sql - schemas/ - public/ - tables/ - users.sql - orders.sql - functions/ - calculate_total.sql - views/ - active_users.sql -``` - -### Step 2 -- Edit - -Edit the `.sql` files as needed. Each file contains `CREATE` or `ALTER` statements for that object. Related objects (indexes, triggers, RLS policies) share the file with their parent table. - -### Step 3 -- Apply - -```bash -pgdelta declarative apply \ - --path ./declarative-schemas/ \ - --target -``` - -Add `--verbose` to see per-round progress. -Add `--skip-function-validation` if functions reference objects outside the schema. - -#### Interpreting exit codes - -| Exit code | Meaning | Action | -|-----------|---------|--------| -| 0 | All statements applied | Done | -| 1 | Hard failure | Read stderr for the error | -| 2 | Stuck (unresolvable dependencies) | Check diagnostics; may need `--skip-function-validation` or manual fixes | - -#### Debugging apply failures - -```bash -DEBUG=pg-delta:declarative-apply pgdelta declarative apply \ - --path ./declarative-schemas/ \ - --target \ - --verbose -``` - -This shows which statements are deferred and why in each round. - -## Workflow B: Migration diff generation - -Use this when you need to generate a migration script between two database states. - -### Step 1 -- Plan - -```bash -pgdelta plan \ - --source \ - --target \ - --output migration.sql -``` - -`--source` and `--target` accept PostgreSQL URLs or catalog snapshot JSON files. -Omit `--source` to diff from an empty baseline (full export). -Use `--format sql` for a migration script, `--format json` for a plan file, or omit for a tree preview. - -#### Exit codes - -| Exit code | Meaning | -|-----------|---------| -| 0 | No changes detected | -| 2 | Changes detected | -| 1 | Error | - -### Step 2 -- Review - -If output was a tree (default), review it in stdout. If saved to a file, read the file. - -### Step 3 -- Apply - -```bash -pgdelta apply \ - --plan plan.json \ - --source \ - --target -``` - -Add `--unsafe` if the plan contains data-loss operations (drops, truncates). Without it, pg-delta refuses to apply. - -### Offline variant with catalog-export - -```bash -# Snapshot the current state -pgdelta catalog-export --target --output baseline.json - -# Later, generate a migration against the snapshot -pgdelta plan --source baseline.json --target --output migration.sql -``` - -## Quick reference - -| Command | Key flags | Description | -|---------|-----------|-------------| -| `catalog-export` | `--target`, `--output`, `--role` | Snapshot DB catalog to JSON | -| `declarative export` | `--target`, `--output`, `--source`, `--integration`, `--force`, `--dry-run` | Export schema as `.sql` files | -| `declarative apply` | `--path`, `--target`, `--verbose`, `--skip-function-validation` | Apply `.sql` files to DB | -| `plan` | `--source`, `--target`, `--output`, `--format`, `--integration` | Compute diff, output plan | -| `apply` | `--plan`, `--source`, `--target`, `--unsafe` | Apply a saved plan file | -| `sync` | `--source`, `--target`, `--yes`, `--unsafe`, `--integration` | Plan + apply in one step | - -## Supabase integration - -When working with Supabase projects, always use `--integration supabase`. This: - -- Filters out system schemas (`pg_catalog`, `information_schema`, `supabase_*`, etc.). -- Customizes SQL serialization for Supabase conventions. -- Provides the correct empty-catalog baseline so `--source` can be omitted for full exports. - -```bash -pgdelta declarative export --target $DATABASE_URL --output ./schemas/ --integration supabase -pgdelta plan --target $DATABASE_URL --integration supabase --output migration.sql -``` - -## Filter and serialize DSL - -Fine-grained control over which changes are included and how SQL is generated: - -```bash -# Only include changes in the public schema ---filter '{"schema":"public"}' - -# Exclude specific schemas ---filter '{"not":{"schema":["pg_catalog","information_schema"]}}' - -# Skip AUTHORIZATION on schema creation ---serialize '[{"when":{"type":"schema"},"options":{"skipAuthorization":true}}]' -``` - -These flags are available on `plan`, `sync`, and `declarative export`. - -## Debugging - -| Method | When to use | -|--------|-------------| -| `DEBUG=pg-delta:*` | Full debug output for any command | -| `DEBUG=pg-delta:declarative-apply` | Declarative apply: deferred statements, per-round summaries | -| `--verbose` | Declarative apply: per-round applied/deferred/failed counts | -| `--ungroup-diagnostics` | Declarative apply: full per-diagnostic detail | diff --git a/packages/pg-delta/API-REVIEW.md b/packages/pg-delta/API-REVIEW.md new file mode 100644 index 000000000..25387f01c --- /dev/null +++ b/packages/pg-delta/API-REVIEW.md @@ -0,0 +1,135 @@ +# API Review — `@supabase/pg-delta` public exports + +Stage-9 deliverable 8. Every name exported from `src/index.ts` reviewed +name-by-name. Architecture vocabulary check: **facts** / **deltas** / +**actions** / **proof** columns indicate whether the name is grounded in +the documented vocabulary. + +## Vocabulary key + +| symbol | meaning | +|--------|---------| +| ✓ | name is clearly grounded in the arch vocabulary | +| ~ | grounded but indirectly (utility/supporting concept) | +| — | not applicable (e.g., error class, generic utility) | + +--- + +## Core primitives + +| Name | Kind | Contract | facts | deltas | actions | proof | +|------|------|----------|:-----:|:------:|:-------:|:-----:| +| `NotImplementedError` | class | Thrown by API stubs for not-yet-implemented stages. Safe to export; callers that import stubs need a concrete catch type. | — | — | — | — | +| `Diagnostic` | type | One shared structured-error shape used by every layer (extraction, loader, planner, apply). `{ code, severity, subject?, message, context? }` | ~ | ~ | ~ | ~ | +| `StableId` | type | Discriminated union of all addressable identity shapes (simple/qualified/sub-entity/routine/membership/…). The identity layer of facts. | ✓ | ✓ | ✓ | ✓ | +| `FactKind` | type | Union of all `StableId["kind"]` strings. Useful for narrowing/mapping. | ✓ | — | — | — | +| `encodeId` | function | `StableId → string` — the canonical string codec. Only place string encoding exists (guardrail 1). | ✓ | — | — | — | +| `parseId` | function | `string → StableId` — inverse of `encodeId`. | ✓ | — | — | — | +| `Payload` | type | `{ [key: string]: PayloadValue }` — the identity-free content of a fact. | ✓ | — | — | — | +| `ContentHash` | type | Opaque brand for a SHA-256 hex string produced by `contentHash`. | ✓ | — | — | — | +| `canonicalize` | function | Canonical deterministic JSON serialization of a `PayloadValue` — the equality surface of the whole system. | ✓ | — | — | — | +| `contentHash` | function | `PayloadValue → ContentHash` — SHA-256 of the canonical form. | ✓ | — | — | — | +| `Fact` | type | `{ id: StableId; parent?: StableId; payload: Payload }` — the atomic unit of schema knowledge. | ✓ | ✓ | ✓ | ✓ | +| `DependencyEdge` | type | `{ from, to: StableId; kind: EdgeKind }` — a directed dependency between two facts (tear-down / build-up order). | ✓ | ✓ | ✓ | — | +| `EdgeKind` | type | `"depends" \| "owner" \| "memberOfExtension"` — semantic type of a `DependencyEdge`. | ✓ | — | — | — | +| `FactBase` | class | Immutable, content-addressed collection of `Fact`s and `DependencyEdge`s. The canonical in-memory schema representation. | ✓ | ✓ | ✓ | ✓ | +| `buildFactBase` | function | Construct a `FactBase` from arrays of facts and edges. Entry point for test fixtures and snapshot deserialization. | ✓ | — | — | — | +| `serializeSnapshot` | function | `(FactBase, { pgVersion }) → string` — format-v1 bigint-safe JSON, includes digest. | ✓ | — | — | — | +| `deserializeSnapshot` | function | `string → { factBase: FactBase; pgVersion: string }` — verifies digest on load; throws on corruption. | ✓ | — | — | — | +| `Delta` | type | Tagged union: `add / remove / set / link / unlink` — one atomic change between two fact bases. The diff vocabulary. | — | ✓ | — | — | +| `diff` | function | `(a: FactBase, b: FactBase) → Delta[]` — rollup-guided, zero per-kind code, deterministically sorted. | — | ✓ | — | — | + +--- + +## Extract + +| Name | Kind | Contract | facts | deltas | actions | proof | +|------|------|----------|:-----:|:------:|:-------:|:-----:| +| `ExtractResult` | type | `{ factBase: FactBase; pgVersion: string; diagnostics: Diagnostic[] }` — everything `extract` returns. | ✓ | — | — | — | +| `extract` | function | `Pool → Promise` — single REPEATABLE READ snapshot of a live database into a `FactBase`. | ✓ | — | — | — | + +--- + +## Plan + +| Name | Kind | Contract | facts | deltas | actions | proof | +|------|------|----------|:-----:|:------:|:-------:|:-----:| +| `ENGINE_VERSION` | const | `"0.1.0"` — stamped into plan artifacts; `apply` refuses artifacts from other engines. | — | — | ✓ | — | +| `Action` | type | One executable DDL statement with `sql`, `verb`, `produces/consumes/destroys/releases`, `transactionality`, `lockClass`, `dataLoss`, `rewriteRisk`. The unit the executor runs. | — | — | ✓ | ✓ | +| `Plan` | type | `{ actions, deltas, filteredDeltas, renameCandidates, safetyReport, source/target fingerprints, … }` — the complete output of the planner. | ✓ | ✓ | ✓ | ✓ | +| `SafetyReport` | type | Aggregated per-plan counts: destructive, rewriteRisk, nonTransactional actions; lock class histogram. | — | — | ✓ | — | +| `PlanOptions` | type | `{ params?, policy?, baseline?, renames?, acceptRenames?, compact?, capability? }` — the complete option bag for `plan()`. `baseline?` is the resolved platform `FactBase` whose facts are subtracted before diffing; `capability?` is the `ApplierCapability` that projects out operations the applier cannot execute. | — | — | ✓ | — | +| `plan` | function | `(source, desired: FactBase, options?: PlanOptions) → Plan` — the planner: deltas × rule table → topologically sorted actions. | ✓ | ✓ | ✓ | — | +| `serializePlan` | function | `Plan → string` — bigint-safe JSON artifact, version-tagged. | — | — | ✓ | — | +| `parsePlan` | function | `string → Plan` — validates formatVersion/engineVersion; throws on mismatch. | — | — | ✓ | — | +| `RenameCandidate` | type | `{ kind, from, to: StableId; status: "unambiguous"\|"ambiguous"\|"nearMiss"; reason? }` — one detected rename candidate with its disposition. | ✓ | ✓ | — | — | +| `RenameMode` | type | `"auto" \| "prompt" \| "off"` — controls whether unambiguous renames are accepted automatically, surfaced for confirmation, or suppressed. | — | — | ✓ | — | +| `LockClass` | type | `"none" \| "share" \| "shareRowExclusive" \| "shareUpdateExclusive" \| "accessExclusive"` — documented lock level of a DDL statement. Reported, not certified. | — | — | ✓ | — | + +--- + +## Apply + +| Name | Kind | Contract | facts | deltas | actions | proof | +|------|------|----------|:-----:|:------:|:-------:|:-----:| +| `ActionStatus` | type | `"applied" \| "unapplied" \| "inDoubt"` — per-action outcome after execution. | — | — | ✓ | ✓ | +| `ApplyOptions` | type | `{ fingerprintGate?, lockTimeoutMs?, statementTimeoutMs? }` — executor configuration. | — | — | ✓ | — | +| `ApplyReport` | type | `{ status, appliedActions, actionStatuses, error? }` — execution outcome with per-action status and a structured error entry. | — | — | ✓ | ✓ | +| `apply` | function | `(Plan, Pool, options?) → Promise` — sequential, segmented, lock-aware execution. | — | — | ✓ | ✓ | + +--- + +## Proof + +| Name | Kind | Contract | facts | deltas | actions | proof | +|------|------|----------|:-----:|:------:|:-------:|:-----:| +| `ProofVerdict` | type | `{ ok, applyError?, driftDeltas, dataViolations }` — full proof result including state check and data preservation. | ✓ | ✓ | ✓ | ✓ | +| `provePlan` | function | `(Plan, clonePool: Pool, desired: FactBase) → Promise` — apply to sacrificial clone, re-extract, diff against desired, check row counts. | ✓ | ✓ | ✓ | ✓ | + +--- + +## Frontends + +| Name | Kind | Contract | facts | deltas | actions | proof | +|------|------|----------|:-----:|:------:|:-------:|:-----:| +| `SqlFile` | type | `{ name: string; sql: string }` — the file abstraction used by both `loadSqlFiles` and `exportSqlFiles`. | ~ | — | — | — | +| `LoadResult` | type | `{ factBase, pgVersion, diagnostics, rounds }` — everything `loadSqlFiles` returns. | ✓ | — | — | — | +| `ShadowLoadError` | class | Structured error from `loadSqlFiles` — carries `details: Diagnostic[]` for stuck files, role leaks, body failures, and DML. | — | — | — | — | +| `loadSqlFiles` | function | `(SqlFile[], shadow: Pool) → Promise` — parser-free, retry-round ordering, validation, DML rejection. | ✓ | — | — | — | +| `ExportOptions` | type | `{ layout?: "by-object" \| "ordered" }` — controls whether exported files are human-layout or lexicographically ordered. | — | — | — | — | +| `exportSqlFiles` | function | `(FactBase, options?) → SqlFile[]` — render the fact base to SQL files via the planner (plan(∅ → fb)). | ✓ | — | ✓ | — | +| `saveSnapshot` | function | `(FactBase, pgVersion: string, path: string) → void` — serialize and write to disk. | ✓ | — | — | — | +| `loadSnapshot` | function | `(path: string) → { factBase: FactBase; pgVersion: string }` — read, deserialize, verify digest. | ✓ | — | — | — | + +--- + +## Names explicitly NOT exported (and why) + +| Name | Location | Reason not exported | +|------|----------|---------------------| +| `segmentActions` | `apply/apply.ts` | Internal executor utility; the action segmentation boundary is plan metadata (`newSegmentBefore`), not a user concern. | +| `matchRenameCandidates` | `plan/renames.ts` | Internal planner helper; the rename results surface via `Plan.renameCandidates`. | +| `subtreeIds` | `plan/renames.ts` | Internal planner utility for rename subtree cancellation. | +| `lockClassFor` | `plan/locks.ts` | Internal rule-table helper; lock information is accessible via `Action.lockClass` on each action. | +| `rulesFor` / `ActionSpec` / `PlanParams` | `plan/rules.ts` | The rule table is an implementation detail of the planner; no caller should need to invoke rules directly. | +| `topoSort` | `plan/graph.ts` | Internal graph utility. | +| `grantTarget` / `qid` | `plan/render.ts` | Internal SQL-rendering utilities. | +| `PayloadValue` | `core/hash.ts` | Implementation detail of `Payload`; not part of the public comparison vocabulary. Callers work with `Payload`. | +| `hashString` | `core/hash.ts` | Internal primitive not needed by API consumers. | +| `FORMAT_VERSION` | `core/snapshot.ts` | Version management is handled inside `serializeSnapshot` / `deserializeSnapshot`; callers need not reference the constant. | + +## Policy module (added after the concurrent stage-8 agent landed) + +| Name | Kind | Contract | Vocabulary | +|---|---|---|---| +| `Policy` | type | vendor behavior as data: filter + serialize rules + baseline ref + extends | ✓ (policy over deltas, §3.9) | +| `Predicate` | type | serializable matchers over fact kind/identity/provenance/verbs | ✓ | +| `FilterRule` / `SerializeRule` | type | first-match-wins rule shapes | ✓ | +| `factMatches` / `deltaMatches` | function | predicate evaluation against a fact/delta in context | ✓ | +| `filterDeltas` | function | split deltas into kept/filtered — filtered is reported, never silent | ✓ | +| `flattenPolicy` | function | resolve extends with cycle detection | ✓ | +| `validatePolicy` | function | unknown-param + extends-cycle validation | ✓ | +| `serializeParams` | function | **DEPRECATED** — aggregates serialize params globally without per-delta context; misleading second path alongside the planner's own per-fact resolution. Retained in the export surface only because `src/index.ts` re-exports it and that file is outside the current edit boundary. Do not use in new code. | ✗ | +| `subtractBaseline` | function | drop facts present-and-identical in a baseline fact base | ✓ (baselines = fact-base subtraction) | +| `loadBaseline` | function | snapshot file → FactBase with digest verification | ✓ | +| `supabasePolicy` | const | the Supabase vendor package (first DSL consumer) | ✓ | diff --git a/packages/pg-delta/CLAUDE.md b/packages/pg-delta/CLAUDE.md deleted file mode 100644 index bd3282b9e..000000000 --- a/packages/pg-delta/CLAUDE.md +++ /dev/null @@ -1,167 +0,0 @@ -# CLAUDE.md -- @supabase/pg-delta - -## What This Package Does - -PostgreSQL schema diff and migration tool. Connects to two PostgreSQL databases (source + target), extracts their catalogs, diffs them, and generates ordered DDL migration scripts. Safety-first: detects data-loss operations and supports a plan-based workflow for preview and version control. - -## Commands - -```bash -bun test # All tests (unit + integration) -bun test src/ # Unit tests only (no Docker) -bun test tests/ # Integration tests (Docker required) -bun run build # Compile with tsc -bun run check-types # Type check without emitting -bun run pgdelta # Run CLI (e.g. bun run pgdelta plan --help) -``` - -## Test Patterns - -### Unit tests (`src/**/*.test.ts`) - -```typescript -import { describe, expect, test } from "bun:test"; -``` - -No database needed. Test change classes, diff logic, SQL formatting. - -### Integration tests (`tests/**/*.test.ts`) - -```typescript -import { describe, test } from "bun:test"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`feature (pg${pgVersion})`, () => { - // Fast: shared container, database-level isolation - test( - "test name", - withDb(pgVersion, async (db) => { - // db.main, db.branch are pg Pool instances - }), - ); - - // Slow: fresh containers per test, full isolation - test( - "isolated test", - withDbIsolated(pgVersion, async (db) => { - // db.main, db.branch are pg Pool instances - }), - ); - }); -} -``` - -### Key files - -- `tests/utils.ts` -- `withDb`, `withDbIsolated`, `withDbSupabaseIsolated` wrappers -- `tests/container-manager.ts` -- Singleton container pool management -- `tests/integration/roundtrip.ts` -- Core roundtrip fidelity test helper -- `tests/constants.ts` -- PostgreSQL version config (reads `PGDELTA_TEST_POSTGRES_VERSIONS` env) -- `tests/global-setup.ts` -- Preload for integration test container lifecycle - -## Architecture - -### Core (`src/core/`) - -- **Catalog**: `catalog.model.ts`, `catalog.diff.ts` — extract and diff catalogs; each object type has an extractor and diff. -- **postgres-config.ts** — pg Pool factory with custom type parsers (bigint, arrays, int2vector). -- **objects/** — Per-object-type modules (table, function, view, role, etc.): - - `.model.ts` — Types and catalog extraction. - - `.diff.ts` — Diff logic. - - `changes/` — create, alter, drop, comment, privilege; each implements `serialize()` and `depends`. -- **sort/** — Dependency-aware change sorting (two-phase: DROP then CREATE/ALTER; topological + logical grouping). -- **plan/** — Migration plan generation and SQL formatting (`create.ts`, `apply.ts`, `risk.ts`, `sql-format/`). -- **integrations/** — Filter and serialization DSL; `filter/dsl.ts`, `serialize/dsl.ts`, `supabase.ts`. - -### CLI (`src/cli/`) - -- `bin/cli.ts` — Entry point. -- `app.ts` — Stricli CLI framework. -- `commands/` — plan, apply, sync, declarative-export, declarative-apply, etc. -- `formatters/` — Tree view, SQL scripts. -- `utils.ts` — Shared CLI helpers. - -### Cycle Breaking / Normalization - -Keep cycle handling split by the scope of information it needs: - -- **Object-local PostgreSQL semantics stay in `diff*`**. If a single object diff can prove a statement is redundant or invalid on its own, fix it there. Example: `src/core/objects/sequence/sequence.diff.ts` skips `DROP SEQUENCE` when `OWNED BY` means PostgreSQL will already cascade-drop it with the owning table/column. -- **Deterministic whole-plan rewrites belong in post-diff normalization**. If the final `Change[]` itself should be rewritten before dependency sorting, implement it in `src/core/post-diff-normalization.ts` (`normalizePostDiffChanges`), wired from `src/core/catalog.diff.ts` after raw diffs and `expandReplaceDependencies()`. The pass is the single chokepoint that observes the final `Change[]`, so it catches cross-object effects regardless of whether the relevant change pair was emitted by an object's `diff*` (e.g. `index.diff` for a definition-changed index) or by `expandReplaceDependencies()` (dependency-closure replacement). Current examples: pruning same-table `AlterTableDropColumn` / `AlterTableDropConstraint` changes superseded by an expansion-added `DropTable+CreateTable` pair, deduplicating constraint Add/Validate/Comment on replaced tables, and re-emitting `ALTER TABLE … REPLICA IDENTITY USING INDEX` after a replica-identity index is dropped+recreated. -- **Unbreakable graph cycles belong in sort-phase change injection**. If the emitted statements are valid but topological sorting discovers a hard dependency cycle that cannot be solved by weak-edge filtering, implement the narrow pattern in `src/core/sort/cycle-breakers.ts` (`tryBreakCycleByChangeInjection`). Existing examples: injecting explicit FK constraint drops for dropped-table FK cycles and rebuilding `AlterTableDropColumn` for publication-column cycles on surviving tables. -- **`expandReplaceDependencies()` only computes replacement closure**. It may report metadata such as which tables were promoted to replacement pairs, but it should not own unrelated cycle-pruning policy. -- **`src/core/sort/dependency-filter.ts` is a narrow last resort**. Use it only for safe edge filtering where the emitted statements are already valid and only the graph edge is artificial. Do not extend sort-phase filtering to paper over plans that would still fail at apply time. -- **In-place mutations that invalidate dependents declare `invalidates`, not a graph hack**. When a change keeps an object's identity but rewrites it so dependents bound to the old definition must be dropped before it and rebuilt after (the canonical case is `AlterTableAlterColumnType`, whose `ALTER COLUMN ... TYPE` forces a PostgreSQL table rewrite), override the `invalidates` getter on the change (sibling to `creates`/`drops`/`requires` in `base.change.ts`) to return the affected stable id. `buildGraphData` folds `invalidates` into the drop-phase producer set exactly like `drops`, so the existing `pg_depend` edges order each dependent's teardown ahead of the mutation. This is ordering-only: `invalidates` does not feed `Change.drops`, so phase assignment (`getExecutionPhase`), filtering, fingerprints, and serialization are unchanged, and recreation order needs no help because the create phase always runs after the entire drop phase. Prefer this over adding a change-type `instanceof` to the otherwise generic `graph-builder.ts`. - -Rule of thumb: if the fix changes a valid final `Change[]` before graph construction, it is post-diff; if it reacts to a concrete unbreakable dependency cycle and needs to inject or rebuild changes, it belongs in the sort-phase cycle breakers; if it needs only one object's semantics, it belongs in that object's `diff*`; if it only removes a graph edge without changing emitted SQL, it belongs in the sort filter; if a change mutates an object in place such that its dependents must be torn down first, it declares `invalidates`. - -### Execution model invariants - -A plan is an ordered list of `MigrationUnit`s (`src/core/plan/types.ts`), each with an explicit `transactionMode` and boundary `reason`, plus session-level statements applied once per session. `plan.units` + `plan.sessionStatements` are the **single execution source of truth** — there is no flat statement list on the plan; render via `renderPlanSql`/`renderPlanFiles` or flatten via `flattenPlanStatements`. Apply counts and messaging derive from units. Rendered scripts (`renderPlanSql`/`renderPlanFiles`) are for statement-splitting runners like `psql -f`; a plan containing a `"none"` unit can never run as a single multi-command query string (PostgreSQL wraps those in an implicit transaction block), which is why such units carry a run-statement-by-statement header instead of any SET/COMMIT reshuffling. - -Execution semantics are declared on the change classes (`base.change.ts`), **never inferred from rendered SQL** — no regex or string matching over serialized statements, ever (a sibling rule to the no-SQL-parsing rule for the plan path): - -- **A statement PostgreSQL rejects inside a transaction block** (SQLSTATE 25001) declares `get nonTransactional(): boolean` on its change class. The planner emits it as its own single-statement unit with `transactionMode: "none"`, and `applyPlan` runs it without `BEGIN`/`COMMIT`. Current cases: `AlterSubscriptionSetPublication` (when `enabled` keeps the `refresh = true` default), `DropSubscription` (when a replication slot is associated). `CreateSubscription` is deliberately NOT one: the 25001 gate is on `create_slot = true` (verified empirically on PG 17), and its serializer always emits `create_slot = false` — with `connect = true` when the slot already exists so it is reused, never recreated. If pg-delta later emits `CREATE INDEX CONCURRENTLY`, subscription `two_phase` alterations, or `ALTER SUBSCRIPTION ... REFRESH PUBLICATION`, those change classes must declare the trait too. -- **A statement whose effects only become usable after COMMIT** declares `get commitBoundary(): CommitBoundaryReason | null`. The canonical case is `AlterEnumAddValue` (55P04). The boundary rule is deliberately conservative: consecutive producers of the same kind share a unit; the next statement of any other kind starts a new unit whose `reason` is the producer's token. No consumer detection is attempted — correctness must not depend on enumerating reference sites. Adding a new kind = one getter override + one member in `CommitBoundaryReason` + one arm in `unitName` (`src/core/plan/render.ts`); the planner fold, render, and apply need no changes. - -## Key Concepts - -### Change object structure - -Every change has: **type**, **operation** (create/alter/drop), **scope** (object/comment/privilege/membership), **properties**, **serialize()**, and **depends** (array of stable identifiers). - -### Stable identifiers - -Used to track objects across databases (OIDs differ per environment): - -- Schema objects: `type:schema.name` (e.g. `table:public.users`). -- Sub-entities: `type:schema.parent.name` (e.g. `column:public.users.email`). -- Metadata: `scope:target` (e.g. `comment:public.users`). - -**Always build stable identifiers through the `stableId.*` helpers in -`src/core/objects/utils.ts` (or the `.stableId` getter on a model -instance) — never inline the prefix as a template literal.** Inline strings -like `` `index:${schema}.${table}.${name}` `` drift from the helper if -prefixes or escaping rules change, scatter the format across the codebase, -and were caught in review on this exact pattern. If you need a stable id -for an object type that does not have a helper yet, add the helper to -`stableId` first, then use it everywhere — including new code paths, -post-diff passes, and dependency wiring inside change classes. - -When asserting stable ids in tests, the literal form is fine as the -expected value (it documents the on-the-wire format), but the **production -side** of the comparison should still call the helper. - -### Integration DSL - -- **Filter**: JSON pattern to include/exclude changes (e.g. `{ "not": { "schema": ["pg_catalog"] } }`). -- **Serialize**: Rules to customize SQL (e.g. `skipAuthorization` for schema create). - -## Working with Database Objects - -To add a new PostgreSQL object type: - -1. Create `src/core/objects//` with `.model.ts` and `.diff.ts`. -2. Add change classes in `changes/` (create, alter, drop, comment, privilege as needed). -3. Register in `catalog.model.ts` (Catalog type + extractor). -4. Register in `catalog.diff.ts` (diff function in `diffCatalogs`). - -### Physical attnums vs logical names - -Never place raw PostgreSQL attnums (`pg_trigger.tgattr`, `pg_index.indkey`, `pg_constraint.conkey`/`confkey`, `pg_publication_rel.prattrs`, etc.) inside a model's `dataFields()` or `NON_ALTERABLE_FIELDS`. Attnums are **physical** and diverge between logically-identical tables whose column layouts were built differently (`CREATE TABLE` vs `ALTER TABLE DROP/ADD COLUMN`) — dropped columns leave "dead" attnums that are never renumbered, so every subsequent diff would emit a spurious replace and never converge. Compare either: - -- the authoritative `pg_get_def()` output (see `Index` and `Trigger`), or -- column **names** resolved via `pg_attribute` at extraction time (see `Table.conkey`/`confkey`, `Publication.prattrs`). - -Storing the raw attnum array purely for debugging/introspection is fine — just keep it out of equality. - -## Conventions - -- TypeScript strict; oxfmt + oxlint for format/lint; kebab-case files with `.model.ts`, `.diff.ts`, `.test.ts`. -- SQL via `@ts-safeql/sql-tag`. - -## Dependencies & Debug - -- **Runtime**: `pg`, `@stricli/core`, `@ts-safeql/sql-tag`, `zod`, `debug`. -- **Debug**: `DEBUG=pg-delta:* bun run pgdelta ...`; for declarative apply, `DEBUG=pg-delta:declarative-apply` (or `DEBUG=pg-delta:*`) shows deferred statements and per-round summaries. diff --git a/packages/pg-delta/COVERAGE.md b/packages/pg-delta/COVERAGE.md new file mode 100644 index 000000000..4472162f3 --- /dev/null +++ b/packages/pg-delta/COVERAGE.md @@ -0,0 +1,181 @@ +# Catalog coverage & deliberate exclusions + +What the extractor models, and where it deliberately stops. Stage-2 doctrine: +extract everything as facts at fact grain; deliberate gaps are recorded here, +never silently dropped. + +## Fully modeled (own fact kind + create/drop/alter rules + corpus proof) + +schema, role (+ config), role membership, default privilege, extension, +table (incl. partitioned/partitions, INHERITS, replica identity), column, +default, constraint (table + domain + foreign-table CHECK), index, sequence (+ OWNED BY), view, +materialized view, function, procedure, aggregate, trigger, policy, rewrite +rule, event trigger, domain, enum / composite / range type, collation, +publication, subscription, FDW, server, user mapping, foreign table. + +Global satellite facts (one rule each): comment (any COMMENT-addressable target), +ACL (acldefault-normalized, per grantable object), security label (every +SECURITY-LABEL-addressable *modeled* target — see the security-label scope note +below; an addressable-but-unmodeled label is diagnosed, never silently dropped). + +### Scope notes (where a family is partially scoped) + +Most families are fully modeled end-to-end. The cases worth calling out — so +"modeled" is never read as "modeled without limits": + +| Family | Extracted | Planned / Proven | Scope notes | +|---|---|---|---| +| Constraints | table + domain + foreign-table CHECK | yes | foreign tables carry only CHECK (no PK/FK/UNIQUE/EXCLUSION); serialized via `ALTER FOREIGN TABLE` | +| Foreign tables | yes (columns, options, local CHECK) | yes | inherit/partition-of foreign tables out of scope | +| Security labels | yes (`SECURITY LABEL`) | yes | needs a provider; CI uses the `dummy_seclabel` image. See the dedicated scope note below for which targets are supported / diagnosed / out of scope | +| Extension members | observed via `memberOfExtension` edges | projected out by default | sub-entity member families use the extract-time anti-join (tier-4-deferrals.md) | +| Not modeled | — | — | casts, operators (class/family), text-search, statistics, transforms, user languages: **detected + reported** as `unmodeled_kind`, never silently dropped | + +Ownership is modeled as an `owner` EDGE (object --owner--> role), not a payload +field: it diffs as an edge link/unlink that the planner renders as `ALTER … +OWNER TO` (per-kind prefix), and an owner role projected out of the managed view +prunes the edge so the object is created applier-owned — no `skipAuthorization` +param. The `CREATE EXTENSION … SCHEMA` clause is likewise derived from the +extension's `relocatable` fact, not a `skipSchema` param. The only serialize +param is `concurrentIndexes` (an apply-time strategy). See +[`../../docs/architecture/managed-view-architecture.md`](../../docs/architecture/managed-view-architecture.md). + +## Sub-entity facts (granularity is one, §3.1) + +Composite-type attributes and publication members are full facts, not +payload blobs — so they diff at sub-entity grain, are rename candidates, +and can be `pg_depend` edge targets: + +- **Composite type attributes** → `typeAttribute` facts (schema, type, + name; payload type + collation), parented to the `type`. On a fresh type + they inline into `CREATE TYPE AS (…)` (delta-set); on an existing type + they are managed incrementally: `ADD` / `DROP` / `RENAME ATTRIBUTE … + CASCADE` all work even while the type is used by table columns and + preserve the stored data. +- **Published tables** → `publicationRel` facts (publication, schema, + table; payload column-list + `WHERE`), parented to the `publication`. + **Published schemas** → `publicationSchema` facts. On a fresh publication + they inline into `CREATE PUBLICATION FOR …`; otherwise managed with + `ALTER PUBLICATION ADD/DROP`. A per-table column-list / `WHERE` change + replaces that member (`DROP TABLE` + re-`ADD`), with no churn on the rest + of the publication. + +One irreducible PostgreSQL limitation: `ALTER TYPE … ALTER ATTRIBUTE … +TYPE` is rejected while the composite is used by a table column (`CASCADE` +only reaches typed tables, not columns). The `typeAttribute` rule supports +the attribute-type change for unused composites and fails loudly with a +clear remediation message for in-use ones — it does not emit a statement +that would fail at apply. + +## Environment-gated (modeled; integration proof needs a non-default image) + +- **Security labels** — extraction (`pg_seclabel` / `pg_shseclabel`), the + `securityLabel` rule, and rendering are implemented and unit-proven + (`src/plan/security-label.test.ts`). The create / change-in-place / drop + cycle is proven **end-to-end** (`tests/security-label-proof.test.ts`) + against a `postgres:-alpine` image with the `dummy_seclabel` test + module compiled in and preloaded (`tests/dummy-seclabel.Dockerfile`, + `tests/containers.ts::seclabelCluster`). The `dummy` provider stores labels + verbatim (clean apply → re-extract roundtrip), validating against its fixed + vocabulary (unclassified / classified / secret / top secret). The image is + built on first run; `PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1` skips the proof in + sandboxes that cannot reach the Alpine / GitHub CDNs at build time. The main + corpus stays on stock `postgres:*-alpine` (label catalogs are empty there, so + it is unaffected); a CI prebuild of the image is a possible follow-up. + + Three tiers of label TARGET (verified on PG17 with the dummy provider — + `src/extract/security-labels.ts`): + - **Supported** (extracted, planned, proven per kind in + `tests/security-label-proof.test.ts`): table, view, materialized view, + sequence, foreign table, column, schema, type, domain, function, procedure, + aggregate, role, event trigger, publication, subscription. + - **Deliberately unsupported but DIAGNOSED**: a valid SECURITY LABEL target + that the engine does not model — `LANGUAGE`, `LARGE OBJECT`, `DATABASE`, + `TABLESPACE`. A label on one of these surfaces an `unresolved_security_label` + warning (escalated to a hard stop by `--strict-coverage`) instead of being + silently dropped — the failure mode that would otherwise let a proof pass + vacuously. + - **Not addressable at all** (so a label can never exist): index, collation, + foreign data wrapper, foreign server, constraint, trigger, policy, rule. + PostgreSQL rejects `SECURITY LABEL ON …` for these object types + ("security labels are not supported for this type of object"), so they + cannot appear in `pg_seclabel`. + +## Not modeled (deliberate) — but DETECTED, never silently missed + +These kinds are not modeled, but a user-created object of one of them is no +longer invisible: `extract()` runs a provenance-aware **catalog completeness +check** (`src/extract/unmodeled.ts`) that emits an `unmodeled_kind` diagnostic +naming each kind found, and the CLI's `--strict-coverage` refuses to plan while +any exist. Built-in (OID < `FirstNormalObjectId`) and extension-owned objects +are excluded — only genuine user state is reported. So the exclusions below are +*enforced and visible*, not a silent gap (review finding 1). + +- **Languages** (`pg_language`) — the `language` StableId kind is reserved in + the codec but not extracted; user-defined languages are rare and the + built-ins (`sql`, `plpgsql`, `c`, `internal`) are not user state. Add a + `language` extractor + rule when a real need appears. +- **FTS configs/dictionaries/parsers/templates, operator classes/families as + first-class facts, casts, transforms, statistics objects** — out of v1 scope; + none are modeled, all are detected. Extension-provided variants are filtered + at extract time (see below). +- **Large objects** — out of v1 scope; not modeled and (as data state rather + than schema DDL) not part of the unmodeled-kind schema check. +- **Sequence `last_value`** — runtime state, not desired schema state + (matches every comparable tool). Never extracted. +- **Extension version** — excluded from the `extension` payload (a managed + platform pins versions out of band; including it produces phantom diffs). +- **Collation `collversion`** — excluded (host-glibc/ICU dependent). + +## Extension members: observed, projected by default (4b) + +Extension-owned objects are **observed** at extraction as ordinary facts +carrying a `memberOfExtension` edge to their extension — "provenance is data, an +edge fact, not an extraction-time filter" (§3.1) — and then **projected out of +the managed universe by default** in `plan()`/`prove()` +(`excludeExtensionMembers`, the counterpart of `excludeManaged`). So policy-free +behaviour is unchanged (members never diff), while raw `extract()` can see them +with full ownership provenance. + +Flipped (member-ROOT families, each observed + tagged, verified by the +`extension-member-parity` pg_depend oracle): schemas, tables, sequences, +views/materialized views, routines (functions + procedures), aggregates, +domains, enum/composite/range types, collations. + +A reference INTO a member (a user table column of an extension type, a default +calling an extension function) resolves to the **extension**, not the member +fact (the resolver's collapse branches): the member is projected out, so a +member-targeted edge would be pruned with it and the dependent would lose its +ordering on the extension. The collapsed edge points at the extension (which +survives), so ordering holds — pinned by `extension-member-ordering`. + +**Still filtered (documented, regression-free):** sub-entity families (columns, +constraints, indexes, triggers, policies, rewrite rules) and rare member-root +kinds (foreign data wrappers, foreign servers, foreign tables, event triggers, +publications) keep their `notExtensionMember` anti-joins. Their members ride out +with the projected parent (sub-entities) or are vanishingly rare (the rest), so +the default projected behaviour is identical either way; they were left to keep +the migration bounded and fully parity-gated. + +## Field-issue corpus (old-engine bugs, verified gone or fixed) + +Open `supabase/pg-toolbelt` issues converted to corpus scenarios. Most were +old-engine flaws the new architecture does not reproduce; one (#263) exposed +a real gap that is now fixed. + +| Issue | Scenario(s) | Outcome in the new engine | +|---|---|---| +| #286 | `domain-operations--check-references-replaced-function` | green — generic forced-dependent-rebuild drops/recreates the domain CHECK around the function replace (old engine silently skipped it) | +| #280 | `function-ops--signature-change-referenced-by-{default,check}` | green — table is never dropped (data-preservation proof on seeded rows); minimal-by-construction | +| #263 | `alter-column-type--blocked-by-{policy,view}`, `function-ops--signature-change-referenced-by-policy` | **fixed** — `ALTER COLUMN … TYPE` now force-rebuilds dependent views/rules/policies (kind-selective `rebuildsDependents`); DROP FUNCTION ref'd by a policy already worked | +| #269 | `mixed-objects--cross-schema-reference` | green — plan applies to a target that has the unchanged managed-schema object; no round-apply "stuck statement" | +| #282 | `type-ops--range-used-in-table` + loader shuffle test | green — `CREATE TYPE AS RANGE` is a first-class fact with a column→type edge; a pg-topo classification bug that does not exist in the new engine | +| #219 | `function-ops--enum-arg-privilege` | green — enum-arg function signatures render stably in GRANT/REVOKE (no temp-schema artifacts) | +| #218 | `constraint-ops--deferrable-unique` | green — `DEFERRABLE INITIALLY DEFERRED` roundtrips via `pg_get_constraintdef` | + +Not converted (old-engine architectural chores the new design resolves +differently, no scenario): #250 perf benchmarking (the new engine ships +`scripts/benchmark.ts` + a CI benchmark job + the generative soak), #244 +`change.phase` (replaced by per-action three-valued `transactionality` + +the segmented executor), #115 shared topological-sort abstraction (the new +engine has one mixed graph and no pg-topo coupling in the trusted path, P1). diff --git a/packages/pg-delta/MIGRATION.md b/packages/pg-delta/MIGRATION.md new file mode 100644 index 000000000..cd40c65cd --- /dev/null +++ b/packages/pg-delta/MIGRATION.md @@ -0,0 +1,100 @@ +# Migrating from `@supabase/pg-delta` (old engine) — DRAFT + +Status: draft for the stage-10 cutover review. The naming decision (new +major vs new package name) is open; this guide uses the working name. + +## API mapping + +| Old call | New call | Notes | +|---|---|---| +| `extractCatalog(pool)` | `extract(pool)` → `{ factBase, pgVersion }` | catalogs are gone; the fact base is the only state model | +| `createPlan(source, target, opts)` | `plan(extract(source).factBase, extract(target).factBase, opts)` | plan is pure — extraction is explicit and reusable | +| `applyPlan(plan, pool)` | `apply(plan, pool, { fingerprintGate? })` | the gate re-extracts and refuses stale plans by default | +| `plan.statements` / serialized SQL list | `plan.actions[].sql` + `serializePlan(plan)` | plans are version-tagged JSON artifacts, never bare SQL lists | +| post-apply verification (built into applyPlan) | `provePlan(plan, clonePool, desiredFactBase)` | opt-in, and stronger: state proof + data-preservation proof | +| `declarativeApply(files, pool)` (round-apply against live targets) | `loadSqlFiles(files, shadowPool)` → `plan` → `apply` | bounded rounds run against a throwaway shadow ONLY; the live target gets a planned, provable artifact | +| `catalog-export` CLI | `pg-delta-next snapshot` | snapshots are fact bases with digest verification | +| filter DSL (`IntegrationDSL`) | policy DSL v2 (`Policy` in `src/policy/policy.ts`) | see the cookbook below | +| `supabase` integration | `supabasePolicy` (`src/policy/supabase.ts`) | the module docblock carries the rule-by-rule mapping table | + +## Plan artifact differences + +- The artifact is JSON with `formatVersion: 1` and `engineVersion`; + `apply` refuses artifacts it does not understand. Old plans are not + readable — re-plan. +- `source.fingerprint` / `target.fingerprint` are fact-base rollup + digests. Apply gates on the source fingerprint by default. +- Every action carries `produces` / `consumes` / `destroys` / + `releases` fact ids, a vetted `lockClass` (reported, not certified), + three-valued `transactionality`, `dataLoss`, and `rewriteRisk`. The + plan-level `safetyReport` aggregates them. +- `filteredDeltas` lists what the policy hid — drift you chose not to + manage is still drift you can ask about. +- `renameCandidates` carries the stage-9 rename verdicts (including + near-miss explanations). + +## Output shape: the biggest consumer surprise + +The new engine emits **maximally decomposed** DDL, then compacts the +merges that matter for readability (column definitions fold into +`CREATE TABLE` when no dependency edge crosses the merge). Differences +you WILL see against old-engine output: + +1. Constraints are separate `ALTER TABLE … ADD CONSTRAINT` statements + (FKs always — that is what makes mutual-FK teardown cycles + unconstructible). +2. ACLs normalize through `acldefault()`: a plan may contain + REVOKE-then-GRANT pairs that instantiate an owner's implicit + privileges. The resulting state is identical; the bytes differ. +3. Enum value removal is a rename-aside migration + (`ALTER TYPE … RENAME TO … __pgdelta_replaced`, `CREATE TYPE`, + per-column `USING … ::text::…` casts, `DROP TYPE`), with dependent + views/defaults/routines force-rebuilt around it. The old engine + refused these. +4. `ALTER TYPE … ADD VALUE` plans may apply across MORE THAN ONE + transaction (a commit boundary is placed before the first consumer + of the new value). Mid-plan failure reporting tells you exactly + which actions are applied/unapplied/in doubt. +5. Plans never contain `SET check_function_bodies` statements — session + settings ride in `plan.preamble`. + +Accepted differences (each is deliberate; none changes converged +state): decomposed-then-compacted statement shapes (1), ACL +instantiation pairs (2), the enum migration sequence (3). + +## Policy DSL v1 → v2 cookbook + +v2 is typed, serializable data over the fact model — no pattern-string +paths, no function escape hatches. + +| v1 | v2 | +|---|---| +| `{ "*/schema": ["auth", …] }` | `{ match: { schema: ["auth", …] }, action: "exclude" }` | +| `{ "schema/name": [...] }` | `{ match: { all: [{ kind: "schema" }, { name: [...] }] }, action: "exclude" }` | +| `{ "*/owner": [...] }` | `{ match: { owner: [...] }, action: "exclude" }` | +| `{ objectType: "extension", operation: "create" }` | `{ match: { all: [{ kind: "extension" }, { verb: "add" }] }, action: "include" }` | +| `or` / `and` / `not` | `any` / `all` / `not` | +| allow-list evaluation | ordered rules, first-match-wins, no-match = include | +| `emptyCatalog` snapshot | `baseline` ref + `subtractBaseline(fb, baseline)` | +| serialize options (`skipAuthorization`) | `serialize: [{ match, params }]` — params validated against the rule table | + +Provenance is first-class: `{ ownedByExtension: "postgres_fdw" }` and +`{ edgeTo: { kind: "extension" } }` replace extraction-time suppression. + +## Snapshots + +Old `catalog-export` JSON is not readable. Regenerate: +`pg-delta-next snapshot --source --out baseline.json`. Snapshots +embed a format version and digest; corrupted or foreign-version files +refuse to load. The Supabase platform baselines are regenerated with +`scripts/generate-supabase-baseline.ts` against the pinned image tag. + +## What the old engine still does that the new one does not + +- Security-label diffing (the corpus needs the dummy_seclabel image — + extraction/rules land with it). +- The Supabase-image end-to-end scenarios (the policy package exists; + the real-image baseline proof needs the image in CI). + +Hold the stage-10 parity bar (all six items, simultaneously) before +cutover; this guide ships with the cutover PR, not before. diff --git a/packages/pg-delta/README.md b/packages/pg-delta/README.md index 8b97c8a82..d39264b10 100644 --- a/packages/pg-delta/README.md +++ b/packages/pg-delta/README.md @@ -1,194 +1,219 @@ -# pg-delta +# @supabase/pg-delta -PostgreSQL migrations made easy. +A clean-room rebuild of the PostgreSQL schema-diff engine, per +[`docs/architecture/target-architecture.md`](../../docs/architecture/target-architecture.md) +(see [the build log](../../docs/build-log.md) for how it was built, stage by +stage). This is the published `@supabase/pg-delta` package; the CLI binary is +`pgdelta`. It replaced the legacy engine in a hard breaking-change alpha +release. -Generate migration scripts by comparing two PostgreSQL databases. Automatically detects schema differences and creates safe, ordered migration scripts. Supports both imperative diff-based migrations and declarative file-based schema management. +> **Using it?** See [docs/getting-started.md](../../docs/getting-started.md) for +> the CLI and the programmatic API. -## Features +## What works today (proven by the test suite) -- 🔍 Compare databases and generate migration scripts automatically -- 🔒 Safety-first: detects data-loss operations and requires explicit confirmation -- 📋 Plan-based workflow: preview changes before applying, store plans for version control -- 📁 Declarative schemas: export/apply schemas as version-controlled `.sql` files -- 🎯 Integration DSL: filter and customize serialization with JSON-based rules -- 🛠️ Developer-friendly: interactive CLI with tree-formatted change previews +The full pipeline, end to end, on the covered kinds: -## Installation - -```bash -npm install @supabase/pg-delta -``` - -Or use with `npx`: - -```bash -npx @supabase/pg-delta --source --target -``` - -## Quick Start - -### CLI Usage - -The CLI provides two paradigms: **imperative** (diff-based migrations) and **declarative** (file-based schemas). - -#### Imperative: diff-based migrations - -**Sync (default)** - Plan and apply changes in one go: - -```bash -pg-delta sync \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db -``` - -**Plan** - Preview changes before applying: - -```bash -pg-delta plan \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --output plan.json -``` - -**Apply** - Apply a previously created plan: - -```bash -pg-delta apply \ - --plan plan.json \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db -``` - -#### Declarative: file-based schemas - -**Declarative export** - Export a database schema as `.sql` files: - -```bash -pg-delta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ -``` - -**Declarative apply** - Apply `.sql` files to a database: - -```bash -pg-delta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db -``` - -#### Utilities - -**Catalog export** - Snapshot a database catalog to JSON for offline diffing: - -```bash -pg-delta catalog-export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output snapshot.json +```text +extract (one consistent txn) → fact base (content-addressed, Merkle rollups) + → generic diff (fact deltas — zero per-kind code) + → rule table → atomic actions → ONE dependency graph → deterministic sort + → apply (single txn, per-statement attribution) + → provePlan (state proof + data-preservation proof on a TEMPLATE clone) ``` -The snapshot can be used as `--source` or `--target` for `plan` and `declarative export`, enabling offline diffs without a live database connection. - -See the [Workflow Guide](./docs/workflow.md) for end-to-end examples combining these commands. - -### Using Integrations - -Use built-in integrations or custom JSON files: +plus the **declarative frontend**: `loadSqlFiles` applies files to a shadow +database with fail-safe ordering (bounded rounds), routine-body +re-validation, shared-object leak detection, and parser-free DML rejection +— then the result flows through the same plan/prove path. + +### Statement reordering assist (opt-in) + +`loadSqlFiles` is parser-free: it sequences whole *files* into the shadow, so it +tolerates cross-file disorder but cannot reorder statements *within* a file. The +opt-in **statement reordering assist** restores "author in any internal order, +it still loads" by splitting files into one-statement units and topologically +pre-sorting them (via `@supabase/pg-topo`) before the loader runs. See +[target-architecture §4.4.1](../../docs/architecture/target-architecture.md). + +- **Subpath:** `@supabase/pg-delta/sql-order` exposes + `orderForShadow(files)` / `analyzeForShadow(files)` (returning single-statement + `SqlFile`s ready to feed straight into `loadSqlFiles`), `canReorder()`, and the + typed `ReorderUnavailableError`. +- **Dependency posture:** `@supabase/pg-topo` is an **optional peer dependency**, + loaded only through a guarded dynamic `import()` when this subpath runs — + importing the core (`fact` / `diff` / `plan` / `apply` / `loadSqlFiles`) never + pulls the libpg-query WASM parser. If the peer is absent the subpath throws + `ReorderUnavailableError` with an install hint; `canReorder()` probes instead. +- **CLI:** `schema apply` runs the assist by default (`--no-reorder` reproduces + raw file granularity for debugging). On a non-converging load it rewrites + synthetic ordinal names back to `file:line:col` and attaches any detected + shadow-load cycle as an advisory hint on top of the authoritative Postgres + error. `schema lint --dir ` runs the analyzer statically (no database) to + surface cycles and other diagnostics for proactive authoring — deliberately + out of the apply path so apply stays Postgres-truth. + +- **Corpus proof loop**: every scenario in `corpus/` is proven in BOTH + directions (build and teardown), with independently built compact and + uncompacted plan artifacts applied end-to-end. Compaction is therefore + enforced as cosmetic rather than required for convergence. State proof = + zero drift deltas after applying the plan to a clone; data proof = seeded + rows survive. The proof + reports honest per-table **coverage** (`tablesChecked`, `tablesSkipped`, + and a `contentMode` of `fingerprint` / `count` / `none`) rather than a bare + boolean: a non-empty table whose schema is unchanged is content-fingerprinted + (a count-preserving content change is caught); a table whose schema changed + is count-checked; an empty table is not checked (seed it for teeth). `ok` is + backed by that coverage — it is not a guarantee beyond what was checked. +- **Fixture-validity layer**: green independently of the engine, so an + engine failure can never be a broken fixture. +- **Extractor ring**: fixture DDL → asserted facts/payloads/edges, + deterministic re-extraction, snapshot round-trip, clone fidelity. + +## Kind coverage + +schema, role (incl. configs), role memberships, default privileges, +extension, table (incl. partitioned/partitions, INHERITS, replica +identity), column, default, constraint (tables + domains), index, +sequence (incl. OWNED BY), view, materialized view, function/procedure, +aggregate, trigger, policy, rewrite rule, event trigger, domain, +enum/composite/range types, collation, publication, subscription, +FDW/server/user-mapping/foreign-table, comments (one global rule), +ACLs (one global rule, REVOKE-first). + +The corpus (`corpus/`, ~210 scenarios) is the port of the old pg-delta +integration suite — see `PORTING.md` for the per-case ledger and the +not-ported-with-reason list (Supabase-image, policy-layer/stage-8, +dummy_seclabel, stage-9 renames/export). + +## Stage coverage (target-architecture) + +All engineering stages are implemented: + +- **Stages 0–4** — corpus + EXPECTED_RED ledger, fact core (Merkle + rollups, snapshots), extractors (one consistent txn, acldefault- + normalized ACLs), proof harness (state + data preservation), diff. +- **Stage 5** — the rule table, one mixed graph, deterministic sort, + compaction (column clauses fold into `CREATE TABLE` when no edge + crosses the merge — cosmetic by contract, proof-stability asserted), + the vetted lock-class table, the 10k-object benchmark fixture + + timing harness (`scripts/benchmark.ts`, in CI), the generative engine + + soak (`tests/generative.test.ts`, scale with `PGDELTA_NEXT_SOAK`). +- **Stage 6** — plan artifact v1 (engineVersion, safetyReport, lossless + round-trip), segmented executor (three-valued transactionality: + `CREATE INDEX CONCURRENTLY` runs alone, `ALTER TYPE … ADD VALUE` + forces a commit boundary before its first consumer), per-action + applied/unapplied/inDoubt failure reporting, the fingerprint gate, + session preamble as metadata, render-from-fact-base materialization. +- **Stage 7** — shadow-DB SQL loader + snapshot frontend. +- **Stage 8** — policy DSL v2 (typed serializable predicates, + first-match-wins, extends with cycle detection), delta filtering with + reported (never silent) filtered deltas, serialize parameters declared + by the rule table, baseline subtraction, the Supabase policy package. +- **Stage 9** — rename detection over structural rollups + (`renames: "auto" | "prompt" | "off"`, ambiguity/near-miss verdicts, + data preservation proven down to column values), declarative export + with the `load(export(fb)) ≡ fb` gate (+ an "ordered" layout that + loads in a single pass, and a "grouped" layout that restores the old + engine's category-grouped/readable output with opt-in name-pattern, + flat-schema, and partition grouping; opt-in SQL pretty-printing via + `--format-options`, also exposed as the `@supabase/pg-delta/sql-format` + library helper), drift, finalized public API (subpath + exports, reviewed name-by-name in `API-REVIEW.md`), CLI v2. + +The proof loop now verifies the two safety fields state-proof alone can't +see (§3.7): **rewrite risk** is observed on the clone (a kept table whose +`relfilenode` changed under no `rewriteRisk`-declaring action fails the +proof) and **data preservation** can be sharpened with opt-in `autoSeed` +(synthetic rows in empty kept tables), which now reports a per-table +outcome on the verdict (`seedOutcomes`: `seeded` / `skipped` / `failed`) +so an unseedable table is never confused with one that failed for a reason +nobody saw. A `skipped` is either a class-23 integrity-constraint SQLSTATE +or the synthetic `no_row` code (the insert resolved but a trigger/rule left +the row absent from the final pre-apply snapshot — persistence is judged by +reconciling against that snapshot, not the command tag). Tables that were +already populated remain anchored to their pre-seed stats, so trigger side +effects from synthetic inserts cannot be baselined away. Those tables are also +compared immediately after seeding, before any plan action can make their +fingerprints incomparable through a schema change; any schema change caused by +seeding itself also fails the proof before the plan runs. Per-kind graph policy +(cascade/rebuild/suppression/defacl) lives entirely in the rule table as +`KindRules` flags — the planner body holds no kind-name lists (guardrail 3). + +Every addressable thing is a fact at one grain (§3.1): composite-type +attributes (`typeAttribute`) and publication members (`publicationRel` / +`publicationSchema`) are sub-entity facts, so they diff at sub-entity grain +and are rename candidates — a composite attribute renames in place, +data-preserving, instead of forcing a type rebuild. See `COVERAGE.md` for +the full catalog-coverage map and deliberate exclusions (languages, large +objects, …). + +Environment-gated leftovers: security labels are fully modeled and proven +end-to-end (`tests/security-label-proof.test.ts`) against a built +`dummy_seclabel` image (`tests/dummy-seclabel.Dockerfile`); the image builds +on first run and `PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1` skips it where the +build CDNs are unreachable. The real-Supabase-image baseline proof needs a +Supabase container (mechanism + generation script exist — run +`scripts/generate-supabase-baseline.ts`). Stage 10 (cutover) is a product +decision gated on the parity bar — the porting ledger, soak quota at +scale, and naming are deliberately not unilateral engineering calls. + +Known v1 simplifications: + +- extension members of the common object kinds (tables, sequences, views, + routines, types, domains, collations, schemas) are observed at extraction + with `memberOfExtension` provenance edges and projected out by default + (4b); sub-entity families (columns, constraints, indexes, triggers, + policies, rules) and rare member-root kinds (fdw, server, foreign table, + event trigger, publication) are still filtered at extraction — a + documented, regression-free limitation (see COVERAGE.md) +- capture is serial on one snapshot connection (parallel + `pg_export_snapshot()` workers are a measured optimization) +- a surviving dependent of a destroyed fact is force-rebuilt when its kind + declares `rebuildable` in the rule table (view, matview, index, policy, + trigger, rule, constraint, default, procedure); a non-rebuildable + survivor whose dependency stays gone fails the plan loudly + +## Running ```bash -# Built-in Supabase integration -pg-delta sync --source --target --integration supabase - -# Custom integration file -pg-delta sync --source --target --integration ./my-integration.json +bun test src/ # unit: codec, hashing, fact base, snapshot, diff, policy +bun test tests/ # integration: Docker required (postgres:17-alpine) +bun run check-types +PGDELTA_TEST_IMAGE=postgres:15-alpine bun test tests/ # other PG versions +PGDELTA_NEXT_ONLY=enum bun test tests/engine.test.ts # corpus subset +PGDELTA_NEXT_SHARD=0/4 bun test tests/engine.test.ts # parallel shard +PGDELTA_NEXT_SOAK=200 bun test tests/generative.test.ts # bigger soak +bun scripts/benchmark.ts # timing numbers ``` -### Programmatic Usage - -```typescript -import { main } from "@supabase/pg-delta"; - -const result = await main( - "postgresql://source", - "postgresql://target" -); - -if (result) { - console.log(result.migrationScript); -} -``` - -For plan-based workflow: - -```typescript -import { createPlan, applyPlan } from "@supabase/pg-delta"; - -// Create a plan -const planResult = await createPlan(sourceUrl, targetUrl, { - filter: { schema: "public" }, - serialize: [{ when: { type: "schema" }, options: { skipAuthorization: true } }] -}); - -if (planResult) { - // Apply the plan - const result = await applyPlan( - planResult.plan, - sourceUrl, - targetUrl - ); -} -``` - -## Documentation - -- [Workflow Guide](./docs/workflow.md) - Full flow documentation for all commands and end-to-end workflows -- [CLI Reference](./docs/cli.md) - Complete CLI documentation with all commands and options -- [API Reference](./docs/api.md) - Programmatic API documentation -- [Integrations](./docs/integrations.md) - Using and creating integrations with the DSL system -- [Sorting & Safety](./docs/sorting.md) - How migrations are ordered for safety - -## Key Concepts - -### Plan-Based Workflow - -`pg-delta` uses a plan-based workflow that provides: - -- **Preview before apply**: Review changes before executing them -- **Self-contained plans**: Plans store filtering and serialization rules -- **Reproducibility**: Plans can be version-controlled and shared -- **Safety checks**: Automatic detection of data-loss operations - -### Integration DSL - -Integrations use a JSON-based DSL for filtering and serialization: - -- **Filter DSL**: Pattern matching to include/exclude changes -- **Serialization DSL**: Rules to customize SQL generation -- **Serializable**: Can be stored in plans and passed as CLI flags - -See [Integrations Documentation](./docs/integrations.md) for complete details. - -## Use Cases - -- Generate migrations between environments (dev → staging → production) -- Compare database states and review differences -- Automate migration creation in CI/CD pipelines -- Maintain schema version control with plan files -- Export and version-control schemas as declarative `.sql` files -- Apply declarative schemas to fresh databases (provisioning, restore) -- Snapshot databases for offline, reproducible diffs -- Filter platform-specific changes (e.g., Supabase system schemas) - -## Contributing - -Please follow the repository-level guide in [../../CONTRIBUTING.md](../../CONTRIBUTING.md). - -In particular: - -- Open an issue first. -- Wait for maintainer triage via one of `✨ Feature`, `🐛 Bug`, `📘 Docs`, or `🛠️ Chore` before opening a pull request. -- Use [../../ISSUES.md](../../ISSUES.md) when reporting `pg-delta` bugs so maintainers have what they need to reproduce them. - -## License - -MIT +Compaction (cosmetic clause folding + redundant-drop / default-ACL elision) is +**on by default** and proof-stable — the corpus independently builds, applies, +and proves compact and uncompacted artifacts for every scenario and direction. +The passes, in order: + +- **column folds** — `ADD COLUMN` clauses fold into their bare `CREATE TABLE` + when no graph edge crosses the merge. +- **redundant-drop elision** — a replace's drop is dropped when the create + reproduces the byte-identical statement (self-resetting ACL/REVOKE). +- **default-ACL elision** — whole `REVOKE`/`GRANT` groups that only + re-materialize a freshly-created object's built-in owner/PUBLIC defaults are + removed. +- **co-create REVOKE elision** — the leading `REVOKE ALL` is trimmed off a + remaining third-party grant on a co-created object (the `GRANT` is kept), gated + by a strict-superset guard against any create-time `defaultPrivilege` for the + applier role. +- **co-create ownership fold** — a co-created object's owner `ALTER` folds into + its `CREATE`: `CREATE SCHEMA … AUTHORIZATION owner` (always, syntactic), and a + no-op `ALTER … OWNER TO` is dropped when the desired owner is the applier + (`capability.role`, probed at apply time). + +Pass `--no-compact` to `compare` to emit the maximally-inlined DDL (one +statement per action, every `REVOKE`/`GRANT`/`OWNER TO` spelled out), which is +useful when diffing engine output statement-by-statement. + + +See `docs/architecture/target-architecture.md` §10. The ones most often relevant here: +no SQL parsing in the trusted path; no per-kind code outside the rule +table; a cycle is a rule bug (there is no breaker module, ever); never +assert SQL bytes in tests — assert state, data survival, or action shape. diff --git a/packages/pg-delta/bunfig.toml b/packages/pg-delta/bunfig.toml index 66f15fade..3e596025e 100644 --- a/packages/pg-delta/bunfig.toml +++ b/packages/pg-delta/bunfig.toml @@ -1,27 +1,6 @@ -# KNOWN BUG: Bun ignores [test] settings in bunfig.toml (oven-sh/bun#17664, oven-sh/bun#7789). -# All test config is applied via CLI flags in package.json scripts instead. -# Kept here for documentation / when the bug is fixed. - [test] -preload = ["./tests/global-setup.ts"] -timeout = 15000 -# concurrentTestGlob = "tests/integration/*.test.ts" -coverageSkipTestFiles = true -coveragePathIgnorePatterns = [ - # Test infrastructure (helpers, container management, roundtrip harness) - "tests/constants.ts", - "tests/container-manager.ts", - "tests/global-setup.ts", - "tests/integration/roundtrip.ts", - "tests/postgres-alpine.ts", - "tests/postgres-ssl.ts", - "tests/ssl-utils.ts", - "tests/supabase-postgres.ts", - "tests/utils.ts", - # Abstract base classes (0% funcs by design, no runtime logic) - "**/changes/*.base.ts", - # Debug-only visualization (gated behind DEBUG=pg-delta:graph) - "src/core/sort/debug-visualization.ts", - # Separate package leaking into report - "../pg-topo/**", -] \ No newline at end of file +# Guard the test process against stray process.exit() calls in in-process code +# (CLI command handlers must throw and let main() be the sole exiter). See +# tests/no-process-exit-guard.ts. Applies only to `bun test`, so subprocess CLI +# runs (Bun.spawn ["bun", main.ts, …]) keep their real exit codes. +preload = ["./tests/no-process-exit-guard.ts"] diff --git a/packages/pg-delta/corpus/acl-operations--owner-full-revoke/a.sql b/packages/pg-delta/corpus/acl-operations--owner-full-revoke/a.sql new file mode 100644 index 000000000..e188c5f2c --- /dev/null +++ b/packages/pg-delta/corpus/acl-operations--owner-full-revoke/a.sql @@ -0,0 +1,2 @@ +-- the table owner exists with default privileges +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/acl-operations--owner-full-revoke/b.sql b/packages/pg-delta/corpus/acl-operations--owner-full-revoke/b.sql new file mode 100644 index 000000000..f08886820 --- /dev/null +++ b/packages/pg-delta/corpus/acl-operations--owner-full-revoke/b.sql @@ -0,0 +1,6 @@ +-- the owner has ALL of its own privileges revoked (relacl has no owner row); +-- plan-from-empty must emit REVOKE ALL FROM owner, so an empty owner ACL fact +-- must be synthesized at extract. +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.t (id integer); +REVOKE ALL ON TABLE test_schema.t FROM CURRENT_USER; diff --git a/packages/pg-delta/corpus/aggregate-operations--comment-zero-arg/a.sql b/packages/pg-delta/corpus/aggregate-operations--comment-zero-arg/a.sql new file mode 100644 index 000000000..0cec6fc95 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--comment-zero-arg/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.count_all(*) +( + SFUNC = pg_catalog.int8inc, + STYPE = int8, + INITCOND = '0' +); diff --git a/packages/pg-delta/corpus/aggregate-operations--comment-zero-arg/b.sql b/packages/pg-delta/corpus/aggregate-operations--comment-zero-arg/b.sql new file mode 100644 index 000000000..ac1804f42 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--comment-zero-arg/b.sql @@ -0,0 +1,12 @@ +-- A zero-argument aggregate's COMMENT / SECURITY LABEL target must render the +-- signature as (*), not (): PostgreSQL requires COMMENT ON AGGREGATE name(*). +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.count_all(*) +( + SFUNC = pg_catalog.int8inc, + STYPE = int8, + INITCOND = '0' +); + +COMMENT ON AGGREGATE test_schema.count_all(*) IS 'counts all rows'; diff --git a/packages/pg-delta/corpus/aggregate-operations--comment/a.sql b/packages/pg-delta/corpus/aggregate-operations--comment/a.sql new file mode 100644 index 000000000..9e66cc7aa --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--comment/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text(text) +( + SFUNC = pg_catalog.array_append, + STYPE = text[], + INITCOND = '{}' +); diff --git a/packages/pg-delta/corpus/aggregate-operations--comment/b.sql b/packages/pg-delta/corpus/aggregate-operations--comment/b.sql new file mode 100644 index 000000000..d42eadc2e --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--comment/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text(text) +( + SFUNC = pg_catalog.array_append, + STYPE = text[], + INITCOND = '{}' +); + +COMMENT ON AGGREGATE test_schema.collect_text(text) IS 'aggregate comment'; diff --git a/packages/pg-delta/corpus/aggregate-operations--create-with-comment/a.sql b/packages/pg-delta/corpus/aggregate-operations--create-with-comment/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--create-with-comment/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/aggregate-operations--create-with-comment/b.sql b/packages/pg-delta/corpus/aggregate-operations--create-with-comment/b.sql new file mode 100644 index 000000000..986207295 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--create-with-comment/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text_dependency(text) +( + SFUNC = pg_catalog.array_append, + STYPE = text[], + INITCOND = '{}' +); + +COMMENT ON AGGREGATE test_schema.collect_text_dependency(text) IS 'dependency check'; diff --git a/packages/pg-delta/corpus/aggregate-operations--create/a.sql b/packages/pg-delta/corpus/aggregate-operations--create/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--create/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/aggregate-operations--create/b.sql b/packages/pg-delta/corpus/aggregate-operations--create/b.sql new file mode 100644 index 000000000..9e66cc7aa --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--create/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text(text) +( + SFUNC = pg_catalog.array_append, + STYPE = text[], + INITCOND = '{}' +); diff --git a/packages/pg-delta/corpus/aggregate-operations--definition-options/a.sql b/packages/pg-delta/corpus/aggregate-operations--definition-options/a.sql new file mode 100644 index 000000000..b7d29f981 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--definition-options/a.sql @@ -0,0 +1,16 @@ +CREATE SCHEMA app; + +CREATE FUNCTION app.add_int(integer, integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS $$ SELECT $1 + $2 $$; +CREATE FUNCTION app.sub_int(integer, integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS $$ SELECT $1 - $2 $$; +CREATE FUNCTION app.larger_int(integer, integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS $$ SELECT greatest($1, $2) $$; + +-- Plain aggregate: no combine / moving / parallel options. +CREATE AGGREGATE app.mysum(integer) +( + SFUNC = app.add_int, + STYPE = integer, + INITCOND = '0' +); diff --git a/packages/pg-delta/corpus/aggregate-operations--definition-options/b.sql b/packages/pg-delta/corpus/aggregate-operations--definition-options/b.sql new file mode 100644 index 000000000..f3d4962bb --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--definition-options/b.sql @@ -0,0 +1,33 @@ +CREATE SCHEMA app; + +CREATE FUNCTION app.add_int(integer, integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS $$ SELECT $1 + $2 $$; +CREATE FUNCTION app.sub_int(integer, integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS $$ SELECT $1 - $2 $$; +CREATE FUNCTION app.larger_int(integer, integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS $$ SELECT greatest($1, $2) $$; + +-- Same aggregate, now carrying combine + moving-aggregate + parallel options. +-- These are drop+create only, so the forward diff must recreate it; without +-- extracting/rendering the options the recreated aggregate would silently +-- lose them (and the diff would be empty, the payloads having hashed equal). +CREATE AGGREGATE app.mysum(integer) +( + SFUNC = app.add_int, + STYPE = integer, + INITCOND = '0', + COMBINEFUNC = app.add_int, + MSFUNC = app.add_int, + MINVFUNC = app.sub_int, + MSTYPE = integer, + MINITCOND = '0', + PARALLEL = SAFE +); + +-- New aggregate exercising SORTOP rendering through the create path. +CREATE AGGREGATE app.mymax(integer) +( + SFUNC = app.larger_int, + STYPE = integer, + SORTOP = > +); diff --git a/packages/pg-delta/corpus/aggregate-operations--drop/a.sql b/packages/pg-delta/corpus/aggregate-operations--drop/a.sql new file mode 100644 index 000000000..7b5569b59 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--drop/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text(text) +( + SFUNC = array_append, + STYPE = text[], + INITCOND = '{}' +); diff --git a/packages/pg-delta/corpus/aggregate-operations--drop/b.sql b/packages/pg-delta/corpus/aggregate-operations--drop/b.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--drop/b.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/aggregate-operations--grant/a.sql b/packages/pg-delta/corpus/aggregate-operations--grant/a.sql new file mode 100644 index 000000000..d660b4469 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--grant/a.sql @@ -0,0 +1,10 @@ +DO $$ BEGIN CREATE ROLE corpus_aggregate_executor NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text(text) +( + SFUNC = pg_catalog.array_append, + STYPE = text[], + INITCOND = '{}' +); diff --git a/packages/pg-delta/corpus/aggregate-operations--grant/b.sql b/packages/pg-delta/corpus/aggregate-operations--grant/b.sql new file mode 100644 index 000000000..f60301520 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--grant/b.sql @@ -0,0 +1,12 @@ +DO $$ BEGIN CREATE ROLE corpus_aggregate_executor NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text(text) +( + SFUNC = pg_catalog.array_append, + STYPE = text[], + INITCOND = '{}' +); + +GRANT EXECUTE ON FUNCTION test_schema.collect_text(text) TO corpus_aggregate_executor; diff --git a/packages/pg-delta/corpus/aggregate-operations--grant/meta.json b/packages/pg-delta/corpus/aggregate-operations--grant/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--grant/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/aggregate-operations--ordered-set-comment/a.sql b/packages/pg-delta/corpus/aggregate-operations--ordered-set-comment/a.sql new file mode 100644 index 000000000..44b12dd16 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--ordered-set-comment/a.sql @@ -0,0 +1,8 @@ +CREATE FUNCTION public.os_last_sfunc(state anyelement, value anyelement) + RETURNS anyelement LANGUAGE sql IMMUTABLE AS $$ SELECT value $$; + +CREATE AGGREGATE public.os_last(anyelement ORDER BY anyelement) +( + SFUNC = public.os_last_sfunc, + STYPE = anyelement +); diff --git a/packages/pg-delta/corpus/aggregate-operations--ordered-set-comment/b.sql b/packages/pg-delta/corpus/aggregate-operations--ordered-set-comment/b.sql new file mode 100644 index 000000000..9622bb9e7 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--ordered-set-comment/b.sql @@ -0,0 +1,10 @@ +CREATE FUNCTION public.os_last_sfunc(state anyelement, value anyelement) + RETURNS anyelement LANGUAGE sql IMMUTABLE AS $$ SELECT value $$; + +CREATE AGGREGATE public.os_last(anyelement ORDER BY anyelement) +( + SFUNC = public.os_last_sfunc, + STYPE = anyelement +); + +COMMENT ON AGGREGATE public.os_last(anyelement ORDER BY anyelement) IS 'keep the last ordered value'; diff --git a/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/a.sql b/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/a.sql new file mode 100644 index 000000000..71dd13ab9 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/a.sql @@ -0,0 +1 @@ +DO $$ BEGIN CREATE ROLE corpus_aggregate_executor NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/b.sql b/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/b.sql new file mode 100644 index 000000000..2b65fdd9a --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/b.sql @@ -0,0 +1,12 @@ +DO $$ BEGIN CREATE ROLE corpus_aggregate_executor NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE FUNCTION public.os_last_sfunc(state anyelement, value anyelement) + RETURNS anyelement LANGUAGE sql IMMUTABLE AS $$ SELECT value $$; + +CREATE AGGREGATE public.os_last(anyelement ORDER BY anyelement) +( + SFUNC = public.os_last_sfunc, + STYPE = anyelement +); + +GRANT ALL ON FUNCTION public.os_last(anyelement, anyelement) TO corpus_aggregate_executor; diff --git a/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/meta.json b/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--ordered-set-create-grant/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/aggregate-operations--owner-change/a.sql b/packages/pg-delta/corpus/aggregate-operations--owner-change/a.sql new file mode 100644 index 000000000..2c6875a07 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--owner-change/a.sql @@ -0,0 +1,10 @@ +DO $$ BEGIN CREATE ROLE corpus_aggregate_owner NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text(text) +( + SFUNC = array_append, + STYPE = text[], + INITCOND = '{}' +); diff --git a/packages/pg-delta/corpus/aggregate-operations--owner-change/b.sql b/packages/pg-delta/corpus/aggregate-operations--owner-change/b.sql new file mode 100644 index 000000000..4fb13c2e5 --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--owner-change/b.sql @@ -0,0 +1,12 @@ +DO $$ BEGIN CREATE ROLE corpus_aggregate_owner NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE AGGREGATE test_schema.collect_text(text) +( + SFUNC = array_append, + STYPE = text[], + INITCOND = '{}' +); + +ALTER AGGREGATE test_schema.collect_text(text) OWNER TO corpus_aggregate_owner; diff --git a/packages/pg-delta/corpus/aggregate-operations--owner-change/meta.json b/packages/pg-delta/corpus/aggregate-operations--owner-change/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/aggregate-operations--owner-change/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/a.sql b/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/a.sql new file mode 100644 index 000000000..471c9afac --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/a.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA t; +CREATE TYPE t.user_role_enum AS ENUM ('admin', 'user', 'guest'); +CREATE TABLE t.cats (id integer PRIMARY KEY, name text NOT NULL, role text NOT NULL); +ALTER TABLE t.cats ENABLE ROW LEVEL SECURITY; +CREATE POLICY cats_admin ON t.cats FOR ALL TO public + USING (role = 'admin') WITH CHECK (role = 'admin'); diff --git a/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/b.sql b/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/b.sql new file mode 100644 index 000000000..a28e862e2 --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA t; +CREATE TYPE t.user_role_enum AS ENUM ('admin', 'user', 'guest'); +-- role column retyped text -> enum; a policy references the column, so the +-- policy must be dropped before ALTER COLUMN TYPE and recreated after +CREATE TABLE t.cats (id integer PRIMARY KEY, name text NOT NULL, role t.user_role_enum NOT NULL); +ALTER TABLE t.cats ENABLE ROW LEVEL SECURITY; +CREATE POLICY cats_admin ON t.cats FOR ALL TO public + USING (role = 'admin'::t.user_role_enum) WITH CHECK (role = 'admin'::t.user_role_enum); diff --git a/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/seed-b.sql b/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/seed-b.sql new file mode 100644 index 000000000..4b4f06c7e --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO t.cats (id, name, role) VALUES (1, 'Tom', 'admin'); diff --git a/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/seed.sql b/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/seed.sql new file mode 100644 index 000000000..4b4f06c7e --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--blocked-by-policy/seed.sql @@ -0,0 +1 @@ +INSERT INTO t.cats (id, name, role) VALUES (1, 'Tom', 'admin'); diff --git a/packages/pg-delta/corpus/alter-column-type--blocked-by-view/a.sql b/packages/pg-delta/corpus/alter-column-type--blocked-by-view/a.sql new file mode 100644 index 000000000..e3c27e8da --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--blocked-by-view/a.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA t; +CREATE TABLE t.users (id integer PRIMARY KEY, age numeric); +CREATE VIEW t.user_ages AS SELECT id, age FROM t.users WHERE age > 0; diff --git a/packages/pg-delta/corpus/alter-column-type--blocked-by-view/b.sql b/packages/pg-delta/corpus/alter-column-type--blocked-by-view/b.sql new file mode 100644 index 000000000..79d570238 --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--blocked-by-view/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA t; +-- age retyped numeric -> integer; a view references the column, so the view +-- must be dropped before ALTER COLUMN TYPE and recreated after +CREATE TABLE t.users (id integer PRIMARY KEY, age integer); +CREATE VIEW t.user_ages AS SELECT id, age FROM t.users WHERE age > 0; diff --git a/packages/pg-delta/corpus/alter-column-type--blocked-by-view/seed-b.sql b/packages/pg-delta/corpus/alter-column-type--blocked-by-view/seed-b.sql new file mode 100644 index 000000000..6adf7cb69 --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--blocked-by-view/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO t.users (id, age) VALUES (1, 30); diff --git a/packages/pg-delta/corpus/alter-column-type--blocked-by-view/seed.sql b/packages/pg-delta/corpus/alter-column-type--blocked-by-view/seed.sql new file mode 100644 index 000000000..6adf7cb69 --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--blocked-by-view/seed.sql @@ -0,0 +1 @@ +INSERT INTO t.users (id, age) VALUES (1, 30); diff --git a/packages/pg-delta/corpus/alter-column-type--swap-user-types/a.sql b/packages/pg-delta/corpus/alter-column-type--swap-user-types/a.sql new file mode 100644 index 000000000..3c305f49d --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--swap-user-types/a.sql @@ -0,0 +1,12 @@ +-- column typed by a user domain that only exists in this state; the type is +-- created/dropped in the same plan as the ALTER COLUMN … TYPE, so the alter +-- must consume the new type (run after its CREATE) and release the old type +-- (run before its DROP). +CREATE SCHEMA app; + +CREATE DOMAIN app.code_old AS text; + +CREATE TABLE app.items ( + id integer PRIMARY KEY, + code app.code_old NOT NULL +); diff --git a/packages/pg-delta/corpus/alter-column-type--swap-user-types/b.sql b/packages/pg-delta/corpus/alter-column-type--swap-user-types/b.sql new file mode 100644 index 000000000..511846e0b --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--swap-user-types/b.sql @@ -0,0 +1,11 @@ +-- code column retyped from app.code_old to app.code_new; both are user domains +-- over text, one present per state, so the plan creates code_new + drops +-- code_old around the ALTER COLUMN … TYPE. +CREATE SCHEMA app; + +CREATE DOMAIN app.code_new AS text; + +CREATE TABLE app.items ( + id integer PRIMARY KEY, + code app.code_new NOT NULL +); diff --git a/packages/pg-delta/corpus/alter-column-type--swap-user-types/seed-b.sql b/packages/pg-delta/corpus/alter-column-type--swap-user-types/seed-b.sql new file mode 100644 index 000000000..4ca9a8b8e --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--swap-user-types/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO app.items (id, code) VALUES (1, 'ABC'); diff --git a/packages/pg-delta/corpus/alter-column-type--swap-user-types/seed.sql b/packages/pg-delta/corpus/alter-column-type--swap-user-types/seed.sql new file mode 100644 index 000000000..4ca9a8b8e --- /dev/null +++ b/packages/pg-delta/corpus/alter-column-type--swap-user-types/seed.sql @@ -0,0 +1 @@ +INSERT INTO app.items (id, code) VALUES (1, 'ABC'); diff --git a/packages/pg-delta/corpus/alter-extension--set-schema-non-relocatable/a.sql b/packages/pg-delta/corpus/alter-extension--set-schema-non-relocatable/a.sql new file mode 100644 index 000000000..a008fa95e --- /dev/null +++ b/packages/pg-delta/corpus/alter-extension--set-schema-non-relocatable/a.sql @@ -0,0 +1,6 @@ +-- xml2 is a NON-relocatable extension: PostgreSQL rejects +-- ALTER EXTENSION xml2 SET SCHEMA. Relocating it between states must therefore +-- be planned as drop + recreate in the new schema, not an in-place ALTER. +CREATE SCHEMA schema_a; + +CREATE EXTENSION xml2 SCHEMA schema_a; diff --git a/packages/pg-delta/corpus/alter-extension--set-schema-non-relocatable/b.sql b/packages/pg-delta/corpus/alter-extension--set-schema-non-relocatable/b.sql new file mode 100644 index 000000000..89155f97a --- /dev/null +++ b/packages/pg-delta/corpus/alter-extension--set-schema-non-relocatable/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA schema_b; + +CREATE EXTENSION xml2 SCHEMA schema_b; diff --git a/packages/pg-delta/corpus/alter-extension--set-schema-swap/a.sql b/packages/pg-delta/corpus/alter-extension--set-schema-swap/a.sql new file mode 100644 index 000000000..98554ab1a --- /dev/null +++ b/packages/pg-delta/corpus/alter-extension--set-schema-swap/a.sql @@ -0,0 +1,7 @@ +-- relocatable extension living in a schema that only exists in this state. +-- ALTER EXTENSION … SET SCHEMA must run after CREATE SCHEMA of the new home +-- (consumes, already declared) and before DROP SCHEMA of the old home +-- (releases, the fix under test). +CREATE SCHEMA ext_old; + +CREATE EXTENSION citext SCHEMA ext_old; diff --git a/packages/pg-delta/corpus/alter-extension--set-schema-swap/b.sql b/packages/pg-delta/corpus/alter-extension--set-schema-swap/b.sql new file mode 100644 index 000000000..93d66a6b4 --- /dev/null +++ b/packages/pg-delta/corpus/alter-extension--set-schema-swap/b.sql @@ -0,0 +1,6 @@ +-- citext relocated from ext_old to ext_new; each schema exists in only one +-- state, so the plan creates ext_new and drops ext_old around the +-- ALTER EXTENSION citext SET SCHEMA. +CREATE SCHEMA ext_new; + +CREATE EXTENSION citext SCHEMA ext_new; diff --git a/packages/pg-delta/corpus/alter-table--add-column-then-unique/a.sql b/packages/pg-delta/corpus/alter-table--add-column-then-unique/a.sql new file mode 100644 index 000000000..f4fb9d866 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--add-column-then-unique/a.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.idx_users ( + id integer NOT NULL +); diff --git a/packages/pg-delta/corpus/alter-table--add-column-then-unique/b.sql b/packages/pg-delta/corpus/alter-table--add-column-then-unique/b.sql new file mode 100644 index 000000000..a5f59a345 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--add-column-then-unique/b.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.idx_users ( + id integer NOT NULL +); +ALTER TABLE test_schema.idx_users ADD COLUMN email character varying(255); +ALTER TABLE test_schema.idx_users ADD CONSTRAINT users_email_key UNIQUE (email); diff --git a/packages/pg-delta/corpus/alter-table--add-column-then-unique/seed-b.sql b/packages/pg-delta/corpus/alter-table--add-column-then-unique/seed-b.sql new file mode 100644 index 000000000..ae8678f7d --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--add-column-then-unique/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.idx_users (id, email) VALUES (1, 'user1@example.com'); diff --git a/packages/pg-delta/corpus/alter-table--add-column-then-unique/seed.sql b/packages/pg-delta/corpus/alter-table--add-column-then-unique/seed.sql new file mode 100644 index 000000000..91b800e8c --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--add-column-then-unique/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.idx_users (id) VALUES (1); diff --git a/packages/pg-delta/corpus/alter-table--column-type-cast/a.sql b/packages/pg-delta/corpus/alter-table--column-type-cast/a.sql new file mode 100644 index 000000000..44fd5fb85 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--column-type-cast/a.sql @@ -0,0 +1,12 @@ +-- amount is varchar(10); price is numeric(8,2) with default +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.orders ( + id integer NOT NULL, + amount varchar(10) +); + +CREATE TABLE test_schema.priced ( + id integer NOT NULL, + price numeric(8,2) DEFAULT 0.00 +); diff --git a/packages/pg-delta/corpus/alter-table--column-type-cast/b.sql b/packages/pg-delta/corpus/alter-table--column-type-cast/b.sql new file mode 100644 index 000000000..6ee43ddfb --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--column-type-cast/b.sql @@ -0,0 +1,12 @@ +-- amount cast to integer; price widened to numeric(12,4) preserving default +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.orders ( + id integer NOT NULL, + amount integer +); + +CREATE TABLE test_schema.priced ( + id integer NOT NULL, + price numeric(12,4) DEFAULT 0.00 +); diff --git a/packages/pg-delta/corpus/alter-table--column-type-cast/seed-b.sql b/packages/pg-delta/corpus/alter-table--column-type-cast/seed-b.sql new file mode 100644 index 000000000..db219c02f --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--column-type-cast/seed-b.sql @@ -0,0 +1,3 @@ +-- seed data: orders.amount is already integer; priced uses its numeric(12,4) default +INSERT INTO test_schema.orders (id, amount) VALUES (1, 42), (2, 100); +INSERT INTO test_schema.priced (id) VALUES (1); diff --git a/packages/pg-delta/corpus/alter-table--column-type-cast/seed.sql b/packages/pg-delta/corpus/alter-table--column-type-cast/seed.sql new file mode 100644 index 000000000..73f9b6bdf --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--column-type-cast/seed.sql @@ -0,0 +1,3 @@ +-- seed data: orders.amount contains numeric strings castable to integer +INSERT INTO test_schema.orders (id, amount) VALUES (1, '42'), (2, '100'); +INSERT INTO test_schema.priced (id) VALUES (1); diff --git a/packages/pg-delta/corpus/alter-table--column-type-enum-default/a.sql b/packages/pg-delta/corpus/alter-table--column-type-enum-default/a.sql new file mode 100644 index 000000000..ffe9b73e0 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--column-type-enum-default/a.sql @@ -0,0 +1,9 @@ +-- column uses text type with a text default +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.status AS ENUM ('active', 'inactive', 'archived'); + +CREATE TABLE test_schema.items ( + id integer NOT NULL, + state text NOT NULL DEFAULT 'active' +); diff --git a/packages/pg-delta/corpus/alter-table--column-type-enum-default/b.sql b/packages/pg-delta/corpus/alter-table--column-type-enum-default/b.sql new file mode 100644 index 000000000..71caf5fa2 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--column-type-enum-default/b.sql @@ -0,0 +1,9 @@ +-- column type changed from text to enum, default cast to enum literal +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.status AS ENUM ('active', 'inactive', 'archived'); + +CREATE TABLE test_schema.items ( + id integer NOT NULL, + state test_schema.status NOT NULL DEFAULT 'active'::test_schema.status +); diff --git a/packages/pg-delta/corpus/alter-table--column-type-enum-default/seed-b.sql b/packages/pg-delta/corpus/alter-table--column-type-enum-default/seed-b.sql new file mode 100644 index 000000000..18c17ad8b --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--column-type-enum-default/seed-b.sql @@ -0,0 +1,2 @@ +-- seed data to verify data survival across the column type change (reverse direction) +INSERT INTO test_schema.items (id, state) VALUES (1, 'active'), (2, 'inactive'); diff --git a/packages/pg-delta/corpus/alter-table--column-type-enum-default/seed.sql b/packages/pg-delta/corpus/alter-table--column-type-enum-default/seed.sql new file mode 100644 index 000000000..109deb903 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--column-type-enum-default/seed.sql @@ -0,0 +1,2 @@ +-- seed data to verify data survival across the column type change +INSERT INTO test_schema.items (id, state) VALUES (1, 'active'), (2, 'inactive'); diff --git a/packages/pg-delta/corpus/alter-table--generated-column/a.sql b/packages/pg-delta/corpus/alter-table--generated-column/a.sql new file mode 100644 index 000000000..658a89696 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--generated-column/a.sql @@ -0,0 +1,15 @@ +-- table without generated column (base state) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.calculations ( + id integer NOT NULL, + value_a numeric NOT NULL, + value_b numeric NOT NULL, + computed numeric GENERATED ALWAYS AS (value_a + value_b) STORED +); + +CREATE TABLE test_schema.users ( + id integer NOT NULL, + first_name text NOT NULL, + last_name text NOT NULL +); diff --git a/packages/pg-delta/corpus/alter-table--generated-column/b.sql b/packages/pg-delta/corpus/alter-table--generated-column/b.sql new file mode 100644 index 000000000..3b9eb1107 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--generated-column/b.sql @@ -0,0 +1,17 @@ +-- calculations has a changed generated expression (multiply instead of add); +-- users gains a new generated column for full_name +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.calculations ( + id integer NOT NULL, + value_a numeric NOT NULL, + value_b numeric NOT NULL, + computed numeric GENERATED ALWAYS AS (value_a * value_b) STORED +); + +CREATE TABLE test_schema.users ( + id integer NOT NULL, + first_name text NOT NULL, + last_name text NOT NULL, + full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED +); diff --git a/packages/pg-delta/corpus/alter-table--generated-column/seed-b.sql b/packages/pg-delta/corpus/alter-table--generated-column/seed-b.sql new file mode 100644 index 000000000..ca1f3c499 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--generated-column/seed-b.sql @@ -0,0 +1,7 @@ +-- (2, 2) is a fixed point of both generation expressions (2 + 2 = 2 * 2 = 4), so +-- the stored `computed` value is identical before and after the expression change +-- and the data-proof fingerprint holds — while value_a / value_b (the real user +-- data) gain fingerprint protection. If you change either expression, pick a new +-- fixed point. OMIT `computed`: GENERATED ALWAYS rejects explicit inserts. +INSERT INTO test_schema.calculations (id, value_a, value_b) VALUES (1, 2, 2); +INSERT INTO test_schema.users (id, first_name, last_name) VALUES (1, 'Jane', 'Doe'); diff --git a/packages/pg-delta/corpus/alter-table--generated-column/seed.sql b/packages/pg-delta/corpus/alter-table--generated-column/seed.sql new file mode 100644 index 000000000..ca1f3c499 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--generated-column/seed.sql @@ -0,0 +1,7 @@ +-- (2, 2) is a fixed point of both generation expressions (2 + 2 = 2 * 2 = 4), so +-- the stored `computed` value is identical before and after the expression change +-- and the data-proof fingerprint holds — while value_a / value_b (the real user +-- data) gain fingerprint protection. If you change either expression, pick a new +-- fixed point. OMIT `computed`: GENERATED ALWAYS rejects explicit inserts. +INSERT INTO test_schema.calculations (id, value_a, value_b) VALUES (1, 2, 2); +INSERT INTO test_schema.users (id, first_name, last_name) VALUES (1, 'Jane', 'Doe'); diff --git a/packages/pg-delta/corpus/alter-table--multi-alter-ops/a.sql b/packages/pg-delta/corpus/alter-table--multi-alter-ops/a.sql new file mode 100644 index 000000000..5294b1eea --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--multi-alter-ops/a.sql @@ -0,0 +1,8 @@ +-- table before multiple simultaneous alterations +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.evolution ( + id integer NOT NULL, + old_name varchar(50), + status text DEFAULT 'pending' +); diff --git a/packages/pg-delta/corpus/alter-table--multi-alter-ops/b.sql b/packages/pg-delta/corpus/alter-table--multi-alter-ops/b.sql new file mode 100644 index 000000000..ed6f5ac45 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--multi-alter-ops/b.sql @@ -0,0 +1,8 @@ +-- multiple column changes applied at once: add email, widen old_name type, drop status column +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.evolution ( + id integer NOT NULL, + old_name text, + email character varying(255) +); diff --git a/packages/pg-delta/corpus/alter-table--multi-alter-ops/seed-b.sql b/packages/pg-delta/corpus/alter-table--multi-alter-ops/seed-b.sql new file mode 100644 index 000000000..2770ff8d8 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--multi-alter-ops/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.evolution (id, old_name) VALUES (1, 'legacy'); diff --git a/packages/pg-delta/corpus/alter-table--multi-alter-ops/seed.sql b/packages/pg-delta/corpus/alter-table--multi-alter-ops/seed.sql new file mode 100644 index 000000000..235fa38d8 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--multi-alter-ops/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.evolution (id, old_name, status) VALUES (1, 'legacy', 'pending'); diff --git a/packages/pg-delta/corpus/alter-table--not-null/a.sql b/packages/pg-delta/corpus/alter-table--not-null/a.sql new file mode 100644 index 000000000..737af31ad --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--not-null/a.sql @@ -0,0 +1,8 @@ +-- name is nullable; email has NOT NULL +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer NOT NULL, + name text, + email text NOT NULL +); diff --git a/packages/pg-delta/corpus/alter-table--not-null/b.sql b/packages/pg-delta/corpus/alter-table--not-null/b.sql new file mode 100644 index 000000000..ac2895a02 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--not-null/b.sql @@ -0,0 +1,8 @@ +-- name gains NOT NULL; email drops NOT NULL +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer NOT NULL, + name text NOT NULL, + email text +); diff --git a/packages/pg-delta/corpus/alter-table--not-null/seed-b.sql b/packages/pg-delta/corpus/alter-table--not-null/seed-b.sql new file mode 100644 index 000000000..8bb289351 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--not-null/seed-b.sql @@ -0,0 +1,3 @@ +-- name is NOT NULL in b.sql; email is nullable here but is re-added as NOT NULL +-- by the reverse migration to a.sql, so populate it too to preserve the row. +INSERT INTO test_schema.users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'); diff --git a/packages/pg-delta/corpus/alter-table--not-null/seed.sql b/packages/pg-delta/corpus/alter-table--not-null/seed.sql new file mode 100644 index 000000000..cebef9656 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--not-null/seed.sql @@ -0,0 +1,2 @@ +-- seed ensures no NULLs in name so SET NOT NULL succeeds +INSERT INTO test_schema.users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'); diff --git a/packages/pg-delta/corpus/alter-table--replica-identity/a.sql b/packages/pg-delta/corpus/alter-table--replica-identity/a.sql new file mode 100644 index 000000000..947f98cd7 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--replica-identity/a.sql @@ -0,0 +1,11 @@ +-- table with a unique index but replica identity is still DEFAULT +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.replicated ( + id integer NOT NULL, + tenant_id integer NOT NULL, + payload text +); + +CREATE UNIQUE INDEX replicated_tenant_id_key + ON test_schema.replicated (tenant_id); diff --git a/packages/pg-delta/corpus/alter-table--replica-identity/b.sql b/packages/pg-delta/corpus/alter-table--replica-identity/b.sql new file mode 100644 index 000000000..7339ff1d6 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--replica-identity/b.sql @@ -0,0 +1,15 @@ +-- replica identity is set to use the unique index; index definition widened (triggers DROP+CREATE+re-set) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.replicated ( + id integer NOT NULL, + tenant_id integer NOT NULL, + payload text +); + +-- widened index: includes id column; still used as replica identity +CREATE UNIQUE INDEX replicated_tenant_id_key + ON test_schema.replicated (tenant_id, id); + +ALTER TABLE test_schema.replicated + REPLICA IDENTITY USING INDEX replicated_tenant_id_key; diff --git a/packages/pg-delta/corpus/alter-table--replica-identity/seed-b.sql b/packages/pg-delta/corpus/alter-table--replica-identity/seed-b.sql new file mode 100644 index 000000000..6682424a6 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--replica-identity/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.replicated (id, tenant_id, payload) VALUES (1, 100, 'hello'); diff --git a/packages/pg-delta/corpus/alter-table--replica-identity/seed.sql b/packages/pg-delta/corpus/alter-table--replica-identity/seed.sql new file mode 100644 index 000000000..6682424a6 --- /dev/null +++ b/packages/pg-delta/corpus/alter-table--replica-identity/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.replicated (id, tenant_id, payload) VALUES (1, 100, 'hello'); diff --git a/packages/pg-delta/corpus/catalog-diff--create-sequence/a.sql b/packages/pg-delta/corpus/catalog-diff--create-sequence/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--create-sequence/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/catalog-diff--create-sequence/b.sql b/packages/pg-delta/corpus/catalog-diff--create-sequence/b.sql new file mode 100644 index 000000000..66380030c --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--create-sequence/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE SEQUENCE test_schema.user_id_seq + START WITH 1000 + INCREMENT BY 1 + MINVALUE 1000 + MAXVALUE 999999 + CACHE 1; diff --git a/packages/pg-delta/corpus/catalog-diff--create-view/a.sql b/packages/pg-delta/corpus/catalog-diff--create-view/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--create-view/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/catalog-diff--create-view/b.sql b/packages/pg-delta/corpus/catalog-diff--create-view/b.sql new file mode 100644 index 000000000..72028cc1a --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--create-view/b.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id serial PRIMARY KEY, + username varchar(50) NOT NULL +); + +CREATE VIEW test_schema.active_users AS + SELECT id, username FROM test_schema.users WHERE id > 0; diff --git a/packages/pg-delta/corpus/catalog-diff--domain-add-constraint/a.sql b/packages/pg-delta/corpus/catalog-diff--domain-add-constraint/a.sql new file mode 100644 index 000000000..1a853f0d5 --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--domain-add-constraint/a.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA test_schema; + +CREATE DOMAIN test_schema.age AS integer; diff --git a/packages/pg-delta/corpus/catalog-diff--domain-add-constraint/b.sql b/packages/pg-delta/corpus/catalog-diff--domain-add-constraint/b.sql new file mode 100644 index 000000000..dea57f9cc --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--domain-add-constraint/b.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA test_schema; + +CREATE DOMAIN test_schema.age AS integer + CONSTRAINT age_check CHECK (VALUE >= 0 AND VALUE <= 150); diff --git a/packages/pg-delta/corpus/catalog-diff--drop-heterogeneous-schema/a.sql b/packages/pg-delta/corpus/catalog-diff--drop-heterogeneous-schema/a.sql new file mode 100644 index 000000000..9c1647c28 --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--drop-heterogeneous-schema/a.sql @@ -0,0 +1,34 @@ +CREATE SCHEMA test_schema; + +-- Create enum +CREATE TYPE test_schema.user_role AS ENUM ('admin', 'user', 'moderator'); + +-- Create domain +CREATE DOMAIN test_schema.positive_integer AS integer + CONSTRAINT positive_check CHECK (value > 0); + +-- Create sequence +CREATE SEQUENCE test_schema.global_id_seq START 10000; + +-- Create table +CREATE TABLE test_schema.users ( + id test_schema.positive_integer PRIMARY KEY DEFAULT nextval('test_schema.global_id_seq'), + username varchar(50) UNIQUE NOT NULL, + role test_schema.user_role DEFAULT 'user', + created_at timestamp DEFAULT now() +); + +-- Create view +CREATE VIEW test_schema.admin_users AS + SELECT * FROM test_schema.users WHERE role = 'admin'; + +-- Create procedure +CREATE OR REPLACE PROCEDURE test_schema.create_admin_user( + p_username varchar(50) +) +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO test_schema.users (username, role) VALUES (p_username, 'admin'); +END; +$$; diff --git a/packages/pg-delta/corpus/catalog-diff--drop-heterogeneous-schema/b.sql b/packages/pg-delta/corpus/catalog-diff--drop-heterogeneous-schema/b.sql new file mode 100644 index 000000000..5d157df71 --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--drop-heterogeneous-schema/b.sql @@ -0,0 +1 @@ +-- Empty state: every object from a.sql is dropped in dependency order. diff --git a/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/a.sql b/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/a.sql new file mode 100644 index 000000000..2fca30a45 --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/a.sql @@ -0,0 +1,20 @@ +CREATE SCHEMA test_schema; + +-- Enum with fewer values +CREATE TYPE test_schema.user_role AS ENUM ('admin', 'user'); + +-- Domain without constraint +CREATE DOMAIN test_schema.positive_integer AS integer; + +-- Sequence with low start value +CREATE SEQUENCE test_schema.global_id_seq START 1; + +-- Table with fewer columns +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + username varchar(50) NOT NULL +); + +-- View with simpler definition +CREATE VIEW test_schema.admin_users AS + SELECT id, username FROM test_schema.users WHERE id > 0; diff --git a/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/b.sql b/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/b.sql new file mode 100644 index 000000000..95fb62589 --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/b.sql @@ -0,0 +1,23 @@ +CREATE SCHEMA test_schema; + +-- Enum with more values +CREATE TYPE test_schema.user_role AS ENUM ('admin', 'user', 'moderator'); + +-- Domain with constraint +CREATE DOMAIN test_schema.positive_integer AS integer + CONSTRAINT positive_check CHECK (VALUE > 0); + +-- Sequence with higher start value +CREATE SEQUENCE test_schema.global_id_seq START 10000; + +-- Table with more columns +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + username varchar(50) NOT NULL, + email varchar(255), + created_at timestamp DEFAULT now() +); + +-- View with more columns +CREATE VIEW test_schema.admin_users AS + SELECT id, username, email, created_at FROM test_schema.users WHERE id > 0; diff --git a/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/seed-b.sql b/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/seed-b.sql new file mode 100644 index 000000000..c8dfaf8bc --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, username) VALUES (1, 'alice'); diff --git a/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/seed.sql b/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/seed.sql new file mode 100644 index 000000000..c8dfaf8bc --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--multi-entity-alter/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, username) VALUES (1, 'alice'); diff --git a/packages/pg-delta/corpus/catalog-diff--table-with-constraints/a.sql b/packages/pg-delta/corpus/catalog-diff--table-with-constraints/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--table-with-constraints/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/catalog-diff--table-with-constraints/b.sql b/packages/pg-delta/corpus/catalog-diff--table-with-constraints/b.sql new file mode 100644 index 000000000..2031f9d73 --- /dev/null +++ b/packages/pg-delta/corpus/catalog-diff--table-with-constraints/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id serial PRIMARY KEY, + username varchar(50) UNIQUE NOT NULL, + email varchar(255) UNIQUE NOT NULL, + created_at timestamp DEFAULT now() +); diff --git a/packages/pg-delta/corpus/check-ordering--function-and-type-ref/a.sql b/packages/pg-delta/corpus/check-ordering--function-and-type-ref/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/check-ordering--function-and-type-ref/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/check-ordering--function-and-type-ref/b.sql b/packages/pg-delta/corpus/check-ordering--function-and-type-ref/b.sql new file mode 100644 index 000000000..f7d87a57c --- /dev/null +++ b/packages/pg-delta/corpus/check-ordering--function-and-type-ref/b.sql @@ -0,0 +1,37 @@ +-- CHECK constraints that reference user-defined functions and enum casts; +-- exercises dependency ordering between functions/types and their dependent constraints +CREATE SCHEMA test_schema; + +CREATE OR REPLACE FUNCTION test_schema.validate_price(price decimal) +RETURNS boolean AS $$ +BEGIN + RETURN price > 0 AND price < 1000000; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION test_schema.validate_status(status text) +RETURNS boolean AS $$ +BEGIN + RETURN status IN ('active', 'inactive', 'pending', 'archived'); +END; +$$ LANGUAGE plpgsql; + +CREATE TABLE test_schema.products ( + id integer PRIMARY KEY, + name text NOT NULL, + price decimal NOT NULL, + status text NOT NULL, + CONSTRAINT products_price_valid CHECK (test_schema.validate_price(price)), + CONSTRAINT products_status_valid CHECK (test_schema.validate_status(status)) +); + +CREATE TYPE test_schema.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled'); +CREATE TYPE test_schema.priority_level AS ENUM ('low', 'medium', 'high', 'urgent'); + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + status text NOT NULL, + priority text NOT NULL, + CONSTRAINT orders_status_valid CHECK (status::test_schema.order_status IS NOT NULL), + CONSTRAINT orders_priority_valid CHECK (priority::test_schema.priority_level IS NOT NULL) +); diff --git a/packages/pg-delta/corpus/collation-ops--comment/a.sql b/packages/pg-delta/corpus/collation-ops--comment/a.sql new file mode 100644 index 000000000..c510cd4cb --- /dev/null +++ b/packages/pg-delta/corpus/collation-ops--comment/a.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA coll_schema; + +CREATE COLLATION coll_schema.c2 (LC_COLLATE = 'C', LC_CTYPE = 'C'); diff --git a/packages/pg-delta/corpus/collation-ops--comment/b.sql b/packages/pg-delta/corpus/collation-ops--comment/b.sql new file mode 100644 index 000000000..436763abc --- /dev/null +++ b/packages/pg-delta/corpus/collation-ops--comment/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA coll_schema; + +CREATE COLLATION coll_schema.c2 (LC_COLLATE = 'C', LC_CTYPE = 'C'); + +COMMENT ON COLLATION coll_schema.c2 IS 'Test collation comment'; diff --git a/packages/pg-delta/corpus/collation-ops--create/a.sql b/packages/pg-delta/corpus/collation-ops--create/a.sql new file mode 100644 index 000000000..60cbe18a4 --- /dev/null +++ b/packages/pg-delta/corpus/collation-ops--create/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA coll_schema; diff --git a/packages/pg-delta/corpus/collation-ops--create/b.sql b/packages/pg-delta/corpus/collation-ops--create/b.sql new file mode 100644 index 000000000..bdeef2827 --- /dev/null +++ b/packages/pg-delta/corpus/collation-ops--create/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA coll_schema; + +CREATE COLLATION coll_schema.c1 (LC_COLLATE = 'C', LC_CTYPE = 'C'); diff --git a/packages/pg-delta/corpus/column-add/a.sql b/packages/pg-delta/corpus/column-add/a.sql new file mode 100644 index 000000000..16c99828d --- /dev/null +++ b/packages/pg-delta/corpus/column-add/a.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA app; +CREATE TABLE app.users (id integer PRIMARY KEY); diff --git a/packages/pg-delta/corpus/column-add/b.sql b/packages/pg-delta/corpus/column-add/b.sql new file mode 100644 index 000000000..bde51426e --- /dev/null +++ b/packages/pg-delta/corpus/column-add/b.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA app; +CREATE TABLE app.users ( + id integer PRIMARY KEY, + email text NOT NULL DEFAULT 'unknown@example.com', + bio text +); diff --git a/packages/pg-delta/corpus/column-add/seed-b.sql b/packages/pg-delta/corpus/column-add/seed-b.sql new file mode 100644 index 000000000..a5462532b --- /dev/null +++ b/packages/pg-delta/corpus/column-add/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO app.users (id) VALUES (1), (2), (3); diff --git a/packages/pg-delta/corpus/column-add/seed.sql b/packages/pg-delta/corpus/column-add/seed.sql new file mode 100644 index 000000000..a5462532b --- /dev/null +++ b/packages/pg-delta/corpus/column-add/seed.sql @@ -0,0 +1 @@ +INSERT INTO app.users (id) VALUES (1), (2), (3); diff --git a/packages/pg-delta/corpus/column-type-change/a.sql b/packages/pg-delta/corpus/column-type-change/a.sql new file mode 100644 index 000000000..bea023805 --- /dev/null +++ b/packages/pg-delta/corpus/column-type-change/a.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA app; +CREATE TABLE app.events (id integer PRIMARY KEY, payload_size integer NOT NULL); diff --git a/packages/pg-delta/corpus/column-type-change/b.sql b/packages/pg-delta/corpus/column-type-change/b.sql new file mode 100644 index 000000000..008b28a2f --- /dev/null +++ b/packages/pg-delta/corpus/column-type-change/b.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA app; +CREATE TABLE app.events (id integer PRIMARY KEY, payload_size bigint NOT NULL); diff --git a/packages/pg-delta/corpus/column-type-change/seed-b.sql b/packages/pg-delta/corpus/column-type-change/seed-b.sql new file mode 100644 index 000000000..dcb147fca --- /dev/null +++ b/packages/pg-delta/corpus/column-type-change/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO app.events (id, payload_size) VALUES (1, 100), (2, 2000000); diff --git a/packages/pg-delta/corpus/column-type-change/seed.sql b/packages/pg-delta/corpus/column-type-change/seed.sql new file mode 100644 index 000000000..dcb147fca --- /dev/null +++ b/packages/pg-delta/corpus/column-type-change/seed.sql @@ -0,0 +1 @@ +INSERT INTO app.events (id, payload_size) VALUES (1, 100), (2, 2000000); diff --git a/packages/pg-delta/corpus/comments--schema/a.sql b/packages/pg-delta/corpus/comments--schema/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/comments--schema/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/comments--schema/b.sql b/packages/pg-delta/corpus/comments--schema/b.sql new file mode 100644 index 000000000..692b16d80 --- /dev/null +++ b/packages/pg-delta/corpus/comments--schema/b.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA test_schema; +COMMENT ON SCHEMA test_schema IS 'a test schema'; diff --git a/packages/pg-delta/corpus/comments/a.sql b/packages/pg-delta/corpus/comments/a.sql new file mode 100644 index 000000000..1a4e8691b --- /dev/null +++ b/packages/pg-delta/corpus/comments/a.sql @@ -0,0 +1,3 @@ +CREATE TABLE public.docs (id integer PRIMARY KEY, body text); +COMMENT ON TABLE public.docs IS 'documents'; +COMMENT ON COLUMN public.docs.body IS 'will change'; diff --git a/packages/pg-delta/corpus/comments/b.sql b/packages/pg-delta/corpus/comments/b.sql new file mode 100644 index 000000000..02271ad35 --- /dev/null +++ b/packages/pg-delta/corpus/comments/b.sql @@ -0,0 +1,3 @@ +CREATE TABLE public.docs (id integer PRIMARY KEY, body text); +COMMENT ON COLUMN public.docs.body IS 'changed text'; +COMMENT ON COLUMN public.docs.id IS 'fresh comment'; diff --git a/packages/pg-delta/corpus/comments/seed-b.sql b/packages/pg-delta/corpus/comments/seed-b.sql new file mode 100644 index 000000000..40915b795 --- /dev/null +++ b/packages/pg-delta/corpus/comments/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.docs (id, body) VALUES (1, 'hello world'); diff --git a/packages/pg-delta/corpus/comments/seed.sql b/packages/pg-delta/corpus/comments/seed.sql new file mode 100644 index 000000000..40915b795 --- /dev/null +++ b/packages/pg-delta/corpus/comments/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.docs (id, body) VALUES (1, 'hello world'); diff --git a/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/a.sql b/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/a.sql new file mode 100644 index 000000000..e422f0d3d --- /dev/null +++ b/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/a.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA ecommerce; +DO $$ BEGIN CREATE ROLE corpus_ecommerce_admin LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_ecommerce_user LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_analytics_user LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/b.sql b/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/b.sql new file mode 100644 index 000000000..dd2777c6a --- /dev/null +++ b/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/b.sql @@ -0,0 +1,144 @@ +CREATE SCHEMA ecommerce; +DO $$ BEGIN CREATE ROLE corpus_ecommerce_admin LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_ecommerce_user LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_analytics_user LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE TYPE ecommerce.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled'); +CREATE TYPE ecommerce.priority_level AS ENUM ('low', 'medium', 'high', 'urgent'); + +CREATE OR REPLACE FUNCTION ecommerce.validate_amount(amount decimal) +RETURNS boolean AS $$ +BEGIN + RETURN amount > 0 AND amount < 1000000; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION ecommerce.calculate_tax(amount decimal) +RETURNS decimal AS $$ +BEGIN + RETURN amount * 0.08; +END; +$$ LANGUAGE plpgsql; + +CREATE TABLE ecommerce.customers ( + id integer PRIMARY KEY, + name text NOT NULL, + email text UNIQUE NOT NULL, + created_at timestamp DEFAULT now() +); + +CREATE TABLE ecommerce.categories ( + id integer PRIMARY KEY, + name text NOT NULL, + parent_id integer +); + +CREATE TABLE ecommerce.products ( + id integer PRIMARY KEY, + name text NOT NULL, + price decimal NOT NULL, + category_id integer, + status text NOT NULL +); + +CREATE TABLE ecommerce.orders ( + id integer PRIMARY KEY, + customer_id integer NOT NULL, + order_date date NOT NULL, + status text NOT NULL, + priority text NOT NULL, + total_amount decimal NOT NULL +); + +CREATE TABLE ecommerce.order_items ( + id integer PRIMARY KEY, + order_id integer NOT NULL, + product_id integer NOT NULL, + quantity integer NOT NULL, + unit_price decimal NOT NULL +); + +ALTER TABLE ecommerce.products + ADD CONSTRAINT products_category_fkey + FOREIGN KEY (category_id) REFERENCES ecommerce.categories(id); + +ALTER TABLE ecommerce.categories + ADD CONSTRAINT categories_parent_fkey + FOREIGN KEY (parent_id) REFERENCES ecommerce.categories(id); + +ALTER TABLE ecommerce.orders + ADD CONSTRAINT orders_customer_fkey + FOREIGN KEY (customer_id) REFERENCES ecommerce.customers(id); + +ALTER TABLE ecommerce.order_items + ADD CONSTRAINT order_items_order_fkey + FOREIGN KEY (order_id) REFERENCES ecommerce.orders(id); + +ALTER TABLE ecommerce.order_items + ADD CONSTRAINT order_items_product_fkey + FOREIGN KEY (product_id) REFERENCES ecommerce.products(id); + +ALTER TABLE ecommerce.orders + ADD CONSTRAINT orders_status_valid + CHECK (status::ecommerce.order_status IS NOT NULL); + +ALTER TABLE ecommerce.orders + ADD CONSTRAINT orders_priority_valid + CHECK (priority::ecommerce.priority_level IS NOT NULL); + +ALTER TABLE ecommerce.orders + ADD CONSTRAINT orders_amount_valid + CHECK (ecommerce.validate_amount(total_amount)); + +ALTER TABLE ecommerce.order_items + ADD CONSTRAINT order_items_quantity_valid + CHECK (quantity > 0); + +ALTER TABLE ecommerce.order_items + ADD CONSTRAINT order_items_price_valid + CHECK (ecommerce.validate_amount(unit_price)); + +CREATE VIEW ecommerce.customer_orders AS +SELECT + c.id AS customer_id, + c.name AS customer_name, + c.email, + COUNT(o.id) AS order_count, + SUM(o.total_amount) AS total_spent +FROM ecommerce.customers c +LEFT JOIN ecommerce.orders o ON c.id = o.customer_id +GROUP BY c.id, c.name, c.email; + +CREATE VIEW ecommerce.product_sales AS +SELECT + p.id AS product_id, + p.name AS product_name, + SUM(oi.quantity) AS total_sold, + SUM(oi.quantity * oi.unit_price) AS total_revenue +FROM ecommerce.products p +LEFT JOIN ecommerce.order_items oi ON p.id = oi.product_id +GROUP BY p.id, p.name; + +CREATE MATERIALIZED VIEW ecommerce.daily_sales AS +SELECT + order_date, + COUNT(*) AS order_count, + SUM(total_amount) AS total_revenue, + AVG(total_amount) AS avg_order_value +FROM ecommerce.orders +GROUP BY order_date; + +CREATE INDEX idx_orders_customer_date ON ecommerce.orders(customer_id, order_date); +CREATE INDEX idx_orders_status ON ecommerce.orders(status); +CREATE INDEX idx_order_items_order ON ecommerce.order_items(order_id); +CREATE INDEX idx_products_category ON ecommerce.products(category_id); +CREATE INDEX idx_categories_parent ON ecommerce.categories(parent_id); + +ALTER TABLE ecommerce.customers OWNER TO corpus_ecommerce_admin; +ALTER TABLE ecommerce.products OWNER TO corpus_ecommerce_admin; +ALTER TABLE ecommerce.categories OWNER TO corpus_ecommerce_admin; +ALTER TABLE ecommerce.orders OWNER TO corpus_ecommerce_user; +ALTER TABLE ecommerce.order_items OWNER TO corpus_ecommerce_user; +ALTER VIEW ecommerce.customer_orders OWNER TO corpus_analytics_user; +ALTER VIEW ecommerce.product_sales OWNER TO corpus_analytics_user; +ALTER MATERIALIZED VIEW ecommerce.daily_sales OWNER TO corpus_analytics_user; diff --git a/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/meta.json b/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/complex-dependency-ordering--ecommerce-schema/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/a.sql b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/a.sql new file mode 100644 index 000000000..261ad9e73 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/a.sql @@ -0,0 +1,12 @@ +-- state A: temporal-PK parent + audit table with NO foreign key yet +CREATE EXTENSION btree_gist; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.bookings ( + room_id integer NOT NULL, + booking_period tstzrange NOT NULL, + CONSTRAINT bookings_pkey PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS) +); +CREATE TABLE test_schema.booking_audit ( + room_id integer NOT NULL, + booking_period tstzrange NOT NULL +); diff --git a/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/b.sql b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/b.sql new file mode 100644 index 000000000..436f96194 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/b.sql @@ -0,0 +1,15 @@ +-- state B: temporal FOREIGN KEY (PERIOD) added on the audit table. +CREATE EXTENSION btree_gist; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.bookings ( + room_id integer NOT NULL, + booking_period tstzrange NOT NULL, + CONSTRAINT bookings_pkey PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS) +); +CREATE TABLE test_schema.booking_audit ( + room_id integer NOT NULL, + booking_period tstzrange NOT NULL, + CONSTRAINT booking_audit_room_id_booking_period_fkey + FOREIGN KEY (room_id, PERIOD booking_period) + REFERENCES test_schema.bookings (room_id, PERIOD booking_period) +); diff --git a/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/meta.json b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/meta.json new file mode 100644 index 000000000..5b64042f4 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/meta.json @@ -0,0 +1 @@ +{ "minVersion": 18 } diff --git a/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/seed-b.sql new file mode 100644 index 000000000..6378d5aec --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/seed-b.sql @@ -0,0 +1,4 @@ +INSERT INTO test_schema.bookings + VALUES (1, '[2025-01-01,2025-01-02)'::tstzrange); +INSERT INTO test_schema.booking_audit + VALUES (1, '[2025-01-01,2025-01-02)'::tstzrange); diff --git a/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/seed.sql b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/seed.sql new file mode 100644 index 000000000..6378d5aec --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--add-temporal-fk/seed.sql @@ -0,0 +1,4 @@ +INSERT INTO test_schema.bookings + VALUES (1, '[2025-01-01,2025-01-02)'::tstzrange); +INSERT INTO test_schema.booking_audit + VALUES (1, '[2025-01-01,2025-01-02)'::tstzrange); diff --git a/packages/pg-delta/corpus/constraint-ops--comments/a.sql b/packages/pg-delta/corpus/constraint-ops--comments/a.sql new file mode 100644 index 000000000..1078b7d95 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--comments/a.sql @@ -0,0 +1,9 @@ +-- table with a CHECK constraint but no comment on it yet +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + id integer PRIMARY KEY, + created_at timestamp +); + +ALTER TABLE test_schema.events ADD CONSTRAINT events_created_at_not_null CHECK (created_at IS NOT NULL); diff --git a/packages/pg-delta/corpus/constraint-ops--comments/b.sql b/packages/pg-delta/corpus/constraint-ops--comments/b.sql new file mode 100644 index 000000000..ef2c52b3d --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--comments/b.sql @@ -0,0 +1,11 @@ +-- same constraint now has a COMMENT +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + id integer PRIMARY KEY, + created_at timestamp +); + +ALTER TABLE test_schema.events ADD CONSTRAINT events_created_at_not_null CHECK (created_at IS NOT NULL); + +COMMENT ON CONSTRAINT events_created_at_not_null ON test_schema.events IS 'created_at must be set'; diff --git a/packages/pg-delta/corpus/constraint-ops--comments/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--comments/seed-b.sql new file mode 100644 index 000000000..a9602f1c1 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--comments/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.events (id, created_at) VALUES (1, now()); diff --git a/packages/pg-delta/corpus/constraint-ops--comments/seed.sql b/packages/pg-delta/corpus/constraint-ops--comments/seed.sql new file mode 100644 index 000000000..a9602f1c1 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--comments/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.events (id, created_at) VALUES (1, now()); diff --git a/packages/pg-delta/corpus/constraint-ops--composite-fk/a.sql b/packages/pg-delta/corpus/constraint-ops--composite-fk/a.sql new file mode 100644 index 000000000..8a869f112 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--composite-fk/a.sql @@ -0,0 +1,15 @@ +-- composite FK without ON DELETE action +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.parent ( + x int NOT NULL, + y int NOT NULL, + UNIQUE (y, x) +); + +CREATE TABLE test_schema.child ( + b int NOT NULL, + a int NOT NULL, + CONSTRAINT fk_child_parent + FOREIGN KEY (b, a) REFERENCES test_schema.parent (y, x) +); diff --git a/packages/pg-delta/corpus/constraint-ops--composite-fk/b.sql b/packages/pg-delta/corpus/constraint-ops--composite-fk/b.sql new file mode 100644 index 000000000..ad2afc47f --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--composite-fk/b.sql @@ -0,0 +1,16 @@ +-- same composite FK but with ON DELETE CASCADE added (column order preserved) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.parent ( + x int NOT NULL, + y int NOT NULL, + UNIQUE (y, x) +); + +CREATE TABLE test_schema.child ( + b int NOT NULL, + a int NOT NULL, + CONSTRAINT fk_child_parent + FOREIGN KEY (b, a) REFERENCES test_schema.parent (y, x) + ON DELETE CASCADE +); diff --git a/packages/pg-delta/corpus/constraint-ops--composite-fk/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--composite-fk/seed-b.sql new file mode 100644 index 000000000..3c4521651 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--composite-fk/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.parent (x, y) VALUES (1, 1); +INSERT INTO test_schema.child (b, a) VALUES (1, 1); diff --git a/packages/pg-delta/corpus/constraint-ops--composite-fk/seed.sql b/packages/pg-delta/corpus/constraint-ops--composite-fk/seed.sql new file mode 100644 index 000000000..3c4521651 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--composite-fk/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.parent (x, y) VALUES (1, 1); +INSERT INTO test_schema.child (b, a) VALUES (1, 1); diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/a.sql b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/a.sql new file mode 100644 index 000000000..224101113 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/a.sql @@ -0,0 +1,17 @@ +-- state A: regular PK on contacts + regular composite FK on conversations +CREATE EXTENSION btree_gist; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.contacts ( + contact_id integer NOT NULL, + valid_period tstzrange NOT NULL, + CONSTRAINT contacts_pkey PRIMARY KEY (contact_id, valid_period) +); +CREATE TABLE test_schema.conversations ( + conversation_id integer NOT NULL, + contact_id integer NOT NULL, + valid_period tstzrange NOT NULL, + CONSTRAINT conversations_pkey PRIMARY KEY (conversation_id), + CONSTRAINT conversations_contact_fkey + FOREIGN KEY (contact_id, valid_period) + REFERENCES test_schema.contacts (contact_id, valid_period) +); diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/b.sql b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/b.sql new file mode 100644 index 000000000..304d177f3 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/b.sql @@ -0,0 +1,19 @@ +-- state B: temporal PK (WITHOUT OVERLAPS) + temporal FK (PERIOD). The #182 +-- silent-downgrade regression: converting both together must NOT lose the +-- temporal-ness in either direction. +CREATE EXTENSION btree_gist; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.contacts ( + contact_id integer NOT NULL, + valid_period tstzrange NOT NULL, + CONSTRAINT contacts_pkey PRIMARY KEY (contact_id, valid_period WITHOUT OVERLAPS) +); +CREATE TABLE test_schema.conversations ( + conversation_id integer NOT NULL, + contact_id integer NOT NULL, + valid_period tstzrange NOT NULL, + CONSTRAINT conversations_pkey PRIMARY KEY (conversation_id), + CONSTRAINT conversations_contact_fkey + FOREIGN KEY (contact_id, PERIOD valid_period) + REFERENCES test_schema.contacts (contact_id, PERIOD valid_period) +); diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/meta.json b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/meta.json new file mode 100644 index 000000000..5b64042f4 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/meta.json @@ -0,0 +1 @@ +{ "minVersion": 18 } diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/seed-b.sql new file mode 100644 index 000000000..afad542d5 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/seed-b.sql @@ -0,0 +1,4 @@ +INSERT INTO test_schema.contacts + VALUES (1, '[2025-01-01,2025-01-02)'::tstzrange); +INSERT INTO test_schema.conversations + VALUES (1, 1, '[2025-01-01,2025-01-02)'::tstzrange); diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/seed.sql b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/seed.sql new file mode 100644 index 000000000..afad542d5 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-fk-temporal-together/seed.sql @@ -0,0 +1,4 @@ +INSERT INTO test_schema.contacts + VALUES (1, '[2025-01-01,2025-01-02)'::tstzrange); +INSERT INTO test_schema.conversations + VALUES (1, 1, '[2025-01-01,2025-01-02)'::tstzrange); diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/a.sql b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/a.sql new file mode 100644 index 000000000..2e7a16b54 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/a.sql @@ -0,0 +1,8 @@ +-- state A: regular PRIMARY KEY (room_id, booking_period) +CREATE EXTENSION btree_gist; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.bookings ( + room_id integer NOT NULL, + booking_period tstzrange NOT NULL, + CONSTRAINT bookings_pkey PRIMARY KEY (room_id, booking_period) +); diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/b.sql b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/b.sql new file mode 100644 index 000000000..109d6ae13 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/b.sql @@ -0,0 +1,9 @@ +-- state B: temporal PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS). +-- a<->b proves the regular<->temporal PK conversion (drop+add) converges. +CREATE EXTENSION btree_gist; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.bookings ( + room_id integer NOT NULL, + booking_period tstzrange NOT NULL, + CONSTRAINT bookings_pkey PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS) +); diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/meta.json b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/meta.json new file mode 100644 index 000000000..5b64042f4 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/meta.json @@ -0,0 +1 @@ +{ "minVersion": 18 } diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/seed-b.sql new file mode 100644 index 000000000..f98009476 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.bookings + VALUES (1, '[2025-01-01,2025-01-02)'::tstzrange); diff --git a/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/seed.sql b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/seed.sql new file mode 100644 index 000000000..f98009476 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--convert-pk-to-temporal/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.bookings + VALUES (1, '[2025-01-01,2025-01-02)'::tstzrange); diff --git a/packages/pg-delta/corpus/constraint-ops--deferrable-unique/a.sql b/packages/pg-delta/corpus/constraint-ops--deferrable-unique/a.sql new file mode 100644 index 000000000..27fba6784 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--deferrable-unique/a.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.items (id integer PRIMARY KEY, code text NOT NULL); diff --git a/packages/pg-delta/corpus/constraint-ops--deferrable-unique/b.sql b/packages/pg-delta/corpus/constraint-ops--deferrable-unique/b.sql new file mode 100644 index 000000000..317937881 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--deferrable-unique/b.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.items (id integer PRIMARY KEY, code text NOT NULL); +ALTER TABLE test_schema.items + ADD CONSTRAINT uq_items_code UNIQUE (code) DEFERRABLE INITIALLY DEFERRED; diff --git a/packages/pg-delta/corpus/constraint-ops--deferrable-unique/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--deferrable-unique/seed-b.sql new file mode 100644 index 000000000..2edf22e7c --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--deferrable-unique/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.items (id, code) VALUES (1, 'a'); diff --git a/packages/pg-delta/corpus/constraint-ops--deferrable-unique/seed.sql b/packages/pg-delta/corpus/constraint-ops--deferrable-unique/seed.sql new file mode 100644 index 000000000..2edf22e7c --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--deferrable-unique/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.items (id, code) VALUES (1, 'a'); diff --git a/packages/pg-delta/corpus/constraint-ops--exclude/a.sql b/packages/pg-delta/corpus/constraint-ops--exclude/a.sql new file mode 100644 index 000000000..6e9ad0d02 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--exclude/a.sql @@ -0,0 +1,14 @@ +-- reservations table without EXCLUDE constraint; btree_gist available +CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.reservations ( + id integer PRIMARY KEY, + room_id integer NOT NULL, + during tstzrange NOT NULL +); + +CREATE TABLE test_schema.expr_excl ( + a integer NOT NULL +); diff --git a/packages/pg-delta/corpus/constraint-ops--exclude/b.sql b/packages/pg-delta/corpus/constraint-ops--exclude/b.sql new file mode 100644 index 000000000..522530fb2 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--exclude/b.sql @@ -0,0 +1,17 @@ +-- reservations gains an EXCLUDE USING gist constraint for room/time non-overlap; +-- expr_excl gains an EXCLUDE over an expression (attnum=0 regression guard) +CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.reservations ( + id integer PRIMARY KEY, + room_id integer NOT NULL, + during tstzrange NOT NULL, + CONSTRAINT no_overlap EXCLUDE USING gist (room_id WITH =, during WITH &&) +); + +CREATE TABLE test_schema.expr_excl ( + a integer NOT NULL, + CONSTRAINT expr_excl_check EXCLUDE USING gist ((a + 0) WITH =) +); diff --git a/packages/pg-delta/corpus/constraint-ops--exclude/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--exclude/seed-b.sql new file mode 100644 index 000000000..7c92437f1 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--exclude/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.reservations (id, room_id, during) VALUES (1, 1, tstzrange('2024-01-01', '2024-01-02')); +INSERT INTO test_schema.expr_excl (a) VALUES (1); diff --git a/packages/pg-delta/corpus/constraint-ops--exclude/seed.sql b/packages/pg-delta/corpus/constraint-ops--exclude/seed.sql new file mode 100644 index 000000000..7c92437f1 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--exclude/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.reservations (id, room_id, during) VALUES (1, 1, tstzrange('2024-01-01', '2024-01-02')); +INSERT INTO test_schema.expr_excl (a) VALUES (1); diff --git a/packages/pg-delta/corpus/constraint-ops--no-inherit-check/a.sql b/packages/pg-delta/corpus/constraint-ops--no-inherit-check/a.sql new file mode 100644 index 000000000..ad9ef841c --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--no-inherit-check/a.sql @@ -0,0 +1 @@ +-- empty: no tables yet diff --git a/packages/pg-delta/corpus/constraint-ops--no-inherit-check/b.sql b/packages/pg-delta/corpus/constraint-ops--no-inherit-check/b.sql new file mode 100644 index 000000000..20f90aae5 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--no-inherit-check/b.sql @@ -0,0 +1,12 @@ +-- parent table with CHECK (FALSE) NO INHERIT plus an INHERITS child with its own PK +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.parent_base ( + id uuid PRIMARY KEY, + name text NOT NULL, + CONSTRAINT no_direct_insert CHECK (FALSE) NO INHERIT +); + +CREATE TABLE test_schema.child ( + CONSTRAINT child_pkey PRIMARY KEY (id) +) INHERITS (test_schema.parent_base); diff --git a/packages/pg-delta/corpus/constraint-ops--pk-unique-check/a.sql b/packages/pg-delta/corpus/constraint-ops--pk-unique-check/a.sql new file mode 100644 index 000000000..2dc14c7bb --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--pk-unique-check/a.sql @@ -0,0 +1,14 @@ +-- table exists with columns but no constraints yet +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer NOT NULL, + email character varying(255) NOT NULL, + age integer +); + +CREATE TABLE test_schema.products ( + id integer NOT NULL, + price numeric(10,2) NOT NULL, + CONSTRAINT products_price_check CHECK (price > 0) +); diff --git a/packages/pg-delta/corpus/constraint-ops--pk-unique-check/b.sql b/packages/pg-delta/corpus/constraint-ops--pk-unique-check/b.sql new file mode 100644 index 000000000..de616a9d9 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--pk-unique-check/b.sql @@ -0,0 +1,16 @@ +-- users gains PK, UNIQUE, and CHECK constraints; products drops its CHECK constraint +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer NOT NULL, + email character varying(255) NOT NULL, + age integer, + CONSTRAINT users_pkey PRIMARY KEY (id), + CONSTRAINT users_email_key UNIQUE (email), + CONSTRAINT users_age_check CHECK (age >= 0) +); + +CREATE TABLE test_schema.products ( + id integer NOT NULL, + price numeric(10,2) NOT NULL +); diff --git a/packages/pg-delta/corpus/constraint-ops--pk-unique-check/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--pk-unique-check/seed-b.sql new file mode 100644 index 000000000..c5a7c46a8 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--pk-unique-check/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.users (id, email, age) VALUES (1, 'a@example.com', 30); +INSERT INTO test_schema.products (id, price) VALUES (1, 9.99); diff --git a/packages/pg-delta/corpus/constraint-ops--pk-unique-check/seed.sql b/packages/pg-delta/corpus/constraint-ops--pk-unique-check/seed.sql new file mode 100644 index 000000000..c5a7c46a8 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--pk-unique-check/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.users (id, email, age) VALUES (1, 'a@example.com', 30); +INSERT INTO test_schema.products (id, price) VALUES (1, 9.99); diff --git a/packages/pg-delta/corpus/constraint-ops--quoted-names/a.sql b/packages/pg-delta/corpus/constraint-ops--quoted-names/a.sql new file mode 100644 index 000000000..d54def26e --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--quoted-names/a.sql @@ -0,0 +1,7 @@ +-- table with hyphenated schema/table/column names but no constraint yet +CREATE SCHEMA "my-schema"; + +CREATE TABLE "my-schema"."my-table" ( + id integer NOT NULL, + "my-field" text +); diff --git a/packages/pg-delta/corpus/constraint-ops--quoted-names/b.sql b/packages/pg-delta/corpus/constraint-ops--quoted-names/b.sql new file mode 100644 index 000000000..19aec2f57 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--quoted-names/b.sql @@ -0,0 +1,8 @@ +-- CHECK constraint with special characters in both table name and constraint name +CREATE SCHEMA "my-schema"; + +CREATE TABLE "my-schema"."my-table" ( + id integer NOT NULL, + "my-field" text, + CONSTRAINT "my-table_check$constraint" CHECK ("my-field" IS NOT NULL) +); diff --git a/packages/pg-delta/corpus/constraint-ops--quoted-names/seed-b.sql b/packages/pg-delta/corpus/constraint-ops--quoted-names/seed-b.sql new file mode 100644 index 000000000..e6bfefe9a --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--quoted-names/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO "my-schema"."my-table" (id, "my-field") VALUES (1, 'x'); diff --git a/packages/pg-delta/corpus/constraint-ops--quoted-names/seed.sql b/packages/pg-delta/corpus/constraint-ops--quoted-names/seed.sql new file mode 100644 index 000000000..304ea609d --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--quoted-names/seed.sql @@ -0,0 +1,3 @@ +-- b.sql adds CHECK ("my-field" IS NOT NULL); populate it so the forward +-- migration's constraint validates against the seeded row. +INSERT INTO "my-schema"."my-table" (id, "my-field") VALUES (1, 'x'); diff --git a/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/a.sql b/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/a.sql new file mode 100644 index 000000000..bd0d74f39 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/a.sql @@ -0,0 +1,4 @@ +-- btree_gist provides the GiST opclass the scalar key part of a temporal +-- PRIMARY KEY needs; present in both states so only the tables differ. +CREATE EXTENSION btree_gist; +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/b.sql b/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/b.sql new file mode 100644 index 000000000..12701ecd7 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/b.sql @@ -0,0 +1,18 @@ +-- PG18 temporal PRIMARY KEY (WITHOUT OVERLAPS) + temporal FOREIGN KEY (PERIOD). +-- pg-delta-next carries the temporal-ness in pg_get_constraintdef, so this must +-- round-trip create/drop and converge in both directions. +CREATE EXTENSION btree_gist; +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.bookings ( + room_id integer NOT NULL, + booking_period tstzrange NOT NULL, + CONSTRAINT bookings_pkey PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS) +); + +CREATE TABLE test_schema.booking_audit ( + room_id integer NOT NULL, + booking_period tstzrange NOT NULL, + CONSTRAINT booking_audit_fkey FOREIGN KEY (room_id, PERIOD booking_period) + REFERENCES test_schema.bookings (room_id, PERIOD booking_period) +); diff --git a/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/meta.json b/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/meta.json new file mode 100644 index 000000000..5b64042f4 --- /dev/null +++ b/packages/pg-delta/corpus/constraint-ops--temporal-pk-fk/meta.json @@ -0,0 +1 @@ +{ "minVersion": 18 } diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--aggregate-revoke-after-default/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--aggregate-revoke-after-default/a.sql new file mode 100644 index 000000000..6f63e0e1f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--aggregate-revoke-after-default/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges for functions set; no aggregate yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON FUNCTIONS TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--aggregate-revoke-after-default/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--aggregate-revoke-after-default/b.sql new file mode 100644 index 000000000..d19a9361d --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--aggregate-revoke-after-default/b.sql @@ -0,0 +1,8 @@ +-- state B: aggregate created with default grants, then anon explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON FUNCTIONS TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE AGGREGATE public.test_agg(int) (SFUNC = int4pl, STYPE = int); +REVOKE ALL ON FUNCTION public.test_agg(int) FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--alter-default-privs-then-create/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--alter-default-privs-then-create/a.sql new file mode 100644 index 000000000..8a7a486e5 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--alter-default-privs-then-create/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges grant ALL to anon; initial default privs active +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--alter-default-privs-then-create/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--alter-default-privs-then-create/b.sql new file mode 100644 index 000000000..d57ee80f8 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--alter-default-privs-then-create/b.sql @@ -0,0 +1,15 @@ +-- state B: default privileges revoked for anon, then two tables created (no anon access) +-- first_table additionally has an explicit REVOKE to match the desired final state +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + REVOKE ALL ON TABLES FROM corpus_anon; +CREATE TABLE public.first_table ( + id integer PRIMARY KEY, + data text +); +CREATE TABLE public.second_table ( + id integer PRIMARY KEY, + data text +); diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--custom-schema-table-revoke/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--custom-schema-table-revoke/a.sql new file mode 100644 index 000000000..b8d45f9e3 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--custom-schema-table-revoke/a.sql @@ -0,0 +1,7 @@ +-- state A: default privileges set for custom schema `app`; no table yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA app; +ALTER DEFAULT PRIVILEGES IN SCHEMA app + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--custom-schema-table-revoke/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--custom-schema-table-revoke/b.sql new file mode 100644 index 000000000..bd58c73e9 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--custom-schema-table-revoke/b.sql @@ -0,0 +1,13 @@ +-- state B: table created in custom schema with default grants, then anon explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA app; +ALTER DEFAULT PRIVILEGES IN SCHEMA app + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE TABLE app.user_data ( + id integer PRIMARY KEY, + username text UNIQUE NOT NULL, + email text +); +REVOKE ALL ON app.user_data FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--domain-revoke-after-default/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--domain-revoke-after-default/a.sql new file mode 100644 index 000000000..1104d225d --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--domain-revoke-after-default/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges for types set; no domain yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TYPES TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--domain-revoke-after-default/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--domain-revoke-after-default/b.sql new file mode 100644 index 000000000..2d0d865b0 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--domain-revoke-after-default/b.sql @@ -0,0 +1,8 @@ +-- state B: domain created with default grants, then anon explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TYPES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE DOMAIN public.test_domain AS integer; +REVOKE ALL ON DOMAIN public.test_domain FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-addition/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-addition/a.sql new file mode 100644 index 000000000..50fd7dfd5 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-addition/a.sql @@ -0,0 +1,5 @@ +-- state A: default privileges grant SELECT on TABLES (no grant option) +DO $$ BEGIN CREATE ROLE corpus_r_def_go_add NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_owner_role_go_add NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_owner_role_go_add IN SCHEMA test_schema GRANT SELECT ON TABLES TO corpus_r_def_go_add; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-addition/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-addition/b.sql new file mode 100644 index 000000000..aba67617e --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-addition/b.sql @@ -0,0 +1,5 @@ +-- state B: same default privileges but now WITH GRANT OPTION (grantable bit flipped) +DO $$ BEGIN CREATE ROLE corpus_r_def_go_add NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_owner_role_go_add NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_owner_role_go_add IN SCHEMA test_schema GRANT SELECT ON TABLES TO corpus_r_def_go_add WITH GRANT OPTION; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-downgrade/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-downgrade/a.sql new file mode 100644 index 000000000..30f072525 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-downgrade/a.sql @@ -0,0 +1,5 @@ +-- state A: default privileges grant SELECT, INSERT on TABLES WITH GRANT OPTION +DO $$ BEGIN CREATE ROLE corpus_r_def_go NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_owner_role_go NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_owner_role_go IN SCHEMA test_schema GRANT SELECT, INSERT ON TABLES TO corpus_r_def_go WITH GRANT OPTION; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-downgrade/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-downgrade/b.sql new file mode 100644 index 000000000..52e6f8cd1 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--grant-option-downgrade/b.sql @@ -0,0 +1,6 @@ +-- state B: INSERT downgraded to no grant option; SELECT keeps its grant option +DO $$ BEGIN CREATE ROLE corpus_r_def_go NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_owner_role_go NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_owner_role_go IN SCHEMA test_schema GRANT SELECT ON TABLES TO corpus_r_def_go WITH GRANT OPTION; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_owner_role_go IN SCHEMA test_schema GRANT INSERT ON TABLES TO corpus_r_def_go; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/a.sql new file mode 100644 index 000000000..715107c80 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/a.sql @@ -0,0 +1,3 @@ +-- state A: roles exist, no default privilege set on large objects +DO $$ BEGIN CREATE ROLE r_def_lo NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE owner_role_lo NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/b.sql new file mode 100644 index 000000000..55f6ca412 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/b.sql @@ -0,0 +1,6 @@ +-- state B: ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_lo GRANT SELECT ON +-- LARGE OBJECTS TO r_def_lo (PG18+; large objects are never schema-scoped, so +-- no IN SCHEMA clause is possible here — see helpers.ts DEFACL_OBJTYPE). +DO $$ BEGIN CREATE ROLE r_def_lo NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE owner_role_lo NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_lo GRANT SELECT ON LARGE OBJECTS TO r_def_lo; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/meta.json b/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/meta.json new file mode 100644 index 000000000..5b64042f4 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--large-objects/meta.json @@ -0,0 +1 @@ +{ "minVersion": 18 } diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--multi-role-revoke/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--multi-role-revoke/a.sql new file mode 100644 index 000000000..cade78373 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--multi-role-revoke/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges set; no restricted table yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--multi-role-revoke/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--multi-role-revoke/b.sql new file mode 100644 index 000000000..2792731d5 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--multi-role-revoke/b.sql @@ -0,0 +1,12 @@ +-- state B: table created, anon and authenticated both explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE TABLE public.restricted_table ( + id integer PRIMARY KEY, + sensitive_data text +); +REVOKE ALL ON public.restricted_table FROM corpus_anon; +REVOKE ALL ON public.restricted_table FROM corpus_authenticated; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--owner-revoke-own-default/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--owner-revoke-own-default/a.sql new file mode 100644 index 000000000..20c892cc2 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--owner-revoke-own-default/a.sql @@ -0,0 +1,2 @@ +-- owner has not revoked anything yet +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--owner-revoke-own-default/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--owner-revoke-own-default/b.sql new file mode 100644 index 000000000..eef158906 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--owner-revoke-own-default/b.sql @@ -0,0 +1,6 @@ +-- the table owner revokes one of its own create-time default privileges. +-- pg_dump-style REVOKE/GRANT must survive compaction; eliding the owner ACL +-- group as if it were the built-in default would leave UPDATE in place. +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.t (id integer); +REVOKE UPDATE ON test_schema.t FROM CURRENT_USER; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--procedure-revoke-after-default/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--procedure-revoke-after-default/a.sql new file mode 100644 index 000000000..c8db7a461 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--procedure-revoke-after-default/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges for functions set; no procedure yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON FUNCTIONS TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--procedure-revoke-after-default/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--procedure-revoke-after-default/b.sql new file mode 100644 index 000000000..d96bbb9fc --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--procedure-revoke-after-default/b.sql @@ -0,0 +1,8 @@ +-- state B: procedure created with default grants, then anon explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON FUNCTIONS TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE PROCEDURE public.test_proc() LANGUAGE sql AS $$ SELECT 1; $$; +REVOKE ALL ON PROCEDURE public.test_proc() FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--public-default-revoked/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--public-default-revoked/a.sql new file mode 100644 index 000000000..a5a7a9b3f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--public-default-revoked/a.sql @@ -0,0 +1,4 @@ +-- a default privilege REVOKES the built-in PUBLIC EXECUTE on new functions, +-- so a co-created function does NOT get PUBLIC EXECUTE automatically. +ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--public-default-revoked/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--public-default-revoked/b.sql new file mode 100644 index 000000000..029a92d27 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--public-default-revoked/b.sql @@ -0,0 +1,7 @@ +-- the new function IS meant to have PUBLIC EXECUTE; the co-create GRANT is +-- load-bearing (the default privilege removed PUBLIC EXECUTE), so eliding it as +-- a "matches built-in default" group leaves the function without PUBLIC EXECUTE. +ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; +CREATE SCHEMA test_schema; +CREATE FUNCTION test_schema.f() RETURNS integer LANGUAGE sql AS $$SELECT 1$$; +GRANT EXECUTE ON FUNCTION test_schema.f() TO PUBLIC; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--public-function-execute-revoked-global/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--public-function-execute-revoked-global/a.sql new file mode 100644 index 000000000..298e605c5 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--public-function-execute-revoked-global/a.sql @@ -0,0 +1,2 @@ +-- state A: built-in defaults — PUBLIC keeps its default EXECUTE on new functions +-- (no ALTER DEFAULT PRIVILEGES customization). diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--public-function-execute-revoked-global/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--public-function-execute-revoked-global/b.sql new file mode 100644 index 000000000..89809cd25 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--public-function-execute-revoked-global/b.sql @@ -0,0 +1,2 @@ +-- state B: global hardening — PUBLIC's default EXECUTE on functions is revoked. +ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--public-type-usage-revoked-global/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--public-type-usage-revoked-global/a.sql new file mode 100644 index 000000000..3d5047686 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--public-type-usage-revoked-global/a.sql @@ -0,0 +1 @@ +-- state A: built-in defaults — PUBLIC keeps its default USAGE on new types. diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--public-type-usage-revoked-global/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--public-type-usage-revoked-global/b.sql new file mode 100644 index 000000000..99d955c0f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--public-type-usage-revoked-global/b.sql @@ -0,0 +1,2 @@ +-- state B: global hardening — PUBLIC's default USAGE on types is revoked. +ALTER DEFAULT PRIVILEGES REVOKE USAGE ON TYPES FROM PUBLIC; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--schema-revoke-after-default/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--schema-revoke-after-default/a.sql new file mode 100644 index 000000000..5c2fa284e --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--schema-revoke-after-default/a.sql @@ -0,0 +1,6 @@ +-- state A: global default privileges for schemas set; no new schema yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES + GRANT ALL ON SCHEMAS TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--schema-revoke-after-default/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--schema-revoke-after-default/b.sql new file mode 100644 index 000000000..b035c28df --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--schema-revoke-after-default/b.sql @@ -0,0 +1,8 @@ +-- state B: schema created with default grants, then anon explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES + GRANT ALL ON SCHEMAS TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE SCHEMA corpus_test_schema; +REVOKE ALL ON SCHEMA corpus_test_schema FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--selective-regrant/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--selective-regrant/a.sql new file mode 100644 index 000000000..de24e8be7 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--selective-regrant/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges grant ALL; no selective table yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--selective-regrant/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--selective-regrant/b.sql new file mode 100644 index 000000000..5b7f05aae --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--selective-regrant/b.sql @@ -0,0 +1,18 @@ +-- state B: table created under default ALL, then REVOKE ALL + selective re-grant +-- (SELECT to authenticated, ALL to service_role) forcing a PARTIAL revoke for +-- authenticated against the default-ALL baseline +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE TABLE public.selective_table ( + id integer PRIMARY KEY, + public_data text, + private_data text +); +REVOKE ALL ON public.selective_table FROM corpus_anon; +REVOKE ALL ON public.selective_table FROM corpus_authenticated; +REVOKE ALL ON public.selective_table FROM corpus_service_role; +GRANT SELECT ON public.selective_table TO corpus_authenticated; +GRANT ALL ON public.selective_table TO corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--sequence-revoke-after-default/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--sequence-revoke-after-default/a.sql new file mode 100644 index 000000000..2a7d415f2 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--sequence-revoke-after-default/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges for sequences set; no sequence yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON SEQUENCES TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--sequence-revoke-after-default/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--sequence-revoke-after-default/b.sql new file mode 100644 index 000000000..0d560d85f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--sequence-revoke-after-default/b.sql @@ -0,0 +1,8 @@ +-- state B: sequence created with default grants, then anon explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON SEQUENCES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE SEQUENCE public.test_seq; +REVOKE ALL ON SEQUENCE public.test_seq FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--table-create-and-revoke/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--table-create-and-revoke/a.sql new file mode 100644 index 000000000..905255ef4 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--table-create-and-revoke/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges set for public schema; no table yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--table-create-and-revoke/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--table-create-and-revoke/b.sql new file mode 100644 index 000000000..2260a70f4 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--table-create-and-revoke/b.sql @@ -0,0 +1,11 @@ +-- state B: table created with default grants, then anon explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE TABLE public.test ( + id integer PRIMARY KEY, + data text +); +REVOKE ALL ON public.test FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/a.sql new file mode 100644 index 000000000..c5d85c261 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/a.sql @@ -0,0 +1,10 @@ +-- state A: default privileges grant ALL to anon; table exists with default grants applied +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE TABLE public.test ( + id integer PRIMARY KEY, + data text +); diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/b.sql new file mode 100644 index 000000000..8ad270942 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/b.sql @@ -0,0 +1,11 @@ +-- state B: anon is explicitly revoked from the table +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE TABLE public.test ( + id integer PRIMARY KEY, + data text +); +REVOKE ALL ON public.test FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/seed-b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/seed-b.sql new file mode 100644 index 000000000..b4fad468e --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.test (id) VALUES (1); diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/seed.sql b/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/seed.sql new file mode 100644 index 000000000..b4fad468e --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--table-revoke-after-default/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.test (id) VALUES (1); diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--view-revoke-after-default/a.sql b/packages/pg-delta/corpus/default-privileges-edge-case--view-revoke-after-default/a.sql new file mode 100644 index 000000000..0744bb4b5 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--view-revoke-after-default/a.sql @@ -0,0 +1,6 @@ +-- state A: default privileges for tables set; no view yet +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; diff --git a/packages/pg-delta/corpus/default-privileges-edge-case--view-revoke-after-default/b.sql b/packages/pg-delta/corpus/default-privileges-edge-case--view-revoke-after-default/b.sql new file mode 100644 index 000000000..f76783dae --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-edge-case--view-revoke-after-default/b.sql @@ -0,0 +1,8 @@ +-- state B: view created with default grants, then anon explicitly revoked +DO $$ BEGIN CREATE ROLE corpus_anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_service_role NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO corpus_anon, corpus_authenticated, corpus_service_role; +CREATE VIEW public.test_view AS SELECT 1 AS id; +REVOKE ALL ON public.test_view FROM corpus_anon; diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/a.sql b/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/a.sql new file mode 100644 index 000000000..d52eb2807 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/a.sql @@ -0,0 +1 @@ +-- state A: empty — no roles, no schemas, no default privileges diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/b.sql b/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/b.sql new file mode 100644 index 000000000..9d42a4fd9 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/b.sql @@ -0,0 +1,5 @@ +-- state B: new role and default privileges for that role +-- CREATE ROLE must be ordered before ALTER DEFAULT PRIVILEGES FOR ROLE +CREATE ROLE app_user NOLOGIN; +ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public + GRANT SELECT ON TABLES TO app_user; diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/meta.json b/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-role-and-default-privs/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/a.sql b/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/a.sql new file mode 100644 index 000000000..d7f2ccd29 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/a.sql @@ -0,0 +1 @@ +-- state A: empty diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/b.sql b/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/b.sql new file mode 100644 index 000000000..03411480f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/b.sql @@ -0,0 +1,6 @@ +-- state B: new role, new schema, and default privileges for that role in that schema +-- both CREATE ROLE and CREATE SCHEMA must be ordered before ALTER DEFAULT PRIVILEGES +CREATE ROLE app_user NOLOGIN; +CREATE SCHEMA app; +ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA app + GRANT ALL ON TABLES TO app_user; diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/meta.json b/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-role-schema-and-default-privs/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/a.sql b/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/a.sql new file mode 100644 index 000000000..d40097497 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/a.sql @@ -0,0 +1,2 @@ +-- state A: app_user role exists, no schema yet +CREATE ROLE app_user NOLOGIN; diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/b.sql b/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/b.sql new file mode 100644 index 000000000..94bcfa0d5 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/b.sql @@ -0,0 +1,6 @@ +-- state B: new schema and default privileges in that schema +-- CREATE SCHEMA must be ordered before ALTER DEFAULT PRIVILEGES IN SCHEMA +CREATE ROLE app_user NOLOGIN; +CREATE SCHEMA app; +ALTER DEFAULT PRIVILEGES FOR ROLE test IN SCHEMA app + GRANT ALL ON TABLES TO app_user; diff --git a/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/meta.json b/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-ordering--new-schema-and-default-privs/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/default-privileges-owner-self-entry/a.sql b/packages/pg-delta/corpus/default-privileges-owner-self-entry/a.sql new file mode 100644 index 000000000..d52eb2807 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-owner-self-entry/a.sql @@ -0,0 +1 @@ +-- state A: empty — no roles, no schemas, no default privileges diff --git a/packages/pg-delta/corpus/default-privileges-owner-self-entry/b.sql b/packages/pg-delta/corpus/default-privileges-owner-self-entry/b.sql new file mode 100644 index 000000000..fc9f2e69d --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-owner-self-entry/b.sql @@ -0,0 +1,14 @@ +-- state B: an ADP row that physically contains the grantor's OWN self-entry with +-- privileges EQUAL to acldefault (materialized by an explicit +-- `GRANT ALL ON TABLES TO `), alongside a grant to another role. The +-- stored row is {owner=arwdDxtm/owner, reader=r/owner}. A replayed DB only ever +-- materializes {reader=r/owner} (no self-entry) — a behaviorally identical row — +-- so extraction must treat owner-present-at-default and owner-absent as the SAME +-- state, or re-export emits a spurious `revoke all on tables ... from `. +CREATE ROLE corpus_adp_owner NOLOGIN; +CREATE ROLE corpus_adp_reader NOLOGIN; +CREATE SCHEMA corpus_adp AUTHORIZATION corpus_adp_owner; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_adp_owner IN SCHEMA corpus_adp + GRANT ALL ON TABLES TO corpus_adp_owner; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_adp_owner IN SCHEMA corpus_adp + GRANT SELECT ON TABLES TO corpus_adp_reader; diff --git a/packages/pg-delta/corpus/default-privileges-owner-self-entry/meta.json b/packages/pg-delta/corpus/default-privileges-owner-self-entry/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-owner-self-entry/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/a.sql b/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/a.sql new file mode 100644 index 000000000..7951960f7 --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/a.sql @@ -0,0 +1,8 @@ +-- state A: a GLOBAL (no IN SCHEMA) default-privileges row created purely by a +-- grant to ANOTHER role, which on a global row MATERIALIZES the owner's own +-- acldefault self-entry: {owner=arwdDxtm/owner, reader=r/owner}. The owner sits +-- at its built-in default (no fact) — only reader's SELECT is a customization. +CREATE ROLE corpus_adpg_owner NOLOGIN; +CREATE ROLE corpus_adpg_reader NOLOGIN; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_adpg_owner + GRANT SELECT ON TABLES TO corpus_adpg_reader; diff --git a/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/b.sql b/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/b.sql new file mode 100644 index 000000000..e56a1d91b --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/b.sql @@ -0,0 +1,13 @@ +-- state B: the SAME global row with the owner's self-entry REVOKED while the +-- grant to reader remains: {reader=r/owner}. On a GLOBAL row Postgres uses the +-- stored acl VERBATIM at object creation, so a table later made by the owner +-- really lacks the owner's own privileges — a genuine customization (unlike a +-- per-schema row, where the owner is always re-merged at CREATE). It must +-- round-trip as an owner REVOKE forward, and as an owner GRANT restoring the +-- built-in default in reverse. +CREATE ROLE corpus_adpg_owner NOLOGIN; +CREATE ROLE corpus_adpg_reader NOLOGIN; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_adpg_owner + REVOKE ALL ON TABLES FROM corpus_adpg_owner; +ALTER DEFAULT PRIVILEGES FOR ROLE corpus_adpg_owner + GRANT SELECT ON TABLES TO corpus_adpg_reader; diff --git a/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/meta.json b/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/default-privileges-owner-self-revoke-global/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/defaults/a.sql b/packages/pg-delta/corpus/defaults/a.sql new file mode 100644 index 000000000..27df18da0 --- /dev/null +++ b/packages/pg-delta/corpus/defaults/a.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.items ( + id integer PRIMARY KEY, + price numeric DEFAULT 0.0, + status text DEFAULT 'new' +); diff --git a/packages/pg-delta/corpus/defaults/b.sql b/packages/pg-delta/corpus/defaults/b.sql new file mode 100644 index 000000000..f80cde9d1 --- /dev/null +++ b/packages/pg-delta/corpus/defaults/b.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.items ( + id integer PRIMARY KEY, + price numeric DEFAULT 9.99, + status text +); diff --git a/packages/pg-delta/corpus/defaults/seed-b.sql b/packages/pg-delta/corpus/defaults/seed-b.sql new file mode 100644 index 000000000..502625d00 --- /dev/null +++ b/packages/pg-delta/corpus/defaults/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.items (id) VALUES (10), (20); diff --git a/packages/pg-delta/corpus/defaults/seed.sql b/packages/pg-delta/corpus/defaults/seed.sql new file mode 100644 index 000000000..502625d00 --- /dev/null +++ b/packages/pg-delta/corpus/defaults/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.items (id) VALUES (10), (20); diff --git a/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/a.sql b/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/b.sql b/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/b.sql new file mode 100644 index 000000000..3f027290f --- /dev/null +++ b/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/b.sql @@ -0,0 +1,10 @@ +-- Schema with ACL and role membership edges, used to stress ordering of +-- privilege grants relative to the objects and roles they reference. +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_dep_r1') THEN + CREATE ROLE corpus_dep_r1; + END IF; +END $$; +CREATE SCHEMA corpus_s1; +CREATE TABLE corpus_s1.t1 (a int); +GRANT SELECT ON corpus_s1.t1 TO corpus_dep_r1; diff --git a/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/meta.json b/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/depend-extraction--acl-and-membership-edges/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/a.sql b/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/b.sql b/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/b.sql new file mode 100644 index 000000000..29e6a49e4 --- /dev/null +++ b/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/b.sql @@ -0,0 +1,24 @@ +-- Rich schema exercising many dependency branches: object deps (view→table), +-- ACLs, default privileges, and role membership. Validates ordering correctness +-- when all these edge types are present simultaneously. +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_parent_role_dep') THEN + CREATE ROLE corpus_parent_role_dep; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_dep_role_a') THEN + CREATE ROLE corpus_dep_role_a; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_dep_role_b') THEN + CREATE ROLE corpus_dep_role_b; + END IF; +END $$; +CREATE SCHEMA dep_schema; +CREATE TABLE dep_schema.tab (id int); +CREATE VIEW dep_schema.vw AS SELECT * FROM dep_schema.tab; +CREATE SEQUENCE dep_schema.seq; +CREATE MATERIALIZED VIEW dep_schema.mv AS SELECT 1 AS x; +GRANT SELECT ON dep_schema.tab TO corpus_dep_role_a; +GRANT SELECT ON dep_schema.vw TO corpus_dep_role_b; +GRANT USAGE ON SEQUENCE dep_schema.seq TO corpus_dep_role_a; +GRANT corpus_parent_role_dep TO corpus_dep_role_b; +ALTER DEFAULT PRIVILEGES IN SCHEMA dep_schema GRANT SELECT ON TABLES TO corpus_dep_role_a; diff --git a/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/meta.json b/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/depend-extraction--rich-schema-with-privileges/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/a.sql b/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/a.sql new file mode 100644 index 000000000..a353399db --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/a.sql @@ -0,0 +1,7 @@ +CREATE TABLE public.addons ( + addon_id integer NOT NULL, + label text NOT NULL +); +CREATE SEQUENCE public.addons_addon_id_seq AS integer; +ALTER TABLE public.addons ALTER COLUMN addon_id SET DEFAULT nextval('public.addons_addon_id_seq'::regclass); +ALTER SEQUENCE public.addons_addon_id_seq OWNED BY public.addons.addon_id; diff --git a/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/b.sql b/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/b.sql new file mode 100644 index 000000000..07b6d453d --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/b.sql @@ -0,0 +1,6 @@ +CREATE TABLE public.addons ( + addon_id integer NOT NULL, + label text NOT NULL +); +CREATE SEQUENCE public.addons_addon_id_seq; +ALTER TABLE public.addons ALTER COLUMN addon_id SET DEFAULT nextval('public.addons_addon_id_seq'::regclass); diff --git a/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/seed-b.sql b/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/seed-b.sql new file mode 100644 index 000000000..9aace9998 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.addons (label) VALUES ('x'); diff --git a/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/seed.sql b/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/seed.sql new file mode 100644 index 000000000..9aace9998 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--alter-seq-datatype-owned-col-survives/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.addons (label) VALUES ('x'); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-partial-membership/a.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-partial-membership/a.sql new file mode 100644 index 000000000..d84edbbf3 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-partial-membership/a.sql @@ -0,0 +1,25 @@ +CREATE TABLE public.trades ( + id bigint PRIMARY KEY, + trade_id bigint NOT NULL, + CONSTRAINT trades_trade_id_key UNIQUE (trade_id) +); + +CREATE TABLE public.trade_status_events ( + id bigint PRIMARY KEY, + trade_id bigint NOT NULL, + CONSTRAINT trade_status_events_trade_id_fkey + FOREIGN KEY (trade_id) + REFERENCES public.trades(trade_id) +); + +CREATE TABLE public.public_offering_events ( + id bigint PRIMARY KEY, + source_event_id bigint NOT NULL, + CONSTRAINT public_offering_events_source_event_id_fkey + FOREIGN KEY (source_event_id) + REFERENCES public.trade_status_events(id) +); + +-- trade_status_events is deliberately NOT in the publication. +CREATE PUBLICATION supabase_realtime + FOR TABLE public.trades, public.public_offering_events; diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-partial-membership/b.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-partial-membership/b.sql new file mode 100644 index 000000000..3ba74de82 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-partial-membership/b.sql @@ -0,0 +1 @@ +-- Empty: all FK-chain tables and the publication are dropped. diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/a.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/a.sql new file mode 100644 index 000000000..62f768eaf --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/a.sql @@ -0,0 +1,17 @@ +CREATE TABLE public.labs ( + id bigint PRIMARY KEY, + lab_id bigint NOT NULL, + CONSTRAINT unique_lab_id UNIQUE (lab_id) +); +CREATE TABLE public.posts ( + id bigint PRIMARY KEY, + lab_id bigint NOT NULL, + CONSTRAINT posts_lab_id_fkey FOREIGN KEY (lab_id) REFERENCES public.labs(lab_id) +); +CREATE TABLE public.post_attachments ( + id bigint PRIMARY KEY, + post_id bigint NOT NULL, + CONSTRAINT post_attachments_post_id_fkey FOREIGN KEY (post_id) REFERENCES public.posts(id) +); +CREATE PUBLICATION supabase_realtime + FOR TABLE public.labs, public.posts, public.post_attachments; diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/b.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/b.sql new file mode 100644 index 000000000..50f599a69 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/b.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.labs ( + id bigint PRIMARY KEY, + lab_id bigint NOT NULL +); +CREATE PUBLICATION supabase_realtime FOR TABLE public.labs; diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/seed-b.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/seed-b.sql new file mode 100644 index 000000000..4a3ed11c1 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.labs (id, lab_id) VALUES (1, 1); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/seed.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/seed.sql new file mode 100644 index 000000000..4a3ed11c1 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-fk-chain-tables/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.labs (id, lab_id) VALUES (1, 1); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/a.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/a.sql new file mode 100644 index 000000000..56b2ae18e --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/a.sql @@ -0,0 +1,6 @@ +CREATE TABLE public.lab_results ( + id bigint PRIMARY KEY, + flash_summary text +); +CREATE PUBLICATION cycle_repro_realtime + FOR TABLE public.lab_results (id, flash_summary); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/b.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/b.sql new file mode 100644 index 000000000..1cf33ebaf --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/b.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.lab_results ( + id bigint PRIMARY KEY +); +CREATE PUBLICATION cycle_repro_realtime + FOR TABLE public.lab_results (id); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/meta.json b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/seed-b.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/seed-b.sql new file mode 100644 index 000000000..3bf2ec306 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.lab_results (id) VALUES (1); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/seed.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/seed.sql new file mode 100644 index 000000000..c8f691825 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-publication-listed-column/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.lab_results (id, flash_summary) VALUES (1, 'note'); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-serial-col-surviving-table/a.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-serial-col-surviving-table/a.sql new file mode 100644 index 000000000..65e46558a --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-serial-col-surviving-table/a.sql @@ -0,0 +1,4 @@ +CREATE TABLE public.widgets ( + id SERIAL PRIMARY KEY, + label TEXT +); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-serial-col-surviving-table/b.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-serial-col-surviving-table/b.sql new file mode 100644 index 000000000..9e3fc24a8 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-serial-col-surviving-table/b.sql @@ -0,0 +1,3 @@ +CREATE TABLE public.widgets ( + label TEXT +); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-three-tables-n3-fk-cycle/a.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-three-tables-n3-fk-cycle/a.sql new file mode 100644 index 000000000..17b95172c --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-three-tables-n3-fk-cycle/a.sql @@ -0,0 +1,15 @@ +CREATE TABLE public.a ( + id bigserial PRIMARY KEY, + b_id bigint +); +CREATE TABLE public.b ( + id bigserial PRIMARY KEY, + c_id bigint +); +CREATE TABLE public.c ( + id bigserial PRIMARY KEY, + a_id bigint +); +ALTER TABLE public.a ADD CONSTRAINT a_b_fkey FOREIGN KEY (b_id) REFERENCES public.b(id); +ALTER TABLE public.b ADD CONSTRAINT b_c_fkey FOREIGN KEY (c_id) REFERENCES public.c(id); +ALTER TABLE public.c ADD CONSTRAINT c_a_fkey FOREIGN KEY (a_id) REFERENCES public.a(id); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-three-tables-n3-fk-cycle/b.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-three-tables-n3-fk-cycle/b.sql new file mode 100644 index 000000000..82197faf1 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-three-tables-n3-fk-cycle/b.sql @@ -0,0 +1 @@ +-- state B: all three tables dropped diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-two-tables-mutual-fk/a.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-two-tables-mutual-fk/a.sql new file mode 100644 index 000000000..11b9379b2 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-two-tables-mutual-fk/a.sql @@ -0,0 +1,11 @@ +CREATE TABLE public.a ( + id bigserial PRIMARY KEY, + name text NOT NULL +); +CREATE TABLE public.b ( + id bigserial PRIMARY KEY, + name text NOT NULL, + a_id bigint REFERENCES public.a(id) +); +ALTER TABLE public.a ADD COLUMN b_id bigint; +ALTER TABLE public.a ADD CONSTRAINT a_b_fkey FOREIGN KEY (b_id) REFERENCES public.b(id); diff --git a/packages/pg-delta/corpus/dependencies-cycles--drop-two-tables-mutual-fk/b.sql b/packages/pg-delta/corpus/dependencies-cycles--drop-two-tables-mutual-fk/b.sql new file mode 100644 index 000000000..6c24e1df4 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--drop-two-tables-mutual-fk/b.sql @@ -0,0 +1 @@ +-- state B: all objects dropped diff --git a/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/a.sql b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/a.sql new file mode 100644 index 000000000..1749472aa --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/a.sql @@ -0,0 +1,13 @@ +CREATE TYPE public.item_status AS ENUM ('draft', 'published', 'archived'); + +CREATE TABLE public.parents ( + id INTEGER PRIMARY KEY, + label TEXT +); + +CREATE TABLE public.children ( + id INTEGER PRIMARY KEY, + parent_ref INTEGER REFERENCES public.parents(id), + status public.item_status, + notes TEXT +); diff --git a/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/b.sql b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/b.sql new file mode 100644 index 000000000..f3b45c131 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/b.sql @@ -0,0 +1,12 @@ +CREATE TYPE public.item_status AS ENUM ('draft', 'published'); + +CREATE TABLE public.parents ( + id INTEGER PRIMARY KEY, + label TEXT +); + +CREATE TABLE public.children ( + id INTEGER PRIMARY KEY, + status public.item_status, + notes TEXT +); diff --git a/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/seed-b.sql b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/seed-b.sql new file mode 100644 index 000000000..c1f4d8111 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO public.parents (id, label) VALUES (1, 'p1'); +INSERT INTO public.children (id, status, notes) VALUES (1, 'draft', 'n1'); diff --git a/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/seed.sql b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/seed.sql new file mode 100644 index 000000000..ea2e21f04 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-dependent-table-drops-fk-col/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO public.parents (id, label) VALUES (1, 'p1'); +INSERT INTO public.children (id, parent_ref, status, notes) VALUES (1, 1, 'draft', 'n1'); diff --git a/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/a.sql b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/a.sql new file mode 100644 index 000000000..096238f95 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/a.sql @@ -0,0 +1,6 @@ +CREATE TYPE public.project_link_type_kind AS ENUM ('a', 'b', 'c'); + +CREATE TABLE public.project_link_type ( + id SERIAL PRIMARY KEY, + kind public.project_link_type_kind +); diff --git a/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/b.sql b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/b.sql new file mode 100644 index 000000000..620b05b60 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/b.sql @@ -0,0 +1,6 @@ +CREATE TYPE public.project_link_type_kind AS ENUM ('a', 'b'); + +CREATE TABLE public.project_link_type ( + id integer PRIMARY KEY, + kind public.project_link_type_kind +); diff --git a/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/seed-b.sql b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/seed-b.sql new file mode 100644 index 000000000..ac7c811ae --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--enum-replace-drops-serial-on-promoted-table/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.project_link_type (id, kind) VALUES (1, 'a'); diff --git a/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/a.sql b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/a.sql new file mode 100644 index 000000000..80362a2b7 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/a.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.users ( + name text NOT NULL +); diff --git a/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/b.sql b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/b.sql new file mode 100644 index 000000000..c8a79abf5 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/b.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.user_id_seq; +CREATE TABLE test_schema.users ( + name text NOT NULL, + id bigint DEFAULT nextval('test_schema.user_id_seq') +); +ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; diff --git a/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/seed-b.sql b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/seed-b.sql new file mode 100644 index 000000000..6a6fa30bb --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (name) VALUES ('alice'); diff --git a/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/seed.sql b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/seed.sql new file mode 100644 index 000000000..6a6fa30bb --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-add-column/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (name) VALUES ('alice'); diff --git a/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-col-with-default/a.sql b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-col-with-default/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-col-with-default/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-col-with-default/b.sql b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-col-with-default/b.sql new file mode 100644 index 000000000..bcaf46208 --- /dev/null +++ b/packages/pg-delta/corpus/dependencies-cycles--sequence-owned-by-col-with-default/b.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.user_id_seq; +CREATE TABLE test_schema.users ( + id bigint PRIMARY KEY DEFAULT nextval('test_schema.user_id_seq') +); +ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; diff --git a/packages/pg-delta/corpus/domain-operations--check-references-replaced-function/a.sql b/packages/pg-delta/corpus/domain-operations--check-references-replaced-function/a.sql new file mode 100644 index 000000000..2f03aa3c5 --- /dev/null +++ b/packages/pg-delta/corpus/domain-operations--check-references-replaced-function/a.sql @@ -0,0 +1,11 @@ +CREATE SCHEMA probe_domain_check; + +CREATE FUNCTION probe_domain_check.is_valid(value text) +RETURNS boolean +LANGUAGE sql +IMMUTABLE +AS $$ SELECT length(value) > 0 $$; + +-- a domain CHECK constraint whose expression calls the function +CREATE DOMAIN probe_domain_check.code AS text + CONSTRAINT code_is_valid CHECK (probe_domain_check.is_valid(VALUE)); diff --git a/packages/pg-delta/corpus/domain-operations--check-references-replaced-function/b.sql b/packages/pg-delta/corpus/domain-operations--check-references-replaced-function/b.sql new file mode 100644 index 000000000..d6de97d8e --- /dev/null +++ b/packages/pg-delta/corpus/domain-operations--check-references-replaced-function/b.sql @@ -0,0 +1,14 @@ +CREATE SCHEMA probe_domain_check; + +-- parameter renamed value->input: CREATE OR REPLACE cannot rename a +-- parameter, so this forces DROP+CREATE of the function (same stable id). +-- The domain CHECK expression is textually IDENTICAL, so only the function +-- changes — the domain constraint must still be rebuilt around the replace. +CREATE FUNCTION probe_domain_check.is_valid(input text) +RETURNS boolean +LANGUAGE sql +IMMUTABLE +AS $$ SELECT length(input) > 0 $$; + +CREATE DOMAIN probe_domain_check.code AS text + CONSTRAINT code_is_valid CHECK (probe_domain_check.is_valid(VALUE)); diff --git a/packages/pg-delta/corpus/domain-operations--not-valid-constraint/a.sql b/packages/pg-delta/corpus/domain-operations--not-valid-constraint/a.sql new file mode 100644 index 000000000..f6fcc7146 --- /dev/null +++ b/packages/pg-delta/corpus/domain-operations--not-valid-constraint/a.sql @@ -0,0 +1,2 @@ +-- domain does not exist yet +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/domain-operations--not-valid-constraint/b.sql b/packages/pg-delta/corpus/domain-operations--not-valid-constraint/b.sql new file mode 100644 index 000000000..dc47a0c6b --- /dev/null +++ b/packages/pg-delta/corpus/domain-operations--not-valid-constraint/b.sql @@ -0,0 +1,10 @@ +-- domain carries a CHECK constraint added as NOT VALID (convalidated = false). +-- NOT VALID is only legal on ALTER DOMAIN ... ADD CONSTRAINT, never inline on +-- CREATE DOMAIN, so plan-from-empty must emit a standalone ALTER DOMAIN action +-- rather than splicing "NOT VALID" into CREATE DOMAIN. +CREATE SCHEMA test_schema; + +CREATE DOMAIN test_schema.positive_int AS integer; + +ALTER DOMAIN test_schema.positive_int + ADD CONSTRAINT positive_int_check CHECK (VALUE > 0) NOT VALID; diff --git a/packages/pg-delta/corpus/domain-operations--replace-with-inlined-constraint/a.sql b/packages/pg-delta/corpus/domain-operations--replace-with-inlined-constraint/a.sql new file mode 100644 index 000000000..23996ba5b --- /dev/null +++ b/packages/pg-delta/corpus/domain-operations--replace-with-inlined-constraint/a.sql @@ -0,0 +1,3 @@ +-- existing domain with a validated CHECK constraint +CREATE SCHEMA test_schema; +CREATE DOMAIN test_schema.d AS integer CONSTRAINT d_check CHECK (VALUE > 0); diff --git a/packages/pg-delta/corpus/domain-operations--replace-with-inlined-constraint/b.sql b/packages/pg-delta/corpus/domain-operations--replace-with-inlined-constraint/b.sql new file mode 100644 index 000000000..9fb22ad77 --- /dev/null +++ b/packages/pg-delta/corpus/domain-operations--replace-with-inlined-constraint/b.sql @@ -0,0 +1,4 @@ +-- base type change forces a domain REPLACE (drop + recreate); the inlined +-- CHECK must not also be recreated separately. +CREATE SCHEMA test_schema; +CREATE DOMAIN test_schema.d AS bigint CONSTRAINT d_check CHECK (VALUE > 0); diff --git a/packages/pg-delta/corpus/empty-catalog-export--app-schema-with-fk/a.sql b/packages/pg-delta/corpus/empty-catalog-export--app-schema-with-fk/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/empty-catalog-export--app-schema-with-fk/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/empty-catalog-export--app-schema-with-fk/b.sql b/packages/pg-delta/corpus/empty-catalog-export--app-schema-with-fk/b.sql new file mode 100644 index 000000000..a897a8b21 --- /dev/null +++ b/packages/pg-delta/corpus/empty-catalog-export--app-schema-with-fk/b.sql @@ -0,0 +1,12 @@ +-- Schema with a FK reference between two tables in a non-public schema. +-- Exercises ordering: schema must precede tables, referenced table must precede the FK constraint. +CREATE SCHEMA app; +CREATE TABLE app.users ( + id serial PRIMARY KEY, + name text NOT NULL +); +CREATE TABLE app.posts ( + id serial PRIMARY KEY, + user_id int REFERENCES app.users(id), + title text NOT NULL +); diff --git a/packages/pg-delta/corpus/empty-catalog-export--public-schema-table/a.sql b/packages/pg-delta/corpus/empty-catalog-export--public-schema-table/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/empty-catalog-export--public-schema-table/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/empty-catalog-export--public-schema-table/b.sql b/packages/pg-delta/corpus/empty-catalog-export--public-schema-table/b.sql new file mode 100644 index 000000000..22b032e08 --- /dev/null +++ b/packages/pg-delta/corpus/empty-catalog-export--public-schema-table/b.sql @@ -0,0 +1,3 @@ +-- Simple table in public schema. Exercises that the diff engine does not +-- spuriously emit CREATE SCHEMA public (it is pre-populated in the baseline). +CREATE TABLE public.items (id serial PRIMARY KEY); diff --git a/packages/pg-delta/corpus/event-trigger-operations--create-with-function/a.sql b/packages/pg-delta/corpus/event-trigger-operations--create-with-function/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--create-with-function/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/event-trigger-operations--create-with-function/b.sql b/packages/pg-delta/corpus/event-trigger-operations--create-with-function/b.sql new file mode 100644 index 000000000..07e74ad6d --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--create-with-function/b.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl_dependency() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'dependency %', TG_TAG; +END; +$$; + +CREATE EVENT TRIGGER ddl_logger_dependency + ON ddl_command_start + EXECUTE FUNCTION test_schema.log_ddl_dependency(); diff --git a/packages/pg-delta/corpus/event-trigger-operations--create-with-tag-filter/a.sql b/packages/pg-delta/corpus/event-trigger-operations--create-with-tag-filter/a.sql new file mode 100644 index 000000000..ab5b01aa8 --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--create-with-tag-filter/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'DDL event %', TG_TAG; +END; +$$; diff --git a/packages/pg-delta/corpus/event-trigger-operations--create-with-tag-filter/b.sql b/packages/pg-delta/corpus/event-trigger-operations--create-with-tag-filter/b.sql new file mode 100644 index 000000000..e78385c1a --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--create-with-tag-filter/b.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'DDL event %', TG_TAG; +END; +$$; + +CREATE EVENT TRIGGER ddl_logger + ON ddl_command_start + WHEN TAG IN ('CREATE TABLE', 'ALTER TABLE') + EXECUTE FUNCTION test_schema.log_ddl(); diff --git a/packages/pg-delta/corpus/event-trigger-operations--disable/a.sql b/packages/pg-delta/corpus/event-trigger-operations--disable/a.sql new file mode 100644 index 000000000..83ef9e89a --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--disable/a.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'DDL event %', TG_TAG; +END; +$$; + +CREATE EVENT TRIGGER ddl_logger + ON ddl_command_start + EXECUTE FUNCTION test_schema.log_ddl(); diff --git a/packages/pg-delta/corpus/event-trigger-operations--disable/b.sql b/packages/pg-delta/corpus/event-trigger-operations--disable/b.sql new file mode 100644 index 000000000..576a5ab01 --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--disable/b.sql @@ -0,0 +1,14 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'DDL event %', TG_TAG; +END; +$$; + +CREATE EVENT TRIGGER ddl_logger + ON ddl_command_start + EXECUTE FUNCTION test_schema.log_ddl(); + +ALTER EVENT TRIGGER ddl_logger DISABLE; diff --git a/packages/pg-delta/corpus/event-trigger-operations--drop/a.sql b/packages/pg-delta/corpus/event-trigger-operations--drop/a.sql new file mode 100644 index 000000000..cd01cc058 --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--drop/a.sql @@ -0,0 +1,14 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'DDL event %', TG_TAG; +END; +$$; + +CREATE EVENT TRIGGER ddl_logger + ON ddl_command_start + EXECUTE FUNCTION test_schema.log_ddl(); + +COMMENT ON EVENT TRIGGER ddl_logger IS 'Logs DDL statements'; diff --git a/packages/pg-delta/corpus/event-trigger-operations--drop/b.sql b/packages/pg-delta/corpus/event-trigger-operations--drop/b.sql new file mode 100644 index 000000000..ab5b01aa8 --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--drop/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'DDL event %', TG_TAG; +END; +$$; diff --git a/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/a.sql b/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/a.sql new file mode 100644 index 000000000..42db5ed5b --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/a.sql @@ -0,0 +1,14 @@ +DO $$ BEGIN CREATE ROLE corpus_ddl_owner SUPERUSER NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'DDL event %', TG_TAG; +END; +$$; + +CREATE EVENT TRIGGER ddl_logger + ON ddl_command_start + EXECUTE FUNCTION test_schema.log_ddl(); diff --git a/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/b.sql b/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/b.sql new file mode 100644 index 000000000..a4123eee8 --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/b.sql @@ -0,0 +1,17 @@ +DO $$ BEGIN CREATE ROLE corpus_ddl_owner SUPERUSER NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.log_ddl() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'DDL event %', TG_TAG; +END; +$$; + +CREATE EVENT TRIGGER ddl_logger + ON ddl_command_start + EXECUTE FUNCTION test_schema.log_ddl(); + +ALTER EVENT TRIGGER ddl_logger OWNER TO corpus_ddl_owner; +COMMENT ON EVENT TRIGGER ddl_logger IS 'Logs DDL statements'; diff --git a/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/meta.json b/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--owner-and-comment/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/event-trigger-operations--replace-backing-function/a.sql b/packages/pg-delta/corpus/event-trigger-operations--replace-backing-function/a.sql new file mode 100644 index 000000000..baf4f8935 --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--replace-backing-function/a.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA ext; + +CREATE FUNCTION ext.grant_access() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'v1'; +END; +$$; + +CREATE EVENT TRIGGER issue_access + ON ddl_command_end + EXECUTE FUNCTION ext.grant_access(); diff --git a/packages/pg-delta/corpus/event-trigger-operations--replace-backing-function/b.sql b/packages/pg-delta/corpus/event-trigger-operations--replace-backing-function/b.sql new file mode 100644 index 000000000..c1e38b787 --- /dev/null +++ b/packages/pg-delta/corpus/event-trigger-operations--replace-backing-function/b.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA ext; + +CREATE FUNCTION ext.grant_access() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'v2 changed body'; +END; +$$; + +CREATE EVENT TRIGGER issue_access + ON ddl_command_end + EXECUTE FUNCTION ext.grant_access(); diff --git a/packages/pg-delta/corpus/extension-member--drop-with-grant/a.sql b/packages/pg-delta/corpus/extension-member--drop-with-grant/a.sql new file mode 100644 index 000000000..4396f8e3d --- /dev/null +++ b/packages/pg-delta/corpus/extension-member--drop-with-grant/a.sql @@ -0,0 +1,5 @@ +DO $$ BEGIN CREATE ROLE corpus_extacl_d NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE EXTENSION hstore SCHEMA public; + +GRANT EXECUTE ON FUNCTION hstore(text, text) TO corpus_extacl_d; diff --git a/packages/pg-delta/corpus/extension-member--drop-with-grant/b.sql b/packages/pg-delta/corpus/extension-member--drop-with-grant/b.sql new file mode 100644 index 000000000..1322fc679 --- /dev/null +++ b/packages/pg-delta/corpus/extension-member--drop-with-grant/b.sql @@ -0,0 +1,5 @@ +DO $$ BEGIN CREATE ROLE corpus_extacl_d NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- No extension: a->b DROPs hstore, which cascades away the member function AND +-- its grant. The member grant's REVOKE must be ordered BEFORE DROP EXTENSION +-- (while the function still exists), not after it. diff --git a/packages/pg-delta/corpus/extension-member--drop-with-grant/meta.json b/packages/pg-delta/corpus/extension-member--drop-with-grant/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/extension-member--drop-with-grant/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/extension-member--grant-on-function/a.sql b/packages/pg-delta/corpus/extension-member--grant-on-function/a.sql new file mode 100644 index 000000000..633b519e0 --- /dev/null +++ b/packages/pg-delta/corpus/extension-member--grant-on-function/a.sql @@ -0,0 +1,3 @@ +DO $$ BEGIN CREATE ROLE corpus_extacl_g NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE EXTENSION hstore SCHEMA public; diff --git a/packages/pg-delta/corpus/extension-member--grant-on-function/b.sql b/packages/pg-delta/corpus/extension-member--grant-on-function/b.sql new file mode 100644 index 000000000..eb60e8100 --- /dev/null +++ b/packages/pg-delta/corpus/extension-member--grant-on-function/b.sql @@ -0,0 +1,9 @@ +DO $$ BEGIN CREATE ROLE corpus_extacl_g NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE EXTENSION hstore SCHEMA public; + +-- A GRANT on an extension-member function is USER state layered on the +-- extension. The member object stays reference-only (never re-created — CREATE +-- EXTENSION owns it), but this grant is an init-privs delta that IS diffed and +-- applied. Reverse (b->a) REVOKEs it back to the extension's install state. +GRANT EXECUTE ON FUNCTION hstore(text, text) TO corpus_extacl_g; diff --git a/packages/pg-delta/corpus/extension-member--grant-on-function/meta.json b/packages/pg-delta/corpus/extension-member--grant-on-function/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/extension-member--grant-on-function/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/extension-member--revoke-public-execute/a.sql b/packages/pg-delta/corpus/extension-member--revoke-public-execute/a.sql new file mode 100644 index 000000000..524a0c1dd --- /dev/null +++ b/packages/pg-delta/corpus/extension-member--revoke-public-execute/a.sql @@ -0,0 +1 @@ +CREATE EXTENSION hstore SCHEMA public; diff --git a/packages/pg-delta/corpus/extension-member--revoke-public-execute/b.sql b/packages/pg-delta/corpus/extension-member--revoke-public-execute/b.sql new file mode 100644 index 000000000..0cd92c19c --- /dev/null +++ b/packages/pg-delta/corpus/extension-member--revoke-public-execute/b.sql @@ -0,0 +1,10 @@ +CREATE EXTENSION hstore SCHEMA public; + +-- Revoke an extension member's INSTALL-TIME PUBLIC EXECUTE (acldefault gives it +-- to every function). This is a customization BELOW the as-installed state; the +-- init-privs delta must emit an empty-privileges marker so the diff plans a +-- REVOKE (forward) and restores the install grant (reverse). No roles → shared +-- cluster. NB: the corpus proof loop cannot catch a regression here — extraction +-- is symmetrically blind to a lost member REVOKE — so plan shape is pinned in +-- tests/extension-member-acl.test.ts; this scenario guards end-to-end convergence. +REVOKE EXECUTE ON FUNCTION hstore(text, text) FROM PUBLIC; diff --git a/packages/pg-delta/corpus/fdw-option-secret-redaction--multi-layer-fdw-schema/a.sql b/packages/pg-delta/corpus/fdw-option-secret-redaction--multi-layer-fdw-schema/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/fdw-option-secret-redaction--multi-layer-fdw-schema/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/fdw-option-secret-redaction--multi-layer-fdw-schema/b.sql b/packages/pg-delta/corpus/fdw-option-secret-redaction--multi-layer-fdw-schema/b.sql new file mode 100644 index 000000000..2564a77dc --- /dev/null +++ b/packages/pg-delta/corpus/fdw-option-secret-redaction--multi-layer-fdw-schema/b.sql @@ -0,0 +1,24 @@ +-- Rich FDW schema with options at every layer (FDW, server, user mapping, foreign table). +-- Exercises ordering: FDW must precede server, server must precede user mapping and foreign table. +-- Secret values are present in OPTIONS to verify the new engine also redacts them in output. +CREATE FOREIGN DATA WRAPPER corpus_cli1467_fdw OPTIONS ( + use_remote_estimate 'true', + password 'fdw-shared-secret', + api_key 'fdw-api-key' +); +CREATE SERVER corpus_cli1467_server FOREIGN DATA WRAPPER corpus_cli1467_fdw OPTIONS ( + host 'remote.example.com', + port '5432', + password 'real-user-password', + passfile '/etc/secrets/passfile' +); +CREATE USER MAPPING FOR CURRENT_USER SERVER corpus_cli1467_server OPTIONS ( + "user" 'fdw_reader', + password 'real-user-password', + passcode 'krb-passcode', + sslpassword 'ssl-secret' +); +CREATE FOREIGN TABLE public.corpus_cli1467_table (id integer) SERVER corpus_cli1467_server OPTIONS ( + schema_name 'remote_schema', + password 'table-shared-secret' +); diff --git a/packages/pg-delta/corpus/fk-ordering--basic-fk-new-tables/a.sql b/packages/pg-delta/corpus/fk-ordering--basic-fk-new-tables/a.sql new file mode 100644 index 000000000..3c207ef08 --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--basic-fk-new-tables/a.sql @@ -0,0 +1 @@ +-- empty starting state: no tables diff --git a/packages/pg-delta/corpus/fk-ordering--basic-fk-new-tables/b.sql b/packages/pg-delta/corpus/fk-ordering--basic-fk-new-tables/b.sql new file mode 100644 index 000000000..90c84de66 --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--basic-fk-new-tables/b.sql @@ -0,0 +1,16 @@ +-- orders references customers; both tables are new (referencing table defined first in DDL) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.customers ( + id integer PRIMARY KEY, + name text NOT NULL, + email text UNIQUE +); + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + customer_id integer NOT NULL, + order_date date, + CONSTRAINT orders_customer_fkey + FOREIGN KEY (customer_id) REFERENCES test_schema.customers (id) +); diff --git a/packages/pg-delta/corpus/fk-ordering--deferred-fk/a.sql b/packages/pg-delta/corpus/fk-ordering--deferred-fk/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--deferred-fk/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/fk-ordering--deferred-fk/b.sql b/packages/pg-delta/corpus/fk-ordering--deferred-fk/b.sql new file mode 100644 index 000000000..2110c6370 --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--deferred-fk/b.sql @@ -0,0 +1,16 @@ +-- FK with DEFERRABLE INITIALLY DEFERRED (allows circular inserts within a transaction) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.parent ( + id integer PRIMARY KEY, + name text NOT NULL +); + +CREATE TABLE test_schema.child ( + id integer PRIMARY KEY, + parent_id integer, + name text NOT NULL, + CONSTRAINT child_parent_fkey + FOREIGN KEY (parent_id) REFERENCES test_schema.parent (id) + DEFERRABLE INITIALLY DEFERRED +); diff --git a/packages/pg-delta/corpus/fk-ordering--multi-fk-chain/a.sql b/packages/pg-delta/corpus/fk-ordering--multi-fk-chain/a.sql new file mode 100644 index 000000000..3c207ef08 --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--multi-fk-chain/a.sql @@ -0,0 +1 @@ +-- empty starting state: no tables diff --git a/packages/pg-delta/corpus/fk-ordering--multi-fk-chain/b.sql b/packages/pg-delta/corpus/fk-ordering--multi-fk-chain/b.sql new file mode 100644 index 000000000..b4b2fc474 --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--multi-fk-chain/b.sql @@ -0,0 +1,33 @@ +-- four tables in an ecommerce schema with a multi-level FK chain +CREATE SCHEMA ecommerce; + +CREATE TABLE ecommerce.customers ( + id integer PRIMARY KEY, + name text NOT NULL, + email text UNIQUE NOT NULL +); + +CREATE TABLE ecommerce.products ( + id integer PRIMARY KEY, + name text NOT NULL, + price decimal NOT NULL +); + +CREATE TABLE ecommerce.orders ( + id integer PRIMARY KEY, + customer_id integer NOT NULL, + order_date date NOT NULL, + CONSTRAINT orders_customer_fkey + FOREIGN KEY (customer_id) REFERENCES ecommerce.customers (id) +); + +CREATE TABLE ecommerce.order_items ( + id integer PRIMARY KEY, + order_id integer NOT NULL, + product_id integer NOT NULL, + quantity integer NOT NULL, + CONSTRAINT order_items_order_fkey + FOREIGN KEY (order_id) REFERENCES ecommerce.orders (id), + CONSTRAINT order_items_product_fkey + FOREIGN KEY (product_id) REFERENCES ecommerce.products (id) +); diff --git a/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/a.sql b/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/a.sql new file mode 100644 index 000000000..3777519aa --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/a.sql @@ -0,0 +1,15 @@ +-- FK without ON DELETE/UPDATE actions +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL +); + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + user_id integer NOT NULL, + status text NOT NULL, + CONSTRAINT orders_user_fkey + FOREIGN KEY (user_id) REFERENCES test_schema.users (id) +); diff --git a/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/b.sql b/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/b.sql new file mode 100644 index 000000000..a5525fe56 --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/b.sql @@ -0,0 +1,16 @@ +-- FK updated to have ON DELETE CASCADE ON UPDATE CASCADE +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL +); + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + user_id integer NOT NULL, + status text NOT NULL, + CONSTRAINT orders_user_fkey + FOREIGN KEY (user_id) REFERENCES test_schema.users (id) + ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/seed-b.sql b/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/seed-b.sql new file mode 100644 index 000000000..65ac6725b --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.users (id, name) VALUES (1, 'alice'); +INSERT INTO test_schema.orders (id, user_id, status) VALUES (1, 1, 'pending'); diff --git a/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/seed.sql b/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/seed.sql new file mode 100644 index 000000000..65ac6725b --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--on-delete-cascade/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.users (id, name) VALUES (1, 'alice'); +INSERT INTO test_schema.orders (id, user_id, status) VALUES (1, 1, 'pending'); diff --git a/packages/pg-delta/corpus/fk-ordering--self-referencing/a.sql b/packages/pg-delta/corpus/fk-ordering--self-referencing/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--self-referencing/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/fk-ordering--self-referencing/b.sql b/packages/pg-delta/corpus/fk-ordering--self-referencing/b.sql new file mode 100644 index 000000000..d67ceb73b --- /dev/null +++ b/packages/pg-delta/corpus/fk-ordering--self-referencing/b.sql @@ -0,0 +1,10 @@ +-- table with a self-referencing FK (adjacency list / tree structure) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.categories ( + id integer PRIMARY KEY, + name text NOT NULL, + parent_id integer, + CONSTRAINT categories_parent_fkey + FOREIGN KEY (parent_id) REFERENCES test_schema.categories (id) +); diff --git a/packages/pg-delta/corpus/fk-pair/a.sql b/packages/pg-delta/corpus/fk-pair/a.sql new file mode 100644 index 000000000..1e69262a2 --- /dev/null +++ b/packages/pg-delta/corpus/fk-pair/a.sql @@ -0,0 +1 @@ +-- empty: two tables linked by FK arrive together (ordering test) diff --git a/packages/pg-delta/corpus/fk-pair/b.sql b/packages/pg-delta/corpus/fk-pair/b.sql new file mode 100644 index 000000000..f9230edb3 --- /dev/null +++ b/packages/pg-delta/corpus/fk-pair/b.sql @@ -0,0 +1,6 @@ +CREATE TABLE public.users (id integer PRIMARY KEY, name text NOT NULL); +CREATE TABLE public.orders ( + id integer PRIMARY KEY, + user_id integer NOT NULL, + CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES public.users (id) +); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-fdw-options/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-fdw-options/a.sql new file mode 100644 index 000000000..ba828ca25 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-fdw-options/a.sql @@ -0,0 +1 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw OPTIONS (debug 'true'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-fdw-options/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-fdw-options/b.sql new file mode 100644 index 000000000..60890f82a --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-fdw-options/b.sql @@ -0,0 +1 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw OPTIONS (debug 'false', option1 'value1'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-options/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-options/a.sql new file mode 100644 index 000000000..f0afae919 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-options/a.sql @@ -0,0 +1,2 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server FOREIGN DATA WRAPPER corpus_test_fdw OPTIONS (host 'localhost'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-options/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-options/b.sql new file mode 100644 index 000000000..d0c2ad065 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-options/b.sql @@ -0,0 +1,2 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server FOREIGN DATA WRAPPER corpus_test_fdw OPTIONS (host 'newhost', port '5432'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-owner/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-owner/a.sql new file mode 100644 index 000000000..920590bc1 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-owner/a.sql @@ -0,0 +1,3 @@ +DO $$ BEGIN CREATE ROLE corpus_server_owner NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server FOREIGN DATA WRAPPER corpus_test_fdw; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-owner/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-owner/b.sql new file mode 100644 index 000000000..ef49785af --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-owner/b.sql @@ -0,0 +1,4 @@ +DO $$ BEGIN CREATE ROLE corpus_server_owner NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server FOREIGN DATA WRAPPER corpus_test_fdw; +ALTER SERVER corpus_test_server OWNER TO corpus_server_owner; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-version/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-version/a.sql new file mode 100644 index 000000000..24f4cf0f0 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-version/a.sql @@ -0,0 +1,2 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server VERSION '1.0' FOREIGN DATA WRAPPER corpus_test_fdw; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-version/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-version/b.sql new file mode 100644 index 000000000..11c18a7a2 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-server-version/b.sql @@ -0,0 +1,2 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server VERSION '2.0' FOREIGN DATA WRAPPER corpus_test_fdw; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-user-mapping-options/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-user-mapping-options/a.sql new file mode 100644 index 000000000..5cfe9aaab --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-user-mapping-options/a.sql @@ -0,0 +1,4 @@ +-- state A: FDW + server + user mapping with a non-secret option only +CREATE FOREIGN DATA WRAPPER test_fdw; +CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; +CREATE USER MAPPING FOR CURRENT_USER SERVER test_server OPTIONS (user 'remote_user'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-user-mapping-options/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-user-mapping-options/b.sql new file mode 100644 index 000000000..5b955257c --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--alter-user-mapping-options/b.sql @@ -0,0 +1,6 @@ +-- state B: same mapping with the non-secret option changed (SET user) and a +-- secret option added (ADD password) -- the password must be redacted in the +-- emitted ALTER USER MAPPING ... OPTIONS (SET user 'new_user', ADD password ...) +CREATE FOREIGN DATA WRAPPER test_fdw; +CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; +CREATE USER MAPPING FOR CURRENT_USER SERVER test_server OPTIONS (user 'new_user', password 'secret'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-fdw-basic/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-fdw-basic/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-fdw-basic/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-fdw-basic/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-fdw-basic/b.sql new file mode 100644 index 000000000..6d67bbaf8 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-fdw-basic/b.sql @@ -0,0 +1 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw OPTIONS (debug 'true', option1 'value1', option2 'value2'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-server-with-options/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-server-with-options/a.sql new file mode 100644 index 000000000..6242c3492 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-server-with-options/a.sql @@ -0,0 +1 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-server-with-options/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-server-with-options/b.sql new file mode 100644 index 000000000..e5a0588c0 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-server-with-options/b.sql @@ -0,0 +1,3 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server TYPE 'postgres_fdw' VERSION '1.0' + FOREIGN DATA WRAPPER corpus_test_fdw OPTIONS (host 'localhost', port '5432'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/a.sql new file mode 100644 index 000000000..1907a6f1e --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/a.sql @@ -0,0 +1,7 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server FOREIGN DATA WRAPPER corpus_test_fdw; +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_test_user') THEN + CREATE ROLE corpus_test_user; + END IF; +END $$; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/b.sql new file mode 100644 index 000000000..f16c5549f --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/b.sql @@ -0,0 +1,8 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server FOREIGN DATA WRAPPER corpus_test_fdw; +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_test_user') THEN + CREATE ROLE corpus_test_user; + END IF; +END $$; +CREATE USER MAPPING FOR corpus_test_user SERVER corpus_test_server OPTIONS (user 'remote_user', password 'secret'); diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/meta.json b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--create-user-mapping-with-options/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--full-lifecycle/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--full-lifecycle/a.sql new file mode 100644 index 000000000..4ce9bf2f3 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--full-lifecycle/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA corpus_test_schema; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--full-lifecycle/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--full-lifecycle/b.sql new file mode 100644 index 000000000..72697ba19 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--full-lifecycle/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA corpus_test_schema; +CREATE FOREIGN DATA WRAPPER corpus_fdw1; +CREATE SERVER corpus_server1 FOREIGN DATA WRAPPER corpus_fdw1; +CREATE SERVER corpus_server2 FOREIGN DATA WRAPPER corpus_fdw1; +CREATE USER MAPPING FOR CURRENT_USER SERVER corpus_server1; +CREATE USER MAPPING FOR PUBLIC SERVER corpus_server2; +CREATE FOREIGN TABLE corpus_test_schema.table1 (id integer) SERVER corpus_server1; +CREATE FOREIGN TABLE corpus_test_schema.table2 (id integer) SERVER corpus_server2; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--remove-server-version/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--remove-server-version/a.sql new file mode 100644 index 000000000..24f4cf0f0 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--remove-server-version/a.sql @@ -0,0 +1,2 @@ +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server VERSION '1.0' FOREIGN DATA WRAPPER corpus_test_fdw; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--remove-server-version/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--remove-server-version/b.sql new file mode 100644 index 000000000..7aca5dcab --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--remove-server-version/b.sql @@ -0,0 +1,5 @@ +-- Same server without VERSION. PostgreSQL has no ALTER SERVER grammar to unset +-- a version, so the forward diff (removal) must route to drop + recreate; the +-- reverse (adding VERSION) is a plain ALTER SERVER … VERSION. +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server FOREIGN DATA WRAPPER corpus_test_fdw; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--replace-server-with-children/a.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--replace-server-with-children/a.sql new file mode 100644 index 000000000..0595d191d --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--replace-server-with-children/a.sql @@ -0,0 +1,14 @@ +-- The server's FOREIGN DATA WRAPPER differs between states (fdw1 → fdw2), which +-- forces a server replace (there is no ALTER SERVER … FOREIGN DATA WRAPPER). A +-- foreign table and a user mapping depend on the server in BOTH states, so the +-- DROP SERVER (RESTRICT) fails unless those dependents are rebuilt around it. +CREATE SCHEMA corpus_test_schema; + +CREATE FOREIGN DATA WRAPPER corpus_fdw1; +CREATE FOREIGN DATA WRAPPER corpus_fdw2; + +CREATE SERVER corpus_server FOREIGN DATA WRAPPER corpus_fdw1; + +CREATE USER MAPPING FOR CURRENT_USER SERVER corpus_server; + +CREATE FOREIGN TABLE corpus_test_schema.remote_items (id integer) SERVER corpus_server; diff --git a/packages/pg-delta/corpus/foreign-data-wrapper-operations--replace-server-with-children/b.sql b/packages/pg-delta/corpus/foreign-data-wrapper-operations--replace-server-with-children/b.sql new file mode 100644 index 000000000..83427d2de --- /dev/null +++ b/packages/pg-delta/corpus/foreign-data-wrapper-operations--replace-server-with-children/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA corpus_test_schema; + +CREATE FOREIGN DATA WRAPPER corpus_fdw1; +CREATE FOREIGN DATA WRAPPER corpus_fdw2; + +CREATE SERVER corpus_server FOREIGN DATA WRAPPER corpus_fdw2; + +CREATE USER MAPPING FOR CURRENT_USER SERVER corpus_server; + +CREATE FOREIGN TABLE corpus_test_schema.remote_items (id integer) SERVER corpus_server; diff --git a/packages/pg-delta/corpus/foreign-table-constraints--add-check/a.sql b/packages/pg-delta/corpus/foreign-table-constraints--add-check/a.sql new file mode 100644 index 000000000..211b334a5 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-table-constraints--add-check/a.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA corpus_test_schema; +CREATE FOREIGN DATA WRAPPER corpus_fdw1; +CREATE SERVER corpus_server1 FOREIGN DATA WRAPPER corpus_fdw1; +CREATE FOREIGN TABLE corpus_test_schema.ft (id integer, qty integer) SERVER corpus_server1; diff --git a/packages/pg-delta/corpus/foreign-table-constraints--add-check/b.sql b/packages/pg-delta/corpus/foreign-table-constraints--add-check/b.sql new file mode 100644 index 000000000..c910dcf6c --- /dev/null +++ b/packages/pg-delta/corpus/foreign-table-constraints--add-check/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA corpus_test_schema; +CREATE FOREIGN DATA WRAPPER corpus_fdw1; +CREATE SERVER corpus_server1 FOREIGN DATA WRAPPER corpus_fdw1; +CREATE FOREIGN TABLE corpus_test_schema.ft (id integer, qty integer) SERVER corpus_server1; +ALTER FOREIGN TABLE corpus_test_schema.ft ADD CONSTRAINT ft_qty_check CHECK (qty > 0); diff --git a/packages/pg-delta/corpus/foreign-table-operations--alter-options/a.sql b/packages/pg-delta/corpus/foreign-table-operations--alter-options/a.sql new file mode 100644 index 000000000..d2a799823 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-table-operations--alter-options/a.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA corpus_ft; +CREATE FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE SERVER corpus_ft_server FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE FOREIGN TABLE corpus_ft.ft (id integer) SERVER corpus_ft_server + OPTIONS (schema_name 'public', table_name 'remote_a'); diff --git a/packages/pg-delta/corpus/foreign-table-operations--alter-options/b.sql b/packages/pg-delta/corpus/foreign-table-operations--alter-options/b.sql new file mode 100644 index 000000000..28fce25a9 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-table-operations--alter-options/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA corpus_ft; +CREATE FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE SERVER corpus_ft_server FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE FOREIGN TABLE corpus_ft.ft (id integer) SERVER corpus_ft_server + OPTIONS (schema_name 'public', table_name 'remote_b', updatable 'false'); diff --git a/packages/pg-delta/corpus/foreign-table-operations--column-alters/a.sql b/packages/pg-delta/corpus/foreign-table-operations--column-alters/a.sql new file mode 100644 index 000000000..0315a2aa4 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-table-operations--column-alters/a.sql @@ -0,0 +1,11 @@ +-- state A: foreign table with a NOT NULL column, a defaulted column, and a +-- column that B drops. a<->b exercises every column alter in both directions. +CREATE SCHEMA corpus_ft; +CREATE FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE SERVER corpus_ft_server FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE FOREIGN TABLE corpus_ft.ft ( + id integer, + qty integer NOT NULL, + note text DEFAULT 'x', + old_col text +) SERVER corpus_ft_server; diff --git a/packages/pg-delta/corpus/foreign-table-operations--column-alters/b.sql b/packages/pg-delta/corpus/foreign-table-operations--column-alters/b.sql new file mode 100644 index 000000000..816a72070 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-table-operations--column-alters/b.sql @@ -0,0 +1,11 @@ +-- state B: id widened, qty NOT NULL dropped, note default dropped, old_col +-- dropped, new_col added (with default + NOT NULL). +CREATE SCHEMA corpus_ft; +CREATE FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE SERVER corpus_ft_server FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE FOREIGN TABLE corpus_ft.ft ( + id bigint, + qty integer, + note text, + new_col integer DEFAULT 0 NOT NULL +) SERVER corpus_ft_server; diff --git a/packages/pg-delta/corpus/foreign-table-operations--owner-change/a.sql b/packages/pg-delta/corpus/foreign-table-operations--owner-change/a.sql new file mode 100644 index 000000000..bebf3d362 --- /dev/null +++ b/packages/pg-delta/corpus/foreign-table-operations--owner-change/a.sql @@ -0,0 +1,5 @@ +DO $$ BEGIN CREATE ROLE corpus_ft_owner NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA corpus_ft; +CREATE FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE SERVER corpus_ft_server FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE FOREIGN TABLE corpus_ft.ft (id integer) SERVER corpus_ft_server; diff --git a/packages/pg-delta/corpus/foreign-table-operations--owner-change/b.sql b/packages/pg-delta/corpus/foreign-table-operations--owner-change/b.sql new file mode 100644 index 000000000..4d393feee --- /dev/null +++ b/packages/pg-delta/corpus/foreign-table-operations--owner-change/b.sql @@ -0,0 +1,6 @@ +DO $$ BEGIN CREATE ROLE corpus_ft_owner NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA corpus_ft; +CREATE FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE SERVER corpus_ft_server FOREIGN DATA WRAPPER corpus_ft_fdw; +CREATE FOREIGN TABLE corpus_ft.ft (id integer) SERVER corpus_ft_server; +ALTER FOREIGN TABLE corpus_ft.ft OWNER TO corpus_ft_owner; diff --git a/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/a.sql b/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/a.sql new file mode 100644 index 000000000..c2872b52d --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/a.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA app; + +-- touch() keeps its signature + return type across a/b, so a change to its body +-- takes the CREATE OR REPLACE alter path. A BEGIN ATOMIC (SQL-standard) body is +-- parsed and dependency-checked at CREATE / CREATE OR REPLACE time (unlike +-- plpgsql / quoted bodies under check_function_bodies=off). +CREATE FUNCTION app.touch() +RETURNS int +LANGUAGE sql +BEGIN ATOMIC + SELECT 1; +END; diff --git a/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/b.sql b/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/b.sql new file mode 100644 index 000000000..c2910dd8d --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/b.sql @@ -0,0 +1,23 @@ +CREATE SCHEMA app; + +-- NEW helper function, named so it sorts AFTER touch() — so the deterministic +-- tie-break alone would place its CREATE *after* touch()'s CREATE OR REPLACE. +-- touch()'s updated BEGIN ATOMIC body calls it, and that body is dependency- +-- checked at replace time, so the alter must be ordered AFTER the helper's +-- create. Only the def-alter's `consumes` of touch's `depends` targets forces +-- that order; without it apply fails ("function app.zzz_helper() does not +-- exist"). Reverse (b->a): the alter drops the call and must precede +-- DROP FUNCTION app.zzz_helper() (the alterer-before-dependency-teardown edge). +CREATE FUNCTION app.zzz_helper() +RETURNS int +LANGUAGE sql +BEGIN ATOMIC + SELECT 42; +END; + +CREATE FUNCTION app.touch() +RETURNS int +LANGUAGE sql +BEGIN ATOMIC + SELECT app.zzz_helper(); +END; diff --git a/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/meta.json b/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/meta.json new file mode 100644 index 000000000..24fda6da1 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--begin-atomic-alter-forward-ref/meta.json @@ -0,0 +1 @@ +{ "minVersion": 14 } diff --git a/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/a.sql b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/a.sql new file mode 100644 index 000000000..e29f1c316 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/a.sql @@ -0,0 +1,16 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.accounts ( + user_id int PRIMARY KEY, + balance int NOT NULL DEFAULT 0 +); + +CREATE FUNCTION test_schema.transfer_funds( + sender_id int, receiver_id int, amount numeric +) +RETURNS void +LANGUAGE SQL +BEGIN ATOMIC + UPDATE test_schema.accounts + SET balance = balance - amount WHERE user_id = sender_id; +END; diff --git a/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/b.sql b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/b.sql new file mode 100644 index 000000000..0fa5b6e7c --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/b.sql @@ -0,0 +1,18 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.accounts ( + user_id int PRIMARY KEY, + balance int NOT NULL DEFAULT 0 +); + +CREATE FUNCTION test_schema.transfer_funds( + sender_id int, receiver_id int, amount numeric +) +RETURNS void +LANGUAGE SQL +BEGIN ATOMIC + UPDATE test_schema.accounts + SET balance = balance - amount WHERE user_id = sender_id; + UPDATE test_schema.accounts + SET balance = balance + amount WHERE user_id = receiver_id; +END; diff --git a/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/meta.json b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/meta.json new file mode 100644 index 000000000..24fda6da1 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/meta.json @@ -0,0 +1 @@ +{ "minVersion": 14 } diff --git a/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/seed-b.sql b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/seed-b.sql new file mode 100644 index 000000000..7d55447d9 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.accounts (user_id) VALUES (1); diff --git a/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/seed.sql b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/seed.sql new file mode 100644 index 000000000..7d55447d9 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--begin-atomic-replacement/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.accounts (user_id) VALUES (1); diff --git a/packages/pg-delta/corpus/function-ops--complex-attributes/a.sql b/packages/pg-delta/corpus/function-ops--complex-attributes/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--complex-attributes/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/function-ops--complex-attributes/b.sql b/packages/pg-delta/corpus/function-ops--complex-attributes/b.sql new file mode 100644 index 000000000..842e715e4 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--complex-attributes/b.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.expensive_function(input_data text) + RETURNS text + LANGUAGE plpgsql + PARALLEL RESTRICTED STRICT COST 1000 +AS $function$ +BEGIN + -- Simulate expensive operation + PERFORM pg_sleep(0.1); + RETURN upper(input_data); +END; +$function$; diff --git a/packages/pg-delta/corpus/function-ops--dependency-ordering/a.sql b/packages/pg-delta/corpus/function-ops--dependency-ordering/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--dependency-ordering/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/function-ops--dependency-ordering/b.sql b/packages/pg-delta/corpus/function-ops--dependency-ordering/b.sql new file mode 100644 index 000000000..0204a9eef --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--dependency-ordering/b.sql @@ -0,0 +1,28 @@ +CREATE SCHEMA test_schema; + +-- Function used in a CHECK constraint; must be created before the constraint +CREATE FUNCTION test_schema.validate_email(email text) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +AS $function$ + SELECT email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' +$function$; + +CREATE TABLE test_schema.users ( + email text, + CONSTRAINT valid_email CHECK (test_schema.validate_email(email)) +); + +-- Function used in a view; must be created before the view +CREATE FUNCTION test_schema.format_price(price numeric) + RETURNS text + LANGUAGE sql + IMMUTABLE +AS $function$SELECT '$' || price::text$function$; + +CREATE TABLE test_schema.products (price numeric(10,2)); + +CREATE VIEW test_schema.product_display AS + SELECT test_schema.format_price(price) AS formatted_price + FROM test_schema.products; diff --git a/packages/pg-delta/corpus/function-ops--enum-arg-privilege/a.sql b/packages/pg-delta/corpus/function-ops--enum-arg-privilege/a.sql new file mode 100644 index 000000000..0bb735fb1 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--enum-arg-privilege/a.sql @@ -0,0 +1,5 @@ +CREATE ROLE pgdelta_app_user_219 NOLOGIN; +CREATE SCHEMA test_schema; +CREATE TYPE test_schema.entity_kind AS ENUM ('person', 'company', 'organization'); +CREATE FUNCTION test_schema.create_entity(p_name text, p_kind test_schema.entity_kind) +RETURNS uuid LANGUAGE sql AS $$ SELECT gen_random_uuid(); $$; diff --git a/packages/pg-delta/corpus/function-ops--enum-arg-privilege/b.sql b/packages/pg-delta/corpus/function-ops--enum-arg-privilege/b.sql new file mode 100644 index 000000000..6ea7ea604 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--enum-arg-privilege/b.sql @@ -0,0 +1,9 @@ +CREATE ROLE pgdelta_app_user_219 NOLOGIN; +CREATE SCHEMA test_schema; +CREATE TYPE test_schema.entity_kind AS ENUM ('person', 'company', 'organization'); +CREATE FUNCTION test_schema.create_entity(p_name text, p_kind test_schema.entity_kind) +RETURNS uuid LANGUAGE sql AS $$ SELECT gen_random_uuid(); $$; +-- privilege change on a function whose signature contains an enum type: +-- the signature must render stably (no temp-schema qualification) +REVOKE ALL ON FUNCTION test_schema.create_entity(text, test_schema.entity_kind) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION test_schema.create_entity(text, test_schema.entity_kind) TO pgdelta_app_user_219; diff --git a/packages/pg-delta/corpus/function-ops--enum-arg-privilege/meta.json b/packages/pg-delta/corpus/function-ops--enum-arg-privilege/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--enum-arg-privilege/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/function-ops--language-change/a.sql b/packages/pg-delta/corpus/function-ops--language-change/a.sql new file mode 100644 index 000000000..6c82705ae --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--language-change/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA s; + +-- LANGUAGE change (sql -> plpgsql, same signature + return type) is in the +-- replace set: pg-delta-next demolishes (drop + recreate) rather than altering +-- in place. Postgres actually permits CREATE OR REPLACE to switch language, but +-- drop-and-recreate is unconditionally safe and keeps the classifier simple. +CREATE FUNCTION s.f() RETURNS int LANGUAGE sql AS 'SELECT 1'; diff --git a/packages/pg-delta/corpus/function-ops--language-change/b.sql b/packages/pg-delta/corpus/function-ops--language-change/b.sql new file mode 100644 index 000000000..79b1109d9 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--language-change/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA s; + +CREATE FUNCTION s.f() RETURNS int LANGUAGE plpgsql AS 'BEGIN RETURN 1; END'; diff --git a/packages/pg-delta/corpus/function-ops--overloads/a.sql b/packages/pg-delta/corpus/function-ops--overloads/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--overloads/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/function-ops--overloads/b.sql b/packages/pg-delta/corpus/function-ops--overloads/b.sql new file mode 100644 index 000000000..bb53a8ba2 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--overloads/b.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.format_value(input_val integer) + RETURNS text + LANGUAGE sql + IMMUTABLE +AS $function$SELECT input_val::text$function$; + +CREATE FUNCTION test_schema.format_value(input_val integer, prefix text) + RETURNS text + LANGUAGE sql + IMMUTABLE +AS $function$SELECT prefix || input_val::text$function$; diff --git a/packages/pg-delta/corpus/function-ops--plpgsql-body-forward-ref/a.sql b/packages/pg-delta/corpus/function-ops--plpgsql-body-forward-ref/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--plpgsql-body-forward-ref/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/function-ops--plpgsql-body-forward-ref/b.sql b/packages/pg-delta/corpus/function-ops--plpgsql-body-forward-ref/b.sql new file mode 100644 index 000000000..15a3f6752 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--plpgsql-body-forward-ref/b.sql @@ -0,0 +1,21 @@ +CREATE SCHEMA test_schema; + +CREATE OR REPLACE FUNCTION test_schema.a_wrapper(input text) + RETURNS text + LANGUAGE plpgsql + IMMUTABLE +AS $function$ +BEGIN + RETURN test_schema.z_helper_parse(input) || '!'; +END; +$function$; + +CREATE OR REPLACE FUNCTION test_schema.z_helper_parse(input text) + RETURNS text + LANGUAGE plpgsql + IMMUTABLE +AS $function$ +BEGIN + RETURN upper(input); +END; +$function$; diff --git a/packages/pg-delta/corpus/function-ops--replace-preserves-owner/a.sql b/packages/pg-delta/corpus/function-ops--replace-preserves-owner/a.sql new file mode 100644 index 000000000..5b680550a --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--replace-preserves-owner/a.sql @@ -0,0 +1,6 @@ +DO $$ BEGIN CREATE ROLE corpus_fn_owner NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA s; + +CREATE FUNCTION s.f() RETURNS int LANGUAGE sql AS 'SELECT 1'; +ALTER FUNCTION s.f() OWNER TO corpus_fn_owner; diff --git a/packages/pg-delta/corpus/function-ops--replace-preserves-owner/b.sql b/packages/pg-delta/corpus/function-ops--replace-preserves-owner/b.sql new file mode 100644 index 000000000..da96640d1 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--replace-preserves-owner/b.sql @@ -0,0 +1,13 @@ +DO $$ BEGIN CREATE ROLE corpus_fn_owner NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA s; + +-- Same signature, changed RETURN TYPE (int -> bigint). CREATE OR REPLACE refuses +-- a return-type change, so pg-delta-next demolishes (drop + recreate) the +-- function. The recreate must re-establish the owner (an unchanged owner has no +-- link/unlink delta, so it is otherwise reset to the applying role). A body-only +-- change would take the CREATE OR REPLACE alter path (owner preserved, nothing +-- to re-establish), so this scenario changes the return type to keep pinning the +-- demolition + owner re-establish path. +CREATE FUNCTION s.f() RETURNS bigint LANGUAGE sql AS 'SELECT 1'; +ALTER FUNCTION s.f() OWNER TO corpus_fn_owner; diff --git a/packages/pg-delta/corpus/function-ops--replace-preserves-owner/meta.json b/packages/pg-delta/corpus/function-ops--replace-preserves-owner/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--replace-preserves-owner/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/a.sql b/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/a.sql new file mode 100644 index 000000000..fad27d6ce --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/a.sql @@ -0,0 +1,11 @@ +DO $$ BEGIN CREATE ROLE corpus_adp_grantee NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA s; + +-- Function created BEFORE the default privilege below, so it does NOT carry the +-- grant. The ADP is identical in a and b (no delta) but stays ACTIVE on the +-- target: a replace (drop + recreate) of s.f must not let the recreate acquire +-- the default-privilege grant the desired state does not have. +CREATE FUNCTION s.f() RETURNS int LANGUAGE sql AS 'SELECT 1'; + +ALTER DEFAULT PRIVILEGES IN SCHEMA s GRANT EXECUTE ON FUNCTIONS TO corpus_adp_grantee; diff --git a/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/b.sql b/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/b.sql new file mode 100644 index 000000000..0d616e4de --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/b.sql @@ -0,0 +1,15 @@ +DO $$ BEGIN CREATE ROLE corpus_adp_grantee NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA s; + +-- Function created BEFORE the default privilege below, so it does NOT carry the +-- grant. The ADP is identical in a and b (no delta) but stays ACTIVE on the +-- target. Same signature, changed RETURN TYPE (int -> bigint): CREATE OR REPLACE +-- refuses that, so pg-delta-next demolishes (drop + recreate) s.f — and the +-- recreate must not let the fresh function acquire the default-privilege grant +-- the desired state does not have. A body-only change would alter in place +-- (CREATE OR REPLACE re-fires no default ACLs), so the return type is changed to +-- keep pinning the demolition + default-ACL hygiene path. +CREATE FUNCTION s.f() RETURNS bigint LANGUAGE sql AS 'SELECT 1'; + +ALTER DEFAULT PRIVILEGES IN SCHEMA s GRANT EXECUTE ON FUNCTIONS TO corpus_adp_grantee; diff --git a/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/meta.json b/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--replace-under-default-privileges/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/function-ops--replacement/a.sql b/packages/pg-delta/corpus/function-ops--replacement/a.sql new file mode 100644 index 000000000..ff00883b1 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--replacement/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.version_function() +RETURNS text +LANGUAGE sql +IMMUTABLE +AS 'SELECT ''v1.0'''; diff --git a/packages/pg-delta/corpus/function-ops--replacement/b.sql b/packages/pg-delta/corpus/function-ops--replacement/b.sql new file mode 100644 index 000000000..d19b5847a --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--replacement/b.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.version_function() +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $function$SELECT 'v2.0'$function$; diff --git a/packages/pg-delta/corpus/function-ops--set-config/a.sql b/packages/pg-delta/corpus/function-ops--set-config/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--set-config/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/function-ops--set-config/b.sql b/packages/pg-delta/corpus/function-ops--set-config/b.sql new file mode 100644 index 000000000..195edcbd3 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--set-config/b.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.config_function() + RETURNS void + LANGUAGE plpgsql + SET work_mem TO '256MB' + SET statement_timeout TO '30s' +AS $function$ +BEGIN + -- Function with custom configuration + RAISE NOTICE 'Function executed with custom config'; +END; +$function$; diff --git a/packages/pg-delta/corpus/function-ops--signature-cascades-view/a.sql b/packages/pg-delta/corpus/function-ops--signature-cascades-view/a.sql new file mode 100644 index 000000000..05bb06410 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-cascades-view/a.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.format_id(id integer) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $function$SELECT 'id:' || id::text$function$; + +CREATE TABLE test_schema.items (id integer); + +CREATE VIEW test_schema.items_formatted AS + SELECT test_schema.format_id(id) AS formatted_id FROM test_schema.items; diff --git a/packages/pg-delta/corpus/function-ops--signature-cascades-view/b.sql b/packages/pg-delta/corpus/function-ops--signature-cascades-view/b.sql new file mode 100644 index 000000000..bf912eeda --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-cascades-view/b.sql @@ -0,0 +1,14 @@ +CREATE SCHEMA test_schema; + +-- Argument type changed from integer to bigint (forces DROP+CREATE of function and dependent view) +CREATE FUNCTION test_schema.format_id(id bigint) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $function$SELECT 'id:' || id::text$function$; + +CREATE TABLE test_schema.items (id integer); + +-- View recreated to use the new overload signature +CREATE VIEW test_schema.items_formatted AS + SELECT test_schema.format_id(id::bigint) AS formatted_id FROM test_schema.items; diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/a.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/a.sql new file mode 100644 index 000000000..de80f4a48 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA probe_constraint; +CREATE FUNCTION probe_constraint.is_valid_amount(value integer) +RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT value > 0 $$; +CREATE TABLE probe_constraint.items ( + id integer PRIMARY KEY, + amount integer NOT NULL, + CONSTRAINT amount_is_valid CHECK (probe_constraint.is_valid_amount(amount)) +); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/b.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/b.sql new file mode 100644 index 000000000..5cc24d60a --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA probe_constraint; +CREATE FUNCTION probe_constraint.is_valid_amount(value bigint) +RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT value > 0 $$; +CREATE TABLE probe_constraint.items ( + id integer PRIMARY KEY, + amount integer NOT NULL, + CONSTRAINT amount_is_valid CHECK (probe_constraint.is_valid_amount(amount::bigint)) +); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/seed-b.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/seed-b.sql new file mode 100644 index 000000000..f5ffd2172 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO probe_constraint.items (id, amount) VALUES (1, 5), (2, 9); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/seed.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/seed.sql new file mode 100644 index 000000000..f5ffd2172 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-check/seed.sql @@ -0,0 +1 @@ +INSERT INTO probe_constraint.items (id, amount) VALUES (1, 5), (2, 9); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/a.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/a.sql new file mode 100644 index 000000000..b39b23907 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA probe_default; +CREATE FUNCTION probe_default.make_amount(value integer) +RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT value $$; +CREATE TABLE probe_default.items ( + id integer PRIMARY KEY, + amount integer DEFAULT probe_default.make_amount(1) +); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/b.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/b.sql new file mode 100644 index 000000000..3f1840642 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/b.sql @@ -0,0 +1,10 @@ +-- the function's ARG TYPE changes (integer -> bigint): a different signature +-- entirely. Only the column default references it; the table itself is +-- unchanged and must NOT be dropped/recreated (it holds data). +CREATE SCHEMA probe_default; +CREATE FUNCTION probe_default.make_amount(value bigint) +RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT value::integer $$; +CREATE TABLE probe_default.items ( + id integer PRIMARY KEY, + amount integer DEFAULT probe_default.make_amount(1::bigint) +); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/seed-b.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/seed-b.sql new file mode 100644 index 000000000..965100bea --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO probe_default.items (id, amount) VALUES (1, 5), (2, 9); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/seed.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/seed.sql new file mode 100644 index 000000000..965100bea --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-default/seed.sql @@ -0,0 +1 @@ +INSERT INTO probe_default.items (id, amount) VALUES (1, 5), (2, 9); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/a.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/a.sql new file mode 100644 index 000000000..f23413ff0 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA t; +CREATE TABLE t.profiles (id uuid PRIMARY KEY, role text); +ALTER TABLE t.profiles ENABLE ROW LEVEL SECURITY; +CREATE FUNCTION t.check_role(_id uuid, _role text) RETURNS boolean + LANGUAGE plpgsql AS $$ BEGIN RETURN true; END; $$; +CREATE POLICY check_role_policy ON t.profiles FOR SELECT + USING (t.check_role(id, role)); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/b.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/b.sql new file mode 100644 index 000000000..3914d201c --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/b.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA t; +CREATE TABLE t.profiles (id uuid PRIMARY KEY, role text); +ALTER TABLE t.profiles ENABLE ROW LEVEL SECURITY; +-- a new parameter changes the signature -> different function; the policy +-- that calls it must be dropped before DROP FUNCTION and recreated after +CREATE FUNCTION t.check_role(_id uuid, _role text, _extra text DEFAULT 'd') RETURNS boolean + LANGUAGE plpgsql AS $$ BEGIN RETURN true; END; $$; +CREATE POLICY check_role_policy ON t.profiles FOR SELECT + USING (t.check_role(id, role)); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/seed-b.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/seed-b.sql new file mode 100644 index 000000000..df11ada74 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO t.profiles (id, role) VALUES ('11111111-1111-1111-1111-111111111111', 'admin'); diff --git a/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/seed.sql b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/seed.sql new file mode 100644 index 000000000..df11ada74 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change-referenced-by-policy/seed.sql @@ -0,0 +1 @@ +INSERT INTO t.profiles (id, role) VALUES ('11111111-1111-1111-1111-111111111111', 'admin'); diff --git a/packages/pg-delta/corpus/function-ops--signature-change/a.sql b/packages/pg-delta/corpus/function-ops--signature-change/a.sql new file mode 100644 index 000000000..41494407c --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.lookup(id integer) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS $function$SELECT id$function$; diff --git a/packages/pg-delta/corpus/function-ops--signature-change/b.sql b/packages/pg-delta/corpus/function-ops--signature-change/b.sql new file mode 100644 index 000000000..8da4f6cc5 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--signature-change/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +-- Return type changed from integer to text (requires DROP + CREATE) +CREATE FUNCTION test_schema.lookup(id integer) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $function$SELECT id::text$function$; diff --git a/packages/pg-delta/corpus/function-ops--simple-create/a.sql b/packages/pg-delta/corpus/function-ops--simple-create/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--simple-create/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/function-ops--simple-create/b.sql b/packages/pg-delta/corpus/function-ops--simple-create/b.sql new file mode 100644 index 000000000..254249a4a --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--simple-create/b.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.add_numbers(a integer, b integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE +AS $function$SELECT $1 + $2$function$; diff --git a/packages/pg-delta/corpus/function-ops--sql-body-cross-reference/a.sql b/packages/pg-delta/corpus/function-ops--sql-body-cross-reference/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--sql-body-cross-reference/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/function-ops--sql-body-cross-reference/b.sql b/packages/pg-delta/corpus/function-ops--sql-body-cross-reference/b.sql new file mode 100644 index 000000000..c091fd676 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--sql-body-cross-reference/b.sql @@ -0,0 +1,18 @@ +CREATE SCHEMA test_schema; + +-- z_helper_parse is created first so the b.sql FIXTURE applies cleanly under the +-- default check_function_bodies. The point of the scenario: a_wrapper's +-- string-literal SQL body calls z_helper_parse but records NO pg_depend edge, so +-- the engine cannot topologically order the two functions and must rely on its +-- unconditional check_function_bodies=off plan preamble to converge (a->b/b->a). +CREATE OR REPLACE FUNCTION test_schema.z_helper_parse(input text) + RETURNS text + LANGUAGE sql + IMMUTABLE +AS $function$SELECT upper(input)$function$; + +CREATE OR REPLACE FUNCTION test_schema.a_wrapper(input text) + RETURNS text + LANGUAGE sql + IMMUTABLE +AS $function$SELECT test_schema.z_helper_parse(input) || '!'$function$; diff --git a/packages/pg-delta/corpus/function-ops--window-function-view-dependency/a.sql b/packages/pg-delta/corpus/function-ops--window-function-view-dependency/a.sql new file mode 100644 index 000000000..3dae8d9f7 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--window-function-view-dependency/a.sql @@ -0,0 +1,22 @@ +CREATE SCHEMA test_schema; + +-- A user-defined WINDOW function (prokind 'w'). LANGUAGE internal WINDOW lets us +-- create one without a C compiler (superuser only) by aliasing a built-in. +CREATE FUNCTION test_schema.my_lag(x anyelement) RETURNS anyelement + LANGUAGE internal WINDOW AS 'window_lag'; + +CREATE TABLE test_schema.events ( + id int, + val int +); + +-- A view whose query uses the window function OVER (...). This records a +-- pg_depend edge from the view's _RETURN rule to the window function. When the +-- function is forced to drop+recreate (an arg-name change is a "replace" that +-- CREATE OR REPLACE cannot express), the view is otherwise UNCHANGED, so it can +-- only be rebuilt around the function via the view -> my_lag dependency edge. If +-- that edge is dropped (window functions excluded from the resolver), DROP +-- FUNCTION my_lag fails because the view still depends on it. +CREATE VIEW test_schema.lagged AS + SELECT id, test_schema.my_lag(val) OVER (ORDER BY id) AS prev_val + FROM test_schema.events; diff --git a/packages/pg-delta/corpus/function-ops--window-function-view-dependency/b.sql b/packages/pg-delta/corpus/function-ops--window-function-view-dependency/b.sql new file mode 100644 index 000000000..11e1bf853 --- /dev/null +++ b/packages/pg-delta/corpus/function-ops--window-function-view-dependency/b.sql @@ -0,0 +1,18 @@ +CREATE SCHEMA test_schema; + +-- Same window function and signature TYPES, but the parameter is renamed +-- (x -> y). CREATE OR REPLACE cannot rename a parameter, so the engine must +-- drop+recreate my_lag (a "replace"). The view below is byte-identical to side a +-- (the call does not name the parameter), so it is only rebuilt if the +-- view -> my_lag dependency edge exists. +CREATE FUNCTION test_schema.my_lag(y anyelement) RETURNS anyelement + LANGUAGE internal WINDOW AS 'window_lag'; + +CREATE TABLE test_schema.events ( + id int, + val int +); + +CREATE VIEW test_schema.lagged AS + SELECT id, test_schema.my_lag(val) OVER (ORDER BY id) AS prev_val + FROM test_schema.events; diff --git a/packages/pg-delta/corpus/function-with-grant/a.sql b/packages/pg-delta/corpus/function-with-grant/a.sql new file mode 100644 index 000000000..2efd98f30 --- /dev/null +++ b/packages/pg-delta/corpus/function-with-grant/a.sql @@ -0,0 +1 @@ +DO $$ BEGIN CREATE ROLE corpus_reader NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/packages/pg-delta/corpus/function-with-grant/b.sql b/packages/pg-delta/corpus/function-with-grant/b.sql new file mode 100644 index 000000000..b001b9b42 --- /dev/null +++ b/packages/pg-delta/corpus/function-with-grant/b.sql @@ -0,0 +1,5 @@ +DO $$ BEGIN CREATE ROLE corpus_reader NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE FUNCTION public.add(a integer, b integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS 'SELECT a + b'; +COMMENT ON FUNCTION public.add(integer, integer) IS 'adds two integers'; +GRANT EXECUTE ON FUNCTION public.add(integer, integer) TO corpus_reader; diff --git a/packages/pg-delta/corpus/identity-operations--alter-bounds/a.sql b/packages/pg-delta/corpus/identity-operations--alter-bounds/a.sql new file mode 100644 index 000000000..784003700 --- /dev/null +++ b/packages/pg-delta/corpus/identity-operations--alter-bounds/a.sql @@ -0,0 +1,4 @@ +CREATE TABLE public.counter ( + id bigint GENERATED BY DEFAULT AS IDENTITY (MINVALUE 1 MAXVALUE 50), + label text +); diff --git a/packages/pg-delta/corpus/identity-operations--alter-bounds/b.sql b/packages/pg-delta/corpus/identity-operations--alter-bounds/b.sql new file mode 100644 index 000000000..eebabdf04 --- /dev/null +++ b/packages/pg-delta/corpus/identity-operations--alter-bounds/b.sql @@ -0,0 +1,4 @@ +CREATE TABLE public.counter ( + id bigint GENERATED BY DEFAULT AS IDENTITY (MINVALUE 100 MAXVALUE 200), + label text +); diff --git a/packages/pg-delta/corpus/identity-operations--sequence-options/a.sql b/packages/pg-delta/corpus/identity-operations--sequence-options/a.sql new file mode 100644 index 000000000..a4d899ef8 --- /dev/null +++ b/packages/pg-delta/corpus/identity-operations--sequence-options/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA app; + +-- Default sequence parameters (START WITH 1, INCREMENT BY 1) — the bare-clause +-- baseline that the b-state alters in place. +CREATE TABLE app.altered ( + id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name text +); diff --git a/packages/pg-delta/corpus/identity-operations--sequence-options/b.sql b/packages/pg-delta/corpus/identity-operations--sequence-options/b.sql new file mode 100644 index 000000000..2094bf8d5 --- /dev/null +++ b/packages/pg-delta/corpus/identity-operations--sequence-options/b.sql @@ -0,0 +1,17 @@ +CREATE SCHEMA app; + +-- Same identity column, non-default parameters: the forward diff must emit +-- in-place ALTER TABLE … ALTER COLUMN … SET INCREMENT BY / START WITH / CACHE / +-- CYCLE. Before the options were extracted these hashed identically and the +-- diff was empty. +CREATE TABLE app.altered ( + id integer GENERATED ALWAYS AS IDENTITY (START WITH 500 INCREMENT BY 10 CACHE 20 CYCLE), + name text +); + +-- A freshly created identity column carrying non-default parameters, to +-- exercise the GENERATED … AS IDENTITY (…) render in the create path. +CREATE TABLE app.created ( + id bigint GENERATED BY DEFAULT AS IDENTITY (START WITH 1000 INCREMENT BY 7 MINVALUE 100 MAXVALUE 9999), + label text +); diff --git a/packages/pg-delta/corpus/index-extension-deps--basic/a.sql b/packages/pg-delta/corpus/index-extension-deps--basic/a.sql new file mode 100644 index 000000000..d7ab70977 --- /dev/null +++ b/packages/pg-delta/corpus/index-extension-deps--basic/a.sql @@ -0,0 +1 @@ +-- Empty source: engine must order CREATE EXTENSION before CREATE INDEX diff --git a/packages/pg-delta/corpus/index-extension-deps--basic/b.sql b/packages/pg-delta/corpus/index-extension-deps--basic/b.sql new file mode 100644 index 000000000..b2c1b77d7 --- /dev/null +++ b/packages/pg-delta/corpus/index-extension-deps--basic/b.sql @@ -0,0 +1,9 @@ +CREATE EXTENSION pg_trgm; + +CREATE TABLE public.documents ( + id integer, + content text +); + +CREATE INDEX idx_documents_content_trgm + ON public.documents USING gin (content gin_trgm_ops); diff --git a/packages/pg-delta/corpus/index-extension-deps--cross-schema/a.sql b/packages/pg-delta/corpus/index-extension-deps--cross-schema/a.sql new file mode 100644 index 000000000..8a9afc7ac --- /dev/null +++ b/packages/pg-delta/corpus/index-extension-deps--cross-schema/a.sql @@ -0,0 +1 @@ +-- Empty source: extension must be created before index in another schema diff --git a/packages/pg-delta/corpus/index-extension-deps--cross-schema/b.sql b/packages/pg-delta/corpus/index-extension-deps--cross-schema/b.sql new file mode 100644 index 000000000..ca7256bb9 --- /dev/null +++ b/packages/pg-delta/corpus/index-extension-deps--cross-schema/b.sql @@ -0,0 +1,11 @@ +CREATE EXTENSION pg_trgm WITH SCHEMA public; + +CREATE SCHEMA app; + +CREATE TABLE app.search_items ( + id integer, + name text +); + +CREATE INDEX idx_search_items_name_trgm + ON app.search_items USING gin (name public.gin_trgm_ops); diff --git a/packages/pg-delta/corpus/index-extension-deps--from-empty/a.sql b/packages/pg-delta/corpus/index-extension-deps--from-empty/a.sql new file mode 100644 index 000000000..58f8c09c9 --- /dev/null +++ b/packages/pg-delta/corpus/index-extension-deps--from-empty/a.sql @@ -0,0 +1 @@ +-- Explicitly empty: plan generated from null source (no objects at all) diff --git a/packages/pg-delta/corpus/index-extension-deps--from-empty/b.sql b/packages/pg-delta/corpus/index-extension-deps--from-empty/b.sql new file mode 100644 index 000000000..81278bf0e --- /dev/null +++ b/packages/pg-delta/corpus/index-extension-deps--from-empty/b.sql @@ -0,0 +1,5 @@ +CREATE EXTENSION pg_trgm; + +CREATE TABLE public.items (id integer, label text); + +CREATE INDEX idx_items_label_trgm ON public.items USING gin (label gin_trgm_ops); diff --git a/packages/pg-delta/corpus/index-operations--btree-and-multicolumn/a.sql b/packages/pg-delta/corpus/index-operations--btree-and-multicolumn/a.sql new file mode 100644 index 000000000..3d09e0827 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--btree-and-multicolumn/a.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + email character varying(255) +); + +CREATE TABLE test_schema.sales ( + id integer, + region character varying(50), + product_id integer, + sale_date date +); diff --git a/packages/pg-delta/corpus/index-operations--btree-and-multicolumn/b.sql b/packages/pg-delta/corpus/index-operations--btree-and-multicolumn/b.sql new file mode 100644 index 000000000..f83cc92ef --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--btree-and-multicolumn/b.sql @@ -0,0 +1,17 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + email character varying(255) +); + +CREATE INDEX idx_users_email ON test_schema.users USING btree (email); + +CREATE TABLE test_schema.sales ( + id integer, + region character varying(50), + product_id integer, + sale_date date +); + +CREATE INDEX idx_sales_region_date ON test_schema.sales USING btree (region, sale_date); diff --git a/packages/pg-delta/corpus/index-operations--comment/a.sql b/packages/pg-delta/corpus/index-operations--comment/a.sql new file mode 100644 index 000000000..95d9aff6d --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--comment/a.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.items (id integer, name text); + +CREATE INDEX idx_items_name ON test_schema.items (name); diff --git a/packages/pg-delta/corpus/index-operations--comment/b.sql b/packages/pg-delta/corpus/index-operations--comment/b.sql new file mode 100644 index 000000000..b6158996b --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--comment/b.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.items (id integer, name text); + +CREATE INDEX idx_items_name ON test_schema.items (name); + +COMMENT ON INDEX test_schema.idx_items_name IS 'items name index'; diff --git a/packages/pg-delta/corpus/index-operations--drop-table-cascades-index/a.sql b/packages/pg-delta/corpus/index-operations--drop-table-cascades-index/a.sql new file mode 100644 index 000000000..8790d615b --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--drop-table-cascades-index/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.test_table ( + id integer PRIMARY KEY, + name text +); + +CREATE INDEX test_table_name_index ON test_schema.test_table (name); diff --git a/packages/pg-delta/corpus/index-operations--drop-table-cascades-index/b.sql b/packages/pg-delta/corpus/index-operations--drop-table-cascades-index/b.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--drop-table-cascades-index/b.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/index-operations--drop/a.sql b/packages/pg-delta/corpus/index-operations--drop/a.sql new file mode 100644 index 000000000..129ac2a84 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--drop/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.items ( + id integer, + name character varying(100) +); + +CREATE INDEX idx_items_name ON test_schema.items (name); diff --git a/packages/pg-delta/corpus/index-operations--drop/b.sql b/packages/pg-delta/corpus/index-operations--drop/b.sql new file mode 100644 index 000000000..14d0d28e5 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--drop/b.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.items ( + id integer, + name character varying(100) +); diff --git a/packages/pg-delta/corpus/index-operations--functional/a.sql b/packages/pg-delta/corpus/index-operations--functional/a.sql new file mode 100644 index 000000000..83586b947 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--functional/a.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.customers ( + id integer, + email character varying(255) +); diff --git a/packages/pg-delta/corpus/index-operations--functional/b.sql b/packages/pg-delta/corpus/index-operations--functional/b.sql new file mode 100644 index 000000000..1e6a59478 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--functional/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.customers ( + id integer, + email character varying(255) +); + +CREATE INDEX idx_customers_email_lower ON test_schema.customers USING btree (lower(email::text)); diff --git a/packages/pg-delta/corpus/index-operations--partial/a.sql b/packages/pg-delta/corpus/index-operations--partial/a.sql new file mode 100644 index 000000000..02c74d9ef --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--partial/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.orders ( + id integer, + status character varying(20), + created_at timestamp +); diff --git a/packages/pg-delta/corpus/index-operations--partial/b.sql b/packages/pg-delta/corpus/index-operations--partial/b.sql new file mode 100644 index 000000000..b63833166 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--partial/b.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.orders ( + id integer, + status character varying(20), + created_at timestamp +); + +CREATE INDEX idx_orders_pending ON test_schema.orders USING btree (created_at) WHERE status::text = 'pending'::text; diff --git a/packages/pg-delta/corpus/index-operations--standalone-unique-referenced-by-fk/a.sql b/packages/pg-delta/corpus/index-operations--standalone-unique-referenced-by-fk/a.sql new file mode 100644 index 000000000..593ed4e68 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--standalone-unique-referenced-by-fk/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA s; diff --git a/packages/pg-delta/corpus/index-operations--standalone-unique-referenced-by-fk/b.sql b/packages/pg-delta/corpus/index-operations--standalone-unique-referenced-by-fk/b.sql new file mode 100644 index 000000000..5ea0ad67d --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--standalone-unique-referenced-by-fk/b.sql @@ -0,0 +1,20 @@ +CREATE SCHEMA s; + +CREATE TABLE s.tenants ( + id bigint PRIMARY KEY, + external_id text NOT NULL +); + +-- A STANDALONE unique index (not a UNIQUE constraint). An FK below references it. +-- pg_constraint.conindid on the FK points at this index, so a filter that excludes +-- "any index referenced by a constraint's conindid" wrongly drops it from extraction. +CREATE UNIQUE INDEX tenants_external_id_index ON s.tenants (external_id); + +CREATE TABLE s.extensions ( + id bigint PRIMARY KEY, + tenant_external_id text +); + +ALTER TABLE s.extensions + ADD CONSTRAINT extensions_tenant_external_id_fkey + FOREIGN KEY (tenant_external_id) REFERENCES s.tenants (external_id) ON DELETE CASCADE; diff --git a/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/a.sql b/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/a.sql new file mode 100644 index 000000000..6d31fe00c --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.accounts ( + id integer, + email character varying(255) +); + +CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts USING btree (email); diff --git a/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/b.sql b/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/b.sql new file mode 100644 index 000000000..4bd39d167 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.accounts ( + id integer, + email character varying(255) +); + +CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts USING btree (email) NULLS NOT DISTINCT; diff --git a/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/meta.json b/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/index-operations--unique-nulls-not-distinct/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/index/a.sql b/packages/pg-delta/corpus/index/a.sql new file mode 100644 index 000000000..1efbeeb98 --- /dev/null +++ b/packages/pg-delta/corpus/index/a.sql @@ -0,0 +1 @@ +CREATE TABLE public.orders (id bigint PRIMARY KEY, user_id integer NOT NULL, created_at timestamptz); diff --git a/packages/pg-delta/corpus/index/b.sql b/packages/pg-delta/corpus/index/b.sql new file mode 100644 index 000000000..d5dbbbd85 --- /dev/null +++ b/packages/pg-delta/corpus/index/b.sql @@ -0,0 +1,3 @@ +CREATE TABLE public.orders (id bigint PRIMARY KEY, user_id integer NOT NULL, created_at timestamptz); +CREATE INDEX orders_user_idx ON public.orders (user_id); +CREATE UNIQUE INDEX orders_created_key ON public.orders (created_at) WHERE created_at IS NOT NULL; diff --git a/packages/pg-delta/corpus/index/seed-b.sql b/packages/pg-delta/corpus/index/seed-b.sql new file mode 100644 index 000000000..bdef21f51 --- /dev/null +++ b/packages/pg-delta/corpus/index/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.orders (id, user_id, created_at) VALUES (1, 100, '2024-01-01T00:00:00Z'); diff --git a/packages/pg-delta/corpus/index/seed.sql b/packages/pg-delta/corpus/index/seed.sql new file mode 100644 index 000000000..bdef21f51 --- /dev/null +++ b/packages/pg-delta/corpus/index/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.orders (id, user_id, created_at) VALUES (1, 100, '2024-01-01T00:00:00Z'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--comment/a.sql b/packages/pg-delta/corpus/materialized-view-operations--comment/a.sql new file mode 100644 index 000000000..a345034c5 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--comment/a.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text +); + +CREATE MATERIALIZED VIEW test_schema.user_names AS + SELECT id, name FROM test_schema.users WITH NO DATA; diff --git a/packages/pg-delta/corpus/materialized-view-operations--comment/b.sql b/packages/pg-delta/corpus/materialized-view-operations--comment/b.sql new file mode 100644 index 000000000..39d11714e --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--comment/b.sql @@ -0,0 +1,11 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text +); + +CREATE MATERIALIZED VIEW test_schema.user_names AS + SELECT id, name FROM test_schema.users WITH NO DATA; + +COMMENT ON MATERIALIZED VIEW test_schema.user_names IS 'user names matview'; diff --git a/packages/pg-delta/corpus/materialized-view-operations--comment/seed-b.sql b/packages/pg-delta/corpus/materialized-view-operations--comment/seed-b.sql new file mode 100644 index 000000000..3ad62817c --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--comment/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name) VALUES (1, 'Alice'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--comment/seed.sql b/packages/pg-delta/corpus/materialized-view-operations--comment/seed.sql new file mode 100644 index 000000000..3ad62817c --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--comment/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name) VALUES (1, 'Alice'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--create/a.sql b/packages/pg-delta/corpus/materialized-view-operations--create/a.sql new file mode 100644 index 000000000..82ba39afa --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--create/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL, + email text, + active boolean DEFAULT true +); diff --git a/packages/pg-delta/corpus/materialized-view-operations--create/b.sql b/packages/pg-delta/corpus/materialized-view-operations--create/b.sql new file mode 100644 index 000000000..5df883e40 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--create/b.sql @@ -0,0 +1,14 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL, + email text, + active boolean DEFAULT true +); + +CREATE MATERIALIZED VIEW test_schema.active_users AS +SELECT id, name, email +FROM test_schema.users +WHERE active = true +WITH NO DATA; diff --git a/packages/pg-delta/corpus/materialized-view-operations--create/seed-b.sql b/packages/pg-delta/corpus/materialized-view-operations--create/seed-b.sql new file mode 100644 index 000000000..9e76cfcfd --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--create/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--create/seed.sql b/packages/pg-delta/corpus/materialized-view-operations--create/seed.sql new file mode 100644 index 000000000..9e76cfcfd --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--create/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--drop/a.sql b/packages/pg-delta/corpus/materialized-view-operations--drop/a.sql new file mode 100644 index 000000000..2390ecbc7 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--drop/a.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL, + active boolean DEFAULT true +); + +CREATE MATERIALIZED VIEW test_schema.active_users AS +SELECT id, name +FROM test_schema.users +WHERE active = true +WITH NO DATA; diff --git a/packages/pg-delta/corpus/materialized-view-operations--drop/b.sql b/packages/pg-delta/corpus/materialized-view-operations--drop/b.sql new file mode 100644 index 000000000..95a940828 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--drop/b.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL, + active boolean DEFAULT true +); diff --git a/packages/pg-delta/corpus/materialized-view-operations--drop/seed-b.sql b/packages/pg-delta/corpus/materialized-view-operations--drop/seed-b.sql new file mode 100644 index 000000000..3ad62817c --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--drop/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name) VALUES (1, 'Alice'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--drop/seed.sql b/packages/pg-delta/corpus/materialized-view-operations--drop/seed.sql new file mode 100644 index 000000000..3ad62817c --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--drop/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name) VALUES (1, 'Alice'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--joins/a.sql b/packages/pg-delta/corpus/materialized-view-operations--joins/a.sql new file mode 100644 index 000000000..b77a17919 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--joins/a.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA ecommerce; + +CREATE TABLE ecommerce.customers ( + id integer PRIMARY KEY, + name text NOT NULL +); + +CREATE TABLE ecommerce.orders ( + id integer PRIMARY KEY, + customer_id integer, + total decimal(10,2) +); diff --git a/packages/pg-delta/corpus/materialized-view-operations--joins/b.sql b/packages/pg-delta/corpus/materialized-view-operations--joins/b.sql new file mode 100644 index 000000000..1a8f7f747 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--joins/b.sql @@ -0,0 +1,23 @@ +CREATE SCHEMA ecommerce; + +CREATE TABLE ecommerce.customers ( + id integer PRIMARY KEY, + name text NOT NULL +); + +CREATE TABLE ecommerce.orders ( + id integer PRIMARY KEY, + customer_id integer, + total decimal(10,2) +); + +CREATE MATERIALIZED VIEW ecommerce.customer_orders AS +SELECT + c.id as customer_id, + c.name, + COUNT(o.id) as order_count, + COALESCE(SUM(o.total), 0) as total_spent +FROM ecommerce.customers c +LEFT JOIN ecommerce.orders o ON c.id = o.customer_id +GROUP BY c.id, c.name +WITH NO DATA; diff --git a/packages/pg-delta/corpus/materialized-view-operations--joins/seed-b.sql b/packages/pg-delta/corpus/materialized-view-operations--joins/seed-b.sql new file mode 100644 index 000000000..e905180d0 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--joins/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO ecommerce.customers (id, name) VALUES (1, 'Alice'); +INSERT INTO ecommerce.orders (id, customer_id, total) VALUES (1, 1, 10.00); diff --git a/packages/pg-delta/corpus/materialized-view-operations--joins/seed.sql b/packages/pg-delta/corpus/materialized-view-operations--joins/seed.sql new file mode 100644 index 000000000..e905180d0 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--joins/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO ecommerce.customers (id, name) VALUES (1, 'Alice'); +INSERT INTO ecommerce.orders (id, customer_id, total) VALUES (1, 1, 10.00); diff --git a/packages/pg-delta/corpus/materialized-view-operations--replace-definition/a.sql b/packages/pg-delta/corpus/materialized-view-operations--replace-definition/a.sql new file mode 100644 index 000000000..f4b8264f4 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--replace-definition/a.sql @@ -0,0 +1,14 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL, + email text, + active boolean DEFAULT true +); + +CREATE MATERIALIZED VIEW test_schema.user_summary AS +SELECT id, name +FROM test_schema.users +WHERE active = true +WITH NO DATA; diff --git a/packages/pg-delta/corpus/materialized-view-operations--replace-definition/b.sql b/packages/pg-delta/corpus/materialized-view-operations--replace-definition/b.sql new file mode 100644 index 000000000..b9dcc1016 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--replace-definition/b.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL, + email text, + active boolean DEFAULT true +); + +CREATE MATERIALIZED VIEW test_schema.user_summary AS +SELECT id, name, email +FROM test_schema.users +WHERE active = true +ORDER BY name +WITH NO DATA; diff --git a/packages/pg-delta/corpus/materialized-view-operations--replace-definition/seed-b.sql b/packages/pg-delta/corpus/materialized-view-operations--replace-definition/seed-b.sql new file mode 100644 index 000000000..9e76cfcfd --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--replace-definition/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--replace-definition/seed.sql b/packages/pg-delta/corpus/materialized-view-operations--replace-definition/seed.sql new file mode 100644 index 000000000..9e76cfcfd --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--replace-definition/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'); diff --git a/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/a.sql b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/a.sql new file mode 100644 index 000000000..e738460c5 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/a.sql @@ -0,0 +1,18 @@ +DO $$ BEGIN CREATE ROLE corpus_matview_reader NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + age numeric +); + +CREATE MATERIALIZED VIEW test_schema.user_ages AS + SELECT id, age + FROM test_schema.users + WHERE age > 0 + WITH NO DATA; + +COMMENT ON MATERIALIZED VIEW test_schema.user_ages IS 'user ages matview'; + +GRANT SELECT ON test_schema.user_ages TO corpus_matview_reader; diff --git a/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/b.sql b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/b.sql new file mode 100644 index 000000000..78192edd0 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/b.sql @@ -0,0 +1,18 @@ +DO $$ BEGIN CREATE ROLE corpus_matview_reader NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + age integer +); + +CREATE MATERIALIZED VIEW test_schema.user_ages AS + SELECT id, age + FROM test_schema.users + WHERE age > 0 + WITH NO DATA; + +COMMENT ON MATERIALIZED VIEW test_schema.user_ages IS 'user ages matview'; + +GRANT SELECT ON test_schema.user_ages TO corpus_matview_reader; diff --git a/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/meta.json b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/seed-b.sql b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/seed-b.sql new file mode 100644 index 000000000..8fd4cadb4 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, age) VALUES (1, 30); diff --git a/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/seed.sql b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/seed.sql new file mode 100644 index 000000000..8fd4cadb4 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--restore-metadata-on-replace/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, age) VALUES (1, 30); diff --git a/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/a.sql b/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/a.sql new file mode 100644 index 000000000..122ca8c69 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/a.sql @@ -0,0 +1,20 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.orders ( + id serial PRIMARY KEY, + customer text NOT NULL, + total numeric NOT NULL, + created_at timestamptz DEFAULT now() +); + +CREATE MATERIALIZED VIEW test_schema.order_summary AS + SELECT customer, sum(total) AS total_spent, count(*) AS order_count + FROM test_schema.orders + GROUP BY customer; + +CREATE UNIQUE INDEX order_summary_customer_idx + ON test_schema.order_summary (customer); + +CREATE VIEW test_schema.top_customers AS + SELECT * FROM test_schema.order_summary + WHERE total_spent > 1000; diff --git a/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/b.sql b/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/b.sql new file mode 100644 index 000000000..fde992ef0 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/b.sql @@ -0,0 +1,23 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.orders ( + id serial PRIMARY KEY, + customer text NOT NULL, + total numeric NOT NULL, + created_at timestamptz DEFAULT now() +); + +CREATE MATERIALIZED VIEW test_schema.order_summary AS + SELECT customer, + sum(total) AS total_spent, + count(*) AS order_count, + max(created_at) AS last_order + FROM test_schema.orders + GROUP BY customer; + +CREATE UNIQUE INDEX order_summary_customer_idx + ON test_schema.order_summary (customer); + +CREATE VIEW test_schema.top_customers AS + SELECT * FROM test_schema.order_summary + WHERE total_spent > 1000; diff --git a/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/seed-b.sql b/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/seed-b.sql new file mode 100644 index 000000000..62dd13ee6 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.orders (customer, total) VALUES ('Alice', 1500.00); diff --git a/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/seed.sql b/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/seed.sql new file mode 100644 index 000000000..62dd13ee6 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--with-dependent-index-and-view/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.orders (customer, total) VALUES ('Alice', 1500.00); diff --git a/packages/pg-delta/corpus/materialized-view-operations--with-domain-dependency/a.sql b/packages/pg-delta/corpus/materialized-view-operations--with-domain-dependency/a.sql new file mode 100644 index 000000000..baa4b4491 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--with-domain-dependency/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA financial; diff --git a/packages/pg-delta/corpus/materialized-view-operations--with-domain-dependency/b.sql b/packages/pg-delta/corpus/materialized-view-operations--with-domain-dependency/b.sql new file mode 100644 index 000000000..bf4f4b459 --- /dev/null +++ b/packages/pg-delta/corpus/materialized-view-operations--with-domain-dependency/b.sql @@ -0,0 +1,16 @@ +CREATE SCHEMA financial; + +CREATE DOMAIN financial.currency AS DECIMAL(10,2) CHECK (VALUE >= 0); + +CREATE TABLE financial.transactions ( + id INTEGER PRIMARY KEY, + amount financial.currency NOT NULL, + description TEXT +); + +CREATE MATERIALIZED VIEW financial.transaction_summary AS +SELECT + SUM(amount) as total_amount, + COUNT(*) as transaction_count +FROM financial.transactions +WHERE amount > 0; diff --git a/packages/pg-delta/corpus/mixed-objects--complex-column-types/a.sql b/packages/pg-delta/corpus/mixed-objects--complex-column-types/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--complex-column-types/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/mixed-objects--complex-column-types/b.sql b/packages/pg-delta/corpus/mixed-objects--complex-column-types/b.sql new file mode 100644 index 000000000..ab063c649 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--complex-column-types/b.sql @@ -0,0 +1,11 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.complex_table ( + id uuid, + metadata jsonb, + tags text[], + coordinates point, + price numeric(10,2), + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT now() +); diff --git a/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/a.sql b/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/a.sql new file mode 100644 index 000000000..01808f952 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/a.sql @@ -0,0 +1,7 @@ +-- "mgmt" stands in for a managed schema (auth/storage): present on both +-- sides, unchanged, so it never enters the diff — but user objects in "app" +-- reference it, and the plan applies to a target that already has it. +CREATE SCHEMA mgmt; +CREATE FUNCTION mgmt.is_admin() RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT true $$; +CREATE SCHEMA app; +CREATE TABLE app.docs (id integer PRIMARY KEY, body text); diff --git a/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/b.sql b/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/b.sql new file mode 100644 index 000000000..135f7d3f1 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA mgmt; +CREATE FUNCTION mgmt.is_admin() RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT true $$; +CREATE SCHEMA app; +CREATE TABLE app.docs (id integer PRIMARY KEY, body text); +-- a policy AND a view, both referencing the unchanged managed function: +-- the old engine got stuck here (managed schema excluded from the apply +-- set); the new engine plans against a target that has mgmt.is_admin(). +ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY; +CREATE POLICY docs_admin ON app.docs FOR ALL USING (mgmt.is_admin()); +CREATE VIEW app.admin_docs AS SELECT id FROM app.docs WHERE mgmt.is_admin(); diff --git a/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/seed-b.sql b/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/seed-b.sql new file mode 100644 index 000000000..3440d870f --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO app.docs (id, body) VALUES (1, 'hello world'); diff --git a/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/seed.sql b/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/seed.sql new file mode 100644 index 000000000..3440d870f --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--cross-schema-reference/seed.sql @@ -0,0 +1 @@ +INSERT INTO app.docs (id, body) VALUES (1, 'hello world'); diff --git a/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/a.sql b/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/a.sql new file mode 100644 index 000000000..1ba90bc8a --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/a.sql @@ -0,0 +1,30 @@ +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.order_status AS ENUM ('pending', 'processing', 'shipped'); + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + status test_schema.order_status DEFAULT 'pending', + customer_id integer, + total_amount numeric(10,2) +); + +CREATE TABLE test_schema.order_history ( + id integer PRIMARY KEY, + order_id integer, + old_status test_schema.order_status, + new_status test_schema.order_status, + changed_at timestamp DEFAULT now() +); + +CREATE OR REPLACE FUNCTION test_schema.get_orders_by_status(status_filter test_schema.order_status) + RETURNS TABLE(order_id integer, customer_id integer, total_amount numeric) + LANGUAGE plpgsql +AS $function$ +begin + return query + select o.id, o.customer_id, o.total_amount + from test_schema.orders o + where o.status = status_filter; +end; +$function$; diff --git a/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/b.sql b/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/b.sql new file mode 100644 index 000000000..aa8e6158e --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/b.sql @@ -0,0 +1,31 @@ +CREATE SCHEMA test_schema; + +-- Enum with additional values (delivered, cancelled, returned added) +CREATE TYPE test_schema.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled', 'returned'); + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + status test_schema.order_status DEFAULT 'pending', + customer_id integer, + total_amount numeric(10,2) +); + +CREATE TABLE test_schema.order_history ( + id integer PRIMARY KEY, + order_id integer, + old_status test_schema.order_status, + new_status test_schema.order_status, + changed_at timestamp DEFAULT now() +); + +CREATE OR REPLACE FUNCTION test_schema.get_orders_by_status(status_filter test_schema.order_status) + RETURNS TABLE(order_id integer, customer_id integer, total_amount numeric) + LANGUAGE plpgsql +AS $function$ +begin + return query + select o.id, o.customer_id, o.total_amount + from test_schema.orders o + where o.status = status_filter; +end; +$function$; diff --git a/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/seed-b.sql b/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/seed-b.sql new file mode 100644 index 000000000..b1d9f8787 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.orders (id, status, customer_id, total_amount) VALUES (1, 'pending', 1, 100.00); +INSERT INTO test_schema.order_history (id, order_id, old_status, new_status) VALUES (1, 1, 'pending', 'processing'); diff --git a/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/seed.sql b/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/seed.sql new file mode 100644 index 000000000..b1d9f8787 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--enum-add-value-with-functions/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.orders (id, status, customer_id, total_amount) VALUES (1, 'pending', 1, 100.00); +INSERT INTO test_schema.order_history (id, order_id, old_status, new_status) VALUES (1, 1, 'pending', 'processing'); diff --git a/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/a.sql b/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/a.sql new file mode 100644 index 000000000..5981f0cab --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/a.sql @@ -0,0 +1,26 @@ +CREATE SCHEMA test_schema; + +-- Enum with 6 values +CREATE TYPE test_schema.priority AS ENUM ('low', 'medium', 'high', 'critical', 'urgent', 'blocked'); + +CREATE TABLE test_schema.tasks ( + id integer PRIMARY KEY, + title text, + priority test_schema.priority DEFAULT 'medium', + assigned_to text, + created_at timestamp DEFAULT now() +); + +CREATE TABLE test_schema.task_history ( + id integer PRIMARY KEY, + task_id integer, + old_priority test_schema.priority, + new_priority test_schema.priority, + changed_at timestamp DEFAULT now() +); + +-- View filtering on specific enum values +CREATE VIEW test_schema.high_priority_tasks AS + SELECT id, title, assigned_to, created_at + FROM test_schema.tasks + WHERE priority IN ('high', 'critical', 'urgent'); diff --git a/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/b.sql b/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/b.sql new file mode 100644 index 000000000..1a3f35d48 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/b.sql @@ -0,0 +1,26 @@ +CREATE SCHEMA test_schema; + +-- Enum with 4 values (urgent and blocked removed) +CREATE TYPE test_schema.priority AS ENUM ('low', 'medium', 'high', 'critical'); + +CREATE TABLE test_schema.tasks ( + id integer PRIMARY KEY, + title text, + priority test_schema.priority DEFAULT 'medium', + assigned_to text, + created_at timestamp DEFAULT now() +); + +CREATE TABLE test_schema.task_history ( + id integer PRIMARY KEY, + task_id integer, + old_priority test_schema.priority, + new_priority test_schema.priority, + changed_at timestamp DEFAULT now() +); + +-- View updated to reflect reduced enum values +CREATE VIEW test_schema.high_priority_tasks AS + SELECT id, title, assigned_to, created_at + FROM test_schema.tasks + WHERE priority IN ('high', 'critical'); diff --git a/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/seed-b.sql b/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/seed-b.sql new file mode 100644 index 000000000..d0eb5159d --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.tasks (id, title, priority, assigned_to) VALUES (1, 'Fix bug', 'high', 'Alice'); +INSERT INTO test_schema.task_history (id, task_id, old_priority, new_priority) VALUES (1, 1, 'high', 'critical'); diff --git a/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/seed.sql b/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/seed.sql new file mode 100644 index 000000000..ceb060f16 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--enum-replace-with-dependents/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.tasks (id, title, priority, assigned_to) VALUES (1, 'Fix bug', 'high', 'Alice'); +INSERT INTO test_schema.task_history (id, task_id, old_priority, new_priority) VALUES (1, 1, 'medium', 'high'); diff --git a/packages/pg-delta/corpus/mixed-objects--multi-schema-drop/a.sql b/packages/pg-delta/corpus/mixed-objects--multi-schema-drop/a.sql new file mode 100644 index 000000000..3d2636824 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--multi-schema-drop/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA core; +CREATE SCHEMA analytics; +CREATE SCHEMA reporting; + +CREATE TABLE core.users (id integer); +CREATE TABLE analytics.events (id integer); +CREATE TABLE reporting.summary (id integer); diff --git a/packages/pg-delta/corpus/mixed-objects--multi-schema-drop/b.sql b/packages/pg-delta/corpus/mixed-objects--multi-schema-drop/b.sql new file mode 100644 index 000000000..0c9068049 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--multi-schema-drop/b.sql @@ -0,0 +1 @@ +-- empty ending state: all schemas and tables dropped diff --git a/packages/pg-delta/corpus/mixed-objects--schema-and-table/a.sql b/packages/pg-delta/corpus/mixed-objects--schema-and-table/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--schema-and-table/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/mixed-objects--schema-and-table/b.sql b/packages/pg-delta/corpus/mixed-objects--schema-and-table/b.sql new file mode 100644 index 000000000..cfd486155 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--schema-and-table/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text NOT NULL, + email text, + created_at timestamp DEFAULT now() +); diff --git a/packages/pg-delta/corpus/mixed-objects--view-chain-dependency/a.sql b/packages/pg-delta/corpus/mixed-objects--view-chain-dependency/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--view-chain-dependency/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/mixed-objects--view-chain-dependency/b.sql b/packages/pg-delta/corpus/mixed-objects--view-chain-dependency/b.sql new file mode 100644 index 000000000..db49995c6 --- /dev/null +++ b/packages/pg-delta/corpus/mixed-objects--view-chain-dependency/b.sql @@ -0,0 +1,24 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text +); + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + user_id integer, + amount numeric +); + +-- View depending on both tables +CREATE VIEW test_schema.user_orders AS + SELECT u.id, u.name, SUM(o.amount) AS total + FROM test_schema.users u + LEFT JOIN test_schema.orders o ON u.id = o.user_id + GROUP BY u.id, u.name; + +-- View depending on the first view +CREATE VIEW test_schema.top_users AS + SELECT * FROM test_schema.user_orders + WHERE total > 1000; diff --git a/packages/pg-delta/corpus/not-valid--create-not-valid/a.sql b/packages/pg-delta/corpus/not-valid--create-not-valid/a.sql new file mode 100644 index 000000000..b7a3adddf --- /dev/null +++ b/packages/pg-delta/corpus/not-valid--create-not-valid/a.sql @@ -0,0 +1,7 @@ +-- table exists with no constraint +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.messages ( + payload jsonb, + binary_payload bytea +); diff --git a/packages/pg-delta/corpus/not-valid--create-not-valid/b.sql b/packages/pg-delta/corpus/not-valid--create-not-valid/b.sql new file mode 100644 index 000000000..350df3fa9 --- /dev/null +++ b/packages/pg-delta/corpus/not-valid--create-not-valid/b.sql @@ -0,0 +1,9 @@ +-- CHECK constraint added with NOT VALID (should not trigger a VALIDATE CONSTRAINT step) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.messages ( + payload jsonb, + binary_payload bytea, + CONSTRAINT messages_payload_exclusive + CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID +); diff --git a/packages/pg-delta/corpus/not-valid--fk-validate-drift/a.sql b/packages/pg-delta/corpus/not-valid--fk-validate-drift/a.sql new file mode 100644 index 000000000..505988755 --- /dev/null +++ b/packages/pg-delta/corpus/not-valid--fk-validate-drift/a.sql @@ -0,0 +1,15 @@ +-- FK constraint exists as NOT VALID (not yet validated) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY +); + +CREATE TABLE test_schema.items ( + id integer PRIMARY KEY, + order_id integer +); + +ALTER TABLE test_schema.items + ADD CONSTRAINT items_order_id_fkey + FOREIGN KEY (order_id) REFERENCES test_schema.orders (id) NOT VALID; diff --git a/packages/pg-delta/corpus/not-valid--fk-validate-drift/b.sql b/packages/pg-delta/corpus/not-valid--fk-validate-drift/b.sql new file mode 100644 index 000000000..7cb97d973 --- /dev/null +++ b/packages/pg-delta/corpus/not-valid--fk-validate-drift/b.sql @@ -0,0 +1,17 @@ +-- same FK constraint is now validated (convalidated = true); convergence +-- should validate it (and the reverse direction, b -> a, exercises +-- validated -> NOT VALID) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY +); + +CREATE TABLE test_schema.items ( + id integer PRIMARY KEY, + order_id integer +); + +ALTER TABLE test_schema.items + ADD CONSTRAINT items_order_id_fkey + FOREIGN KEY (order_id) REFERENCES test_schema.orders (id); diff --git a/packages/pg-delta/corpus/not-valid--fk-validate-drift/seed-b.sql b/packages/pg-delta/corpus/not-valid--fk-validate-drift/seed-b.sql new file mode 100644 index 000000000..8a13b22f8 --- /dev/null +++ b/packages/pg-delta/corpus/not-valid--fk-validate-drift/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.orders (id) VALUES (1); +INSERT INTO test_schema.items (id, order_id) VALUES (1, 1); diff --git a/packages/pg-delta/corpus/not-valid--fk-validate-drift/seed.sql b/packages/pg-delta/corpus/not-valid--fk-validate-drift/seed.sql new file mode 100644 index 000000000..8a13b22f8 --- /dev/null +++ b/packages/pg-delta/corpus/not-valid--fk-validate-drift/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.orders (id) VALUES (1); +INSERT INTO test_schema.items (id, order_id) VALUES (1, 1); diff --git a/packages/pg-delta/corpus/not-valid--validate-drift/a.sql b/packages/pg-delta/corpus/not-valid--validate-drift/a.sql new file mode 100644 index 000000000..9aecafbc2 --- /dev/null +++ b/packages/pg-delta/corpus/not-valid--validate-drift/a.sql @@ -0,0 +1,11 @@ +-- constraint exists as NOT VALID (not yet validated) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.messages ( + payload jsonb, + binary_payload bytea +); + +ALTER TABLE test_schema.messages + ADD CONSTRAINT messages_payload_exclusive + CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID; diff --git a/packages/pg-delta/corpus/not-valid--validate-drift/b.sql b/packages/pg-delta/corpus/not-valid--validate-drift/b.sql new file mode 100644 index 000000000..861b7a9da --- /dev/null +++ b/packages/pg-delta/corpus/not-valid--validate-drift/b.sql @@ -0,0 +1,11 @@ +-- same constraint is now validated (convalidated = true); convergence should use VALIDATE CONSTRAINT +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.messages ( + payload jsonb, + binary_payload bytea +); + +ALTER TABLE test_schema.messages + ADD CONSTRAINT messages_payload_exclusive + CHECK (payload IS NULL OR binary_payload IS NULL); diff --git a/packages/pg-delta/corpus/ordering-validation--fk-constraint-ordering/a.sql b/packages/pg-delta/corpus/ordering-validation--fk-constraint-ordering/a.sql new file mode 100644 index 000000000..6daf7cd76 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--fk-constraint-ordering/a.sql @@ -0,0 +1,2 @@ +-- state A: schema exists, no tables +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/ordering-validation--fk-constraint-ordering/b.sql b/packages/pg-delta/corpus/ordering-validation--fk-constraint-ordering/b.sql new file mode 100644 index 000000000..192916620 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--fk-constraint-ordering/b.sql @@ -0,0 +1,14 @@ +-- state B: orders table with FK to customers; both tables new +-- FK constraint requires customers to exist before orders FK is added +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.customers ( + id integer PRIMARY KEY, + name text NOT NULL +); +CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + customer_id integer, + order_date date, + CONSTRAINT orders_customer_fkey FOREIGN KEY (customer_id) + REFERENCES test_schema.customers (id) +); diff --git a/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/a.sql b/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/a.sql new file mode 100644 index 000000000..8832054e7 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/a.sql @@ -0,0 +1,3 @@ +-- state A: schemas exist, no roles, no tables +CREATE SCHEMA app_schema; +CREATE SCHEMA analytics_schema; diff --git a/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/b.sql b/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/b.sql new file mode 100644 index 000000000..0df539401 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/b.sql @@ -0,0 +1,21 @@ +-- state B: multiple roles, multiple tables across schemas, each table owned by different role +CREATE ROLE app_admin LOGIN; +CREATE ROLE analytics_user LOGIN; +CREATE SCHEMA app_schema; +CREATE SCHEMA analytics_schema; +CREATE TABLE app_schema.users ( + id integer PRIMARY KEY, + email text UNIQUE +); +CREATE TABLE app_schema.orders ( + id integer PRIMARY KEY, + user_id integer, + amount decimal +); +CREATE TABLE analytics_schema.reports ( + id integer PRIMARY KEY, + data jsonb +); +ALTER TABLE app_schema.users OWNER TO app_admin; +ALTER TABLE app_schema.orders OWNER TO app_admin; +ALTER TABLE analytics_schema.reports OWNER TO analytics_user; diff --git a/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/meta.json b/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--multi-table-multi-role-owners/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/ordering-validation--schema-owner-change/a.sql b/packages/pg-delta/corpus/ordering-validation--schema-owner-change/a.sql new file mode 100644 index 000000000..d7f2ccd29 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--schema-owner-change/a.sql @@ -0,0 +1 @@ +-- state A: empty diff --git a/packages/pg-delta/corpus/ordering-validation--schema-owner-change/b.sql b/packages/pg-delta/corpus/ordering-validation--schema-owner-change/b.sql new file mode 100644 index 000000000..c80aca250 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--schema-owner-change/b.sql @@ -0,0 +1,9 @@ +-- state B: new role, new schema owned by that role, table in that schema +-- CREATE ROLE must be ordered before ALTER SCHEMA OWNER TO +CREATE ROLE schema_owner LOGIN; +CREATE SCHEMA test_schema; +ALTER SCHEMA test_schema OWNER TO schema_owner; +CREATE TABLE test_schema.data ( + id integer PRIMARY KEY, + value text +); diff --git a/packages/pg-delta/corpus/ordering-validation--schema-owner-change/meta.json b/packages/pg-delta/corpus/ordering-validation--schema-owner-change/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--schema-owner-change/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/ordering-validation--table-owner-change/a.sql b/packages/pg-delta/corpus/ordering-validation--table-owner-change/a.sql new file mode 100644 index 000000000..2c5036305 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--table-owner-change/a.sql @@ -0,0 +1,6 @@ +-- state A: table exists owned by default (test superuser), no app_user role +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text +); diff --git a/packages/pg-delta/corpus/ordering-validation--table-owner-change/b.sql b/packages/pg-delta/corpus/ordering-validation--table-owner-change/b.sql new file mode 100644 index 000000000..2605fb163 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--table-owner-change/b.sql @@ -0,0 +1,9 @@ +-- state B: new role app_user created and table ownership transferred +-- CREATE ROLE must be ordered before ALTER TABLE OWNER TO +CREATE ROLE app_user LOGIN; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text +); +ALTER TABLE test_schema.users OWNER TO app_user; diff --git a/packages/pg-delta/corpus/ordering-validation--table-owner-change/meta.json b/packages/pg-delta/corpus/ordering-validation--table-owner-change/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--table-owner-change/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/ordering-validation--table-owner-change/seed-b.sql b/packages/pg-delta/corpus/ordering-validation--table-owner-change/seed-b.sql new file mode 100644 index 000000000..3ad62817c --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--table-owner-change/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name) VALUES (1, 'Alice'); diff --git a/packages/pg-delta/corpus/ordering-validation--table-owner-change/seed.sql b/packages/pg-delta/corpus/ordering-validation--table-owner-change/seed.sql new file mode 100644 index 000000000..3ad62817c --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--table-owner-change/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, name) VALUES (1, 'Alice'); diff --git a/packages/pg-delta/corpus/ordering-validation--type-owner-change/a.sql b/packages/pg-delta/corpus/ordering-validation--type-owner-change/a.sql new file mode 100644 index 000000000..db9f54e99 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--type-owner-change/a.sql @@ -0,0 +1,2 @@ +-- state A: schema exists, no role, no type +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/ordering-validation--type-owner-change/b.sql b/packages/pg-delta/corpus/ordering-validation--type-owner-change/b.sql new file mode 100644 index 000000000..578a12130 --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--type-owner-change/b.sql @@ -0,0 +1,10 @@ +-- state B: new role, enum type owned by that role, table using the type +-- CREATE ROLE must be ordered before ALTER TYPE OWNER TO +CREATE ROLE type_owner LOGIN; +CREATE SCHEMA test_schema; +CREATE TYPE test_schema.status_enum AS ENUM ('active', 'inactive', 'pending'); +ALTER TYPE test_schema.status_enum OWNER TO type_owner; +CREATE TABLE test_schema.items ( + id integer PRIMARY KEY, + status test_schema.status_enum DEFAULT 'pending' +); diff --git a/packages/pg-delta/corpus/ordering-validation--type-owner-change/meta.json b/packages/pg-delta/corpus/ordering-validation--type-owner-change/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/ordering-validation--type-owner-change/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/overloaded-fns--two-overloads/a.sql b/packages/pg-delta/corpus/overloaded-fns--two-overloads/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/overloaded-fns--two-overloads/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/overloaded-fns--two-overloads/b.sql b/packages/pg-delta/corpus/overloaded-fns--two-overloads/b.sql new file mode 100644 index 000000000..2dcef0197 --- /dev/null +++ b/packages/pg-delta/corpus/overloaded-fns--two-overloads/b.sql @@ -0,0 +1,6 @@ +-- Two overloads of the same function in public schema +CREATE FUNCTION public.overload_me(a integer, b text) +RETURNS void LANGUAGE plpgsql AS $$ begin end; $$; + +CREATE FUNCTION public.overload_me(x bigint) +RETURNS void LANGUAGE plpgsql AS $$ begin end; $$; diff --git a/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/a.sql b/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/a.sql new file mode 100644 index 000000000..54b85aa1a --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/a.sql @@ -0,0 +1,22 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.events ( + event_id integer NOT NULL, + created_at timestamp NOT NULL, + data jsonb, + PRIMARY KEY (event_id, created_at) +) PARTITION BY RANGE (created_at); +CREATE TABLE test_schema.events_2024 PARTITION OF test_schema.events + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +CREATE INDEX idx_events_created ON test_schema.events (created_at); +CREATE FUNCTION test_schema.audit_event() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN NEW; +END; +$$; +CREATE TRIGGER trg_events_audit + AFTER INSERT OR UPDATE OR DELETE ON test_schema.events + FOR EACH ROW + EXECUTE FUNCTION test_schema.audit_event(); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/b.sql b/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/b.sql new file mode 100644 index 000000000..f395a58ea --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/b.sql @@ -0,0 +1,24 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.events ( + event_id integer NOT NULL, + created_at timestamp NOT NULL, + data jsonb, + PRIMARY KEY (event_id, created_at) +) PARTITION BY RANGE (created_at); +CREATE TABLE test_schema.events_2024 PARTITION OF test_schema.events + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +CREATE TABLE test_schema.events_2025 PARTITION OF test_schema.events + FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); +CREATE INDEX idx_events_created ON test_schema.events (created_at); +CREATE FUNCTION test_schema.audit_event() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN NEW; +END; +$$; +CREATE TRIGGER trg_events_audit + AFTER INSERT OR UPDATE OR DELETE ON test_schema.events + FOR EACH ROW + EXECUTE FUNCTION test_schema.audit_event(); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/seed-b.sql b/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/seed-b.sql new file mode 100644 index 000000000..1501a4f6a --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.events_2024 (event_id, created_at, data) VALUES (1, '2024-06-15 00:00:00', '{"kind": "login"}'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/seed.sql b/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/seed.sql new file mode 100644 index 000000000..1501a4f6a --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--add-partition-to-existing/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.events_2024 (event_id, created_at, data) VALUES (1, '2024-06-15 00:00:00', '{"kind": "login"}'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/a.sql b/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/a.sql new file mode 100644 index 000000000..c8d3359d2 --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/a.sql @@ -0,0 +1,35 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.customers ( + customer_id integer PRIMARY KEY, + name text NOT NULL +); +CREATE TABLE test_schema.orders ( + order_id integer NOT NULL, + created_on date NOT NULL, + customer_id integer NOT NULL, + status text DEFAULT 'pending', + total_amount numeric(10,2), + updated_at timestamp DEFAULT now(), + PRIMARY KEY (order_id, created_on) +) PARTITION BY RANGE (created_on); +CREATE TABLE test_schema.orders_2024 PARTITION OF test_schema.orders + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +CREATE TABLE test_schema.orders_2025 PARTITION OF test_schema.orders + FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); +CREATE FUNCTION test_schema.update_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; +CREATE FUNCTION test_schema.log_order_changes() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN NEW; +END; +$$; diff --git a/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/b.sql b/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/b.sql new file mode 100644 index 000000000..23d594b97 --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/b.sql @@ -0,0 +1,63 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.customers ( + customer_id integer PRIMARY KEY, + name text NOT NULL +); +CREATE TABLE test_schema.orders ( + order_id integer NOT NULL, + created_on date NOT NULL, + customer_id integer NOT NULL, + status text DEFAULT 'pending', + total_amount numeric(10,2), + updated_at timestamp DEFAULT now(), + PRIMARY KEY (order_id, created_on) +) PARTITION BY RANGE (created_on); +CREATE TABLE test_schema.orders_2024 PARTITION OF test_schema.orders + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +CREATE TABLE test_schema.orders_2025 PARTITION OF test_schema.orders + FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); +CREATE FUNCTION test_schema.update_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; +CREATE FUNCTION test_schema.log_order_changes() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN NEW; +END; +$$; +ALTER TABLE test_schema.orders + ADD CONSTRAINT fk_orders_customer + FOREIGN KEY (customer_id) + REFERENCES test_schema.customers(customer_id) + ON DELETE RESTRICT; +CREATE INDEX idx_orders_status ON test_schema.orders (status); +CREATE INDEX idx_orders_customer ON test_schema.orders (customer_id); +CREATE INDEX idx_orders_created_brin ON test_schema.orders USING brin (created_on); +CREATE TRIGGER trg_orders_updated_at + BEFORE UPDATE ON test_schema.orders + FOR EACH ROW + EXECUTE FUNCTION test_schema.update_updated_at(); +CREATE TRIGGER trg_orders_audit + AFTER INSERT OR UPDATE OR DELETE ON test_schema.orders + FOR EACH ROW + EXECUTE FUNCTION test_schema.log_order_changes(); +CREATE TABLE test_schema.order_items ( + item_id integer PRIMARY KEY, + order_id integer NOT NULL, + order_created_on date NOT NULL, + product_name text, + quantity integer +); +ALTER TABLE test_schema.order_items + ADD CONSTRAINT fk_order_items_order + FOREIGN KEY (order_id, order_created_on) + REFERENCES test_schema.orders(order_id, created_on) + ON DELETE CASCADE; diff --git a/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/seed-b.sql b/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/seed-b.sql new file mode 100644 index 000000000..c74cab93a --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/seed-b.sql @@ -0,0 +1,3 @@ +INSERT INTO test_schema.customers (customer_id, name) VALUES (1, 'Acme Corp'); +INSERT INTO test_schema.orders_2024 (order_id, created_on, customer_id, status, total_amount) VALUES (1, '2024-06-15', 1, 'pending', 100.00); +INSERT INTO test_schema.orders_2025 (order_id, created_on, customer_id, status, total_amount) VALUES (2, '2025-06-15', 1, 'shipped', 200.00); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/seed.sql b/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/seed.sql new file mode 100644 index 000000000..c74cab93a --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--comprehensive-all-features/seed.sql @@ -0,0 +1,3 @@ +INSERT INTO test_schema.customers (customer_id, name) VALUES (1, 'Acme Corp'); +INSERT INTO test_schema.orders_2024 (order_id, created_on, customer_id, status, total_amount) VALUES (1, '2024-06-15', 1, 'pending', 100.00); +INSERT INTO test_schema.orders_2025 (order_id, created_on, customer_id, status, total_amount) VALUES (2, '2025-06-15', 1, 'shipped', 200.00); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/a.sql b/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/a.sql new file mode 100644 index 000000000..4e25902de --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/a.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.documents ( + document_id uuid NOT NULL, + file_name text NOT NULL, + tenant_id uuid NOT NULL, + PRIMARY KEY (document_id, tenant_id) +) PARTITION BY LIST (tenant_id); +CREATE TABLE test_schema.documents_default + PARTITION OF test_schema.documents DEFAULT; +CREATE TABLE test_schema.documents_paxafe + PARTITION OF test_schema.documents + FOR VALUES IN ('019b8184-fa49-4a46-b429-4fe4cd9b1a8a'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/b.sql b/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/b.sql new file mode 100644 index 000000000..df237d2b1 --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/b.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.documents ( + document_id uuid NOT NULL, + file_name text NOT NULL, + tenant_id uuid NOT NULL, + PRIMARY KEY (document_id, tenant_id) +) PARTITION BY LIST (tenant_id); +CREATE TABLE test_schema.documents_default + PARTITION OF test_schema.documents DEFAULT; +CREATE TABLE test_schema.documents_paxafe + PARTITION OF test_schema.documents + FOR VALUES IN ('019b8184-fa49-4a46-b429-4fe4cd9b1a8a'); +ALTER TABLE test_schema.documents + ADD CONSTRAINT documents_file_name_check + CHECK (char_length(file_name) <= 255); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/seed-b.sql b/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/seed-b.sql new file mode 100644 index 000000000..b849aeaac --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.documents_paxafe (document_id, file_name, tenant_id) VALUES ('11111111-1111-1111-1111-111111111111', 'paxafe-file.txt', '019b8184-fa49-4a46-b429-4fe4cd9b1a8a'); +INSERT INTO test_schema.documents_default (document_id, file_name, tenant_id) VALUES ('22222222-2222-2222-2222-222222222222', 'other-file.txt', '00000000-0000-0000-0000-000000000001'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/seed.sql b/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/seed.sql new file mode 100644 index 000000000..b849aeaac --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--list-partition-with-default/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.documents_paxafe (document_id, file_name, tenant_id) VALUES ('11111111-1111-1111-1111-111111111111', 'paxafe-file.txt', '019b8184-fa49-4a46-b429-4fe4cd9b1a8a'); +INSERT INTO test_schema.documents_default (document_id, file_name, tenant_id) VALUES ('22222222-2222-2222-2222-222222222222', 'other-file.txt', '00000000-0000-0000-0000-000000000001'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/a.sql b/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/a.sql new file mode 100644 index 000000000..f7d492ec7 --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/a.sql @@ -0,0 +1,14 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.products ( + product_id integer NOT NULL, + created_on date NOT NULL, + sku text, + name text, + PRIMARY KEY (product_id, created_on) +) PARTITION BY RANGE (created_on); + +CREATE TABLE test_schema.products_2024 PARTITION OF test_schema.products +FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + +CREATE TABLE test_schema.products_2025 PARTITION OF test_schema.products +FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/b.sql b/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/b.sql new file mode 100644 index 000000000..9af7080ad --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/b.sql @@ -0,0 +1,17 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.products ( + product_id integer NOT NULL, + created_on date NOT NULL, + sku text, + name text, + PRIMARY KEY (product_id, created_on) +) PARTITION BY RANGE (created_on); + +CREATE TABLE test_schema.products_2024 PARTITION OF test_schema.products +FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + +CREATE TABLE test_schema.products_2025 PARTITION OF test_schema.products +FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); + +ALTER TABLE test_schema.products +ADD CONSTRAINT products_sku_key UNIQUE (sku, created_on); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/seed-b.sql b/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/seed-b.sql new file mode 100644 index 000000000..d87897c2e --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.products_2024 (product_id, created_on, sku, name) VALUES (1, '2024-06-15', 'SKU-001', 'Widget'); +INSERT INTO test_schema.products_2025 (product_id, created_on, sku, name) VALUES (2, '2025-06-15', 'SKU-002', 'Gadget'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/seed.sql b/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/seed.sql new file mode 100644 index 000000000..d87897c2e --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--parent-unique-with-partition-key/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.products_2024 (product_id, created_on, sku, name) VALUES (1, '2024-06-15', 'SKU-001', 'Widget'); +INSERT INTO test_schema.products_2025 (product_id, created_on, sku, name) VALUES (2, '2025-06-15', 'SKU-002', 'Gadget'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/a.sql b/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/a.sql new file mode 100644 index 000000000..395da34e1 --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/a.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.orders ( + order_id integer NOT NULL, + created_on date NOT NULL, + customer_id integer, + status text, + amount numeric(10,2) +) PARTITION BY RANGE (created_on); +CREATE TABLE test_schema.orders_2024 PARTITION OF test_schema.orders + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +CREATE TABLE test_schema.orders_2025 PARTITION OF test_schema.orders + FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/b.sql b/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/b.sql new file mode 100644 index 000000000..23a1e3cec --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/b.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.orders ( + order_id integer NOT NULL, + created_on date NOT NULL, + customer_id integer, + status text, + amount numeric(10,2) +) PARTITION BY RANGE (created_on); +CREATE TABLE test_schema.orders_2024 PARTITION OF test_schema.orders + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +CREATE TABLE test_schema.orders_2025 PARTITION OF test_schema.orders + FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); +CREATE INDEX idx_orders_status ON test_schema.orders (status); +CREATE INDEX idx_orders_customer ON test_schema.orders (customer_id); +CREATE INDEX idx_orders_created_brin ON test_schema.orders USING brin (created_on); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/seed-b.sql b/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/seed-b.sql new file mode 100644 index 000000000..95794eb5d --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.orders_2024 (order_id, created_on, customer_id, status, amount) VALUES (1, '2024-06-15', 1, 'pending', 100.00); +INSERT INTO test_schema.orders_2025 (order_id, created_on, customer_id, status, amount) VALUES (2, '2025-06-15', 1, 'shipped', 200.00); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/seed.sql b/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/seed.sql new file mode 100644 index 000000000..95794eb5d --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--range-partition-with-indexes/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.orders_2024 (order_id, created_on, customer_id, status, amount) VALUES (1, '2024-06-15', 1, 'pending', 100.00); +INSERT INTO test_schema.orders_2025 (order_id, created_on, customer_id, status, amount) VALUES (2, '2025-06-15', 1, 'shipped', 200.00); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--subpartitioned-multilevel/a.sql b/packages/pg-delta/corpus/partitioned-table-operations--subpartitioned-multilevel/a.sql new file mode 100644 index 000000000..7ec51fc02 --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--subpartitioned-multilevel/a.sql @@ -0,0 +1,10 @@ +-- 3-level partitioning: only the root exists here. The forward diff must +-- create the middle layer (itself PARTITION BY RANGE) and the leaf under it, +-- exercising the subpartitioned-partition create path. +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + id integer NOT NULL, + region text NOT NULL, + created_on date NOT NULL +) PARTITION BY LIST (region); diff --git a/packages/pg-delta/corpus/partitioned-table-operations--subpartitioned-multilevel/b.sql b/packages/pg-delta/corpus/partitioned-table-operations--subpartitioned-multilevel/b.sql new file mode 100644 index 000000000..ce8c796ac --- /dev/null +++ b/packages/pg-delta/corpus/partitioned-table-operations--subpartitioned-multilevel/b.sql @@ -0,0 +1,17 @@ +-- root (PARTITION BY LIST) → events_us (a partition that is ITSELF +-- PARTITION BY RANGE) → events_us_2024 (leaf). The middle partition must +-- retain its PARTITION BY clause or the leaf cannot attach. +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + id integer NOT NULL, + region text NOT NULL, + created_on date NOT NULL +) PARTITION BY LIST (region); + +CREATE TABLE test_schema.events_us PARTITION OF test_schema.events + FOR VALUES IN ('us') + PARTITION BY RANGE (created_on); + +CREATE TABLE test_schema.events_us_2024 PARTITION OF test_schema.events_us + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/a.sql b/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/a.sql new file mode 100644 index 000000000..d1df5257c --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/a.sql @@ -0,0 +1,15 @@ +CREATE TABLE public.alter_function_sign_policy_dependent_profiles ( + id uuid PRIMARY KEY, + role text +); +ALTER TABLE public.alter_function_sign_policy_dependent_profiles ENABLE ROW LEVEL SECURITY; +CREATE OR REPLACE FUNCTION public.alter_function_sign_policy_dependent_check_role( + _id uuid, _role text +) RETURNS boolean AS $$ +BEGIN RETURN true; END; +$$ LANGUAGE plpgsql; +CREATE POLICY alter_function_sign_policy_dependent_check_role_policy + ON public.alter_function_sign_policy_dependent_profiles + FOR SELECT USING ( + public.alter_function_sign_policy_dependent_check_role(id, role) + ); diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/b.sql b/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/b.sql new file mode 100644 index 000000000..c0eba2666 --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/b.sql @@ -0,0 +1,15 @@ +CREATE TABLE public.alter_function_sign_policy_dependent_profiles ( + id uuid PRIMARY KEY, + role text +); +ALTER TABLE public.alter_function_sign_policy_dependent_profiles ENABLE ROW LEVEL SECURITY; +CREATE OR REPLACE FUNCTION public.alter_function_sign_policy_dependent_check_role( + _id uuid, _role text, _extra text DEFAULT 'default'::text +) RETURNS boolean AS $$ +BEGIN RETURN true; END; +$$ LANGUAGE plpgsql; +CREATE POLICY alter_function_sign_policy_dependent_check_role_policy + ON public.alter_function_sign_policy_dependent_profiles + FOR SELECT USING ( + public.alter_function_sign_policy_dependent_check_role(id, role) + ); diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/seed-b.sql b/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/seed-b.sql new file mode 100644 index 000000000..4b7214b61 --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.alter_function_sign_policy_dependent_profiles (id, role) VALUES ('11111111-1111-1111-1111-111111111111', 'admin'); diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/seed.sql b/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/seed.sql new file mode 100644 index 000000000..4b7214b61 --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-depending-on-replaced-function/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.alter_function_sign_policy_dependent_profiles (id, role) VALUES ('11111111-1111-1111-1111-111111111111', 'admin'); diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-using-calls-new-function/a.sql b/packages/pg-delta/corpus/policy-dependencies--policy-using-calls-new-function/a.sql new file mode 100644 index 000000000..51abd88d4 --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-using-calls-new-function/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA app; diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-using-calls-new-function/b.sql b/packages/pg-delta/corpus/policy-dependencies--policy-using-calls-new-function/b.sql new file mode 100644 index 000000000..4280d0200 --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-using-calls-new-function/b.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA app; +CREATE TABLE app.accounts ( + id INTEGER PRIMARY KEY +); +CREATE FUNCTION app.is_admin() RETURNS BOOLEAN + LANGUAGE sql + STABLE + AS $$ SELECT true $$; +ALTER TABLE app.accounts ENABLE ROW LEVEL SECURITY; +CREATE POLICY account_access ON app.accounts + FOR SELECT + TO public + USING (app.is_admin()); diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-using-exists-new-table/a.sql b/packages/pg-delta/corpus/policy-dependencies--policy-using-exists-new-table/a.sql new file mode 100644 index 000000000..51abd88d4 --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-using-exists-new-table/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA app; diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-using-exists-new-table/b.sql b/packages/pg-delta/corpus/policy-dependencies--policy-using-exists-new-table/b.sql new file mode 100644 index 000000000..32d53fbde --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-using-exists-new-table/b.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA app; +CREATE TABLE app.accounts ( + id INTEGER PRIMARY KEY +); +CREATE TABLE app.users ( + id INTEGER PRIMARY KEY +); +ALTER TABLE app.accounts ENABLE ROW LEVEL SECURITY; +CREATE POLICY account_access ON app.accounts + FOR SELECT + TO public + USING (EXISTS (SELECT 1 FROM app.users)); diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-using-references-new-view/a.sql b/packages/pg-delta/corpus/policy-dependencies--policy-using-references-new-view/a.sql new file mode 100644 index 000000000..51abd88d4 --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-using-references-new-view/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA app; diff --git a/packages/pg-delta/corpus/policy-dependencies--policy-using-references-new-view/b.sql b/packages/pg-delta/corpus/policy-dependencies--policy-using-references-new-view/b.sql new file mode 100644 index 000000000..2143e47dc --- /dev/null +++ b/packages/pg-delta/corpus/policy-dependencies--policy-using-references-new-view/b.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA app; +CREATE TABLE app.accounts ( + id INTEGER PRIMARY KEY, + active BOOLEAN NOT NULL +); +CREATE VIEW app.active_accounts AS + SELECT id FROM app.accounts WHERE active; +ALTER TABLE app.accounts ENABLE ROW LEVEL SECURITY; +CREATE POLICY account_access ON app.accounts + FOR SELECT + TO public + USING (id IN (SELECT id FROM app.active_accounts)); diff --git a/packages/pg-delta/corpus/privilege-operations--column-privileges/a.sql b/packages/pg-delta/corpus/privilege-operations--column-privileges/a.sql new file mode 100644 index 000000000..3929f3642 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--column-privileges/a.sql @@ -0,0 +1,5 @@ +-- state A: SELECT on all columns, no UPDATE column privilege +DO $$ BEGIN CREATE ROLE corpus_col_g NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.tcg_g (a int, b int); +GRANT SELECT (a, b) ON TABLE test_schema.tcg_g TO corpus_col_g; diff --git a/packages/pg-delta/corpus/privilege-operations--column-privileges/b.sql b/packages/pg-delta/corpus/privilege-operations--column-privileges/b.sql new file mode 100644 index 000000000..9c72cbc9c --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--column-privileges/b.sql @@ -0,0 +1,6 @@ +-- state B: SELECT on all columns plus UPDATE (b) column privilege +DO $$ BEGIN CREATE ROLE corpus_col_g NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.tcg_g (a int, b int); +GRANT SELECT (a, b) ON TABLE test_schema.tcg_g TO corpus_col_g; +GRANT UPDATE (b) ON TABLE test_schema.tcg_g TO corpus_col_g; diff --git a/packages/pg-delta/corpus/privilege-operations--create-grant-drop-unrelated/a.sql b/packages/pg-delta/corpus/privilege-operations--create-grant-drop-unrelated/a.sql new file mode 100644 index 000000000..7a50e299a --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--create-grant-drop-unrelated/a.sql @@ -0,0 +1,3 @@ +-- state A: a pre-existing, unrelated schema + table +CREATE SCHEMA drop_s; +CREATE TABLE drop_s.old_t (a int); diff --git a/packages/pg-delta/corpus/privilege-operations--create-grant-drop-unrelated/b.sql b/packages/pg-delta/corpus/privilege-operations--create-grant-drop-unrelated/b.sql new file mode 100644 index 000000000..59c6eedbf --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--create-grant-drop-unrelated/b.sql @@ -0,0 +1,9 @@ +-- state B: unrelated drop_s.old_t is gone (schema drop_s remains empty), +-- and a new role + new schema + new table + GRANT are all created together. +-- a->b must DROP the unrelated table while CREATE+GRANTing the new objects in one plan; +-- b->a does the reverse. +CREATE SCHEMA drop_s; +DO $$ BEGIN CREATE ROLE corpus_r_mix NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA dep_mix; +CREATE TABLE dep_mix.t (a int); +GRANT SELECT ON TABLE dep_mix.t TO corpus_r_mix; diff --git a/packages/pg-delta/corpus/privilege-operations--create-grant-ordering/a.sql b/packages/pg-delta/corpus/privilege-operations--create-grant-ordering/a.sql new file mode 100644 index 000000000..88d934d11 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--create-grant-ordering/a.sql @@ -0,0 +1 @@ +-- state A: nothing exists diff --git a/packages/pg-delta/corpus/privilege-operations--create-grant-ordering/b.sql b/packages/pg-delta/corpus/privilege-operations--create-grant-ordering/b.sql new file mode 100644 index 000000000..534308385 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--create-grant-ordering/b.sql @@ -0,0 +1,6 @@ +-- state B: new role, new schema, new table, and a grant — all created together +-- exercises that CREATE ROLE/SCHEMA/TABLE are ordered before GRANT +DO $$ BEGIN CREATE ROLE corpus_dep_g NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA dep_s; +CREATE TABLE dep_s.dep_t (a int); +GRANT SELECT, UPDATE ON TABLE dep_s.dep_t TO corpus_dep_g; diff --git a/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/a.sql b/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/a.sql new file mode 100644 index 000000000..e057fefd1 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/a.sql @@ -0,0 +1,4 @@ +-- state A: roles and schema exist, no default privileges set +CREATE ROLE r_def_g NOLOGIN; +CREATE ROLE owner_role_g NOLOGIN; +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/b.sql b/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/b.sql new file mode 100644 index 000000000..4e0e5a59a --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/b.sql @@ -0,0 +1,5 @@ +-- state B: ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_g IN SCHEMA test_schema grants SELECT on TABLES to r_def_g +CREATE ROLE r_def_g NOLOGIN; +CREATE ROLE owner_role_g NOLOGIN; +CREATE SCHEMA test_schema; +ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_g IN SCHEMA test_schema GRANT SELECT ON TABLES TO r_def_g; diff --git a/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/meta.json b/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--default-privileges-for-role-in-schema/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/privilege-operations--public-grantee/a.sql b/packages/pg-delta/corpus/privilege-operations--public-grantee/a.sql new file mode 100644 index 000000000..f3a68c0ef --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--public-grantee/a.sql @@ -0,0 +1,3 @@ +-- state A: view exists, no grants to PUBLIC +CREATE SCHEMA test_schema; +CREATE VIEW test_schema.v AS SELECT 1 AS a; diff --git a/packages/pg-delta/corpus/privilege-operations--public-grantee/b.sql b/packages/pg-delta/corpus/privilege-operations--public-grantee/b.sql new file mode 100644 index 000000000..7add9578c --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--public-grantee/b.sql @@ -0,0 +1,4 @@ +-- state B: SELECT granted to PUBLIC on view +CREATE SCHEMA test_schema; +CREATE VIEW test_schema.v AS SELECT 1 AS a; +GRANT SELECT ON test_schema.v TO PUBLIC; diff --git a/packages/pg-delta/corpus/privilege-operations--role-membership/a.sql b/packages/pg-delta/corpus/privilege-operations--role-membership/a.sql new file mode 100644 index 000000000..565f1329f --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--role-membership/a.sql @@ -0,0 +1,3 @@ +-- state A: two roles exist, no membership +CREATE ROLE parent_role_g NOLOGIN; +CREATE ROLE child_role_g NOLOGIN; diff --git a/packages/pg-delta/corpus/privilege-operations--role-membership/b.sql b/packages/pg-delta/corpus/privilege-operations--role-membership/b.sql new file mode 100644 index 000000000..a469d1500 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--role-membership/b.sql @@ -0,0 +1,4 @@ +-- state B: child_role_g is a member of parent_role_g WITH ADMIN OPTION +CREATE ROLE parent_role_g NOLOGIN; +CREATE ROLE child_role_g NOLOGIN; +GRANT parent_role_g TO child_role_g WITH ADMIN OPTION; diff --git a/packages/pg-delta/corpus/privilege-operations--role-membership/meta.json b/packages/pg-delta/corpus/privilege-operations--role-membership/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--role-membership/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/privilege-operations--table-grant/a.sql b/packages/pg-delta/corpus/privilege-operations--table-grant/a.sql new file mode 100644 index 000000000..ca17cd288 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--table-grant/a.sql @@ -0,0 +1,4 @@ +-- state A: table and role exist, no grants +DO $$ BEGIN CREATE ROLE corpus_obj_g NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.tg (a int); diff --git a/packages/pg-delta/corpus/privilege-operations--table-grant/b.sql b/packages/pg-delta/corpus/privilege-operations--table-grant/b.sql new file mode 100644 index 000000000..a58856def --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--table-grant/b.sql @@ -0,0 +1,5 @@ +-- state B: UPDATE privilege granted to corpus_obj_g +DO $$ BEGIN CREATE ROLE corpus_obj_g NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.tg (a int); +GRANT UPDATE ON TABLE test_schema.tg TO corpus_obj_g; diff --git a/packages/pg-delta/corpus/privilege-operations--table-privilege-swap/a.sql b/packages/pg-delta/corpus/privilege-operations--table-privilege-swap/a.sql new file mode 100644 index 000000000..b972e4aba --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--table-privilege-swap/a.sql @@ -0,0 +1,5 @@ +-- state A: INSERT granted to corpus_swap_r +DO $$ BEGIN CREATE ROLE corpus_swap_r NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.t_swap (a int); +GRANT INSERT ON TABLE test_schema.t_swap TO corpus_swap_r; diff --git a/packages/pg-delta/corpus/privilege-operations--table-privilege-swap/b.sql b/packages/pg-delta/corpus/privilege-operations--table-privilege-swap/b.sql new file mode 100644 index 000000000..a1069d3e4 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--table-privilege-swap/b.sql @@ -0,0 +1,5 @@ +-- state B: INSERT revoked, UPDATE granted (disjoint privilege swap on same table+role) +DO $$ BEGIN CREATE ROLE corpus_swap_r NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.t_swap (a int); +GRANT UPDATE ON TABLE test_schema.t_swap TO corpus_swap_r; diff --git a/packages/pg-delta/corpus/privilege-operations--table-revoke-only/a.sql b/packages/pg-delta/corpus/privilege-operations--table-revoke-only/a.sql new file mode 100644 index 000000000..857901ff6 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--table-revoke-only/a.sql @@ -0,0 +1,5 @@ +-- state A: both SELECT and INSERT granted to corpus_obj_r +DO $$ BEGIN CREATE ROLE corpus_obj_r NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.t (a int); +GRANT SELECT, INSERT ON TABLE test_schema.t TO corpus_obj_r; diff --git a/packages/pg-delta/corpus/privilege-operations--table-revoke-only/b.sql b/packages/pg-delta/corpus/privilege-operations--table-revoke-only/b.sql new file mode 100644 index 000000000..ab75dfa5d --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--table-revoke-only/b.sql @@ -0,0 +1,5 @@ +-- state B: INSERT revoked, only SELECT remains +DO $$ BEGIN CREATE ROLE corpus_obj_r NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.t (a int); +GRANT SELECT ON TABLE test_schema.t TO corpus_obj_r; diff --git a/packages/pg-delta/corpus/privilege-operations--table-to-column-privilege-swap/a.sql b/packages/pg-delta/corpus/privilege-operations--table-to-column-privilege-swap/a.sql new file mode 100644 index 000000000..8709ec89a --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--table-to-column-privilege-swap/a.sql @@ -0,0 +1,5 @@ +-- state A: table-level INSERT, UPDATE granted to corpus_priv +DO $$ BEGIN CREATE ROLE corpus_priv NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.t_priv (a int, b int, c int); +GRANT INSERT, UPDATE ON TABLE test_schema.t_priv TO corpus_priv; diff --git a/packages/pg-delta/corpus/privilege-operations--table-to-column-privilege-swap/b.sql b/packages/pg-delta/corpus/privilege-operations--table-to-column-privilege-swap/b.sql new file mode 100644 index 000000000..0c78f5593 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--table-to-column-privilege-swap/b.sql @@ -0,0 +1,7 @@ +-- state B: table-level INSERT, UPDATE revoked; replaced by column-level grants +-- (planner must emit the table-level REVOKE before the column-level GRANTs) +DO $$ BEGIN CREATE ROLE corpus_priv NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.t_priv (a int, b int, c int); +GRANT INSERT (a, b) ON TABLE test_schema.t_priv TO corpus_priv; +GRANT UPDATE (b) ON TABLE test_schema.t_priv TO corpus_priv; diff --git a/packages/pg-delta/corpus/privilege-operations--view-column-privileges/a.sql b/packages/pg-delta/corpus/privilege-operations--view-column-privileges/a.sql new file mode 100644 index 000000000..f88d0ef9d --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--view-column-privileges/a.sql @@ -0,0 +1,5 @@ +-- state A: view-level SELECT, UPDATE grants on a view +DO $$ BEGIN CREATE ROLE corpus_r_view_priv NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE VIEW test_schema.v_priv AS SELECT 1 AS a, 2 AS b, 3 AS c; +GRANT SELECT, UPDATE ON test_schema.v_priv TO corpus_r_view_priv; diff --git a/packages/pg-delta/corpus/privilege-operations--view-column-privileges/b.sql b/packages/pg-delta/corpus/privilege-operations--view-column-privileges/b.sql new file mode 100644 index 000000000..765fb7460 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--view-column-privileges/b.sql @@ -0,0 +1,6 @@ +-- state B: view-level grants replaced by column-level grants (requires REVOKE before GRANT) +DO $$ BEGIN CREATE ROLE corpus_r_view_priv NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE VIEW test_schema.v_priv AS SELECT 1 AS a, 2 AS b, 3 AS c; +GRANT SELECT (a, b) ON test_schema.v_priv TO corpus_r_view_priv; +GRANT UPDATE (b) ON test_schema.v_priv TO corpus_r_view_priv; diff --git a/packages/pg-delta/corpus/privilege-operations--with-grant-option/a.sql b/packages/pg-delta/corpus/privilege-operations--with-grant-option/a.sql new file mode 100644 index 000000000..eeb4608e7 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--with-grant-option/a.sql @@ -0,0 +1,5 @@ +-- state A: SELECT granted without grant option +DO $$ BEGIN CREATE ROLE corpus_obj_go NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.tg2 (a int); +GRANT SELECT ON TABLE test_schema.tg2 TO corpus_obj_go; diff --git a/packages/pg-delta/corpus/privilege-operations--with-grant-option/b.sql b/packages/pg-delta/corpus/privilege-operations--with-grant-option/b.sql new file mode 100644 index 000000000..3e30a49b1 --- /dev/null +++ b/packages/pg-delta/corpus/privilege-operations--with-grant-option/b.sql @@ -0,0 +1,5 @@ +-- state B: SELECT granted WITH GRANT OPTION (grant option added) +DO $$ BEGIN CREATE ROLE corpus_obj_go NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.tg2 (a int); +GRANT SELECT ON TABLE test_schema.tg2 TO corpus_obj_go WITH GRANT OPTION; diff --git a/packages/pg-delta/corpus/procedure-operations--comment-and-grant/a.sql b/packages/pg-delta/corpus/procedure-operations--comment-and-grant/a.sql new file mode 100644 index 000000000..1de437059 --- /dev/null +++ b/packages/pg-delta/corpus/procedure-operations--comment-and-grant/a.sql @@ -0,0 +1 @@ +DO $$ BEGIN CREATE ROLE corpus_proc_executor NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/packages/pg-delta/corpus/procedure-operations--comment-and-grant/b.sql b/packages/pg-delta/corpus/procedure-operations--comment-and-grant/b.sql new file mode 100644 index 000000000..2a311ceef --- /dev/null +++ b/packages/pg-delta/corpus/procedure-operations--comment-and-grant/b.sql @@ -0,0 +1,10 @@ +DO $$ BEGIN CREATE ROLE corpus_proc_executor NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- A real PROCEDURE (prokind 'p'): COMMENT/GRANT must use the PROCEDURE keyword. +-- PostgreSQL rejects the FUNCTION form here ("public.do_work() is not a function"). +CREATE PROCEDURE public.do_work(amount integer) + LANGUAGE sql AS $$ SELECT amount $$; + +COMMENT ON PROCEDURE public.do_work(integer) IS 'does some work'; + +GRANT EXECUTE ON PROCEDURE public.do_work(integer) TO corpus_proc_executor; diff --git a/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/a.sql b/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/a.sql new file mode 100644 index 000000000..b238bc576 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/a.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.users (id SERIAL PRIMARY KEY, active BOOLEAN); +CREATE TABLE pub_test.sessions (id SERIAL PRIMARY KEY, user_id INTEGER, active BOOLEAN); +CREATE PUBLICATION pub_tables FOR TABLE pub_test.users; diff --git a/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/b.sql b/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/b.sql new file mode 100644 index 000000000..131e49c30 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/b.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.users (id SERIAL PRIMARY KEY, active BOOLEAN); +CREATE TABLE pub_test.sessions (id SERIAL PRIMARY KEY, user_id INTEGER, active BOOLEAN); +CREATE PUBLICATION pub_tables FOR TABLE pub_test.sessions WHERE (active IS TRUE); diff --git a/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/meta.json b/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--add-and-drop-tables/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/publication-operations--all-tables-to-table-list/a.sql b/packages/pg-delta/corpus/publication-operations--all-tables-to-table-list/a.sql new file mode 100644 index 000000000..ab781b4f3 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--all-tables-to-table-list/a.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.metrics (id SERIAL PRIMARY KEY, value INTEGER); +CREATE PUBLICATION pub_all FOR ALL TABLES; diff --git a/packages/pg-delta/corpus/publication-operations--all-tables-to-table-list/b.sql b/packages/pg-delta/corpus/publication-operations--all-tables-to-table-list/b.sql new file mode 100644 index 000000000..86c516adf --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--all-tables-to-table-list/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.metrics (id SERIAL PRIMARY KEY, value INTEGER); +CREATE PUBLICATION pub_all FOR TABLE pub_test.metrics; diff --git a/packages/pg-delta/corpus/publication-operations--alter-publish-options/a.sql b/packages/pg-delta/corpus/publication-operations--alter-publish-options/a.sql new file mode 100644 index 000000000..b0ae52d03 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--alter-publish-options/a.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.logs (id SERIAL PRIMARY KEY, payload JSONB); +CREATE PUBLICATION pub_opts FOR TABLE pub_test.logs; diff --git a/packages/pg-delta/corpus/publication-operations--alter-publish-options/b.sql b/packages/pg-delta/corpus/publication-operations--alter-publish-options/b.sql new file mode 100644 index 000000000..0396af6e0 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--alter-publish-options/b.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.logs (id SERIAL PRIMARY KEY, payload JSONB); +CREATE PUBLICATION pub_opts FOR TABLE pub_test.logs WITH ( + publish = 'insert, update', + publish_via_partition_root = true +); diff --git a/packages/pg-delta/corpus/publication-operations--alter-schema-list/a.sql b/packages/pg-delta/corpus/publication-operations--alter-schema-list/a.sql new file mode 100644 index 000000000..9c8ff7699 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--alter-schema-list/a.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA pub_a; +CREATE SCHEMA pub_b; +CREATE TABLE pub_a.alpha (id INT); +CREATE TABLE pub_b.beta (id INT); +CREATE PUBLICATION pub_schemas FOR TABLES IN SCHEMA pub_a; diff --git a/packages/pg-delta/corpus/publication-operations--alter-schema-list/b.sql b/packages/pg-delta/corpus/publication-operations--alter-schema-list/b.sql new file mode 100644 index 000000000..80d0e6211 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--alter-schema-list/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA pub_a; +CREATE SCHEMA pub_b; +CREATE TABLE pub_a.alpha (id INT); +CREATE TABLE pub_b.beta (id INT); +CREATE PUBLICATION pub_schemas FOR TABLES IN SCHEMA pub_a, pub_b; diff --git a/packages/pg-delta/corpus/publication-operations--alter-schema-list/meta.json b/packages/pg-delta/corpus/publication-operations--alter-schema-list/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--alter-schema-list/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/publication-operations--alter-table-filter/a.sql b/packages/pg-delta/corpus/publication-operations--alter-table-filter/a.sql new file mode 100644 index 000000000..9c4efb7c8 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--alter-table-filter/a.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.accounts ( + id SERIAL PRIMARY KEY, + status TEXT DEFAULT 'inactive', + amount INTEGER +); +-- published with a narrow column list + row filter +CREATE PUBLICATION pub_accounts + FOR TABLE pub_test.accounts (id, amount) + WHERE (status = 'active'); diff --git a/packages/pg-delta/corpus/publication-operations--alter-table-filter/b.sql b/packages/pg-delta/corpus/publication-operations--alter-table-filter/b.sql new file mode 100644 index 000000000..48d3338d5 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--alter-table-filter/b.sql @@ -0,0 +1,11 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.accounts ( + id SERIAL PRIMARY KEY, + status TEXT DEFAULT 'inactive', + amount INTEGER +); +-- column list widened + row filter changed: a per-table column/WHERE change +-- has no in-place form, so the published table is dropped and re-added +CREATE PUBLICATION pub_accounts + FOR TABLE pub_test.accounts (id, status, amount) + WHERE (amount > 0); diff --git a/packages/pg-delta/corpus/publication-operations--alter-table-filter/meta.json b/packages/pg-delta/corpus/publication-operations--alter-table-filter/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--alter-table-filter/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/a.sql b/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/a.sql new file mode 100644 index 000000000..a0233646f --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/a.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA pub_schema_only; +CREATE TABLE pub_schema_only.t1 (id SERIAL PRIMARY KEY); +CREATE TABLE pub_schema_only.t2 (id SERIAL PRIMARY KEY); diff --git a/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/b.sql b/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/b.sql new file mode 100644 index 000000000..3f6271bae --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/b.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA pub_schema_only; +CREATE TABLE pub_schema_only.t1 (id SERIAL PRIMARY KEY); +CREATE TABLE pub_schema_only.t2 (id SERIAL PRIMARY KEY); +CREATE PUBLICATION pub_schema_pub FOR TABLES IN SCHEMA pub_schema_only; diff --git a/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/meta.json b/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-for-tables-in-schema/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/a.sql b/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/a.sql new file mode 100644 index 000000000..8d7d8b7a9 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA pub_dep; diff --git a/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/b.sql b/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/b.sql new file mode 100644 index 000000000..55b3978da --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA pub_dep; +CREATE SCHEMA pub_dep_extra; +CREATE TABLE pub_dep.source_table (id SERIAL PRIMARY KEY); +CREATE TABLE pub_dep_extra.extra_table (id SERIAL PRIMARY KEY); +CREATE PUBLICATION pub_dep_pub FOR TABLE pub_dep.source_table, TABLES IN SCHEMA pub_dep_extra; diff --git a/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/meta.json b/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-with-new-deps-cross-schema/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/publication-operations--create-with-table-filters/a.sql b/packages/pg-delta/corpus/publication-operations--create-with-table-filters/a.sql new file mode 100644 index 000000000..62be68123 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-with-table-filters/a.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.accounts ( + id SERIAL PRIMARY KEY, + status TEXT DEFAULT 'inactive', + amount INTEGER +); diff --git a/packages/pg-delta/corpus/publication-operations--create-with-table-filters/b.sql b/packages/pg-delta/corpus/publication-operations--create-with-table-filters/b.sql new file mode 100644 index 000000000..08d4f7462 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-with-table-filters/b.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.accounts ( + id SERIAL PRIMARY KEY, + status TEXT DEFAULT 'inactive', + amount INTEGER +); +CREATE PUBLICATION pub_accounts_filtered + FOR TABLE pub_test.accounts (id, amount) + WHERE (status = 'active'); diff --git a/packages/pg-delta/corpus/publication-operations--create-with-table-filters/meta.json b/packages/pg-delta/corpus/publication-operations--create-with-table-filters/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--create-with-table-filters/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/publication-operations--drop-publication/a.sql b/packages/pg-delta/corpus/publication-operations--drop-publication/a.sql new file mode 100644 index 000000000..fb028a5bd --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--drop-publication/a.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.messages (id SERIAL PRIMARY KEY, body TEXT); +CREATE PUBLICATION pub_drop_test FOR TABLE pub_test.messages; diff --git a/packages/pg-delta/corpus/publication-operations--drop-publication/b.sql b/packages/pg-delta/corpus/publication-operations--drop-publication/b.sql new file mode 100644 index 000000000..07b0b4fa4 --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--drop-publication/b.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.messages (id SERIAL PRIMARY KEY, body TEXT); diff --git a/packages/pg-delta/corpus/publication-operations--owner-and-comment/a.sql b/packages/pg-delta/corpus/publication-operations--owner-and-comment/a.sql new file mode 100644 index 000000000..6da683ecf --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--owner-and-comment/a.sql @@ -0,0 +1,8 @@ +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_pub_owner') THEN + CREATE ROLE corpus_pub_owner; + END IF; +END $$; +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.audit (id SERIAL PRIMARY KEY, payload JSONB); +CREATE PUBLICATION pub_metadata FOR TABLE pub_test.audit; diff --git a/packages/pg-delta/corpus/publication-operations--owner-and-comment/b.sql b/packages/pg-delta/corpus/publication-operations--owner-and-comment/b.sql new file mode 100644 index 000000000..c3ba47baa --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--owner-and-comment/b.sql @@ -0,0 +1,10 @@ +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_pub_owner') THEN + CREATE ROLE corpus_pub_owner; + END IF; +END $$; +CREATE SCHEMA pub_test; +CREATE TABLE pub_test.audit (id SERIAL PRIMARY KEY, payload JSONB); +CREATE PUBLICATION pub_metadata FOR TABLE pub_test.audit; +ALTER PUBLICATION pub_metadata OWNER TO corpus_pub_owner; +COMMENT ON PUBLICATION pub_metadata IS 'audit publication'; diff --git a/packages/pg-delta/corpus/publication-operations--owner-and-comment/meta.json b/packages/pg-delta/corpus/publication-operations--owner-and-comment/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/publication-operations--owner-and-comment/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/rls-operations--enable-disable-rls/a.sql b/packages/pg-delta/corpus/rls-operations--enable-disable-rls/a.sql new file mode 100644 index 000000000..51bcc7fb4 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--enable-disable-rls/a.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA app; +CREATE TABLE app.users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL +); diff --git a/packages/pg-delta/corpus/rls-operations--enable-disable-rls/b.sql b/packages/pg-delta/corpus/rls-operations--enable-disable-rls/b.sql new file mode 100644 index 000000000..22dacaea8 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--enable-disable-rls/b.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA app; +CREATE TABLE app.users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL +); +ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; diff --git a/packages/pg-delta/corpus/rls-operations--enable-disable-rls/seed-b.sql b/packages/pg-delta/corpus/rls-operations--enable-disable-rls/seed-b.sql new file mode 100644 index 000000000..7b6c50909 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--enable-disable-rls/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO app.users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'); diff --git a/packages/pg-delta/corpus/rls-operations--enable-disable-rls/seed.sql b/packages/pg-delta/corpus/rls-operations--enable-disable-rls/seed.sql new file mode 100644 index 000000000..7b6c50909 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--enable-disable-rls/seed.sql @@ -0,0 +1 @@ +INSERT INTO app.users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'); diff --git a/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/a.sql b/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/a.sql new file mode 100644 index 000000000..120efea70 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/a.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA forum; +CREATE TABLE forum.messages ( + id INTEGER PRIMARY KEY, + content TEXT NOT NULL, + author_id INTEGER NOT NULL, + thread_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE forum.messages ENABLE ROW LEVEL SECURITY; diff --git a/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/b.sql b/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/b.sql new file mode 100644 index 000000000..5c1ea0af6 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/b.sql @@ -0,0 +1,22 @@ +CREATE SCHEMA forum; +CREATE TABLE forum.messages ( + id INTEGER PRIMARY KEY, + content TEXT NOT NULL, + author_id INTEGER NOT NULL, + thread_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE forum.messages ENABLE ROW LEVEL SECURITY; +CREATE POLICY read_messages ON forum.messages + FOR SELECT + TO public + USING (true); +CREATE POLICY insert_own_messages ON forum.messages + FOR INSERT + TO public + WITH CHECK (true); +CREATE POLICY update_own_messages ON forum.messages + FOR UPDATE + TO public + USING (true) + WITH CHECK (true); diff --git a/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/seed-b.sql b/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/seed-b.sql new file mode 100644 index 000000000..17e8767ce --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO forum.messages (id, content, author_id, thread_id) VALUES (1, 'Hello world', 1, 1); diff --git a/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/seed.sql b/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/seed.sql new file mode 100644 index 000000000..17e8767ce --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policies-select-insert-update/seed.sql @@ -0,0 +1 @@ +INSERT INTO forum.messages (id, content, author_id, thread_id) VALUES (1, 'Hello world', 1, 1); diff --git a/packages/pg-delta/corpus/rls-operations--policy-comment/a.sql b/packages/pg-delta/corpus/rls-operations--policy-comment/a.sql new file mode 100644 index 000000000..b0e32a57f --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-comment/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA app; +CREATE TABLE app.docs ( + id integer PRIMARY KEY, + owner_id integer +); +ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY; +CREATE POLICY owner_only ON app.docs FOR ALL TO public USING (true); diff --git a/packages/pg-delta/corpus/rls-operations--policy-comment/b.sql b/packages/pg-delta/corpus/rls-operations--policy-comment/b.sql new file mode 100644 index 000000000..59adceccb --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-comment/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA app; +CREATE TABLE app.docs ( + id integer PRIMARY KEY, + owner_id integer +); +ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY; +CREATE POLICY owner_only ON app.docs FOR ALL TO public USING (true); +COMMENT ON POLICY owner_only ON app.docs IS 'only owners have access'; diff --git a/packages/pg-delta/corpus/rls-operations--policy-comment/seed-b.sql b/packages/pg-delta/corpus/rls-operations--policy-comment/seed-b.sql new file mode 100644 index 000000000..69e957b69 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-comment/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO app.docs (id, owner_id) VALUES (1, 1); diff --git a/packages/pg-delta/corpus/rls-operations--policy-comment/seed.sql b/packages/pg-delta/corpus/rls-operations--policy-comment/seed.sql new file mode 100644 index 000000000..69e957b69 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-comment/seed.sql @@ -0,0 +1 @@ +INSERT INTO app.docs (id, owner_id) VALUES (1, 1); diff --git a/packages/pg-delta/corpus/rls-operations--policy-roles-swap/a.sql b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/a.sql new file mode 100644 index 000000000..6971e6f34 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/a.sql @@ -0,0 +1,10 @@ +-- policy granted TO a role that only exists in this state. The +-- ALTER POLICY … TO must run after CREATE of the newly-listed role (consumes) +-- and before DROP of the removed role (releases) — otherwise dropping the old +-- role fails while the policy still references it. +CREATE ROLE role_a NOLOGIN; + +CREATE TABLE public.docs (id integer PRIMARY KEY); +ALTER TABLE public.docs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY docs_read ON public.docs FOR SELECT TO role_a USING (true); diff --git a/packages/pg-delta/corpus/rls-operations--policy-roles-swap/b.sql b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/b.sql new file mode 100644 index 000000000..1c1bfd02c --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/b.sql @@ -0,0 +1,9 @@ +-- docs_read reassigned from role_a to role_b; each role exists in only one +-- state, so the plan creates role_b and drops role_a around the +-- ALTER POLICY … TO. +CREATE ROLE role_b NOLOGIN; + +CREATE TABLE public.docs (id integer PRIMARY KEY); +ALTER TABLE public.docs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY docs_read ON public.docs FOR SELECT TO role_b USING (true); diff --git a/packages/pg-delta/corpus/rls-operations--policy-roles-swap/meta.json b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/rls-operations--policy-roles-swap/seed-b.sql b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/seed-b.sql new file mode 100644 index 000000000..7ac2de052 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.docs (id) VALUES (1); diff --git a/packages/pg-delta/corpus/rls-operations--policy-roles-swap/seed.sql b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/seed.sql new file mode 100644 index 000000000..7ac2de052 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--policy-roles-swap/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.docs (id) VALUES (1); diff --git a/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/a.sql b/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/a.sql new file mode 100644 index 000000000..ff44a1b8b --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/a.sql @@ -0,0 +1,17 @@ +CREATE SCHEMA app; +CREATE FUNCTION app.check_access(user_id uuid) +RETURNS boolean AS $$ +BEGIN + RETURN true; +END; +$$ LANGUAGE plpgsql; +CREATE TABLE app.docs ( + id integer PRIMARY KEY, + owner_id uuid, + content text +); +ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY; +CREATE POLICY docs_policy ON app.docs + FOR ALL + TO public + USING (app.check_access(owner_id)); diff --git a/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/b.sql b/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/b.sql new file mode 100644 index 000000000..412d107c7 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/b.sql @@ -0,0 +1,17 @@ +CREATE SCHEMA app; +CREATE FUNCTION app.check_access(user_id uuid, resource_id integer) +RETURNS boolean AS $$ +BEGIN + RETURN true; +END; +$$ LANGUAGE plpgsql; +CREATE TABLE app.docs ( + id integer PRIMARY KEY, + owner_id uuid, + content text +); +ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY; +CREATE POLICY docs_policy ON app.docs + FOR ALL + TO public + USING (app.check_access(owner_id, id)); diff --git a/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/seed-b.sql b/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/seed-b.sql new file mode 100644 index 000000000..4323e6dda --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO app.docs (id, owner_id, content) VALUES (1, '11111111-1111-1111-1111-111111111111', 'doc content'); diff --git a/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/seed.sql b/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/seed.sql new file mode 100644 index 000000000..4323e6dda --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--replace-function-referenced-by-policy/seed.sql @@ -0,0 +1 @@ +INSERT INTO app.docs (id, owner_id, content) VALUES (1, '11111111-1111-1111-1111-111111111111', 'doc content'); diff --git a/packages/pg-delta/corpus/rls-operations--restrictive-policy/a.sql b/packages/pg-delta/corpus/rls-operations--restrictive-policy/a.sql new file mode 100644 index 000000000..af7e61392 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--restrictive-policy/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA secure; +CREATE TABLE secure.sensitive_data ( + id INTEGER PRIMARY KEY, + data TEXT NOT NULL, + classification TEXT NOT NULL +); +ALTER TABLE secure.sensitive_data ENABLE ROW LEVEL SECURITY; diff --git a/packages/pg-delta/corpus/rls-operations--restrictive-policy/b.sql b/packages/pg-delta/corpus/rls-operations--restrictive-policy/b.sql new file mode 100644 index 000000000..45ad727f7 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--restrictive-policy/b.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA secure; +CREATE TABLE secure.sensitive_data ( + id INTEGER PRIMARY KEY, + data TEXT NOT NULL, + classification TEXT NOT NULL +); +ALTER TABLE secure.sensitive_data ENABLE ROW LEVEL SECURITY; +CREATE POLICY admin_only ON secure.sensitive_data + AS RESTRICTIVE + FOR SELECT + TO public + USING (true); diff --git a/packages/pg-delta/corpus/rls-operations--restrictive-policy/seed-b.sql b/packages/pg-delta/corpus/rls-operations--restrictive-policy/seed-b.sql new file mode 100644 index 000000000..b21d2bdd2 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--restrictive-policy/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO secure.sensitive_data (id, data, classification) VALUES (1, 'secret info', 'confidential'); diff --git a/packages/pg-delta/corpus/rls-operations--restrictive-policy/seed.sql b/packages/pg-delta/corpus/rls-operations--restrictive-policy/seed.sql new file mode 100644 index 000000000..b21d2bdd2 --- /dev/null +++ b/packages/pg-delta/corpus/rls-operations--restrictive-policy/seed.sql @@ -0,0 +1 @@ +INSERT INTO secure.sensitive_data (id, data, classification) VALUES (1, 'secret info', 'confidential'); diff --git a/packages/pg-delta/corpus/rls-policy/a.sql b/packages/pg-delta/corpus/rls-policy/a.sql new file mode 100644 index 000000000..7084a8771 --- /dev/null +++ b/packages/pg-delta/corpus/rls-policy/a.sql @@ -0,0 +1 @@ +CREATE TABLE public.notes (id integer PRIMARY KEY, owner_name text NOT NULL); diff --git a/packages/pg-delta/corpus/rls-policy/b.sql b/packages/pg-delta/corpus/rls-policy/b.sql new file mode 100644 index 000000000..6db418d96 --- /dev/null +++ b/packages/pg-delta/corpus/rls-policy/b.sql @@ -0,0 +1,3 @@ +CREATE TABLE public.notes (id integer PRIMARY KEY, owner_name text NOT NULL); +ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY; +CREATE POLICY notes_owner ON public.notes FOR SELECT USING (owner_name = current_user); diff --git a/packages/pg-delta/corpus/rls-policy/seed-b.sql b/packages/pg-delta/corpus/rls-policy/seed-b.sql new file mode 100644 index 000000000..6c49e2a7e --- /dev/null +++ b/packages/pg-delta/corpus/rls-policy/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.notes (id, owner_name) VALUES (1, 'testuser'); diff --git a/packages/pg-delta/corpus/rls-policy/seed.sql b/packages/pg-delta/corpus/rls-policy/seed.sql new file mode 100644 index 000000000..6c49e2a7e --- /dev/null +++ b/packages/pg-delta/corpus/rls-policy/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.notes (id, owner_name) VALUES (1, 'testuser'); diff --git a/packages/pg-delta/corpus/role-config--create-configured-role/a.sql b/packages/pg-delta/corpus/role-config--create-configured-role/a.sql new file mode 100644 index 000000000..d2059dc52 --- /dev/null +++ b/packages/pg-delta/corpus/role-config--create-configured-role/a.sql @@ -0,0 +1 @@ +-- state A: the role does not exist at all diff --git a/packages/pg-delta/corpus/role-config--create-configured-role/b.sql b/packages/pg-delta/corpus/role-config--create-configured-role/b.sql new file mode 100644 index 000000000..5d9412b8f --- /dev/null +++ b/packages/pg-delta/corpus/role-config--create-configured-role/b.sql @@ -0,0 +1,5 @@ +-- state B: a freshly created role that already carries GUC config. The CREATE +-- must materialize the config (ALTER ROLE ... SET), not just the flags. +CREATE ROLE configured_api NOLOGIN NOINHERIT; +ALTER ROLE configured_api SET statement_timeout = '5s'; +ALTER ROLE configured_api SET lock_timeout = '1s'; diff --git a/packages/pg-delta/corpus/role-config--create-configured-role/meta.json b/packages/pg-delta/corpus/role-config--create-configured-role/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/role-config--create-configured-role/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/role-config--set-custom-guc/a.sql b/packages/pg-delta/corpus/role-config--set-custom-guc/a.sql new file mode 100644 index 000000000..cb70d9add --- /dev/null +++ b/packages/pg-delta/corpus/role-config--set-custom-guc/a.sql @@ -0,0 +1,2 @@ +-- state A: role exists with no GUC settings +CREATE ROLE authenticator NOLOGIN NOINHERIT; diff --git a/packages/pg-delta/corpus/role-config--set-custom-guc/b.sql b/packages/pg-delta/corpus/role-config--set-custom-guc/b.sql new file mode 100644 index 000000000..4817430b5 --- /dev/null +++ b/packages/pg-delta/corpus/role-config--set-custom-guc/b.sql @@ -0,0 +1,3 @@ +-- state B: role has pgrst.db_aggregates_enabled GUC set (ALTER ROLE ... SET) +CREATE ROLE authenticator NOLOGIN NOINHERIT; +ALTER ROLE authenticator SET pgrst.db_aggregates_enabled = 'true'; diff --git a/packages/pg-delta/corpus/role-config--set-custom-guc/meta.json b/packages/pg-delta/corpus/role-config--set-custom-guc/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/role-config--set-custom-guc/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/role-config--swap-guc-settings/a.sql b/packages/pg-delta/corpus/role-config--swap-guc-settings/a.sql new file mode 100644 index 000000000..8771e65f2 --- /dev/null +++ b/packages/pg-delta/corpus/role-config--swap-guc-settings/a.sql @@ -0,0 +1,3 @@ +-- state A: role has statement_timeout set +CREATE ROLE api_role NOLOGIN NOINHERIT; +ALTER ROLE api_role SET statement_timeout = '3s'; diff --git a/packages/pg-delta/corpus/role-config--swap-guc-settings/b.sql b/packages/pg-delta/corpus/role-config--swap-guc-settings/b.sql new file mode 100644 index 000000000..fc047545c --- /dev/null +++ b/packages/pg-delta/corpus/role-config--swap-guc-settings/b.sql @@ -0,0 +1,3 @@ +-- state B: statement_timeout removed, lock_timeout added instead +CREATE ROLE api_role NOLOGIN NOINHERIT; +ALTER ROLE api_role SET lock_timeout = '5s'; diff --git a/packages/pg-delta/corpus/role-config--swap-guc-settings/meta.json b/packages/pg-delta/corpus/role-config--swap-guc-settings/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/role-config--swap-guc-settings/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/role-membership-dedup--admin-option/a.sql b/packages/pg-delta/corpus/role-membership-dedup--admin-option/a.sql new file mode 100644 index 000000000..86c41a658 --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--admin-option/a.sql @@ -0,0 +1,3 @@ +-- state A: roles exist, no membership +CREATE ROLE parent_role NOLOGIN; +CREATE ROLE child_role NOLOGIN; diff --git a/packages/pg-delta/corpus/role-membership-dedup--admin-option/b.sql b/packages/pg-delta/corpus/role-membership-dedup--admin-option/b.sql new file mode 100644 index 000000000..61d7fec7d --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--admin-option/b.sql @@ -0,0 +1,4 @@ +-- state B: child_role is a member of parent_role WITH ADMIN OPTION +CREATE ROLE parent_role NOLOGIN; +CREATE ROLE child_role NOLOGIN; +GRANT parent_role TO child_role WITH ADMIN OPTION; diff --git a/packages/pg-delta/corpus/role-membership-dedup--admin-option/meta.json b/packages/pg-delta/corpus/role-membership-dedup--admin-option/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--admin-option/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/role-membership-dedup--basic-membership/a.sql b/packages/pg-delta/corpus/role-membership-dedup--basic-membership/a.sql new file mode 100644 index 000000000..a12767591 --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--basic-membership/a.sql @@ -0,0 +1,3 @@ +-- state A: roles exist but no membership +CREATE ROLE parent_role NOLOGIN; +CREATE ROLE child_role NOLOGIN; diff --git a/packages/pg-delta/corpus/role-membership-dedup--basic-membership/b.sql b/packages/pg-delta/corpus/role-membership-dedup--basic-membership/b.sql new file mode 100644 index 000000000..b1d067c29 --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--basic-membership/b.sql @@ -0,0 +1,4 @@ +-- state B: child_role is a member of parent_role +CREATE ROLE parent_role NOLOGIN; +CREATE ROLE child_role NOLOGIN; +GRANT parent_role TO child_role; diff --git a/packages/pg-delta/corpus/role-membership-dedup--basic-membership/meta.json b/packages/pg-delta/corpus/role-membership-dedup--basic-membership/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--basic-membership/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/a.sql b/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/a.sql new file mode 100644 index 000000000..45f503fc4 --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/a.sql @@ -0,0 +1 @@ +-- state A: empty — no roles yet diff --git a/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/b.sql b/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/b.sql new file mode 100644 index 000000000..86891ba25 --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/b.sql @@ -0,0 +1,11 @@ +-- state B: parent_role has child_role as member granted by two grantors +-- (PG16+ allows multiple pg_auth_members rows for same role/member pair with different grantors) +-- The effective membership should deduplicate to a single GRANT in the plan. +CREATE ROLE admin_grantor CREATEROLE; +CREATE ROLE parent_role NOLOGIN; +CREATE ROLE child_role NOLOGIN; +GRANT parent_role TO admin_grantor WITH ADMIN OPTION; +GRANT parent_role TO child_role; +SET ROLE admin_grantor; +GRANT parent_role TO child_role; +RESET ROLE; diff --git a/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/meta.json b/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/meta.json new file mode 100644 index 000000000..13083e028 --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--multi-grantor/meta.json @@ -0,0 +1 @@ +{ "minVersion": 16, "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/a.sql b/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/a.sql new file mode 100644 index 000000000..00f66a29f --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/a.sql @@ -0,0 +1,7 @@ +-- state A: child_role is a member of parent_role, granted by ONE grantor (postgres). +-- A single pg_auth_members row for (parent_role, child_role). +CREATE ROLE admin_grantor CREATEROLE; +CREATE ROLE parent_role NOLOGIN; +CREATE ROLE child_role NOLOGIN; +GRANT parent_role TO admin_grantor WITH ADMIN OPTION; +GRANT parent_role TO child_role; diff --git a/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/b.sql b/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/b.sql new file mode 100644 index 000000000..b00a100e9 --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/b.sql @@ -0,0 +1,14 @@ +-- state B: same effective membership as A — child_role is a member of parent_role — +-- but granted by TWO grantors (postgres and admin_grantor), producing two +-- pg_auth_members rows for (parent_role, child_role) on PG16+. +-- After grantor dedup the membership fact is identical to A, so both the forward +-- (A->B) and reverse (B->A) membership plans must be empty: no GRANT/REVOKE of +-- parent_role to/from child_role. +CREATE ROLE admin_grantor CREATEROLE; +CREATE ROLE parent_role NOLOGIN; +CREATE ROLE child_role NOLOGIN; +GRANT parent_role TO admin_grantor WITH ADMIN OPTION; +GRANT parent_role TO child_role; +SET ROLE admin_grantor; +GRANT parent_role TO child_role; +RESET ROLE; diff --git a/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/meta.json b/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/meta.json new file mode 100644 index 000000000..13083e028 --- /dev/null +++ b/packages/pg-delta/corpus/role-membership-dedup--same-membership-different-grantors/meta.json @@ -0,0 +1 @@ +{ "minVersion": 16, "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/role-option--role-owned-table/a.sql b/packages/pg-delta/corpus/role-option--role-owned-table/a.sql new file mode 100644 index 000000000..fe723229e --- /dev/null +++ b/packages/pg-delta/corpus/role-option--role-owned-table/a.sql @@ -0,0 +1,5 @@ +-- state A: role exists with schema, no table yet +CREATE ROLE extraction_test_role NOLOGIN; +CREATE SCHEMA test_schema; +GRANT USAGE ON SCHEMA test_schema TO extraction_test_role; +GRANT CREATE ON SCHEMA test_schema TO extraction_test_role; diff --git a/packages/pg-delta/corpus/role-option--role-owned-table/b.sql b/packages/pg-delta/corpus/role-option--role-owned-table/b.sql new file mode 100644 index 000000000..58449631f --- /dev/null +++ b/packages/pg-delta/corpus/role-option--role-owned-table/b.sql @@ -0,0 +1,7 @@ +-- state B: table created in schema, owned by the role +CREATE ROLE extraction_test_role NOLOGIN; +CREATE SCHEMA test_schema; +GRANT USAGE ON SCHEMA test_schema TO extraction_test_role; +GRANT CREATE ON SCHEMA test_schema TO extraction_test_role; +CREATE TABLE test_schema.role_owned_table (id integer); +ALTER TABLE test_schema.role_owned_table OWNER TO extraction_test_role; diff --git a/packages/pg-delta/corpus/role-option--role-owned-table/meta.json b/packages/pg-delta/corpus/role-option--role-owned-table/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/role-option--role-owned-table/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/rule-operations--comment/a.sql b/packages/pg-delta/corpus/rule-operations--comment/a.sql new file mode 100644 index 000000000..85c69833b --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--comment/a.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; diff --git a/packages/pg-delta/corpus/rule-operations--comment/b.sql b/packages/pg-delta/corpus/rule-operations--comment/b.sql new file mode 100644 index 000000000..978c09968 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--comment/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; +COMMENT ON RULE prevent_negative_balance ON test_schema.accounts IS 'prevent inserting negative balances'; diff --git a/packages/pg-delta/corpus/rule-operations--create-disabled-rule/a.sql b/packages/pg-delta/corpus/rule-operations--create-disabled-rule/a.sql new file mode 100644 index 000000000..8a8f38c08 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--create-disabled-rule/a.sql @@ -0,0 +1,6 @@ +-- state A: table only, no rewrite rule yet +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); diff --git a/packages/pg-delta/corpus/rule-operations--create-disabled-rule/b.sql b/packages/pg-delta/corpus/rule-operations--create-disabled-rule/b.sql new file mode 100644 index 000000000..617c1c17b --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--create-disabled-rule/b.sql @@ -0,0 +1,14 @@ +-- state B: the rewrite rule is CREATED already DISABLED. A fresh CREATE RULE +-- lands enabled (origin); the create path must append the follow-up +-- `ALTER TABLE … DISABLE RULE …` so the disabled state converges (the +-- rule's ev_enabled is hashed, so an enabled-vs-disabled gap never converges). +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; +ALTER TABLE test_schema.accounts DISABLE RULE prevent_negative_balance; diff --git a/packages/pg-delta/corpus/rule-operations--create-rule-do-instead-nothing/a.sql b/packages/pg-delta/corpus/rule-operations--create-rule-do-instead-nothing/a.sql new file mode 100644 index 000000000..5b4d1a57a --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--create-rule-do-instead-nothing/a.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); diff --git a/packages/pg-delta/corpus/rule-operations--create-rule-do-instead-nothing/b.sql b/packages/pg-delta/corpus/rule-operations--create-rule-do-instead-nothing/b.sql new file mode 100644 index 000000000..85c69833b --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--create-rule-do-instead-nothing/b.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; diff --git a/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/a.sql b/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/a.sql new file mode 100644 index 000000000..a43826c63 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/a.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE TABLE test_schema.rule_events ( + message text NOT NULL, + created_at timestamptz DEFAULT now() +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; diff --git a/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/b.sql b/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/b.sql new file mode 100644 index 000000000..82422da52 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/b.sql @@ -0,0 +1,14 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE TABLE test_schema.rule_events ( + message text NOT NULL, + created_at timestamptz DEFAULT now() +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO ALSO INSERT INTO test_schema.rule_events (message) + VALUES ('negative balance attempt detected'); diff --git a/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/seed-b.sql b/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/seed-b.sql new file mode 100644 index 000000000..541b45295 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.rule_events (message) VALUES ('seed message'); diff --git a/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/seed.sql b/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/seed.sql new file mode 100644 index 000000000..541b45295 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--replace-rule-do-also-insert/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.rule_events (message) VALUES ('seed message'); diff --git a/packages/pg-delta/corpus/rule-operations--rule-depends-on-new-column/a.sql b/packages/pg-delta/corpus/rule-operations--rule-depends-on-new-column/a.sql new file mode 100644 index 000000000..7fd62d95a --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--rule-depends-on-new-column/a.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + note text +); diff --git a/packages/pg-delta/corpus/rule-operations--rule-depends-on-new-column/b.sql b/packages/pg-delta/corpus/rule-operations--rule-depends-on-new-column/b.sql new file mode 100644 index 000000000..04748ef34 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--rule-depends-on-new-column/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + note text, + flagged boolean +); +CREATE RULE prevent_flagged_insert AS + ON INSERT TO test_schema.accounts + WHERE (NEW.flagged) + DO INSTEAD NOTHING; diff --git a/packages/pg-delta/corpus/rule-operations--rule-enable-always/a.sql b/packages/pg-delta/corpus/rule-operations--rule-enable-always/a.sql new file mode 100644 index 000000000..0aa960b31 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--rule-enable-always/a.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; +ALTER TABLE test_schema.accounts DISABLE RULE prevent_negative_balance; diff --git a/packages/pg-delta/corpus/rule-operations--rule-enable-always/b.sql b/packages/pg-delta/corpus/rule-operations--rule-enable-always/b.sql new file mode 100644 index 000000000..4101db818 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--rule-enable-always/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; +ALTER TABLE test_schema.accounts ENABLE ALWAYS RULE prevent_negative_balance; diff --git a/packages/pg-delta/corpus/rule-operations--rule-enabled-state/a.sql b/packages/pg-delta/corpus/rule-operations--rule-enabled-state/a.sql new file mode 100644 index 000000000..85c69833b --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--rule-enabled-state/a.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; diff --git a/packages/pg-delta/corpus/rule-operations--rule-enabled-state/b.sql b/packages/pg-delta/corpus/rule-operations--rule-enabled-state/b.sql new file mode 100644 index 000000000..0aa960b31 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--rule-enabled-state/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + balance numeric NOT NULL DEFAULT 0 +); +CREATE RULE prevent_negative_balance AS + ON INSERT TO test_schema.accounts + WHERE (NEW.balance < 0) + DO INSTEAD NOTHING; +ALTER TABLE test_schema.accounts DISABLE RULE prevent_negative_balance; diff --git a/packages/pg-delta/corpus/rule-operations--table-rule-function-dependency/a.sql b/packages/pg-delta/corpus/rule-operations--table-rule-function-dependency/a.sql new file mode 100644 index 000000000..9edc1ab70 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--table-rule-function-dependency/a.sql @@ -0,0 +1,24 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.audit_log ( + id serial PRIMARY KEY, + msg text +); + +CREATE TABLE test_schema.events ( + id serial PRIMARY KEY, + name text +); + +CREATE FUNCTION test_schema.f1(text) RETURNS text + LANGUAGE sql IMMUTABLE AS $$ SELECT 'f1:' || $1 $$; + +-- A user rule on a PLAIN TABLE (relkind 'r') whose action references f1. +-- pg_depend records rule -> f1. When f1 is forced to drop+recreate (a +-- return-type change is a "replace"), the rule is otherwise UNCHANGED, so it can +-- only be rebuilt around the function via the rule -> f1 dependency edge. If that +-- edge is dropped, DROP FUNCTION f1 fails because the rule still depends on it. +CREATE RULE log_insert AS + ON INSERT TO test_schema.events + DO ALSO INSERT INTO test_schema.audit_log (msg) + VALUES (test_schema.f1(NEW.name)); diff --git a/packages/pg-delta/corpus/rule-operations--table-rule-function-dependency/b.sql b/packages/pg-delta/corpus/rule-operations--table-rule-function-dependency/b.sql new file mode 100644 index 000000000..2dc39f2b0 --- /dev/null +++ b/packages/pg-delta/corpus/rule-operations--table-rule-function-dependency/b.sql @@ -0,0 +1,24 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.audit_log ( + id serial PRIMARY KEY, + msg text +); + +CREATE TABLE test_schema.events ( + id serial PRIMARY KEY, + name text +); + +-- Same function, same signature and body, but the RETURN TYPE changed +-- (text -> varchar). CREATE OR REPLACE cannot change a return type, so the +-- engine must drop+recreate f1 (a "replace"). The rule below is byte-identical +-- to side a (the call renders the same regardless of return type), so it is only +-- rebuilt if the rule -> f1 dependency edge exists. +CREATE FUNCTION test_schema.f1(text) RETURNS varchar + LANGUAGE sql IMMUTABLE AS $$ SELECT 'f1:' || $1 $$; + +CREATE RULE log_insert AS + ON INSERT TO test_schema.events + DO ALSO INSERT INTO test_schema.audit_log (msg) + VALUES (test_schema.f1(NEW.name)); diff --git a/packages/pg-delta/corpus/sensitive-handling--role-with-login/a.sql b/packages/pg-delta/corpus/sensitive-handling--role-with-login/a.sql new file mode 100644 index 000000000..363218558 --- /dev/null +++ b/packages/pg-delta/corpus/sensitive-handling--role-with-login/a.sql @@ -0,0 +1 @@ +-- state A: no login role diff --git a/packages/pg-delta/corpus/sensitive-handling--role-with-login/b.sql b/packages/pg-delta/corpus/sensitive-handling--role-with-login/b.sql new file mode 100644 index 000000000..e86a2ec28 --- /dev/null +++ b/packages/pg-delta/corpus/sensitive-handling--role-with-login/b.sql @@ -0,0 +1,2 @@ +-- state B: role with LOGIN created (password is sensitive and must not appear in plan output) +DO $$ BEGIN CREATE ROLE corpus_test_login_role LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/packages/pg-delta/corpus/sensitive-handling--server-options-alter/a.sql b/packages/pg-delta/corpus/sensitive-handling--server-options-alter/a.sql new file mode 100644 index 000000000..6c88233aa --- /dev/null +++ b/packages/pg-delta/corpus/sensitive-handling--server-options-alter/a.sql @@ -0,0 +1,5 @@ +-- state A: server with initial host/port/dbname/fetch_size options +CREATE FOREIGN DATA WRAPPER corpus_env_fdw; +CREATE SERVER corpus_env_server + FOREIGN DATA WRAPPER corpus_env_fdw + OPTIONS (host 'prod.example.com', port '5432', dbname 'prod_db', fetch_size '100'); diff --git a/packages/pg-delta/corpus/sensitive-handling--server-options-alter/b.sql b/packages/pg-delta/corpus/sensitive-handling--server-options-alter/b.sql new file mode 100644 index 000000000..6f5ac1c37 --- /dev/null +++ b/packages/pg-delta/corpus/sensitive-handling--server-options-alter/b.sql @@ -0,0 +1,5 @@ +-- state B: server options changed to dev environment values +CREATE FOREIGN DATA WRAPPER corpus_env_fdw; +CREATE SERVER corpus_env_server + FOREIGN DATA WRAPPER corpus_env_fdw + OPTIONS (host 'dev.example.com', port '5433', dbname 'dev_db', fetch_size '200'); diff --git a/packages/pg-delta/corpus/sensitive-handling--server-with-sensitive-options/a.sql b/packages/pg-delta/corpus/sensitive-handling--server-with-sensitive-options/a.sql new file mode 100644 index 000000000..19faa8f53 --- /dev/null +++ b/packages/pg-delta/corpus/sensitive-handling--server-with-sensitive-options/a.sql @@ -0,0 +1 @@ +-- state A: no FDW or server diff --git a/packages/pg-delta/corpus/sensitive-handling--server-with-sensitive-options/b.sql b/packages/pg-delta/corpus/sensitive-handling--server-with-sensitive-options/b.sql new file mode 100644 index 000000000..3ccfb0a18 --- /dev/null +++ b/packages/pg-delta/corpus/sensitive-handling--server-with-sensitive-options/b.sql @@ -0,0 +1,5 @@ +-- state B: FDW + server with password option (sensitive option must be redacted in plan) +CREATE FOREIGN DATA WRAPPER corpus_test_fdw; +CREATE SERVER corpus_test_server + FOREIGN DATA WRAPPER corpus_test_fdw + OPTIONS (password 'secret123', user 'testuser', host 'localhost'); diff --git a/packages/pg-delta/corpus/sensitive-handling--user-mapping-options/a.sql b/packages/pg-delta/corpus/sensitive-handling--user-mapping-options/a.sql new file mode 100644 index 000000000..124ba1b70 --- /dev/null +++ b/packages/pg-delta/corpus/sensitive-handling--user-mapping-options/a.sql @@ -0,0 +1,5 @@ +-- state A: postgres_fdw installed, server exists, no user mapping +CREATE EXTENSION IF NOT EXISTS postgres_fdw; +CREATE SERVER corpus_um_server + FOREIGN DATA WRAPPER postgres_fdw + OPTIONS (host 'localhost'); diff --git a/packages/pg-delta/corpus/sensitive-handling--user-mapping-options/b.sql b/packages/pg-delta/corpus/sensitive-handling--user-mapping-options/b.sql new file mode 100644 index 000000000..36848a6b2 --- /dev/null +++ b/packages/pg-delta/corpus/sensitive-handling--user-mapping-options/b.sql @@ -0,0 +1,8 @@ +-- state B: user mapping with password (password must be redacted in plan) +CREATE EXTENSION IF NOT EXISTS postgres_fdw; +CREATE SERVER corpus_um_server + FOREIGN DATA WRAPPER postgres_fdw + OPTIONS (host 'localhost'); +CREATE USER MAPPING FOR CURRENT_USER + SERVER corpus_um_server + OPTIONS (user 'testuser', password 'secret456'); diff --git a/packages/pg-delta/corpus/sequence-default/a.sql b/packages/pg-delta/corpus/sequence-default/a.sql new file mode 100644 index 000000000..8494a4263 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-default/a.sql @@ -0,0 +1 @@ +-- empty: sequence + table whose default references it (ordering test) diff --git a/packages/pg-delta/corpus/sequence-default/b.sql b/packages/pg-delta/corpus/sequence-default/b.sql new file mode 100644 index 000000000..35e554c38 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-default/b.sql @@ -0,0 +1,5 @@ +CREATE SEQUENCE public.order_seq START 1000 INCREMENT 10; +CREATE TABLE public.orders ( + id bigint DEFAULT nextval('public.order_seq') PRIMARY KEY, + note text +); diff --git a/packages/pg-delta/corpus/sequence-operations--alter-bounds/a.sql b/packages/pg-delta/corpus/sequence-operations--alter-bounds/a.sql new file mode 100644 index 000000000..d3d5e756c --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--alter-bounds/a.sql @@ -0,0 +1 @@ +CREATE SEQUENCE public.bounded MINVALUE 1 MAXVALUE 50; diff --git a/packages/pg-delta/corpus/sequence-operations--alter-bounds/b.sql b/packages/pg-delta/corpus/sequence-operations--alter-bounds/b.sql new file mode 100644 index 000000000..939af1b6a --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--alter-bounds/b.sql @@ -0,0 +1 @@ +CREATE SEQUENCE public.bounded MINVALUE 100 MAXVALUE 200; diff --git a/packages/pg-delta/corpus/sequence-operations--alter-owned-sequence-data-type/a.sql b/packages/pg-delta/corpus/sequence-operations--alter-owned-sequence-data-type/a.sql new file mode 100644 index 000000000..a01cce58c --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--alter-owned-sequence-data-type/a.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.user_id_seq AS integer; +CREATE TABLE test_schema.users ( + id bigint PRIMARY KEY DEFAULT nextval('test_schema.user_id_seq'::regclass) +); +ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; diff --git a/packages/pg-delta/corpus/sequence-operations--alter-owned-sequence-data-type/b.sql b/packages/pg-delta/corpus/sequence-operations--alter-owned-sequence-data-type/b.sql new file mode 100644 index 000000000..377e46bcc --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--alter-owned-sequence-data-type/b.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.user_id_seq AS bigint; +CREATE TABLE test_schema.users ( + id bigint PRIMARY KEY DEFAULT nextval('test_schema.user_id_seq'::regclass) +); +ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; diff --git a/packages/pg-delta/corpus/sequence-operations--alter-sequence-properties/a.sql b/packages/pg-delta/corpus/sequence-operations--alter-sequence-properties/a.sql new file mode 100644 index 000000000..cdc550829 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--alter-sequence-properties/a.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.test_seq INCREMENT BY 1 CACHE 1; diff --git a/packages/pg-delta/corpus/sequence-operations--alter-sequence-properties/b.sql b/packages/pg-delta/corpus/sequence-operations--alter-sequence-properties/b.sql new file mode 100644 index 000000000..1b22c3cce --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--alter-sequence-properties/b.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.test_seq INCREMENT BY 5 CACHE 10; diff --git a/packages/pg-delta/corpus/sequence-operations--comment/a.sql b/packages/pg-delta/corpus/sequence-operations--comment/a.sql new file mode 100644 index 000000000..535b6a9e3 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--comment/a.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.seq1; diff --git a/packages/pg-delta/corpus/sequence-operations--comment/b.sql b/packages/pg-delta/corpus/sequence-operations--comment/b.sql new file mode 100644 index 000000000..bf306ea3b --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--comment/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.seq1; +COMMENT ON SEQUENCE test_schema.seq1 IS 'test sequence comment'; diff --git a/packages/pg-delta/corpus/sequence-operations--create-sequence-with-options/a.sql b/packages/pg-delta/corpus/sequence-operations--create-sequence-with-options/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--create-sequence-with-options/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/sequence-operations--create-sequence-with-options/b.sql b/packages/pg-delta/corpus/sequence-operations--create-sequence-with-options/b.sql new file mode 100644 index 000000000..9f7e59c6f --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--create-sequence-with-options/b.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.custom_seq + AS integer + INCREMENT BY 2 + MINVALUE 10 + MAXVALUE 1000 + START WITH 10 + CACHE 5 + CYCLE; diff --git a/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/a.sql b/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/a.sql new file mode 100644 index 000000000..fe4763828 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/a.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.my_seq START 1000; +CREATE TABLE test_schema.items ( + id integer PRIMARY KEY DEFAULT nextval('test_schema.my_seq'::regclass), + name text +); diff --git a/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/b.sql b/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/b.sql new file mode 100644 index 000000000..487c467fa --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.items ( + id integer PRIMARY KEY, + name text +); diff --git a/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/seed-b.sql b/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/seed-b.sql new file mode 100644 index 000000000..b507db19a --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--drop-sequence-referenced-by-default/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.items (id, name) VALUES (1, 'item one'); diff --git a/packages/pg-delta/corpus/sequence-operations--drop-table-with-owned-sequence/a.sql b/packages/pg-delta/corpus/sequence-operations--drop-table-with-owned-sequence/a.sql new file mode 100644 index 000000000..bcaf46208 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--drop-table-with-owned-sequence/a.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.user_id_seq; +CREATE TABLE test_schema.users ( + id bigint PRIMARY KEY DEFAULT nextval('test_schema.user_id_seq') +); +ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; diff --git a/packages/pg-delta/corpus/sequence-operations--drop-table-with-owned-sequence/b.sql b/packages/pg-delta/corpus/sequence-operations--drop-table-with-owned-sequence/b.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--drop-table-with-owned-sequence/b.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/sequence-operations--owned-by-column-with-table-default/a.sql b/packages/pg-delta/corpus/sequence-operations--owned-by-column-with-table-default/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--owned-by-column-with-table-default/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/sequence-operations--owned-by-column-with-table-default/b.sql b/packages/pg-delta/corpus/sequence-operations--owned-by-column-with-table-default/b.sql new file mode 100644 index 000000000..bcaf46208 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--owned-by-column-with-table-default/b.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.user_id_seq; +CREATE TABLE test_schema.users ( + id bigint PRIMARY KEY DEFAULT nextval('test_schema.user_id_seq') +); +ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; diff --git a/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/a.sql b/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/a.sql new file mode 100644 index 000000000..1907cb4be --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/a.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.items ( + c1 int NOT NULL, + c2 serial, + c3 int GENERATED ALWAYS AS IDENTITY +); diff --git a/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/b.sql b/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/b.sql new file mode 100644 index 000000000..7ebd7dd1a --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; +CREATE SEQUENCE test_schema.items_c1_seq; +CREATE TABLE test_schema.items ( + c1 int NOT NULL DEFAULT nextval('test_schema.items_c1_seq'::regclass), + c2 int GENERATED ALWAYS AS IDENTITY, + c3 int GENERATED BY DEFAULT AS IDENTITY +); +ALTER SEQUENCE test_schema.items_c1_seq OWNED BY test_schema.items.c1; diff --git a/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/seed.sql b/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/seed.sql new file mode 100644 index 000000000..1a3493051 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--serial-and-identity-transition/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.items (c1) VALUES (1); diff --git a/packages/pg-delta/corpus/sequence-operations--serial-column/a.sql b/packages/pg-delta/corpus/sequence-operations--serial-column/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--serial-column/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/sequence-operations--serial-column/b.sql b/packages/pg-delta/corpus/sequence-operations--serial-column/b.sql new file mode 100644 index 000000000..5582c144b --- /dev/null +++ b/packages/pg-delta/corpus/sequence-operations--serial-column/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.users ( + id SERIAL PRIMARY KEY, + name TEXT +); diff --git a/packages/pg-delta/corpus/sequence-ops--owned-by-swap-tables/a.sql b/packages/pg-delta/corpus/sequence-ops--owned-by-swap-tables/a.sql new file mode 100644 index 000000000..2fdc06206 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-ops--owned-by-swap-tables/a.sql @@ -0,0 +1,9 @@ +-- sequence OWNED BY a column whose table only exists in this state. The +-- ALTER SEQUENCE … OWNED BY must run after CREATE of the new owning table +-- (consumes) and before DROP of the old owning table (releases) — otherwise +-- dropping the old owner cascades the sequence away first. +CREATE SCHEMA app; + +CREATE TABLE app.t1 (id integer PRIMARY KEY); + +CREATE SEQUENCE app.counter OWNED BY app.t1.id; diff --git a/packages/pg-delta/corpus/sequence-ops--owned-by-swap-tables/b.sql b/packages/pg-delta/corpus/sequence-ops--owned-by-swap-tables/b.sql new file mode 100644 index 000000000..556ce47f7 --- /dev/null +++ b/packages/pg-delta/corpus/sequence-ops--owned-by-swap-tables/b.sql @@ -0,0 +1,8 @@ +-- ownership of app.counter moves from app.t1.id to app.t2.id; each owning +-- table exists in only one state, so the plan creates t2 and drops t1 around +-- the ALTER SEQUENCE … OWNED BY. +CREATE SCHEMA app; + +CREATE TABLE app.t2 (id integer PRIMARY KEY); + +CREATE SEQUENCE app.counter OWNED BY app.t2.id; diff --git a/packages/pg-delta/corpus/subscription-operations--add-comment/a.sql b/packages/pg-delta/corpus/subscription-operations--add-comment/a.sql new file mode 100644 index 000000000..2518cb63e --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--add-comment/a.sql @@ -0,0 +1,5 @@ +CREATE PUBLICATION corpus_sub_comment_pub FOR ALL TABLES; +CREATE SUBSCRIPTION corpus_sub_comment + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_sub_comment_pub + WITH (connect = false, slot_name = NONE, enabled = false); diff --git a/packages/pg-delta/corpus/subscription-operations--add-comment/b.sql b/packages/pg-delta/corpus/subscription-operations--add-comment/b.sql new file mode 100644 index 000000000..c77c69cbd --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--add-comment/b.sql @@ -0,0 +1,6 @@ +CREATE PUBLICATION corpus_sub_comment_pub FOR ALL TABLES; +CREATE SUBSCRIPTION corpus_sub_comment + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_sub_comment_pub + WITH (connect = false, slot_name = NONE, enabled = false); +COMMENT ON SUBSCRIPTION corpus_sub_comment IS 'subscription comment'; diff --git a/packages/pg-delta/corpus/subscription-operations--alter-configuration/a.sql b/packages/pg-delta/corpus/subscription-operations--alter-configuration/a.sql new file mode 100644 index 000000000..dedf448e0 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--alter-configuration/a.sql @@ -0,0 +1,11 @@ +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_sub_owner') THEN + CREATE ROLE corpus_sub_owner SUPERUSER; + END IF; +END $$; +CREATE PUBLICATION corpus_sub_alter_pub FOR ALL TABLES; +CREATE PUBLICATION corpus_sub_alter_pub2 FOR ALL TABLES; +CREATE SUBSCRIPTION corpus_sub_alter + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_sub_alter_pub + WITH (connect = false, slot_name = NONE, enabled = false); diff --git a/packages/pg-delta/corpus/subscription-operations--alter-configuration/b.sql b/packages/pg-delta/corpus/subscription-operations--alter-configuration/b.sql new file mode 100644 index 000000000..9a1395374 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--alter-configuration/b.sql @@ -0,0 +1,22 @@ +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'corpus_sub_owner') THEN + CREATE ROLE corpus_sub_owner SUPERUSER; + END IF; +END $$; +CREATE PUBLICATION corpus_sub_alter_pub FOR ALL TABLES; +CREATE PUBLICATION corpus_sub_alter_pub2 FOR ALL TABLES; +-- Subscription with altered connection string, publication list, and options. +-- The b-state represents the final desired configuration after all ALTER steps. +CREATE SUBSCRIPTION corpus_sub_alter + CONNECTION 'host=localhost dbname=postgres application_name=corpus_sub_alter' + PUBLICATION corpus_sub_alter_pub, corpus_sub_alter_pub2 + WITH ( + connect = false, + slot_name = 'corpus_sub_alter_slot', + enabled = false, + binary = true, + synchronous_commit = 'local', + disable_on_error = true + ); +COMMENT ON SUBSCRIPTION corpus_sub_alter IS 'subscription metadata'; +ALTER SUBSCRIPTION corpus_sub_alter OWNER TO corpus_sub_owner; diff --git a/packages/pg-delta/corpus/subscription-operations--alter-configuration/meta.json b/packages/pg-delta/corpus/subscription-operations--alter-configuration/meta.json new file mode 100644 index 000000000..76a3965dd --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--alter-configuration/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true, "minVersion": 15 } diff --git a/packages/pg-delta/corpus/subscription-operations--comment-dependency-ordering/a.sql b/packages/pg-delta/corpus/subscription-operations--comment-dependency-ordering/a.sql new file mode 100644 index 000000000..98464649b --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--comment-dependency-ordering/a.sql @@ -0,0 +1 @@ +CREATE PUBLICATION corpus_sub_dep_pub FOR ALL TABLES; diff --git a/packages/pg-delta/corpus/subscription-operations--comment-dependency-ordering/b.sql b/packages/pg-delta/corpus/subscription-operations--comment-dependency-ordering/b.sql new file mode 100644 index 000000000..281159b17 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--comment-dependency-ordering/b.sql @@ -0,0 +1,6 @@ +CREATE PUBLICATION corpus_sub_dep_pub FOR ALL TABLES; +CREATE SUBSCRIPTION corpus_sub_dep + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_sub_dep_pub + WITH (connect = false, slot_name = NONE, enabled = false); +COMMENT ON SUBSCRIPTION corpus_sub_dep IS 'dependency check'; diff --git a/packages/pg-delta/corpus/subscription-operations--create/a.sql b/packages/pg-delta/corpus/subscription-operations--create/a.sql new file mode 100644 index 000000000..d2b8f36cf --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--create/a.sql @@ -0,0 +1 @@ +CREATE PUBLICATION corpus_sub_create_pub FOR ALL TABLES; diff --git a/packages/pg-delta/corpus/subscription-operations--create/b.sql b/packages/pg-delta/corpus/subscription-operations--create/b.sql new file mode 100644 index 000000000..261520737 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--create/b.sql @@ -0,0 +1,5 @@ +CREATE PUBLICATION corpus_sub_create_pub FOR ALL TABLES; +CREATE SUBSCRIPTION corpus_sub_create + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_sub_create_pub + WITH (connect = false, slot_name = NONE, enabled = false); diff --git a/packages/pg-delta/corpus/subscription-operations--drop/a.sql b/packages/pg-delta/corpus/subscription-operations--drop/a.sql new file mode 100644 index 000000000..40126cc93 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--drop/a.sql @@ -0,0 +1,5 @@ +CREATE PUBLICATION corpus_sub_drop_pub FOR ALL TABLES; +CREATE SUBSCRIPTION corpus_sub_drop + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_sub_drop_pub + WITH (connect = false, slot_name = NONE, enabled = false); diff --git a/packages/pg-delta/corpus/subscription-operations--drop/b.sql b/packages/pg-delta/corpus/subscription-operations--drop/b.sql new file mode 100644 index 000000000..e85dfd6aa --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--drop/b.sql @@ -0,0 +1 @@ +CREATE PUBLICATION corpus_sub_drop_pub FOR ALL TABLES; diff --git a/packages/pg-delta/corpus/subscription-operations--remove-comment/a.sql b/packages/pg-delta/corpus/subscription-operations--remove-comment/a.sql new file mode 100644 index 000000000..7c098fe38 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--remove-comment/a.sql @@ -0,0 +1,6 @@ +CREATE PUBLICATION corpus_sub_comment_drop_pub FOR ALL TABLES; +CREATE SUBSCRIPTION corpus_sub_comment_drop + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_sub_comment_drop_pub + WITH (connect = false, slot_name = NONE, enabled = false); +COMMENT ON SUBSCRIPTION corpus_sub_comment_drop IS 'subscription comment'; diff --git a/packages/pg-delta/corpus/subscription-operations--remove-comment/b.sql b/packages/pg-delta/corpus/subscription-operations--remove-comment/b.sql new file mode 100644 index 000000000..c0c967618 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--remove-comment/b.sql @@ -0,0 +1,5 @@ +CREATE PUBLICATION corpus_sub_comment_drop_pub FOR ALL TABLES; +CREATE SUBSCRIPTION corpus_sub_comment_drop + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_sub_comment_drop_pub + WITH (connect = false, slot_name = NONE, enabled = false); diff --git a/packages/pg-delta/corpus/subscription-operations--replication-options/a.sql b/packages/pg-delta/corpus/subscription-operations--replication-options/a.sql new file mode 100644 index 000000000..454988baf --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--replication-options/a.sql @@ -0,0 +1,7 @@ +CREATE PUBLICATION corpus_subopt_pub FOR ALL TABLES; +-- Defaults: binary=off, streaming=off, synchronous_commit=off, +-- disable_on_error=off, two_phase=off. +CREATE SUBSCRIPTION corpus_subopt + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_subopt_pub + WITH (connect = false, slot_name = NONE, enabled = false); diff --git a/packages/pg-delta/corpus/subscription-operations--replication-options/b.sql b/packages/pg-delta/corpus/subscription-operations--replication-options/b.sql new file mode 100644 index 000000000..e967bee29 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--replication-options/b.sql @@ -0,0 +1,16 @@ +CREATE PUBLICATION corpus_subopt_pub FOR ALL TABLES; +-- Same subscription with every settable replication option flipped. Before +-- the option set was extracted these hashed identically to the a-state and +-- the diff was empty; now each must plan an in-place ALTER SUBSCRIPTION SET. +CREATE SUBSCRIPTION corpus_subopt + CONNECTION 'host=localhost dbname=postgres' + PUBLICATION corpus_subopt_pub + WITH ( + connect = false, + slot_name = NONE, + enabled = false, + binary = true, + streaming = on, + synchronous_commit = 'local', + disable_on_error = true + ); diff --git a/packages/pg-delta/corpus/subscription-operations--replication-options/meta.json b/packages/pg-delta/corpus/subscription-operations--replication-options/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/subscription-operations--replication-options/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/table-create/a.sql b/packages/pg-delta/corpus/table-create/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/table-create/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/table-create/b.sql b/packages/pg-delta/corpus/table-create/b.sql new file mode 100644 index 000000000..23f7a8025 --- /dev/null +++ b/packages/pg-delta/corpus/table-create/b.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA app; +CREATE TABLE app.users ( + id integer GENERATED ALWAYS AS IDENTITY, + email text NOT NULL, + score numeric(10,2) DEFAULT 0.0, + CONSTRAINT users_pkey PRIMARY KEY (id), + CONSTRAINT score_positive CHECK (score >= 0) +); diff --git a/packages/pg-delta/corpus/table-fn-circular--complex-multi-table/a.sql b/packages/pg-delta/corpus/table-fn-circular--complex-multi-table/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-circular--complex-multi-table/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/table-fn-circular--complex-multi-table/b.sql b/packages/pg-delta/corpus/table-fn-circular--complex-multi-table/b.sql new file mode 100644 index 000000000..defbab54e --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-circular--complex-multi-table/b.sql @@ -0,0 +1,43 @@ +CREATE SCHEMA test_schema; + +-- Shared id generator used as DEFAULT by both tables +CREATE FUNCTION test_schema.generate_id() +RETURNS bigint +LANGUAGE sql +VOLATILE +AS $function$SELECT floor(random() * 1000000)::bigint$function$; + +CREATE TABLE test_schema.customers ( + id bigint PRIMARY KEY DEFAULT test_schema.generate_id(), + email text NOT NULL, + name text +); + +CREATE TABLE test_schema.products ( + id bigint PRIMARY KEY DEFAULT test_schema.generate_id(), + title text NOT NULL, + price numeric(10,2) +); + +-- Functions returning SETOF the tables (each depends on the corresponding table) +CREATE FUNCTION test_schema.get_customers_by_email(search_email text) +RETURNS SETOF test_schema.customers +LANGUAGE sql +STABLE +AS $function$ + SELECT * FROM test_schema.customers WHERE email = search_email +$function$; + +CREATE FUNCTION test_schema.get_products_by_price(max_price numeric) +RETURNS SETOF test_schema.products +LANGUAGE sql +STABLE +AS $function$ + SELECT * FROM test_schema.products WHERE price <= max_price +$function$; + +CREATE FUNCTION test_schema.get_customer_count() +RETURNS bigint +LANGUAGE sql +STABLE +AS $function$SELECT count(*) FROM test_schema.customers$function$; diff --git a/packages/pg-delta/corpus/table-fn-circular--setof-and-default/a.sql b/packages/pg-delta/corpus/table-fn-circular--setof-and-default/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-circular--setof-and-default/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/table-fn-circular--setof-and-default/b.sql b/packages/pg-delta/corpus/table-fn-circular--setof-and-default/b.sql new file mode 100644 index 000000000..4f5fe5775 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-circular--setof-and-default/b.sql @@ -0,0 +1,31 @@ +CREATE SCHEMA test_schema; + +-- Function used as DEFAULT in table below (table depends on function) +CREATE FUNCTION test_schema.next_order_number() +RETURNS integer +LANGUAGE plpgsql +VOLATILE +AS $function$ +BEGIN + RETURN (SELECT coalesce(max(order_number), 0) + 1 FROM test_schema.orders); +END; +$function$; + +-- Table using function as default (depends on function above) +CREATE TABLE test_schema.orders ( + id bigserial PRIMARY KEY, + order_number integer DEFAULT test_schema.next_order_number(), + total_amount numeric(10,2), + created_at timestamp DEFAULT now() +); + +-- Function returning SETOF the table (depends on table above) +CREATE FUNCTION test_schema.get_recent_orders() +RETURNS SETOF test_schema.orders +LANGUAGE sql +STABLE +AS $function$ + SELECT * FROM test_schema.orders + WHERE created_at > now() - interval '7 days' + ORDER BY created_at DESC +$function$; diff --git a/packages/pg-delta/corpus/table-fn-circular--with-matview/a.sql b/packages/pg-delta/corpus/table-fn-circular--with-matview/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-circular--with-matview/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/table-fn-circular--with-matview/b.sql b/packages/pg-delta/corpus/table-fn-circular--with-matview/b.sql new file mode 100644 index 000000000..75304d602 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-circular--with-matview/b.sql @@ -0,0 +1,22 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.transactions ( + id bigserial PRIMARY KEY, + amount numeric(10,2), + status text +); + +-- Function returning SETOF the table +CREATE FUNCTION test_schema.get_transactions_by_status(search_status text) +RETURNS SETOF test_schema.transactions +LANGUAGE sql +STABLE +AS $function$ + SELECT * FROM test_schema.transactions WHERE status = search_status +$function$; + +-- Materialized view also depending on the table +CREATE MATERIALIZED VIEW test_schema.transaction_summary AS +SELECT status, count(*) AS count, sum(amount) AS total +FROM test_schema.transactions +GROUP BY status; diff --git a/packages/pg-delta/corpus/table-fn-dep--function-based-default/a.sql b/packages/pg-delta/corpus/table-fn-dep--function-based-default/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-dep--function-based-default/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/table-fn-dep--function-based-default/b.sql b/packages/pg-delta/corpus/table-fn-dep--function-based-default/b.sql new file mode 100644 index 000000000..e22fdf691 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-dep--function-based-default/b.sql @@ -0,0 +1,18 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.serial_counter() +RETURNS integer +LANGUAGE plpgsql +VOLATILE +AS $function$ +BEGIN + RETURN nextval('test_schema.counter_seq'::regclass); +END; +$function$; + +CREATE SEQUENCE test_schema.counter_seq; + +CREATE TABLE test_schema.event_log ( + id integer PRIMARY KEY DEFAULT test_schema.serial_counter(), + message text +); diff --git a/packages/pg-delta/corpus/table-fn-dep--setof-function/a.sql b/packages/pg-delta/corpus/table-fn-dep--setof-function/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-dep--setof-function/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/table-fn-dep--setof-function/b.sql b/packages/pg-delta/corpus/table-fn-dep--setof-function/b.sql new file mode 100644 index 000000000..05b5c1ec4 --- /dev/null +++ b/packages/pg-delta/corpus/table-fn-dep--setof-function/b.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id bigserial PRIMARY KEY, + email text UNIQUE +); + +CREATE FUNCTION test_schema.get_users() +RETURNS SETOF test_schema.users +LANGUAGE sql +STABLE +AS $function$SELECT * FROM test_schema.users$function$; diff --git a/packages/pg-delta/corpus/table-operations--key-constraint-on-domain-column/a.sql b/packages/pg-delta/corpus/table-operations--key-constraint-on-domain-column/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/table-operations--key-constraint-on-domain-column/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/table-operations--key-constraint-on-domain-column/b.sql b/packages/pg-delta/corpus/table-operations--key-constraint-on-domain-column/b.sql new file mode 100644 index 000000000..272d989be --- /dev/null +++ b/packages/pg-delta/corpus/table-operations--key-constraint-on-domain-column/b.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA s; +CREATE DOMAIN s.slug_text AS text CHECK (length(VALUE) > 0); +CREATE TABLE s.organizations ( + id uuid PRIMARY KEY, + slug s.slug_text NOT NULL, + slug_key text GENERATED ALWAYS AS (lower(slug::text)) STORED, + CONSTRAINT organizations_slug_key UNIQUE (slug), + CONSTRAINT organizations_slug_key_key UNIQUE (slug_key) +); diff --git a/packages/pg-delta/corpus/table-ops--attach-partition/a.sql b/packages/pg-delta/corpus/table-ops--attach-partition/a.sql new file mode 100644 index 000000000..218e01996 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--attach-partition/a.sql @@ -0,0 +1,12 @@ +-- partitioned table with a standalone table that is not yet attached +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + created_at timestamp without time zone NOT NULL, + payload text +) PARTITION BY RANGE (created_at); + +CREATE TABLE test_schema.events_2025 ( + created_at timestamp without time zone NOT NULL, + payload text +); diff --git a/packages/pg-delta/corpus/table-ops--attach-partition/b.sql b/packages/pg-delta/corpus/table-ops--attach-partition/b.sql new file mode 100644 index 000000000..45f9e3caa --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--attach-partition/b.sql @@ -0,0 +1,16 @@ +-- events_2025 is attached as a partition of events +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + created_at timestamp without time zone NOT NULL, + payload text +) PARTITION BY RANGE (created_at); + +CREATE TABLE test_schema.events_2025 ( + created_at timestamp without time zone NOT NULL, + payload text +); + +ALTER TABLE test_schema.events + ATTACH PARTITION test_schema.events_2025 + FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); diff --git a/packages/pg-delta/corpus/table-ops--comments/a.sql b/packages/pg-delta/corpus/table-ops--comments/a.sql new file mode 100644 index 000000000..bfda3678d --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--comments/a.sql @@ -0,0 +1,9 @@ +-- table and column exist but have no comments and no PK +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + id integer, + created_at timestamp without time zone NOT NULL, + payload text, + description text +); diff --git a/packages/pg-delta/corpus/table-ops--comments/b.sql b/packages/pg-delta/corpus/table-ops--comments/b.sql new file mode 100644 index 000000000..21c373e0a --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--comments/b.sql @@ -0,0 +1,16 @@ +-- table with a PK, an extra column, and comments on table/column/constraint +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + id integer, + created_at timestamp without time zone NOT NULL, + payload text, + description text +); + +ALTER TABLE test_schema.events ADD CONSTRAINT events_pkey PRIMARY KEY (id); + +COMMENT ON TABLE test_schema.events IS 'This is a test table'; +COMMENT ON COLUMN test_schema.events.created_at IS 'This is a created_at column'; +COMMENT ON CONSTRAINT events_pkey ON test_schema.events IS 'This is a test constraint'; +COMMENT ON COLUMN test_schema.events.description IS 'This is a description column'; diff --git a/packages/pg-delta/corpus/table-ops--comments/seed-b.sql b/packages/pg-delta/corpus/table-ops--comments/seed-b.sql new file mode 100644 index 000000000..180c28f68 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--comments/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.events (id, created_at, payload, description) VALUES (1, '2024-01-01 00:00:00', 'sample payload', 'sample description'); diff --git a/packages/pg-delta/corpus/table-ops--comments/seed.sql b/packages/pg-delta/corpus/table-ops--comments/seed.sql new file mode 100644 index 000000000..180c28f68 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--comments/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.events (id, created_at, payload, description) VALUES (1, '2024-01-01 00:00:00', 'sample payload', 'sample description'); diff --git a/packages/pg-delta/corpus/table-ops--detach-partition/a.sql b/packages/pg-delta/corpus/table-ops--detach-partition/a.sql new file mode 100644 index 000000000..98675c3f8 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--detach-partition/a.sql @@ -0,0 +1,10 @@ +-- events_2025 is currently a partition of events +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + created_at timestamp without time zone NOT NULL, + payload text +) PARTITION BY RANGE (created_at); + +CREATE TABLE test_schema.events_2025 PARTITION OF test_schema.events + FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); diff --git a/packages/pg-delta/corpus/table-ops--detach-partition/b.sql b/packages/pg-delta/corpus/table-ops--detach-partition/b.sql new file mode 100644 index 000000000..0b2c4ac36 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--detach-partition/b.sql @@ -0,0 +1,12 @@ +-- events_2025 has been detached; it becomes a standalone table +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + created_at timestamp without time zone NOT NULL, + payload text +) PARTITION BY RANGE (created_at); + +CREATE TABLE test_schema.events_2025 ( + created_at timestamp without time zone NOT NULL, + payload text +); diff --git a/packages/pg-delta/corpus/table-ops--empty-table/a.sql b/packages/pg-delta/corpus/table-ops--empty-table/a.sql new file mode 100644 index 000000000..e7e7040dd --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--empty-table/a.sql @@ -0,0 +1 @@ +-- empty starting state diff --git a/packages/pg-delta/corpus/table-ops--empty-table/b.sql b/packages/pg-delta/corpus/table-ops--empty-table/b.sql new file mode 100644 index 000000000..53536c963 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--empty-table/b.sql @@ -0,0 +1,4 @@ +-- table with no columns (zero-column table) +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.empty_table (); diff --git a/packages/pg-delta/corpus/table-ops--multi-schema/a.sql b/packages/pg-delta/corpus/table-ops--multi-schema/a.sql new file mode 100644 index 000000000..0b49e8b56 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--multi-schema/a.sql @@ -0,0 +1,3 @@ +-- empty starting state: both schemas exist but no tables +CREATE SCHEMA schema_a; +CREATE SCHEMA schema_b; diff --git a/packages/pg-delta/corpus/table-ops--multi-schema/b.sql b/packages/pg-delta/corpus/table-ops--multi-schema/b.sql new file mode 100644 index 000000000..a25769545 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--multi-schema/b.sql @@ -0,0 +1,13 @@ +-- one table in each of two distinct schemas +CREATE SCHEMA schema_a; +CREATE SCHEMA schema_b; + +CREATE TABLE schema_a.table_a ( + id integer, + name text +); + +CREATE TABLE schema_b.table_b ( + id integer, + description text +); diff --git a/packages/pg-delta/corpus/table-ops--partition-range/a.sql b/packages/pg-delta/corpus/table-ops--partition-range/a.sql new file mode 100644 index 000000000..d8f6d4c9e --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--partition-range/a.sql @@ -0,0 +1 @@ +-- empty starting state: no partitioned table yet diff --git a/packages/pg-delta/corpus/table-ops--partition-range/b.sql b/packages/pg-delta/corpus/table-ops--partition-range/b.sql new file mode 100644 index 000000000..0eb7ed303 --- /dev/null +++ b/packages/pg-delta/corpus/table-ops--partition-range/b.sql @@ -0,0 +1,13 @@ +-- PARTITION BY RANGE table with two time-bounded partitions +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.events ( + created_at timestamp without time zone NOT NULL, + payload text +) PARTITION BY RANGE (created_at); + +CREATE TABLE test_schema.events_2024 PARTITION OF test_schema.events + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + +CREATE TABLE test_schema.events_2025 PARTITION OF test_schema.events + FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); diff --git a/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/a.sql b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/a.sql new file mode 100644 index 000000000..9096e9406 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/a.sql @@ -0,0 +1,17 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + amount integer NOT NULL, + limit_amount integer NOT NULL +); + +CREATE FUNCTION test_schema.enforce_amount_limit() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.amount > NEW.limit_amount THEN + RAISE EXCEPTION 'amount exceeds limit'; + END IF; + RETURN NEW; +END; +$$; diff --git a/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/b.sql b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/b.sql new file mode 100644 index 000000000..a2edca252 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/b.sql @@ -0,0 +1,23 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.accounts ( + id serial PRIMARY KEY, + amount integer NOT NULL, + limit_amount integer NOT NULL +); + +CREATE FUNCTION test_schema.enforce_amount_limit() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.amount > NEW.limit_amount THEN + RAISE EXCEPTION 'amount exceeds limit'; + END IF; + RETURN NEW; +END; +$$; + +CREATE CONSTRAINT TRIGGER enforce_amount_limit_trigger + AFTER INSERT OR UPDATE ON test_schema.accounts + DEFERRABLE INITIALLY IMMEDIATE + FOR EACH ROW + EXECUTE FUNCTION test_schema.enforce_amount_limit(); diff --git a/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/seed-b.sql b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/seed-b.sql new file mode 100644 index 000000000..c904b6e26 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.accounts (amount, limit_amount) VALUES (10, 100); diff --git a/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/seed.sql b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/seed.sql new file mode 100644 index 000000000..c904b6e26 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-create/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.accounts (amount, limit_amount) VALUES (10, 100); diff --git a/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/a.sql b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/a.sql new file mode 100644 index 000000000..4b353f610 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/a.sql @@ -0,0 +1,28 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.roles ( + id serial PRIMARY KEY, + organization_id integer NOT NULL, + project_ids integer[] NOT NULL +); + +CREATE FUNCTION test_schema.role_and_project_ids_belong_to_org() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM unnest(NEW.project_ids) project_id + ) THEN + -- no-op: keep this function lightweight for the test + NULL; + END IF; + RETURN NULL; +END; +$$; + +CREATE CONSTRAINT TRIGGER role_and_project_ids_belong_to_org +AFTER INSERT OR UPDATE ON test_schema.roles +FOR EACH ROW +EXECUTE FUNCTION test_schema.role_and_project_ids_belong_to_org(); diff --git a/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/b.sql b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/b.sql new file mode 100644 index 000000000..39ac01051 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/b.sql @@ -0,0 +1,29 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.roles ( + id serial PRIMARY KEY, + organization_id integer NOT NULL, + project_ids integer[] NOT NULL +); + +CREATE FUNCTION test_schema.role_and_project_ids_belong_to_org() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM unnest(NEW.project_ids) project_id + ) THEN + -- no-op: keep this function lightweight for the test + NULL; + END IF; + RETURN NULL; +END; +$$; + +CREATE CONSTRAINT TRIGGER role_and_project_ids_belong_to_org +AFTER INSERT OR UPDATE ON test_schema.roles +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION test_schema.role_and_project_ids_belong_to_org(); diff --git a/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/seed-b.sql b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/seed-b.sql new file mode 100644 index 000000000..4275f48b8 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.roles (organization_id, project_ids) VALUES (1, ARRAY[1, 2]); diff --git a/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/seed.sql b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/seed.sql new file mode 100644 index 000000000..4275f48b8 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--constraint-trigger-deferrability-change/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.roles (organization_id, project_ids) VALUES (1, ARRAY[1, 2]); diff --git a/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/a.sql b/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/a.sql new file mode 100644 index 000000000..8c0e99c63 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/a.sql @@ -0,0 +1,25 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + email text NOT NULL +); + +CREATE FUNCTION test_schema.insert_user_email() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO test_schema.users (id, email) VALUES (NEW.id, NEW.email); + RETURN NEW; +END; +$$; + +CREATE FUNCTION test_schema.update_user_email() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + UPDATE test_schema.users SET email = NEW.email WHERE id = OLD.id; + RETURN NEW; +END; +$$; + +CREATE VIEW test_schema.user_emails AS + SELECT id, email FROM test_schema.users; diff --git a/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/b.sql b/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/b.sql new file mode 100644 index 000000000..47e9d10be --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/b.sql @@ -0,0 +1,35 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + email text NOT NULL +); + +CREATE FUNCTION test_schema.insert_user_email() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO test_schema.users (id, email) VALUES (NEW.id, NEW.email); + RETURN NEW; +END; +$$; + +CREATE FUNCTION test_schema.update_user_email() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + UPDATE test_schema.users SET email = NEW.email WHERE id = OLD.id; + RETURN NEW; +END; +$$; + +CREATE VIEW test_schema.user_emails AS + SELECT id, email FROM test_schema.users; + +CREATE TRIGGER user_emails_insert + INSTEAD OF INSERT ON test_schema.user_emails + FOR EACH ROW + EXECUTE FUNCTION test_schema.insert_user_email(); + +CREATE TRIGGER user_emails_update + INSTEAD OF UPDATE ON test_schema.user_emails + FOR EACH ROW + EXECUTE FUNCTION test_schema.update_user_email(); diff --git a/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/seed-b.sql b/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/seed-b.sql new file mode 100644 index 000000000..1c955d2b6 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, email) VALUES (1, 'user1@example.com'); diff --git a/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/seed.sql b/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/seed.sql new file mode 100644 index 000000000..1c955d2b6 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--instead-of-trigger-on-view/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.users (id, email) VALUES (1, 'user1@example.com'); diff --git a/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/a.sql b/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/a.sql new file mode 100644 index 000000000..b4dd66afa --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/a.sql @@ -0,0 +1,24 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.foo (id integer PRIMARY KEY); + +CREATE TABLE test_schema.bar (id integer PRIMARY KEY); + +CREATE FUNCTION test_schema.shared_trigger_fn() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN NEW; +END; +$$; + +CREATE TRIGGER foo_insert +BEFORE INSERT ON test_schema.foo +FOR EACH ROW +EXECUTE FUNCTION test_schema.shared_trigger_fn(); + +CREATE TRIGGER bar_insert +BEFORE INSERT ON test_schema.bar +FOR EACH ROW +EXECUTE FUNCTION test_schema.shared_trigger_fn(); diff --git a/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/b.sql b/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/b.sql new file mode 100644 index 000000000..ce3c4bdbd --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/b.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.foo (id integer PRIMARY KEY); + +CREATE TABLE test_schema.bar (id integer PRIMARY KEY); diff --git a/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/seed-b.sql b/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/seed-b.sql new file mode 100644 index 000000000..02b68dc91 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.foo (id) VALUES (1); +INSERT INTO test_schema.bar (id) VALUES (1); diff --git a/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/seed.sql b/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/seed.sql new file mode 100644 index 000000000..02b68dc91 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--shared-function-multi-trigger-drop/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.foo (id) VALUES (1); +INSERT INTO test_schema.bar (id) VALUES (1); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-comment/a.sql b/packages/pg-delta/corpus/trigger-operations--trigger-comment/a.sql new file mode 100644 index 000000000..aaf23009b --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-comment/a.sql @@ -0,0 +1,19 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.logs ( + id serial PRIMARY KEY, + msg text, + created_at timestamp DEFAULT now() +); + +CREATE FUNCTION test_schema.log_insert() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NEW; +END; +$$; + +CREATE TRIGGER logs_insert_trigger + BEFORE INSERT ON test_schema.logs + FOR EACH ROW + EXECUTE FUNCTION test_schema.log_insert(); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-comment/b.sql b/packages/pg-delta/corpus/trigger-operations--trigger-comment/b.sql new file mode 100644 index 000000000..301c8dff4 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-comment/b.sql @@ -0,0 +1,21 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.logs ( + id serial PRIMARY KEY, + msg text, + created_at timestamp DEFAULT now() +); + +CREATE FUNCTION test_schema.log_insert() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NEW; +END; +$$; + +CREATE TRIGGER logs_insert_trigger + BEFORE INSERT ON test_schema.logs + FOR EACH ROW + EXECUTE FUNCTION test_schema.log_insert(); + +COMMENT ON TRIGGER logs_insert_trigger ON test_schema.logs IS 'logs insert trigger'; diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/a.sql b/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/a.sql new file mode 100644 index 000000000..3ce4230d4 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/a.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.foo (id integer PRIMARY KEY); + +CREATE FUNCTION test_schema.bar() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NULL; +END; +$$; + +CREATE TRIGGER foo_insert + BEFORE INSERT ON test_schema.foo + FOR EACH ROW + EXECUTE FUNCTION test_schema.bar(); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/b.sql b/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/b.sql new file mode 100644 index 000000000..e50f55599 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.foo (id integer PRIMARY KEY); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/seed-b.sql b/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/seed-b.sql new file mode 100644 index 000000000..4fb008b6b --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-drop-before-function-drop/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.foo (id) VALUES (1); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-event-modification/a.sql b/packages/pg-delta/corpus/trigger-operations--trigger-event-modification/a.sql new file mode 100644 index 000000000..72376a4df --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-event-modification/a.sql @@ -0,0 +1,21 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.users ( + id serial PRIMARY KEY, + email text UNIQUE, + created_at timestamp DEFAULT now() +); +CREATE FUNCTION test_schema.validate_email() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN + RAISE EXCEPTION 'Invalid email format'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER email_validation_trigger +BEFORE INSERT ON test_schema.users +FOR EACH ROW +EXECUTE FUNCTION test_schema.validate_email(); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-event-modification/b.sql b/packages/pg-delta/corpus/trigger-operations--trigger-event-modification/b.sql new file mode 100644 index 000000000..be797336a --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-event-modification/b.sql @@ -0,0 +1,26 @@ +CREATE SCHEMA test_schema; +CREATE TABLE test_schema.users ( + id serial PRIMARY KEY, + email text UNIQUE, + created_at timestamp DEFAULT now() +); +CREATE FUNCTION test_schema.validate_email() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +BEGIN + -- Updated validation logic + IF NEW.email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN + RAISE EXCEPTION 'Invalid email format: %', NEW.email; + END IF; + -- Additional validation + IF length(NEW.email) > 255 THEN + RAISE EXCEPTION 'Email too long'; + END IF; + RETURN NEW; +END; +$function$; +CREATE TRIGGER email_validation_trigger +BEFORE INSERT OR UPDATE ON test_schema.users +FOR EACH ROW +EXECUTE FUNCTION test_schema.validate_email(); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/a.sql b/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/a.sql new file mode 100644 index 000000000..35bf5552e --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/a.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.user_account ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text NOT NULL, + verified boolean NOT NULL DEFAULT false +); + +CREATE FUNCTION test_schema.user_account_encrypt_secret_email() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.email := 'enc:' || NEW.email; + RETURN NEW; +END; +$$; diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/b.sql b/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/b.sql new file mode 100644 index 000000000..545e82160 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/b.sql @@ -0,0 +1,20 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.user_account ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text NOT NULL, + verified boolean NOT NULL DEFAULT false +); + +CREATE FUNCTION test_schema.user_account_encrypt_secret_email() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.email := 'enc:' || NEW.email; + RETURN NEW; +END; +$$; + +CREATE TRIGGER user_account_encrypt_secret_trigger_email + BEFORE INSERT OR UPDATE OF email ON test_schema.user_account + FOR EACH ROW + EXECUTE FUNCTION test_schema.user_account_encrypt_secret_email(); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/seed-b.sql b/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/seed-b.sql new file mode 100644 index 000000000..d1d1888b1 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.user_account (email) VALUES ('user@example.com'); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/seed.sql b/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/seed.sql new file mode 100644 index 000000000..d1d1888b1 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-update-of-columns/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.user_account (email) VALUES ('user@example.com'); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/a.sql b/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/a.sql new file mode 100644 index 000000000..998eac14e --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/a.sql @@ -0,0 +1,16 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.products ( + id serial PRIMARY KEY, + name text NOT NULL, + price numeric(10,2), + category text +); + +CREATE FUNCTION test_schema.log_price_changes() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'Price changed for product %: % -> %', NEW.name, OLD.price, NEW.price; + RETURN NEW; +END; +$$; diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/b.sql b/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/b.sql new file mode 100644 index 000000000..cb602dc10 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/b.sql @@ -0,0 +1,22 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.products ( + id serial PRIMARY KEY, + name text NOT NULL, + price numeric(10,2), + category text +); + +CREATE FUNCTION test_schema.log_price_changes() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'Price changed for product %: % -> %', NEW.name, OLD.price, NEW.price; + RETURN NEW; +END; +$$; + +CREATE TRIGGER price_change_trigger + AFTER UPDATE ON test_schema.products + FOR EACH ROW + WHEN (OLD.price IS DISTINCT FROM NEW.price) + EXECUTE FUNCTION test_schema.log_price_changes(); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/seed-b.sql b/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/seed-b.sql new file mode 100644 index 000000000..e6e178b75 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.products (name, price, category) VALUES ('Widget', 9.99, 'Hardware'); diff --git a/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/seed.sql b/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/seed.sql new file mode 100644 index 000000000..e6e178b75 --- /dev/null +++ b/packages/pg-delta/corpus/trigger-operations--trigger-with-when-clause/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.products (name, price, category) VALUES ('Widget', 9.99, 'Hardware'); diff --git a/packages/pg-delta/corpus/trigger-update-of-column-numbers--attnum-regression/a.sql b/packages/pg-delta/corpus/trigger-update-of-column-numbers--attnum-regression/a.sql new file mode 100644 index 000000000..bc90e499c --- /dev/null +++ b/packages/pg-delta/corpus/trigger-update-of-column-numbers--attnum-regression/a.sql @@ -0,0 +1,19 @@ +-- Table built with a single CREATE TABLE: columns a, b, d get consecutive +-- physical attnums 1, 2, 3. +CREATE TABLE public.t ( + a int, + b int, + d int +); + +CREATE FUNCTION public.trg_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg + BEFORE UPDATE OF a, b, d + ON public.t + FOR EACH ROW + EXECUTE FUNCTION public.trg_fn(); diff --git a/packages/pg-delta/corpus/trigger-update-of-column-numbers--attnum-regression/b.sql b/packages/pg-delta/corpus/trigger-update-of-column-numbers--attnum-regression/b.sql new file mode 100644 index 000000000..00131333b --- /dev/null +++ b/packages/pg-delta/corpus/trigger-update-of-column-numbers--attnum-regression/b.sql @@ -0,0 +1,25 @@ +-- Same logical columns (a, b, d) but grown via ALTER TABLE so that b and d +-- have higher physical attnums than in a.sql (dead holes from dropped cols). +-- pg_get_triggerdef() renders identical SQL on both sides, but pg_trigger.tgattr +-- vectors differ. The engine must compare by column names, not raw attnums. +CREATE TABLE public.t ( + a int, + b int, + c int +); +ALTER TABLE public.t DROP COLUMN b; +ALTER TABLE public.t DROP COLUMN c; +ALTER TABLE public.t ADD COLUMN b int; +ALTER TABLE public.t ADD COLUMN d int; + +CREATE FUNCTION public.trg_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg + BEFORE UPDATE OF a, b, d + ON public.t + FOR EACH ROW + EXECUTE FUNCTION public.trg_fn(); diff --git a/packages/pg-delta/corpus/trigger/a.sql b/packages/pg-delta/corpus/trigger/a.sql new file mode 100644 index 000000000..f7884ffcb --- /dev/null +++ b/packages/pg-delta/corpus/trigger/a.sql @@ -0,0 +1 @@ +CREATE TABLE public.audit_me (id integer PRIMARY KEY, updated_at timestamptz); diff --git a/packages/pg-delta/corpus/trigger/b.sql b/packages/pg-delta/corpus/trigger/b.sql new file mode 100644 index 000000000..f1fcf50c2 --- /dev/null +++ b/packages/pg-delta/corpus/trigger/b.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.audit_me (id integer PRIMARY KEY, updated_at timestamptz); +CREATE FUNCTION public.touch() RETURNS trigger LANGUAGE plpgsql AS +$$ BEGIN NEW.updated_at := now(); RETURN NEW; END $$; +CREATE TRIGGER audit_touch BEFORE UPDATE ON public.audit_me + FOR EACH ROW EXECUTE FUNCTION public.touch(); diff --git a/packages/pg-delta/corpus/trigger/seed-b.sql b/packages/pg-delta/corpus/trigger/seed-b.sql new file mode 100644 index 000000000..5a6435368 --- /dev/null +++ b/packages/pg-delta/corpus/trigger/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.audit_me (id, updated_at) VALUES (1, now()); diff --git a/packages/pg-delta/corpus/trigger/seed.sql b/packages/pg-delta/corpus/trigger/seed.sql new file mode 100644 index 000000000..5a6435368 --- /dev/null +++ b/packages/pg-delta/corpus/trigger/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.audit_me (id, updated_at) VALUES (1, now()); diff --git a/packages/pg-delta/corpus/type-operations--array-of-composite-column/a.sql b/packages/pg-delta/corpus/type-operations--array-of-composite-column/a.sql new file mode 100644 index 000000000..593ed4e68 --- /dev/null +++ b/packages/pg-delta/corpus/type-operations--array-of-composite-column/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA s; diff --git a/packages/pg-delta/corpus/type-operations--array-of-composite-column/b.sql b/packages/pg-delta/corpus/type-operations--array-of-composite-column/b.sql new file mode 100644 index 000000000..f044bd7d8 --- /dev/null +++ b/packages/pg-delta/corpus/type-operations--array-of-composite-column/b.sql @@ -0,0 +1,18 @@ +CREATE SCHEMA s; + +CREATE TYPE s.user_defined_filter AS ( + column_name text, + value text +); + +-- Table with a column whose type is an ARRAY of the composite type. Postgres +-- records the column's pg_depend edge against the array type `_user_defined_filter`, +-- so the table must still be ordered AFTER the composite type it elements. +CREATE TABLE s.subscription ( + id bigint NOT NULL, + filters s.user_defined_filter[] NOT NULL DEFAULT '{}' +); + +-- Also exercise the function-argument form (same array-of-composite dependency). +CREATE FUNCTION s.check_filters(filters s.user_defined_filter[]) +RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT true $$; diff --git a/packages/pg-delta/corpus/type-ops--composite-alter-attributes/a.sql b/packages/pg-delta/corpus/type-ops--composite-alter-attributes/a.sql new file mode 100644 index 000000000..6a5ce1218 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-alter-attributes/a.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; + +-- a composite type USED by a table column: it cannot be dropped+recreated +-- (it is in use), so attribute changes must go through ALTER TYPE … CASCADE +CREATE TYPE test_schema.addr AS (street text, city text); + +CREATE TABLE test_schema.locations ( + id integer PRIMARY KEY, + where_at test_schema.addr +); diff --git a/packages/pg-delta/corpus/type-ops--composite-alter-attributes/b.sql b/packages/pg-delta/corpus/type-ops--composite-alter-attributes/b.sql new file mode 100644 index 000000000..23171bbaa --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-alter-attributes/b.sql @@ -0,0 +1,9 @@ +CREATE SCHEMA test_schema; + +-- zip added via ALTER TYPE … ADD ATTRIBUTE … CASCADE (works in-use) +CREATE TYPE test_schema.addr AS (street text, city text, zip text); + +CREATE TABLE test_schema.locations ( + id integer PRIMARY KEY, + where_at test_schema.addr +); diff --git a/packages/pg-delta/corpus/type-ops--composite-alter-attributes/seed-b.sql b/packages/pg-delta/corpus/type-ops--composite-alter-attributes/seed-b.sql new file mode 100644 index 000000000..c81fc06c7 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-alter-attributes/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.locations (id, where_at) +VALUES (1, ROW('main st', 'springfield', '12345')); diff --git a/packages/pg-delta/corpus/type-ops--composite-alter-attributes/seed.sql b/packages/pg-delta/corpus/type-ops--composite-alter-attributes/seed.sql new file mode 100644 index 000000000..ae88541c6 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-alter-attributes/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.locations (id, where_at) +VALUES (1, ROW('main st', 'springfield')); diff --git a/packages/pg-delta/corpus/type-ops--composite-attribute-order/a.sql b/packages/pg-delta/corpus/type-ops--composite-attribute-order/a.sql new file mode 100644 index 000000000..593ed4e68 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-attribute-order/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA s; diff --git a/packages/pg-delta/corpus/type-ops--composite-attribute-order/b.sql b/packages/pg-delta/corpus/type-ops--composite-attribute-order/b.sql new file mode 100644 index 000000000..9e4dfd8c3 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-attribute-order/b.sql @@ -0,0 +1,20 @@ +CREATE SCHEMA s; + +-- Composite attribute ORDER is row-layout state: declared order is NOT +-- alphabetical (wal < is_rls_enabled < subscription_ids < errors), so a +-- name-ordered reconstruction would silently reorder the columns. The dependent +-- SQL function pins that order at body-validation time (the realtime.wal_rls +-- chain that motivated the fix). +CREATE TYPE s.wal_rls AS ( + wal jsonb, + is_rls_enabled boolean, + subscription_ids uuid[], + errors text[] +); + +CREATE FUNCTION s.apply_rls() RETURNS SETOF s.wal_rls + LANGUAGE sql AS $$ SELECT NULL::jsonb, NULL::boolean, NULL::uuid[], NULL::text[] $$; + +CREATE FUNCTION s.list_changes() + RETURNS TABLE(wal jsonb, is_rls_enabled boolean, subscription_ids uuid[], errors text[]) + LANGUAGE sql AS $$ SELECT * FROM s.apply_rls() $$; diff --git a/packages/pg-delta/corpus/type-ops--composite-attribute-retype/a.sql b/packages/pg-delta/corpus/type-ops--composite-attribute-retype/a.sql new file mode 100644 index 000000000..92ea40534 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-attribute-retype/a.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA test_schema; + +-- a standalone composite (NOT used by any table column) +CREATE TYPE test_schema.dims AS (w integer, h integer); diff --git a/packages/pg-delta/corpus/type-ops--composite-attribute-retype/b.sql b/packages/pg-delta/corpus/type-ops--composite-attribute-retype/b.sql new file mode 100644 index 000000000..dec8e8949 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-attribute-retype/b.sql @@ -0,0 +1,4 @@ +CREATE SCHEMA test_schema; + +-- w widened to bigint via ALTER TYPE … ALTER ATTRIBUTE … TYPE (unused → ok) +CREATE TYPE test_schema.dims AS (w bigint, h integer); diff --git a/packages/pg-delta/corpus/type-ops--composite-create/a.sql b/packages/pg-delta/corpus/type-ops--composite-create/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-create/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/type-ops--composite-create/b.sql b/packages/pg-delta/corpus/type-ops--composite-create/b.sql new file mode 100644 index 000000000..0b25967d4 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--composite-create/b.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.address AS ( + street VARCHAR(90), + city VARCHAR(90), + state VARCHAR(2) +); diff --git a/packages/pg-delta/corpus/type-ops--domain-fn-param-type/a.sql b/packages/pg-delta/corpus/type-ops--domain-fn-param-type/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--domain-fn-param-type/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/type-ops--domain-fn-param-type/b.sql b/packages/pg-delta/corpus/type-ops--domain-fn-param-type/b.sql new file mode 100644 index 000000000..1f3c3ae0a --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--domain-fn-param-type/b.sql @@ -0,0 +1,20 @@ +CREATE SCHEMA test_schema; + +CREATE FUNCTION test_schema.check_prefix(val text, prefix text) +RETURNS boolean +LANGUAGE sql +IMMUTABLE +AS $function$ +SELECT starts_with(val, prefix) +$function$; + +CREATE DOMAIN test_schema.user_id AS text + CHECK (test_schema.check_prefix(VALUE, 'user_')); + +CREATE FUNCTION test_schema.normalize_user_id(input test_schema.user_id) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $function$ +SELECT lower(input::text) +$function$; diff --git a/packages/pg-delta/corpus/type-ops--domain-with-check/a.sql b/packages/pg-delta/corpus/type-ops--domain-with-check/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--domain-with-check/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/type-ops--domain-with-check/b.sql b/packages/pg-delta/corpus/type-ops--domain-with-check/b.sql new file mode 100644 index 000000000..d219a1ad7 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--domain-with-check/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA test_schema; + +CREATE DOMAIN test_schema.positive_int AS INTEGER CHECK (VALUE > 0); diff --git a/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/a.sql b/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/a.sql new file mode 100644 index 000000000..dc11d8ba6 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/a.sql @@ -0,0 +1,3 @@ +CREATE TYPE public.mood AS ENUM ('happy', 'sad'); + +CREATE TABLE public.feelings (id integer PRIMARY KEY); diff --git a/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/b.sql b/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/b.sql new file mode 100644 index 000000000..be77b9d0d --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/b.sql @@ -0,0 +1,9 @@ +-- 'meh' is a NEW enum value, used as the DEFAULT of a NEW column in the SAME +-- plan: the ADD VALUE must be ordered before AND committed before the +-- ALTER TABLE ADD COLUMN, or apply fails with 55P04 / invalid enum input. +CREATE TYPE public.mood AS ENUM ('happy', 'sad', 'meh'); + +CREATE TABLE public.feelings ( + id integer PRIMARY KEY, + current_mood public.mood NOT NULL DEFAULT 'meh' +); diff --git a/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/seed-b.sql b/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/seed-b.sql new file mode 100644 index 000000000..35f96d727 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO public.feelings (id) VALUES (1); diff --git a/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/seed.sql b/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/seed.sql new file mode 100644 index 000000000..35f96d727 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-add-value-used-in-new-column/seed.sql @@ -0,0 +1 @@ +INSERT INTO public.feelings (id) VALUES (1); diff --git a/packages/pg-delta/corpus/type-ops--enum-create/a.sql b/packages/pg-delta/corpus/type-ops--enum-create/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-create/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/type-ops--enum-create/b.sql b/packages/pg-delta/corpus/type-ops--enum-create/b.sql new file mode 100644 index 000000000..8d51e0735 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-create/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.mood AS ENUM ('sad', 'ok', 'happy'); diff --git a/packages/pg-delta/corpus/type-ops--enum-replace-array-column/a.sql b/packages/pg-delta/corpus/type-ops--enum-replace-array-column/a.sql new file mode 100644 index 000000000..26b941fbc --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-replace-array-column/a.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; + +-- Enum with 6 values +CREATE TYPE test_schema.priority AS ENUM ('low', 'medium', 'high', 'critical', 'urgent', 'blocked'); + +CREATE TABLE test_schema.tasks ( + id integer PRIMARY KEY, + priority test_schema.priority, + tags test_schema.priority[] +); diff --git a/packages/pg-delta/corpus/type-ops--enum-replace-array-column/b.sql b/packages/pg-delta/corpus/type-ops--enum-replace-array-column/b.sql new file mode 100644 index 000000000..82407be23 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-replace-array-column/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; + +-- Enum with 4 values (urgent and blocked removed) — forces the rebuild path +CREATE TYPE test_schema.priority AS ENUM ('low', 'medium', 'high', 'critical'); + +CREATE TABLE test_schema.tasks ( + id integer PRIMARY KEY, + priority test_schema.priority, + tags test_schema.priority[] +); diff --git a/packages/pg-delta/corpus/type-ops--enum-replace-array-column/seed-b.sql b/packages/pg-delta/corpus/type-ops--enum-replace-array-column/seed-b.sql new file mode 100644 index 000000000..5917493ae --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-replace-array-column/seed-b.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.tasks (id, priority, tags) VALUES + (1, 'high', ARRAY['high', 'critical']::test_schema.priority[]); diff --git a/packages/pg-delta/corpus/type-ops--enum-replace-array-column/seed.sql b/packages/pg-delta/corpus/type-ops--enum-replace-array-column/seed.sql new file mode 100644 index 000000000..5917493ae --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-replace-array-column/seed.sql @@ -0,0 +1,2 @@ +INSERT INTO test_schema.tasks (id, priority, tags) VALUES + (1, 'high', ARRAY['high', 'critical']::test_schema.priority[]); diff --git a/packages/pg-delta/corpus/type-ops--enum-replace-values/a.sql b/packages/pg-delta/corpus/type-ops--enum-replace-values/a.sql new file mode 100644 index 000000000..eb8dcd8de --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-replace-values/a.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.status AS ENUM ('pending', 'approved'); diff --git a/packages/pg-delta/corpus/type-ops--enum-replace-values/b.sql b/packages/pg-delta/corpus/type-ops--enum-replace-values/b.sql new file mode 100644 index 000000000..025f13abc --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-replace-values/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.status AS ENUM ('pending', 'approved', 'rejected'); diff --git a/packages/pg-delta/corpus/type-ops--enum-table-matview-drop/a.sql b/packages/pg-delta/corpus/type-ops--enum-table-matview-drop/a.sql new file mode 100644 index 000000000..fa63172b1 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-table-matview-drop/a.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA reporting; +CREATE TYPE reporting.priority AS ENUM ('low', 'medium', 'high'); +CREATE TABLE reporting.tasks ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + priority reporting.priority DEFAULT 'medium' +); +CREATE MATERIALIZED VIEW reporting.priority_stats AS +SELECT + priority, + COUNT(*) as task_count +FROM reporting.tasks +GROUP BY priority; diff --git a/packages/pg-delta/corpus/type-ops--enum-table-matview-drop/b.sql b/packages/pg-delta/corpus/type-ops--enum-table-matview-drop/b.sql new file mode 100644 index 000000000..864bd45e1 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--enum-table-matview-drop/b.sql @@ -0,0 +1 @@ +CREATE SCHEMA reporting; diff --git a/packages/pg-delta/corpus/type-ops--matview-composite-domain-chain/a.sql b/packages/pg-delta/corpus/type-ops--matview-composite-domain-chain/a.sql new file mode 100644 index 000000000..48f51a4a7 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--matview-composite-domain-chain/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA ecommerce; diff --git a/packages/pg-delta/corpus/type-ops--matview-composite-domain-chain/b.sql b/packages/pg-delta/corpus/type-ops--matview-composite-domain-chain/b.sql new file mode 100644 index 000000000..da900005e --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--matview-composite-domain-chain/b.sql @@ -0,0 +1,41 @@ +CREATE SCHEMA ecommerce; + +-- Create types +CREATE TYPE ecommerce.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered'); +CREATE DOMAIN ecommerce.price AS DECIMAL(10,2) CHECK (VALUE >= 0); +CREATE TYPE ecommerce.product_info AS ( + name TEXT, + description TEXT, + base_price ecommerce.price +); + +-- Create tables using the types +CREATE TABLE ecommerce.products ( + id INTEGER PRIMARY KEY, + info ecommerce.product_info NOT NULL, + category TEXT +); + +CREATE TABLE ecommerce.orders ( + id INTEGER PRIMARY KEY, + status ecommerce.order_status DEFAULT 'pending', + final_price ecommerce.price NOT NULL +); + +-- Create materialized views that depend on the tables and types +CREATE MATERIALIZED VIEW ecommerce.product_pricing AS +SELECT + id, + (info).name as product_name, + (info).base_price as base_price, + category +FROM ecommerce.products +WHERE (info).base_price > 0; + +CREATE MATERIALIZED VIEW ecommerce.order_summary AS +SELECT + status, + COUNT(*) as order_count, + AVG(final_price) as avg_price +FROM ecommerce.orders +GROUP BY status; diff --git a/packages/pg-delta/corpus/type-ops--matview-enum-dependency/a.sql b/packages/pg-delta/corpus/type-ops--matview-enum-dependency/a.sql new file mode 100644 index 000000000..ed39c4d88 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--matview-enum-dependency/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA analytics; diff --git a/packages/pg-delta/corpus/type-ops--matview-enum-dependency/b.sql b/packages/pg-delta/corpus/type-ops--matview-enum-dependency/b.sql new file mode 100644 index 000000000..84c645ed7 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--matview-enum-dependency/b.sql @@ -0,0 +1,11 @@ +CREATE SCHEMA analytics; +CREATE TYPE analytics.status AS ENUM ('active', 'inactive', 'pending'); +CREATE TABLE analytics.users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + status analytics.status DEFAULT 'pending' +); +CREATE MATERIALIZED VIEW analytics.user_status_summary AS +SELECT status, COUNT(*) as count +FROM analytics.users +GROUP BY status; diff --git a/packages/pg-delta/corpus/type-ops--matview-on-composite-type/a.sql b/packages/pg-delta/corpus/type-ops--matview-on-composite-type/a.sql new file mode 100644 index 000000000..f9c769796 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--matview-on-composite-type/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA inventory; diff --git a/packages/pg-delta/corpus/type-ops--matview-on-composite-type/b.sql b/packages/pg-delta/corpus/type-ops--matview-on-composite-type/b.sql new file mode 100644 index 000000000..9b9830921 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--matview-on-composite-type/b.sql @@ -0,0 +1,21 @@ +CREATE SCHEMA inventory; + +CREATE TYPE inventory.address AS ( + street TEXT, + city TEXT, + zip_code TEXT +); + +CREATE TABLE inventory.warehouses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + location inventory.address +); + +CREATE MATERIALIZED VIEW inventory.warehouse_locations AS +SELECT + name, + (location).city as city, + (location).zip_code as zip_code +FROM inventory.warehouses +WHERE (location).city IS NOT NULL; diff --git a/packages/pg-delta/corpus/type-ops--matview-range-dependency/a.sql b/packages/pg-delta/corpus/type-ops--matview-range-dependency/a.sql new file mode 100644 index 000000000..63919487a --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--matview-range-dependency/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA scheduling; diff --git a/packages/pg-delta/corpus/type-ops--matview-range-dependency/b.sql b/packages/pg-delta/corpus/type-ops--matview-range-dependency/b.sql new file mode 100644 index 000000000..1d6c784be --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--matview-range-dependency/b.sql @@ -0,0 +1,16 @@ +CREATE SCHEMA scheduling; + +CREATE TYPE scheduling.time_range AS RANGE (subtype = timestamp); + +CREATE TABLE scheduling.events ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + time_slot scheduling.time_range +); + +CREATE MATERIALIZED VIEW scheduling.event_durations AS +SELECT + name, + EXTRACT(EPOCH FROM (upper(time_slot) - lower(time_slot))) / 3600 as duration_hours +FROM scheduling.events +WHERE time_slot IS NOT NULL; diff --git a/packages/pg-delta/corpus/type-ops--multiple-types-complex-deps/a.sql b/packages/pg-delta/corpus/type-ops--multiple-types-complex-deps/a.sql new file mode 100644 index 000000000..60dbe0686 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--multiple-types-complex-deps/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA commerce; diff --git a/packages/pg-delta/corpus/type-ops--multiple-types-complex-deps/b.sql b/packages/pg-delta/corpus/type-ops--multiple-types-complex-deps/b.sql new file mode 100644 index 000000000..c4b9e4992 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--multiple-types-complex-deps/b.sql @@ -0,0 +1,18 @@ +CREATE SCHEMA commerce; +CREATE TYPE commerce.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled'); +CREATE DOMAIN commerce.price AS DECIMAL(10,2) CHECK (VALUE >= 0); +CREATE TYPE commerce.product_info AS ( + name TEXT, + description TEXT, + unit_price commerce.price +); +CREATE TABLE commerce.products ( + id INTEGER PRIMARY KEY, + info commerce.product_info, + category TEXT +); +CREATE TABLE commerce.orders ( + id INTEGER PRIMARY KEY, + status commerce.order_status DEFAULT 'pending', + total_amount commerce.price +); diff --git a/packages/pg-delta/corpus/type-ops--range-create/a.sql b/packages/pg-delta/corpus/type-ops--range-create/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--range-create/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/type-ops--range-create/b.sql b/packages/pg-delta/corpus/type-ops--range-create/b.sql new file mode 100644 index 000000000..8445a33df --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--range-create/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.floatrange AS RANGE (subtype = float8); diff --git a/packages/pg-delta/corpus/type-ops--range-options/a.sql b/packages/pg-delta/corpus/type-ops--range-options/a.sql new file mode 100644 index 000000000..008b0833d --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--range-options/a.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA app; + +-- Default subtype opclass (text_ops) and auto-generated multirange name. +CREATE TYPE app.textrange AS RANGE (subtype = text); +CREATE TYPE app.numrange_custom AS RANGE (subtype = numeric); diff --git a/packages/pg-delta/corpus/type-ops--range-options/b.sql b/packages/pg-delta/corpus/type-ops--range-options/b.sql new file mode 100644 index 000000000..5a370854d --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--range-options/b.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA app; + +-- Non-default subtype operator class and an explicit multirange type name. +-- Both are drop+create options, so the forward diff must recreate each type; +-- before the options were extracted these hashed identically to the a-state. +CREATE TYPE app.textrange AS RANGE (subtype = text, subtype_opclass = text_pattern_ops); +CREATE TYPE app.numrange_custom AS RANGE (subtype = numeric, multirange_type_name = app.numrange_custom_mr); diff --git a/packages/pg-delta/corpus/type-ops--range-options/meta.json b/packages/pg-delta/corpus/type-ops--range-options/meta.json new file mode 100644 index 000000000..24fda6da1 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--range-options/meta.json @@ -0,0 +1 @@ +{ "minVersion": 14 } diff --git a/packages/pg-delta/corpus/type-ops--range-used-in-table/a.sql b/packages/pg-delta/corpus/type-ops--range-used-in-table/a.sql new file mode 100644 index 000000000..51abd88d4 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--range-used-in-table/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA app; diff --git a/packages/pg-delta/corpus/type-ops--range-used-in-table/b.sql b/packages/pg-delta/corpus/type-ops--range-used-in-table/b.sql new file mode 100644 index 000000000..c76fad4ad --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--range-used-in-table/b.sql @@ -0,0 +1,6 @@ +CREATE SCHEMA app; +CREATE TYPE app.int_range AS RANGE (subtype = int4); +CREATE TABLE app.bookings ( + id integer PRIMARY KEY, + span app.int_range NOT NULL +); diff --git a/packages/pg-delta/corpus/type-ops--special-char-names/a.sql b/packages/pg-delta/corpus/type-ops--special-char-names/a.sql new file mode 100644 index 000000000..e0afbbe89 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--special-char-names/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA "test-schema"; diff --git a/packages/pg-delta/corpus/type-ops--special-char-names/b.sql b/packages/pg-delta/corpus/type-ops--special-char-names/b.sql new file mode 100644 index 000000000..3254f52a3 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--special-char-names/b.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA "test-schema"; +CREATE TYPE "test-schema"."user-status" AS ENUM ('active', 'in-active'); +CREATE DOMAIN "test-schema"."positive-number" AS INTEGER CHECK (VALUE > 0); diff --git a/packages/pg-delta/corpus/type-ops--type-comments/a.sql b/packages/pg-delta/corpus/type-ops--type-comments/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--type-comments/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/type-ops--type-comments/b.sql b/packages/pg-delta/corpus/type-ops--type-comments/b.sql new file mode 100644 index 000000000..7a46c4dbc --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--type-comments/b.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; + +CREATE TYPE test_schema.mood AS ENUM ('sad', 'ok', 'happy'); +CREATE DOMAIN test_schema.positive_int AS INTEGER CHECK (VALUE > 0); +CREATE TYPE test_schema.address AS ( + street TEXT, + city TEXT +); + +COMMENT ON TYPE test_schema.mood IS 'mood type'; +COMMENT ON DOMAIN test_schema.positive_int IS 'positive integer domain'; +COMMENT ON TYPE test_schema.address IS 'address composite type'; diff --git a/packages/pg-delta/corpus/type-ops--types-with-table-deps/a.sql b/packages/pg-delta/corpus/type-ops--types-with-table-deps/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--types-with-table-deps/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/type-ops--types-with-table-deps/b.sql b/packages/pg-delta/corpus/type-ops--types-with-table-deps/b.sql new file mode 100644 index 000000000..0b3e3bafc --- /dev/null +++ b/packages/pg-delta/corpus/type-ops--types-with-table-deps/b.sql @@ -0,0 +1,28 @@ +CREATE SCHEMA test_schema; + +-- Enum type used by a table column +CREATE TYPE test_schema.user_status AS ENUM ('active', 'inactive', 'pending'); + +-- Domain type used by a table column +CREATE DOMAIN test_schema.email AS TEXT CHECK (VALUE ~ '^[^@]+@[^@]+\.[^@]+$'); + +-- Composite type used by a table column +CREATE TYPE test_schema.address AS ( + street TEXT, + city TEXT, + zip_code TEXT +); + +CREATE TABLE test_schema.users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + status test_schema.user_status DEFAULT 'pending', + email_address test_schema.email +); + +CREATE TABLE test_schema.customers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + billing_address test_schema.address, + shipping_address test_schema.address +); diff --git a/packages/pg-delta/corpus/view-on-new-table/a.sql b/packages/pg-delta/corpus/view-on-new-table/a.sql new file mode 100644 index 000000000..07a92cb96 --- /dev/null +++ b/packages/pg-delta/corpus/view-on-new-table/a.sql @@ -0,0 +1 @@ +-- empty: table AND dependent view both arrive in b (ordering test) diff --git a/packages/pg-delta/corpus/view-on-new-table/b.sql b/packages/pg-delta/corpus/view-on-new-table/b.sql new file mode 100644 index 000000000..c36f9564a --- /dev/null +++ b/packages/pg-delta/corpus/view-on-new-table/b.sql @@ -0,0 +1,2 @@ +CREATE TABLE public.users (id integer PRIMARY KEY, email text NOT NULL, active boolean DEFAULT true); +CREATE VIEW public.active_users AS SELECT id, email FROM public.users WHERE active; diff --git a/packages/pg-delta/corpus/view-operations--comment/a.sql b/packages/pg-delta/corpus/view-operations--comment/a.sql new file mode 100644 index 000000000..d585f83db --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--comment/a.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text +); + +CREATE VIEW test_schema.user_names AS SELECT id, name FROM test_schema.users; diff --git a/packages/pg-delta/corpus/view-operations--comment/b.sql b/packages/pg-delta/corpus/view-operations--comment/b.sql new file mode 100644 index 000000000..5e2dc869e --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--comment/b.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text +); + +CREATE VIEW test_schema.user_names AS SELECT id, name FROM test_schema.users; + +COMMENT ON VIEW test_schema.user_names IS 'users names view'; diff --git a/packages/pg-delta/corpus/view-operations--nested-three-levels/a.sql b/packages/pg-delta/corpus/view-operations--nested-three-levels/a.sql new file mode 100644 index 000000000..75bdd7f6a --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--nested-three-levels/a.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text, + email text, + created_at timestamp DEFAULT NOW() +); + +CREATE TABLE test_schema.orders ( + id integer, + user_id integer, + amount decimal(10,2), + created_at timestamp DEFAULT NOW() +); diff --git a/packages/pg-delta/corpus/view-operations--nested-three-levels/b.sql b/packages/pg-delta/corpus/view-operations--nested-three-levels/b.sql new file mode 100644 index 000000000..5817679db --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--nested-three-levels/b.sql @@ -0,0 +1,41 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text, + email text, + created_at timestamp DEFAULT NOW() +); + +CREATE TABLE test_schema.orders ( + id integer, + user_id integer, + amount decimal(10,2), + created_at timestamp DEFAULT NOW() +); + +-- Level 1: Views directly on tables +CREATE VIEW test_schema.recent_users AS +SELECT id, name, email, created_at +FROM test_schema.users +WHERE created_at > NOW() - INTERVAL '30 days'; + +CREATE VIEW test_schema.high_value_orders AS +SELECT id, user_id, amount, created_at +FROM test_schema.orders +WHERE amount > 100; + +-- Level 2: View on level-1 views +CREATE VIEW test_schema.recent_big_spenders AS +SELECT u.id, u.name, u.email, COUNT(o.id) AS order_count, SUM(o.amount) AS total_spent +FROM test_schema.recent_users u +JOIN test_schema.high_value_orders o ON u.id = o.user_id +GROUP BY u.id, u.name, u.email; + +-- Level 3: View on level-2 view +CREATE VIEW test_schema.top_customers AS +SELECT id, name, email, total_spent +FROM test_schema.recent_big_spenders +WHERE total_spent > 1000 +ORDER BY total_spent DESC +LIMIT 10; diff --git a/packages/pg-delta/corpus/view-operations--options/a.sql b/packages/pg-delta/corpus/view-operations--options/a.sql new file mode 100644 index 000000000..1ba369f0a --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--options/a.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text +); + +CREATE VIEW test_schema.alter_options WITH (security_barrier = TRUE) AS + SELECT id, name FROM test_schema.users; + +CREATE VIEW test_schema.reset_options WITH (security_invoker = TRUE) AS + SELECT id, name FROM test_schema.users; diff --git a/packages/pg-delta/corpus/view-operations--options/b.sql b/packages/pg-delta/corpus/view-operations--options/b.sql new file mode 100644 index 000000000..198131133 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--options/b.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text +); + +CREATE VIEW test_schema.alter_options WITH (security_invoker = TRUE) AS + SELECT id, name FROM test_schema.users; + +CREATE VIEW test_schema.reset_options AS + SELECT id, name FROM test_schema.users; + +CREATE VIEW test_schema.create_with_options WITH (security_invoker = TRUE) AS + SELECT id, name FROM test_schema.users; diff --git a/packages/pg-delta/corpus/view-operations--options/meta.json b/packages/pg-delta/corpus/view-operations--options/meta.json new file mode 100644 index 000000000..3babd4fe4 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--options/meta.json @@ -0,0 +1 @@ +{ "minVersion": 15 } diff --git a/packages/pg-delta/corpus/view-operations--owner-change/a.sql b/packages/pg-delta/corpus/view-operations--owner-change/a.sql new file mode 100644 index 000000000..204ac7059 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--owner-change/a.sql @@ -0,0 +1,13 @@ +DO $$ BEGIN CREATE ROLE corpus_view_previous_owner LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text +); + +CREATE VIEW test_schema.owned_view AS + SELECT id, name FROM test_schema.users; + +ALTER VIEW test_schema.owned_view OWNER TO corpus_view_previous_owner; diff --git a/packages/pg-delta/corpus/view-operations--owner-change/b.sql b/packages/pg-delta/corpus/view-operations--owner-change/b.sql new file mode 100644 index 000000000..87960c7a4 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--owner-change/b.sql @@ -0,0 +1,14 @@ +DO $$ BEGIN CREATE ROLE corpus_view_previous_owner LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE ROLE corpus_view_new_owner LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text +); + +CREATE VIEW test_schema.owned_view AS + SELECT id, name FROM test_schema.users; + +ALTER VIEW test_schema.owned_view OWNER TO corpus_view_new_owner; diff --git a/packages/pg-delta/corpus/view-operations--owner-change/meta.json b/packages/pg-delta/corpus/view-operations--owner-change/meta.json new file mode 100644 index 000000000..3dfd5e85f --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--owner-change/meta.json @@ -0,0 +1 @@ +{ "isolatedCluster": true } diff --git a/packages/pg-delta/corpus/view-operations--recreate-select-star/a.sql b/packages/pg-delta/corpus/view-operations--recreate-select-star/a.sql new file mode 100644 index 000000000..e577a6da2 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--recreate-select-star/a.sql @@ -0,0 +1,10 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.items ( + id serial PRIMARY KEY, + title text NOT NULL, + status text DEFAULT 'active' +); + +CREATE VIEW test_schema.item_details AS + SELECT i.* FROM test_schema.items i; diff --git a/packages/pg-delta/corpus/view-operations--recreate-select-star/b.sql b/packages/pg-delta/corpus/view-operations--recreate-select-star/b.sql new file mode 100644 index 000000000..a6d36c6f8 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--recreate-select-star/b.sql @@ -0,0 +1,11 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.items ( + id serial PRIMARY KEY, + title text NOT NULL, + status text DEFAULT 'active', + priority int DEFAULT 0 +); + +CREATE VIEW test_schema.item_details AS + SELECT i.* FROM test_schema.items i; diff --git a/packages/pg-delta/corpus/view-operations--recreate-select-star/seed-b.sql b/packages/pg-delta/corpus/view-operations--recreate-select-star/seed-b.sql new file mode 100644 index 000000000..cc028b690 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--recreate-select-star/seed-b.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.items (title) VALUES ('Item One'); diff --git a/packages/pg-delta/corpus/view-operations--recreate-select-star/seed.sql b/packages/pg-delta/corpus/view-operations--recreate-select-star/seed.sql new file mode 100644 index 000000000..cc028b690 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--recreate-select-star/seed.sql @@ -0,0 +1 @@ +INSERT INTO test_schema.items (title) VALUES ('Item One'); diff --git a/packages/pg-delta/corpus/view-operations--recursive-cte/a.sql b/packages/pg-delta/corpus/view-operations--recursive-cte/a.sql new file mode 100644 index 000000000..ab7a33f58 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--recursive-cte/a.sql @@ -0,0 +1 @@ +CREATE SCHEMA test_schema; diff --git a/packages/pg-delta/corpus/view-operations--recursive-cte/b.sql b/packages/pg-delta/corpus/view-operations--recursive-cte/b.sql new file mode 100644 index 000000000..a546f582c --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--recursive-cte/b.sql @@ -0,0 +1,22 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.employees ( + id integer, + name text, + manager_id integer +); + +-- This is a valid recursive pattern using CTE, not a cycle +CREATE VIEW test_schema.employee_hierarchy AS +WITH RECURSIVE hierarchy AS ( + SELECT id, name, manager_id, 0 as level + FROM test_schema.employees + WHERE manager_id IS NULL + + UNION ALL + + SELECT e.id, e.name, e.manager_id, h.level + 1 + FROM test_schema.employees e + JOIN hierarchy h ON e.manager_id = h.id +) +SELECT * FROM hierarchy; diff --git a/packages/pg-delta/corpus/view-operations--replace-with-new-dep/a.sql b/packages/pg-delta/corpus/view-operations--replace-with-new-dep/a.sql new file mode 100644 index 000000000..32725f85e --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--replace-with-new-dep/a.sql @@ -0,0 +1,17 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text, + status text +); + +CREATE TABLE test_schema.profiles ( + user_id integer, + bio text, + avatar_url text +); + +CREATE VIEW test_schema.user_summary AS +SELECT id, name, status +FROM test_schema.users; diff --git a/packages/pg-delta/corpus/view-operations--replace-with-new-dep/b.sql b/packages/pg-delta/corpus/view-operations--replace-with-new-dep/b.sql new file mode 100644 index 000000000..805e3a4db --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--replace-with-new-dep/b.sql @@ -0,0 +1,18 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text, + status text +); + +CREATE TABLE test_schema.profiles ( + user_id integer, + bio text, + avatar_url text +); + +CREATE VIEW test_schema.user_summary AS +SELECT u.id, u.name, u.status, p.bio, p.avatar_url +FROM test_schema.users u +LEFT JOIN test_schema.profiles p ON u.id = p.user_id; diff --git a/packages/pg-delta/corpus/view-operations--simple-create/a.sql b/packages/pg-delta/corpus/view-operations--simple-create/a.sql new file mode 100644 index 000000000..c0ea18e14 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--simple-create/a.sql @@ -0,0 +1,7 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text, + email text +); diff --git a/packages/pg-delta/corpus/view-operations--simple-create/b.sql b/packages/pg-delta/corpus/view-operations--simple-create/b.sql new file mode 100644 index 000000000..d847dbee0 --- /dev/null +++ b/packages/pg-delta/corpus/view-operations--simple-create/b.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA test_schema; + +CREATE TABLE test_schema.users ( + id integer, + name text, + email text +); + +CREATE VIEW test_schema.active_users AS +SELECT id, name, email +FROM test_schema.users +WHERE email IS NOT NULL; diff --git a/packages/pg-delta/docs/api.md b/packages/pg-delta/docs/api.md deleted file mode 100644 index c4b4ba75e..000000000 --- a/packages/pg-delta/docs/api.md +++ /dev/null @@ -1,353 +0,0 @@ -# API Reference - -The `@supabase/pg-delta` package provides a programmatic API for generating and applying migration plans. - -## Installation - -```bash -npm install @supabase/pg-delta -``` - -## Quick Start - -```typescript -import { createPlan, applyPlan, renderPlanSql } from "@supabase/pg-delta"; -import { supabase } from "@supabase/pg-delta/integrations/supabase"; - -// Create a migration plan -const result = await createPlan( - "postgresql://localhost:5432/source_db", - "postgresql://localhost:5432/target_db", - { filter: supabase.filter, serialize: supabase.serialize } -); - -if (result) { - const { plan } = result; - console.log(renderPlanSql(plan)); // executable SQL script (transaction-aware) - - // Apply the plan - const applyResult = await applyPlan( - plan, - "postgresql://localhost:5432/source_db", - "postgresql://localhost:5432/target_db" - ); - console.log(applyResult.status); -} -``` - -## Exports - -### Main Entry Point - -```typescript -import { - createPlan, - applyPlan, - renderPlanSql, - renderPlanFiles, - flattenPlanStatements, - UnorderableCycleError, - type Plan, - type MigrationUnit, - type CreatePlanOptions, - type IntegrationDSL, -} from "@supabase/pg-delta"; -``` - -### Integrations - -```typescript -import { supabase } from "@supabase/pg-delta/integrations/supabase"; -``` - -## Functions - -### `createPlan(source, target, options?)` - -Create a migration plan by comparing two databases. - -#### Parameters - -- `source` (string | Sql): Source database connection URL or postgres.js client (current state) -- `target` (string | Sql): Target database connection URL or postgres.js client (desired state) -- `options` (CreatePlanOptions, optional): Configuration options - -#### Returns - -`Promise<{ plan: Plan; sortedChanges: Change[]; ctx: DiffContext } | null>` - -- Returns an object with the plan and metadata if there are changes -- Returns `null` if databases are identical -- Throws `UnorderableCycleError` when the changes contain a dependency cycle that cannot be ordered; `error.cycle` carries the offending changes - -#### Example - -```typescript -import { createPlan } from "@supabase/pg-delta"; - -const result = await createPlan( - process.env.SOURCE_DB_URL!, - process.env.TARGET_DB_URL! -); - -if (result) { - console.log(`Found ${result.plan.units.length} migration units`); - console.log(renderPlanSql(result.plan)); -} else { - console.log("No differences found"); -} -``` - ---- - -### `applyPlan(plan, source, target, options?)` - -Apply a plan's migration units to a database with integrity checks. Validates fingerprints before and after application to ensure plan integrity. - -Units are applied in order on a single session: transactional units inside an explicit `BEGIN`/`COMMIT`, non-transactional units without a wrapper. Multi-unit plans are therefore **not atomic as a whole** — a failure in a later unit does not roll back units that already committed. - -#### Parameters - -- `plan` (Plan): The migration plan to apply -- `source` (string | Sql): Source database connection URL or postgres.js client -- `target` (string | Sql): Target database connection URL or postgres.js client -- `options` (ApplyPlanOptions, optional): Configuration options - - `verifyPostApply` (boolean, default: true): Verify fingerprint after applying - -#### Returns - -`Promise` - -The result is a discriminated union with the following possible statuses: - -```typescript -type ApplyPlanResult = - | { status: "invalid_plan"; message: string } - | { status: "fingerprint_mismatch"; current: string; expected: string } - | { status: "already_applied" } - | { status: "applied"; statements: number; units: number; warnings?: string[] } - | { - status: "failed"; - error: unknown; - script: string; - /** 0-based index of the unit that failed; undefined if a session statement failed. */ - failedUnitIndex?: number; - /** Number of units fully committed before the failure. */ - completedUnits: number; - }; -``` - -#### Example - -```typescript -import { createPlan, applyPlan } from "@supabase/pg-delta"; - -const result = await createPlan(sourceUrl, targetUrl); - -if (result) { - const applyResult = await applyPlan(result.plan, sourceUrl, targetUrl); - - switch (applyResult.status) { - case "applied": - console.log( - `Applied ${applyResult.statements} statements across ${applyResult.units} units`, - ); - break; - case "already_applied": - console.log("Plan already applied"); - break; - case "fingerprint_mismatch": - console.error("Source database has changed since plan was created"); - break; - case "failed": - console.error("Failed to apply:", applyResult.error); - break; - } -} -``` - -## Types - -### `Plan` - -A migration plan containing all changes to transform one database schema into another, as an ordered list of execution-aware migration units. - -```typescript -interface Plan { - version: number; // 2 - toolVersion?: string; - source: { fingerprint: string }; - target: { fingerprint: string }; - units: MigrationUnit[]; - sessionStatements: string[]; // SET ROLE, ... applied once per session - role?: string; - filter?: FilterDSL; - serialize?: SerializeDSL; - risk?: { level: "safe" } | { level: "data_loss"; statements: string[] }; -} - -interface MigrationUnit { - transactionMode: "transactional" | "none"; - reason: "default" | "enum_value_visibility" | "non_transactional"; - statements: string[]; -} -``` - -A plan needs more than one unit when a statement's effects only become usable after COMMIT (e.g. `ALTER TYPE ... ADD VALUE`, whose new value cannot be referenced in the same transaction — PostgreSQL error 55P04), or when a statement cannot run inside a transaction block at all (e.g. `DROP SUBSCRIPTION` with an associated replication slot). - -Render a plan with `renderPlanSql(plan)` (single script) or `renderPlanFiles(plan)` (one numbered file per unit, as written by `pgdelta plan --output-dir`). `flattenPlanStatements(plan)` returns the raw ordered statements (session statements included) when transaction context does not matter. - -**Execution contract for rendered SQL:** run scripts with a statement-splitting runner such as `psql -f` (each statement is sent separately, so session `SET`s apply and non-transactional units execute outside any transaction). Do not execute a rendered script as a single multi-statement query string (node-postgres `query(file)`, `PQexec`) and do not use `psql --single-transaction`: PostgreSQL runs every statement of a multi-command string in an implicit transaction block, so any `transactionMode: "none"` unit would fail with 25001 regardless of statement ordering. For programmatic application use `applyPlan`, which executes statement-by-statement on a single session. - -Legacy v1 plan JSON (flat `statements` array) is still accepted by `deserializePlan`/`applyPlan` and is normalized into a single transactional unit. - -### `CreatePlanOptions` - -Options for creating a plan. - -```typescript -interface CreatePlanOptions { - /** Filter - either FilterDSL (stored in plan) or ChangeFilter function (not stored) */ - filter?: FilterDSL | ChangeFilter; - /** Serialize - either SerializeDSL (stored in plan) or ChangeSerializer function (not stored) */ - serialize?: SerializeDSL | ChangeSerializer; - /** Role to use when executing the migration (SET ROLE will be added to statements) */ - role?: string; -} -``` - -### `IntegrationDSL` - -A serializable representation of an integration combining filter and serialization options. - -```typescript -type IntegrationDSL = { - /** Filter DSL - determines which changes to include/exclude */ - filter?: FilterDSL; - /** Serialization DSL - customizes how changes are serialized */ - serialize?: SerializeDSL; -}; -``` - -See [Integrations](./integrations.md) for the full DSL documentation. - -## Using Integrations - -Integrations provide pre-configured filter and serialize options. Use them by spreading their properties into `CreatePlanOptions`: - -```typescript -import { createPlan } from "@supabase/pg-delta"; -import { supabase } from "@supabase/pg-delta/integrations/supabase"; - -// Use the supabase integration -const result = await createPlan(sourceUrl, targetUrl, { - filter: supabase.filter, - serialize: supabase.serialize, -}); -``` - -### Custom Integration - -You can create your own integration using the `IntegrationDSL` type: - -```typescript -import { createPlan, type IntegrationDSL } from "@supabase/pg-delta"; - -const myIntegration: IntegrationDSL = { - filter: { - schema: "public", - }, - serialize: [ - { - when: { type: "schema", operation: "create" }, - options: { skipAuthorization: true }, - }, - ], -}; - -const result = await createPlan(sourceUrl, targetUrl, { - filter: myIntegration.filter, - serialize: myIntegration.serialize, -}); -``` - -## Error Handling - -Both `createPlan` and `applyPlan` may throw errors for connection failures or database query errors. Always wrap calls in try-catch blocks: - -```typescript -try { - const result = await createPlan(sourceUrl, targetUrl); - // Handle result -} catch (error) { - console.error("Failed to create plan:", error); - process.exit(1); -} -``` - -## Examples - -### Basic Migration - -```typescript -import { createPlan, applyPlan } from "@supabase/pg-delta"; - -async function migrate() { - const sourceUrl = process.env.SOURCE_DB_URL!; - const targetUrl = process.env.TARGET_DB_URL!; - - const result = await createPlan(sourceUrl, targetUrl); - - if (!result) { - console.log("No differences found"); - return; - } - - console.log("Migration plan:"); - console.log(result.plan.statements.join(";\n")); - - const applyResult = await applyPlan(result.plan, sourceUrl, targetUrl); - - if (applyResult.status === "applied") { - console.log(`Successfully applied ${applyResult.statements} statements`); - } -} -``` - -### With Supabase Integration - -```typescript -import { createPlan, applyPlan, renderPlanSql } from "@supabase/pg-delta"; -import { supabase } from "@supabase/pg-delta/integrations/supabase"; - -const result = await createPlan(sourceUrl, targetUrl, { - filter: supabase.filter, - serialize: supabase.serialize, -}); - -if (result) { - await applyPlan(result.plan, sourceUrl, targetUrl); -} -``` - -### With Role - -```typescript -import { createPlan } from "@supabase/pg-delta"; - -// SET ROLE will be added to the beginning of the migration -const result = await createPlan(sourceUrl, targetUrl, { - role: "postgres", -}); -``` - -### Filtering by Schema - -```typescript -import { createPlan } from "@supabase/pg-delta"; - -// Only include changes in the public schema -const result = await createPlan(sourceUrl, targetUrl, { - filter: { schema: "public" }, -}); -``` diff --git a/packages/pg-delta/docs/cli.md b/packages/pg-delta/docs/cli.md deleted file mode 100644 index 2b05a3ee0..000000000 --- a/packages/pg-delta/docs/cli.md +++ /dev/null @@ -1,435 +0,0 @@ -# CLI Reference - -The `pg-delta` CLI provides a command-line interface for managing PostgreSQL schemas. It supports imperative diff-based migrations (`plan`, `apply`, `sync`), declarative file-based schemas (`declarative export`, `declarative apply`), and catalog snapshotting (`catalog-export`). - -For end-to-end workflow examples, see the [Workflow Guide](./workflow.md). - -## Installation - -```bash -npm install -g @supabase/pg-delta -``` - -Or use with `npx`: - -```bash -npx @supabase/pg-delta sync --source --target -``` - -## Commands - -### `sync` (default) - -Plan and apply schema changes in one go with confirmation prompt. This is the default command when no command is specified. - -#### Usage - -```bash -pg-delta sync --source --target [options] -# or simply -pg-delta --source --target [options] -``` - -#### Options - -- `-s, --source ` (required): Source database connection URL (current state) -- `-t, --target ` (required): Target database connection URL (desired state) -- `--role `: Role to use when executing the migration (SET ROLE will be added to statements) -- `--filter `: Filter DSL as inline JSON to filter changes (e.g., `'{"*/schema":"public"}'`) -- `--serialize `: Serialize DSL as inline JSON array (e.g., `'[{"when":{"objectType":"schema"},"options":{"skipAuthorization":true}}]'`) -- `--integration `: Integration name (e.g., `supabase`) or path to integration JSON file (must end with `.json`) -- `-y, --yes`: Skip confirmation prompt and apply changes automatically -- `-u, --unsafe`: Allow data-loss operations (unsafe mode) - -#### Examples - -**Basic usage:** - -```bash -pg-delta sync \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db -``` - -**Skip confirmation:** - -```bash -pg-delta sync \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --yes -``` - -**Use Supabase integration:** - -```bash -pg-delta sync \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --integration supabase -``` - -**Use custom integration file:** - -```bash -pg-delta sync \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --integration ./my-integration.json -``` - -**Filter changes:** - -```bash -pg-delta sync \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --filter '{"*/schema":"public"}' -``` - -#### Exit Codes - -- `0`: Success (changes applied or no changes detected) -- `1`: Error occurred -- `2`: User cancelled or changes detected but not applied - ---- - -### `plan` - -Compute schema diff and preview changes. Defaults to tree display; json/sql outputs are available for artifacts or piping. - -Both `--source` and `--target` accept either a PostgreSQL connection URL or a catalog snapshot file path (from `catalog-export`), enabling offline diffs without live database connections. When `--source` is omitted, diffing starts from an empty baseline (or the integration's empty catalog if `--integration` is set). - -#### Usage - -```bash -pg-delta plan --target [options] -``` - -#### Options - -- `-s, --source `: Source (current state): Postgres URL or catalog snapshot file path. Omit for empty baseline -- `-t, --target ` (required): Target (desired state): Postgres URL or catalog snapshot file path -- `-o, --output `: Write output to file (stdout by default). If format is not set: `.sql` infers sql, `.json` infers json, otherwise uses human output -- `--format `: Output format override: `json` (plan) or `sql` (script) -- `--role `: Role to use when executing the migration (SET ROLE will be added to statements) -- `--filter `: Filter DSL as inline JSON to filter changes (e.g., `'{"*/schema":"public"}'`) -- `--serialize `: Serialize DSL as inline JSON array (e.g., `'[{"when":{"objectType":"schema"},"options":{"skipAuthorization":true}}]'`) -- `--integration `: Integration name (e.g., `supabase`) or path to integration JSON file (must end with `.json`) -- `--sql-format`: Format SQL output (opt-in for `--format sql` or `.sql` output) -- `--sql-format-options `: SQL format options as inline JSON (e.g., `'{"keywordCase":"upper","maxWidth":100"}'`) - -#### Examples - -**Preview changes (tree format):** - -```bash -pg-delta plan \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db -``` - -**Save plan as JSON:** - -```bash -pg-delta plan \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --output plan.json -``` - -**Generate SQL script:** - -```bash -pg-delta plan \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --format sql \ - --output migration.sql -``` - -**Use integration:** - -```bash -pg-delta plan \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --integration supabase \ - --output plan.json -``` - -**Offline diff with catalog snapshot:** - -```bash -pg-delta plan \ - --source prod-snapshot.json \ - --target postgresql://user:pass@localhost:5432/staging_db \ - --output migration.sql -``` - -#### Exit Codes - -- `0`: Success - no changes detected -- `2`: Changes detected (plan generated) -- `1`: Error - command execution failed - ---- - -### `apply` - -Apply a plan's migration script to a target database. - -#### Usage - -```bash -pg-delta apply --plan --source --target [options] -``` - -#### Options - -- `-p, --plan ` (required): Path to plan file (JSON format) -- `-s, --source ` (required): Source database connection URL (current state) -- `-t, --target ` (required): Target database connection URL (desired state) -- `-u, --unsafe`: Allow data-loss operations (unsafe mode) - -#### Examples - -**Apply a plan:** - -```bash -pg-delta apply \ - --plan plan.json \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db -``` - -**Apply unsafe plan:** - -```bash -pg-delta apply \ - --plan plan.json \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --unsafe -``` - -#### Exit Codes - -- `0`: Success (changes applied) -- `1`: Error occurred - -**Note:** Safe by default - will refuse plans containing data-loss unless `--unsafe` is set. - ---- - -### `catalog-export` - -Extract the full catalog from a live PostgreSQL database and save it as a JSON snapshot file. The snapshot can be used as `--source` or `--target` for `plan` and `declarative export`, enabling offline diffs without a live database connection. - -#### Usage - -```bash -pg-delta catalog-export --target --output [options] -``` - -#### Options - -- `-t, --target ` (required): Database connection URL to extract the catalog from -- `-o, --output ` (required): Output file path for the catalog snapshot JSON -- `--role `: Role to assume via `SET ROLE` during extraction - -#### Examples - -**Snapshot a database:** - -```bash -pg-delta catalog-export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output snapshot.json -``` - -**Snapshot with a specific role:** - -```bash -pg-delta catalog-export \ - --target postgresql://user:pass@prod:5432/mydb \ - --output prod-snapshot.json \ - --role readonly_role -``` - ---- - -### `declarative export` - -Export a declarative SQL schema by comparing two databases (source -> target). Writes `.sql` files to the output directory, organized by object type. Only `CREATE` and `ALTER` statements are emitted (desired state, not migration steps). - -When `--source` is omitted, all objects from the target database are exported (equivalent to diffing from an empty database). - -#### Usage - -```bash -pg-delta declarative export --target --output [options] -``` - -#### Options - -- `-t, --target ` (required): Target (desired state): Postgres URL or catalog snapshot file path -- `-o, --output ` (required): Output directory for `.sql` files -- `-s, --source `: Source (current state): Postgres URL or catalog snapshot. Omit for full export -- `--integration `: Integration name (e.g., `supabase`) or path to integration JSON file -- `--filter `: Filter DSL as inline JSON (e.g., `'{"*/schema":"public"}'`) -- `--serialize `: Serialize DSL as inline JSON array -- `--grouping-mode `: How grouped entities are organized: `single-file` or `subdirectory` -- `--group-patterns `: JSON array of `{pattern, name}` objects for name-based grouping -- `--flat-schemas `: Comma-separated schemas to flatten (one file per category) -- `--format-options `: SQL format options as inline JSON (e.g., `'{"keywordCase":"lower","maxWidth":180"}'`) -- `--force`: Remove entire output directory before writing -- `--dry-run`: Show tree and summary without writing files -- `--diff-focus`: Show only files that changed (created/updated/deleted) in the tree -- `--verbose`: Show detailed output - -#### Examples - -**Full export:** - -```bash -pg-delta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ -``` - -**Export with Supabase integration:** - -```bash -pg-delta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ \ - --integration supabase -``` - -**Dry-run preview:** - -```bash -pg-delta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ \ - --dry-run -``` - -**Re-export showing only changed files:** - -```bash -pg-delta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ \ - --diff-focus -``` - ---- - -### `declarative apply` - -Apply SQL files from a declarative schema directory to a target database. Uses `pg-topo` for static dependency analysis and topological ordering, then applies statements round-by-round to handle any remaining dependency gaps. - -Function body checks are disabled during rounds to avoid false failures from functions referencing not-yet-created objects. A final validation pass re-runs all function/procedure definitions with body checks enabled. - -#### Usage - -```bash -pg-delta declarative apply --path --target [options] -``` - -#### Options - -- `-p, --path ` (required): Path to the schema directory (containing `.sql` files) or a single `.sql` file -- `-t, --target ` (required): Target database connection URL -- `--max-rounds `: Maximum application rounds before giving up (default: 100) -- `--skip-function-validation`: Skip final function body validation pass -- `-v, --verbose`: Show detailed per-round progress (applied/deferred/failed) -- `--ungroup-diagnostics`: Show full per-diagnostic detail instead of grouped summary - -#### Examples - -**Apply a schema:** - -```bash -pg-delta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db -``` - -**Verbose mode:** - -```bash -pg-delta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db \ - --verbose -``` - -**Skip function validation:** - -```bash -pg-delta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db \ - --skip-function-validation -``` - -**Debug logging:** - -```bash -DEBUG=pg-delta:declarative-apply pg-delta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db -``` - -#### Exit Codes - -- `0`: Success (all statements applied and validation passed) -- `1`: Error (hard failures or validation errors) -- `2`: Stuck (dependency cycle or unresolvable ordering) - ---- - -## Connection URLs - -The connection URL follows the standard PostgreSQL connection string format: - -``` -postgresql://[user[:password]@][host][:port][/database][?params...] -``` - -Examples: - -- `postgresql://localhost/mydb` -- `postgresql://user:password@localhost:5432/mydb` -- `postgresql://user@localhost/mydb?sslmode=require` - ---- - -## Integrations - -Integrations provide pre-configured filter and serialization rules for specific database platforms or use cases. See [Integrations Documentation](./integrations.md) for details. - -Available built-in integrations: -- `supabase` - Supabase-specific filtering and serialization rules - -You can also create custom integrations by providing a JSON file. See the integrations documentation for the DSL format. - ---- - -## Help - -Get help for any command: - -```bash -pg-delta --help -pg-delta sync --help -pg-delta plan --help -pg-delta apply --help -pg-delta catalog-export --help -pg-delta declarative export --help -pg-delta declarative apply --help -``` diff --git a/packages/pg-delta/docs/integrations.md b/packages/pg-delta/docs/integrations.md deleted file mode 100644 index 7f3c92de1..000000000 --- a/packages/pg-delta/docs/integrations.md +++ /dev/null @@ -1,497 +0,0 @@ -# Integrations - -Integrations allow you to customize how `pg-delta` filters and serializes database changes using a JSON-based DSL (Domain-Specific Language). This enables you to: - -- **Filter changes**: Include or exclude specific changes based on patterns -- **Customize serialization**: Control how changes are serialized into SQL (e.g., skip authorization statements) - -Integrations are stored as JSON files and can be: -- Loaded from built-in integrations (e.g., `--integration supabase`) -- Loaded from custom files (e.g., `--integration ./my-integration.json`) -- Passed directly via CLI flags (`--filter` and `--serialize`) - -## Integration DSL Structure - -An integration file is a JSON object with two optional properties: - -```json -{ - "filter": { /* Filter DSL */ }, - "serialize": [ /* Serialization DSL */ ] -} -``` - -- **`filter`**: Determines which changes to include/exclude. If not provided, all changes are included. -- **`serialize`**: Customizes how changes are serialized. If not provided, changes are serialized with default options. - -## Filter DSL - -The Filter DSL uses pattern matching to determine which changes to include or exclude. Patterns can match against change properties and be combined using logical operators. - -### Flat Key Model - -Each change is internally flattened into a `Record` where `FlatValue` is `string | number | boolean | null | Array`. Patterns match against these flat keys. - -There are two kinds of keys: - -- **Bare keys** — top-level scalar properties on a Change: `objectType`, `operation`, `scope`, `member`, `grantee`, `inSchema`, `objtype`, `requires`, `creates`, `drops` -- **Path keys** — model sub-object properties flattened as `/`: `table/schema`, `table/name`, `table/owner`, `view/schema`, `role/name`, `enum/schema`, etc. - -**Glob patterns**: Path pattern keys support full glob syntax (powered by [picomatch](https://github.com/micromatch/picomatch)): - -- `*` matches any single segment: `*/schema` → `table/schema`, `view/schema`, etc. -- Brace expansion: `{table,view}/schema` → matches `table/schema` or `view/schema` -- Partial wildcards: `table/is_*` → matches `table/is_partition`, `table/is_typed`, etc. -- Extglob negation: `!(role)/schema` → matches any objectType's schema except `role` - -Pattern keys are ANDed together: `{ "objectType": "table", "*/schema": "public" }` means the change must be a table AND in the `public` schema. - -### Value Matching - -- **String**: Exact equality (e.g., `{ "objectType": "table" }`) -- **String array**: Inclusion / any-of (e.g., `{ "*/schema": ["public", "app"] }`) -- **Boolean**: Exact equality (e.g., `{ "table/is_partition": true }`) -- **Number**: Exact equality -- **Regex operator**: `{ "op": "regex", "value": "^pattern$" }` or `{ "op": "regex", "value": ["pat1", "pat2"] }` -- **Array values** (`requires`, `creates`, `drops`): Match succeeds if **any** element satisfies the matcher -- **Missing/null**: Doesn't match any positive pattern - -### Logical Operators - -Patterns can be combined using logical operators: - -- **`and`**: All patterns must match (AND logic) -- **`or`**: Any pattern must match (OR logic) -- **`not`**: Negate a pattern (NOT logic) - -**Important**: Composition operators (`and`, `or`, `not`) are exclusive - they cannot be mixed with property patterns in the same object. `cascade` is a reserved key that controls whether filter exclusions cascade to dependent objects (via `requires`/`pg_depend`) and is ignored during filter evaluation. - -### Filter DSL Examples - -**Include only changes in the public schema:** - -```json -{ - "*/schema": "public" -} -``` - -**Exclude system schemas:** - -```json -{ - "not": { - "*/schema": ["pg_catalog", "information_schema"] - } -} -``` - -**Include schema creates OR changes in public schema:** - -```json -{ - "or": [ - { "objectType": "schema", "operation": "create" }, - { "*/schema": "public" } - ] -} -``` - -**Exclude changes owned by system roles:** - -```json -{ - "not": { - "*/owner": ["postgres", "service_role"] - } -} -``` - -**Complex filter (include public schema OR exclude system schemas and system owners):** - -```json -{ - "or": [ - { "*/schema": "public" }, - { - "and": [ - { - "not": { - "*/schema": ["pg_catalog", "information_schema"] - } - }, - { - "not": { - "*/owner": ["postgres"] - } - } - ] - } - ] -} -``` - -**Filter membership changes:** - -```json -{ - "objectType": "role", - "scope": "membership", - "member": ["app_user"] -} -``` - -**Match a specific schema object by name:** - -```json -{ - "objectType": "schema", - "schema/name": "auth" -} -``` - -**Boolean matching (exclude partition tables):** - -```json -{ - "not": { - "table/is_partition": true - } -} -``` - -**Brace expansion — match tables or views in public schema:** - -```json -{ - "{table,view}/schema": "public" -} -``` - -**Partial wildcard — match boolean `is_*` fields:** - -```json -{ - "table/is_*": true -} -``` - -**Extglob negation — match all schemas except role:** - -```json -{ - "!(role)/schema": "public" -} -``` - -**Regex on dependency identifiers:** - -```json -{ - "requires": { "op": "regex", "value": "^schema:myschema$" } -} -``` - -## Serialization DSL - -The Serialization DSL is an array of rules that customize how changes are serialized. Rules are evaluated in order, and the first matching rule's options are applied. If no rule matches, the change is serialized with default options. - -### Serialization Rule Structure - -Each rule has two properties: - -- **`when`**: A Filter Pattern that determines when this rule applies -- **`options`**: Serialization options to apply when the pattern matches - -### Serialization Options - -Currently supported options: - -- **`skipAuthorization`** (boolean): Skip authorization statements (e.g., `ALTER ... OWNER TO`) - -### Serialization DSL Examples - -**Skip authorization for schema creates:** - -```json -[ - { - "when": { - "objectType": "schema", - "operation": "create" - }, - "options": { - "skipAuthorization": true - } - } -] -``` - -**Skip authorization for specific owners:** - -```json -[ - { - "when": { - "*/owner": ["service_role", "authenticator"] - }, - "options": { - "skipAuthorization": true - } - } -] -``` - -**Multiple rules (first match wins):** - -```json -[ - { - "when": { - "objectType": "schema", - "operation": "create", - "schema/owner": ["service_role"] - }, - "options": { - "skipAuthorization": true - } - }, - { - "when": { - "*/schema": "public" - }, - "options": { - "skipAuthorization": false - } - } -] -``` - -## Complete Integration Example - -Here's a complete integration file that combines filtering and serialization: - -```json -{ - "filter": { - "or": [ - { - "*/schema": "public" - }, - { - "and": [ - { - "objectType": "schema", - "operation": "create" - }, - { - "not": { - "schema/name": ["pg_catalog", "information_schema"] - } - } - ] - } - ] - }, - "serialize": [ - { - "when": { - "objectType": "schema", - "operation": "create", - "schema/owner": ["service_role"] - }, - "options": { - "skipAuthorization": true - } - } - ] -} -``` - -## Using Integrations - -### Programmatic Usage - -**Using a built-in integration:** - -```typescript -import { createPlan, applyPlan } from "@supabase/pg-delta"; -import { supabase } from "@supabase/pg-delta/integrations/supabase"; - -const result = await createPlan(sourceUrl, targetUrl, { - filter: supabase.filter, - serialize: supabase.serialize, -}); - -if (result) { - await applyPlan(result.plan, sourceUrl, targetUrl); -} -``` - -**Creating a custom integration:** - -```typescript -import { createPlan, type IntegrationDSL } from "@supabase/pg-delta"; - -const myIntegration: IntegrationDSL = { - filter: { - not: { - "*/schema": ["pg_catalog", "information_schema"], - }, - }, - serialize: [ - { - when: { objectType: "schema", operation: "create" }, - options: { skipAuthorization: true }, - }, - ], -}; - -const result = await createPlan(sourceUrl, targetUrl, { - filter: myIntegration.filter, - serialize: myIntegration.serialize, -}); -``` - -### CLI Usage - -**Built-in integration:** - -```bash -pg-delta plan \ - --source postgresql://... \ - --target postgresql://... \ - --integration supabase -``` - -**Custom integration file:** - -```bash -pg-delta plan \ - --source postgresql://... \ - --target postgresql://... \ - --integration ./my-integration.json -``` - -### Via Filter/Serialize Flags - -You can also pass filter and serialization DSLs directly: - -```bash -pg-delta plan \ - --source postgresql://... \ - --target postgresql://... \ - --filter '{"*/schema":"public"}' \ - --serialize '[{"when":{"objectType":"schema"},"options":{"skipAuthorization":true}}]' -``` - -### Combining Integration with Overrides - -When both an integration and explicit flags are provided, the explicit flags take precedence: - -```bash -pg-delta plan \ - --source postgresql://... \ - --target postgresql://... \ - --integration supabase \ - --filter '{"*/schema":"custom"}' # Overrides integration's filter -``` - -## Object Types - -The following object types can be used in the `objectType` property: - -- `aggregate` -- `collation` -- `composite_type` -- `domain` -- `enum` -- `event_trigger` -- `extension` -- `foreign_data_wrapper` -- `foreign_table` -- `index` -- `language` -- `materialized_view` -- `procedure` -- `publication` -- `range` -- `rls_policy` -- `role` -- `rule` -- `schema` -- `sequence` -- `server` -- `subscription` -- `table` -- `trigger` -- `user_mapping` -- `view` - -## Operations - -The following operations can be used in the `operation` property: - -- `create`: Creating a new object -- `alter`: Modifying an existing object -- `drop`: Dropping an object - -## Scopes - -The following scopes can be used in the `scope` property: - -- `object`: Changes to the object itself -- `comment`: Changes to object comments -- `privilege`: Changes to object privileges -- `membership`: Changes to role memberships (for role changes) - -## Key Availability - -Not all keys are available for all change types: - -- **`*/schema`**: Matches most object types, but not cluster-wide objects like `role`, `publication`, `subscription`, `foreign_data_wrapper`, `server`, `language`, or `user_mapping`. Schema normalization maps `schema/name` for schema objects and `event_trigger/function_schema` for event triggers so that `*/schema` works consistently. -- **`*/owner`**: Available for most object types that have an owner field. -- **`member`**: Bare key, only present when `scope` is `"membership"`. -- **`grantee`**: Bare key, only present when `scope` is `"privilege"`. -- **`inSchema`** / **`objtype`**: Bare keys, only present when `scope` is `"default_privilege"`. -- **`requires`** / **`creates`** / **`drops`**: Always available as arrays (default to `[]`). Contain stable identifiers like `"schema:public"` or `"table:public.users"`. - -If a key is not available for a change, it will not match any pattern that requires that key. - -## Plan Storage - -When you create a plan with a DSL-based filter or serializer, those DSLs are stored in the plan file, making plans self-contained. When applying a plan, the same filtering rules that were used to create it are automatically applied. - -This means: -- Plans are reproducible - they contain all the filtering logic used to create them -- Plans can be shared and applied without needing to remember the original filter/serialize flags -- Plans are version-controlled friendly - the JSON format is human-readable - -## Built-in Integrations - -### Supabase Integration - -The `supabase` integration provides filtering and serialization rules optimized for Supabase databases: - -- **Filter**: Excludes Supabase system schemas and roles, includes user schemas and extensions -- **Serialize**: Skips authorization for schema creates owned by Supabase system roles - -**CLI usage:** - -```bash -pg-delta plan --source --target --integration supabase -``` - -**Programmatic usage:** - -```typescript -import { createPlan } from "@supabase/pg-delta"; -import { supabase } from "@supabase/pg-delta/integrations/supabase"; - -const result = await createPlan(sourceUrl, targetUrl, { - filter: supabase.filter, - serialize: supabase.serialize, -}); -``` - -See `src/core/integrations/supabase.ts` for the complete integration definition. diff --git a/packages/pg-delta/docs/sorting.md b/packages/pg-delta/docs/sorting.md deleted file mode 100644 index a04a0772f..000000000 --- a/packages/pg-delta/docs/sorting.md +++ /dev/null @@ -1,113 +0,0 @@ -# Sorting & Migration Safety - -`pg-delta` generates migration scripts that are both **safe to execute** and **easy to review**. This is achieved through a sophisticated sorting algorithm that balances database constraints with human readability. - -## Overview - -When generating a migration script, `pg-delta` organizes changes to satisfy two competing goals: - -1. **Correctness**: Statements execute in the right order for PostgreSQL (e.g., create a table before its indexes) -2. **Readability**: Related changes are grouped together for easier code review - -## How It Works - -The sorting engine uses a two-pass strategy: - -1. **Logical Organization**: Groups related changes together (by schema, by table, etc.) -2. **Dependency Resolution**: Adjusts ordering to satisfy PostgreSQL's requirements - -This means your migration scripts will have all changes to `public.users` grouped together, while still ensuring that tables are created before their foreign keys reference them. - -## Execution Phases - -Migrations are organized into two distinct phases: - -### DROP Phase (Destructive Operations) - -Runs first, in **reverse dependency order**: -- Drops dependents before their dependencies -- Example: Drop a foreign key before dropping the referenced table - -### CREATE/ALTER Phase (Constructive Operations) - -Runs second, in **forward dependency order**: -- Creates dependencies before their dependents -- Example: Create a role before assigning it as a table owner - -## Dependency Sources - -`pg-delta` automatically handles dependencies from multiple sources: - -| Source | Description | Example | -|:-------|:------------|:--------| -| **Database Catalog** | Dependencies tracked by PostgreSQL (`pg_depend`) | Views depending on tables | -| **Explicit Requirements** | Dependencies declared in object definitions | Column referencing a type | -| **Logical Rules** | Business logic requirements | Default privileges before table creation | - -## Handling Circular Dependencies - -Real-world schemas sometimes contain circular dependencies (e.g., two tables with mutual foreign key references). - -`pg-delta` handles this by: - -1. Detecting the cycle -2. Identifying if any constraint can be deferred (created separately via `ALTER TABLE`) -3. Breaking the cycle by separating the constraint -4. Re-sorting the changes - -If a cycle cannot be broken (only hard dependencies remain), `pg-delta` throws a detailed error explaining the cycle. - -## Examples - -### Basic Dependency Resolution - -**Input changes:** -1. `CREATE TABLE posts` (owner: `admin`) -2. `CREATE ROLE admin` - -**Output (reordered):** -1. `CREATE ROLE admin` -2. `CREATE TABLE posts` - -The role must exist before it can own a table. - -### Logical Grouping - -**Input changes:** -1. `CREATE TABLE users` -2. `CREATE TABLE posts` -3. `CREATE INDEX users_email_idx ON users` -4. `CREATE INDEX posts_author_idx ON posts` - -**Output (grouped by table):** -1. `CREATE TABLE users` -2. `CREATE INDEX users_email_idx ON users` -3. `CREATE TABLE posts` -4. `CREATE INDEX posts_author_idx ON posts` - -Related objects are kept together for easier review. - -### Default Privileges Ordering - -**Input changes:** -1. `CREATE TABLE users` -2. `ALTER DEFAULT PRIVILEGES ... GRANT SELECT ON TABLES` - -**Output (reordered):** -1. `ALTER DEFAULT PRIVILEGES ... GRANT SELECT ON TABLES` -2. `CREATE TABLE users` - -Default privileges must be set before tables are created for them to inherit the correct permissions. - -## Stable Identifiers - -`pg-delta` uses stable string identifiers to track objects across environments (since PostgreSQL OIDs change between databases): - -| Object Type | Identifier Format | Example | -|:------------|:------------------|:--------| -| Schema Object | `type:schema.name` | `table:public.users` | -| Sub-entity | `type:schema.parent.name` | `column:public.users.email` | -| Metadata | `scope:target` | `comment:public.users` | - -These identifiers ensure consistent dependency tracking regardless of the underlying database. - diff --git a/packages/pg-delta/docs/workflow.md b/packages/pg-delta/docs/workflow.md deleted file mode 100644 index dfcb27871..000000000 --- a/packages/pg-delta/docs/workflow.md +++ /dev/null @@ -1,517 +0,0 @@ -# Workflow Guide - -`pg-delta` supports two paradigms for managing PostgreSQL schemas: **imperative diff-based migrations** and **declarative file-based schemas**. A utility command, `catalog-export`, bridges both by snapshotting a live database for offline use. - -## Overview - -```mermaid -flowchart LR - subgraph imperative ["Imperative: diff-based migrations"] - plan[plan] --> review[Review] - review --> applyCmd[apply] - end - - subgraph declarativeFlow ["Declarative: file-based schema"] - declExport["declarative export"] --> sqlFiles[".sql files"] - sqlFiles --> edit["Edit / version-control"] - edit --> declApply["declarative apply"] - end - - catExport["catalog-export"] -->|"snapshot JSON"| plan - catExport -->|"snapshot JSON"| declExport -``` - -| Paradigm | Commands | Best for | -|----------|----------|----------| -| Imperative | `plan`, `apply`, `sync` | Generating migrations between two database states | -| Declarative | `declarative export`, `declarative apply` | Version-controlling a schema as `.sql` files | -| Utility | `catalog-export` | Snapshotting a database for offline diffing | - ---- - -## 1. `catalog-export` - -Snapshot a live PostgreSQL database's catalog to a JSON file. The snapshot can later replace a live connection URL in `plan`, `declarative export`, or any command that accepts `--source` / `--target`. - -### When to use - -- Create an empty-database baseline for full exports. -- Snapshot production to generate revert migrations offline. -- Enable offline or CI-friendly diffs without a live database connection. - -### Flags - -| Flag | Alias | Required | Description | -|------|-------|----------|-------------| -| `--target` | `-t` | Yes | Database connection URL to extract the catalog from | -| `--output` | `-o` | Yes | Output file path for the JSON snapshot | -| `--role` | | No | Role to assume via `SET ROLE` during extraction | - -### Examples - -Snapshot a production database: - -```bash -pgdelta catalog-export \ - --target postgresql://user:pass@prod:5432/mydb \ - --output prod-snapshot.json -``` - -Use the snapshot as `--source` for a plan (offline diff): - -```bash -pgdelta plan \ - --source prod-snapshot.json \ - --target postgresql://user:pass@staging:5432/mydb \ - --output migration.sql -``` - ---- - -## 2. `declarative export` - -Export the desired schema state from a database as a directory of `.sql` files, organized by object type. The output represents the *desired state* -- only `CREATE` and `ALTER` statements are emitted, never `DROP`. - -### When to use - -- Bootstrap a declarative schema from an existing database. -- Version-control your schema as human-readable SQL files. -- Generate an incremental re-export to see what changed. - -### Flags - -| Flag | Alias | Required | Description | -|------|-------|----------|-------------| -| `--target` | `-t` | Yes | Target database URL or catalog snapshot (desired state) | -| `--output` | `-o` | Yes | Output directory for `.sql` files | -| `--source` | `-s` | No | Source database URL or catalog snapshot (current state). Omit for full export | -| `--integration` | | No | Integration name (e.g. `supabase`) or path to JSON file | -| `--filter` | | No | Filter DSL as inline JSON | -| `--serialize` | | No | Serialize DSL as inline JSON array | -| `--grouping-mode` | | No | `single-file` or `subdirectory` for grouped entities | -| `--group-patterns` | | No | JSON array of `{pattern, name}` for name-based grouping | -| `--flat-schemas` | | No | Comma-separated schemas to flatten (one file per category) | -| `--format-options` | | No | SQL format options as inline JSON | -| `--force` | | No | Remove output directory before writing | -| `--dry-run` | | No | Show file tree and summary without writing | -| `--diff-focus` | | No | Show only changed files in the tree | -| `--verbose` | | No | Show detailed output | - -### Output structure - -``` -schemas/ - public/ - tables/ - users.sql - orders.sql - functions/ - calculate_total.sql - views/ - active_users.sql - types/ - status_enum.sql - sequences/ - users_id_seq.sql -cluster/ - roles.sql - extensions/ - pgcrypto.sql -``` - -Files are organized by: -- **Cluster-level objects** (roles, extensions) go under `cluster/`. -- **Schema-level objects** go under `schemas///.sql`. -- Related objects (indexes, triggers, RLS policies) are placed in the same file as their parent table or view. -- Partitions are placed in the same file as their root partition table. - -### Examples - -Full export from a live database: - -```bash -pgdelta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ -``` - -Export with the Supabase integration (filters system schemas): - -```bash -pgdelta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ \ - --integration supabase -``` - -Dry-run to preview what would be exported: - -```bash -pgdelta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ \ - --dry-run -``` - -Incremental re-export showing only changed files: - -```bash -pgdelta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ \ - --diff-focus -``` - -Export with SQL formatting options: - -```bash -pgdelta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ \ - --format-options '{"keywordCase":"lower","maxWidth":120}' -``` - -After exporting, the CLI prints a tip with the command to apply the schema: - -``` -Tip: To apply this schema to an empty database, run: - pgdelta declarative apply --path ./declarative-schemas/ --target -``` - ---- - -## 3. `declarative apply` - -Apply a declarative SQL schema (a directory or single file of `.sql` files) to a target database. Uses `pg-topo` for static dependency analysis and topological ordering, then applies statements in rounds to handle dependency gaps. - -### When to use - -- Apply a version-controlled schema to a fresh or empty database. -- Restore a database schema from exported SQL files. -- CI/CD schema provisioning. - -### How it works - -1. **Discover** -- Recursively loads all `.sql` files from the given path. -2. **Analyze** -- `pg-topo` parses the SQL, extracts object dependencies, and produces a topological execution order. -3. **Round-based execution** -- Statements are applied round by round: - - **Success** -- Statement applied, moves on. - - **Dependency error** (e.g. missing table, function) -- Deferred to the next round. - - **Environment error** (e.g. extension not available, superuser required) -- Skipped permanently. - - **Other error** -- Hard failure. -4. **Stuck detection** -- If a round applies zero statements while deferred ones remain, execution stops. -5. **Function validation** -- An optional final pass re-runs all `CREATE FUNCTION`/`CREATE PROCEDURE` statements with `check_function_bodies = on` to validate function bodies against the now-complete schema. - -### Flags - -| Flag | Alias | Required | Description | -|------|-------|----------|-------------| -| `--path` | `-p` | Yes | Path to the schema directory or a single `.sql` file | -| `--target` | `-t` | Yes | Target database connection URL | -| `--max-rounds` | | No | Maximum application rounds before giving up (default: 100) | -| `--skip-function-validation` | | No | Skip final function body validation pass | -| `--verbose` | `-v` | No | Show per-round progress (applied/deferred/failed) | -| `--ungroup-diagnostics` | | No | Show full per-diagnostic detail instead of grouped summary | - -### Exit codes - -| Code | Meaning | -|------|---------| -| 0 | All statements applied (and validation passed if enabled) | -| 1 | Hard failure or validation error | -| 2 | Stuck -- dependency cycle or unresolvable ordering | - -### Examples - -Apply an exported schema to a fresh database: - -```bash -pgdelta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db -``` - -With verbose per-round progress: - -```bash -pgdelta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db \ - --verbose -``` - -Skip function body validation (useful when functions reference external objects): - -```bash -pgdelta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db \ - --skip-function-validation -``` - -Debug logging for detailed defer/skip/fail information: - -```bash -DEBUG=pg-delta:declarative-apply pgdelta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db -``` - ---- - -## 4. `plan`, `apply`, and `sync` - -The imperative workflow compares two database states (source and target), computes a diff, and generates an ordered migration script. - -### `plan` - -Compute the schema diff and output it for review. Does not apply any changes. - -Both `--source` and `--target` accept either a PostgreSQL connection URL or a catalog snapshot file path (from `catalog-export`). When `--source` is omitted, diffing starts from an empty baseline (or the integration's empty catalog if `--integration` is set). - -#### Flags - -| Flag | Alias | Required | Description | -|------|-------|----------|-------------| -| `--target` | `-t` | Yes | Target (desired state): Postgres URL or catalog snapshot | -| `--source` | `-s` | No | Source (current state): Postgres URL or catalog snapshot. Omit for empty baseline | -| `--output` | `-o` | No | Write to file. `.sql` infers SQL format, `.json` infers JSON, otherwise tree | -| `--format` | | No | Override output format: `json` or `sql` | -| `--role` | | No | Role for migration (`SET ROLE` added to statements) | -| `--filter` | | No | Filter DSL as inline JSON | -| `--serialize` | | No | Serialize DSL as inline JSON array | -| `--integration` | | No | Integration name or path | -| `--sql-format` | | No | Format SQL output | -| `--sql-format-options` | | No | SQL format options as inline JSON | - -#### Exit codes - -| Code | Meaning | -|------|---------| -| 0 | No changes detected | -| 2 | Changes detected (plan generated) | -| 1 | Error | - -#### Examples - -Preview changes as a tree: - -```bash -pgdelta plan \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db -``` - -Save plan as JSON (for later `apply`): - -```bash -pgdelta plan \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --output plan.json -``` - -Generate a formatted SQL migration script: - -```bash -pgdelta plan \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --format sql \ - --sql-format \ - --sql-format-options '{"keywordCase":"upper","maxWidth":100}' \ - --output migration.sql -``` - -Offline diff using a catalog snapshot: - -```bash -pgdelta plan \ - --source prod-snapshot.json \ - --target postgresql://user:pass@localhost:5432/staging_db \ - --output migration.sql -``` - -### `apply` - -Apply a previously saved plan file to a target database. Safe by default -- refuses plans containing data-loss operations unless `--unsafe` is set. - -#### Flags - -| Flag | Alias | Required | Description | -|------|-------|----------|-------------| -| `--plan` | `-p` | Yes | Path to plan file (JSON) | -| `--source` | `-s` | Yes | Source database connection URL | -| `--target` | `-t` | Yes | Target database connection URL | -| `--unsafe` | `-u` | No | Allow data-loss operations | - -#### Example - -```bash -pgdelta apply \ - --plan plan.json \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db -``` - -### `sync` - -Plan and apply in one step with an interactive confirmation prompt. This is the default command. - -#### Flags - -| Flag | Alias | Required | Description | -|------|-------|----------|-------------| -| `--source` | `-s` | Yes | Source database URL | -| `--target` | `-t` | Yes | Target database URL | -| `--yes` | `-y` | No | Skip confirmation prompt | -| `--unsafe` | `-u` | No | Allow data-loss operations | -| `--role` | | No | Role for migration | -| `--filter` | | No | Filter DSL as inline JSON | -| `--serialize` | | No | Serialize DSL as inline JSON array | -| `--integration` | | No | Integration name or path | - -#### Example - -```bash -pgdelta sync \ - --source postgresql://user:pass@localhost:5432/source_db \ - --target postgresql://user:pass@localhost:5432/target_db \ - --yes -``` - ---- - -## End-to-end workflows - -### Workflow 1: Declarative schema management - -Version-control your database schema as `.sql` files and apply it to fresh databases. - -```bash -# 1. Export current schema from an existing database -pgdelta declarative export \ - --target postgresql://user:pass@localhost:5432/mydb \ - --output ./declarative-schemas/ \ - --integration supabase - -# 2. Edit the .sql files as needed (add tables, modify functions, etc.) -# Commit them to version control. - -# 3. Apply the schema to a fresh database -pgdelta declarative apply \ - --path ./declarative-schemas/ \ - --target postgresql://user:pass@localhost:5432/fresh_db \ - --verbose - -# 4. Re-export to see what changed after manual edits on the DB -pgdelta declarative export \ - --target postgresql://user:pass@localhost:5432/fresh_db \ - --output ./declarative-schemas/ \ - --integration supabase \ - --diff-focus -``` - -### Workflow 2: Migration generation with catalog snapshots - -Snapshot a production database, then generate migrations offline. - -```bash -# 1. Snapshot production -pgdelta catalog-export \ - --target postgresql://user:pass@prod:5432/mydb \ - --output prod-baseline.json - -# 2. Make schema changes on a dev database, then generate a migration -pgdelta plan \ - --source prod-baseline.json \ - --target postgresql://user:pass@localhost:5432/dev_db \ - --output migration.sql \ - --format sql \ - --sql-format - -# 3. Review the migration script, then apply -pgdelta apply \ - --plan migration.json \ - --source postgresql://user:pass@prod:5432/mydb \ - --target postgresql://user:pass@prod:5432/mydb -``` - -### Workflow 3: CI/CD pipeline - -Automate migration generation and review in a CI pipeline. - -```bash -# Step 1: Snapshot the current state (run once or on baseline changes) -pgdelta catalog-export \ - --target $DATABASE_URL \ - --output baseline.json - -# Step 2: Generate migration from baseline to desired state -pgdelta plan \ - --source baseline.json \ - --target $DATABASE_URL \ - --output migration.json - -# Step 3: Apply if changes are detected (exit code 2 = changes found) -if [ $? -eq 2 ]; then - pgdelta apply \ - --plan migration.json \ - --source $DATABASE_URL \ - --target $DATABASE_URL \ - --unsafe # Only if data-loss operations are expected -fi -``` - ---- - -## Integrations - -Use `--integration supabase` (or a custom JSON file) to apply pre-configured filter and serialization rules. Integrations: - -- Filter out system schemas and platform-managed objects. -- Customize SQL serialization (e.g. skip `AUTHORIZATION` on schema creation). -- Provide an empty-catalog baseline so `--source` can be omitted for full exports. - -Available with: `plan`, `sync`, `declarative export`. - -```bash -pgdelta declarative export \ - --target $DATABASE_URL \ - --output ./schemas/ \ - --integration supabase -``` - ---- - -## SQL format options - -Commands that produce SQL output (`plan --format sql`, `declarative export`) support formatting via `--sql-format-options` (or `--format-options` for declarative export): - -| Option | Values | Description | -|--------|--------|-------------| -| `keywordCase` | `upper`, `lower`, `preserve` | SQL keyword casing | -| `indent` | number | Spaces per indentation level | -| `maxWidth` | number | Line width for wrapping | -| `commaStyle` | `trailing`, `leading` | Comma placement in lists | -| `alignColumns` | boolean | Align column definitions | -| `alignKeyValues` | boolean | Align key-value pairs | -| `preserveRoutineBodies` | boolean | Keep function/procedure bodies as-is | -| `preserveViewBodies` | boolean | Keep view definitions as-is | -| `preserveRuleBodies` | boolean | Keep rule definitions as-is | - ---- - -## Debugging - -Enable debug logging for detailed internal output: - -```bash -# All pg-delta debug output -DEBUG=pg-delta:* pgdelta ... - -# Declarative apply only (shows deferred statements, per-round summaries) -DEBUG=pg-delta:declarative-apply pgdelta declarative apply ... -``` - -Use `--verbose` with `declarative apply` for per-round applied/deferred/failed counts without full debug output. diff --git a/packages/pg-delta/knip.json b/packages/pg-delta/knip.json index b3253cf81..02273894b 100644 --- a/packages/pg-delta/knip.json +++ b/packages/pg-delta/knip.json @@ -1,16 +1,30 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "entry": [ - "tests/global-setup.ts", + "src/index.ts", + "src/extract/extract.ts", + "src/plan/plan.ts", + "src/apply/apply.ts", + "src/proof/prove.ts", + "src/frontends/index.ts", + "src/frontends/sql-order.ts", + "src/frontends/sql-format/index.ts", + "src/core/index.ts", + "src/policy/policy.ts", + "src/integrations/index.ts", + "src/cli/main.ts", "src/**/*.test.ts", "tests/**/*.ts", - "src/typedoc.ts" + "scripts/**/*.ts" ], - "project": ["**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}!"], - "ignoreBinaries": ["oxfmt", "oxlint", "changeset"], - "ignoreFiles": ["tests/global-setup.ts", "scripts/**/*.ts"], - "ignoreIssues": { - "src/core/objects/**/*.model.ts": ["types"] - }, - "ignoreDependencies": ["@supabase/pg-topo", "@supabase/bun-istanbul-coverage"] + "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"], + "ignoreBinaries": ["oxfmt", "oxlint"], + "ignoreDependencies": ["@supabase/pg-topo"], + "rules": { + "exports": "warn", + "types": "warn", + "enumMembers": "warn", + "classMembers": "warn", + "duplicates": "warn" + } } diff --git a/packages/pg-delta/package.json b/packages/pg-delta/package.json index e66ee7e26..c73b8b383 100644 --- a/packages/pg-delta/package.json +++ b/packages/pg-delta/package.json @@ -1,7 +1,7 @@ { "name": "@supabase/pg-delta", "version": "1.0.0-alpha.32", - "description": "PostgreSQL migrations made easy", + "description": "PostgreSQL migrations made easy — clean-room schema-diff engine that proves its migrations converge", "keywords": [ "diff", "migrations", @@ -20,7 +20,7 @@ "directory": "packages/pg-delta" }, "bin": { - "pgdelta": "./dist/cli/bin/cli.js" + "pgdelta": "./dist/cli/main.js" }, "files": [ "dist", @@ -36,72 +36,106 @@ ".": { "bun": "./src/index.ts", "import": "./dist/index.js", - "require": "./dist/index.js", "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./integrations/supabase": { - "bun": "./src/core/integrations/supabase.ts", - "import": "./dist/core/integrations/supabase.js", - "require": "./dist/core/integrations/supabase.js", - "types": "./dist/core/integrations/supabase.d.ts", - "default": "./dist/core/integrations/supabase.js" + "./extract": { + "bun": "./src/extract/extract.ts", + "import": "./dist/extract/extract.js", + "types": "./dist/extract/extract.d.ts", + "default": "./dist/extract/extract.js" }, - "./declarative": { - "bun": "./src/core/declarative-apply/index.ts", - "import": "./dist/core/declarative-apply/index.js", - "require": "./dist/core/declarative-apply/index.js", - "types": "./dist/core/declarative-apply/index.d.ts", - "default": "./dist/core/declarative-apply/index.js" + "./plan": { + "bun": "./src/plan/plan.ts", + "import": "./dist/plan/plan.js", + "types": "./dist/plan/plan.d.ts", + "default": "./dist/plan/plan.js" }, - "./catalog-export": { - "bun": "./src/core/catalog-export/index.ts", - "import": "./dist/core/catalog-export/index.js", - "require": "./dist/core/catalog-export/index.js", - "types": "./dist/core/catalog-export/index.d.ts", - "default": "./dist/core/catalog-export/index.js" + "./apply": { + "bun": "./src/apply/apply.ts", + "import": "./dist/apply/apply.js", + "types": "./dist/apply/apply.d.ts", + "default": "./dist/apply/apply.js" + }, + "./proof": { + "bun": "./src/proof/prove.ts", + "import": "./dist/proof/prove.js", + "types": "./dist/proof/prove.d.ts", + "default": "./dist/proof/prove.js" + }, + "./frontends": { + "bun": "./src/frontends/index.ts", + "import": "./dist/frontends/index.js", + "types": "./dist/frontends/index.d.ts", + "default": "./dist/frontends/index.js" + }, + "./sql-order": { + "bun": "./src/frontends/sql-order.ts", + "import": "./dist/frontends/sql-order.js", + "types": "./dist/frontends/sql-order.d.ts", + "default": "./dist/frontends/sql-order.js" + }, + "./sql-format": { + "bun": "./src/frontends/sql-format/index.ts", + "import": "./dist/frontends/sql-format/index.js", + "types": "./dist/frontends/sql-format/index.d.ts", + "default": "./dist/frontends/sql-format/index.js" + }, + "./core": { + "bun": "./src/core/index.ts", + "import": "./dist/core/index.js", + "types": "./dist/core/index.d.ts", + "default": "./dist/core/index.js" + }, + "./policy": { + "bun": "./src/policy/policy.ts", + "import": "./dist/policy/policy.js", + "types": "./dist/policy/policy.d.ts", + "default": "./dist/policy/policy.js" + }, + "./integrations": { + "bun": "./src/integrations/index.ts", + "import": "./dist/integrations/index.js", + "types": "./dist/integrations/index.d.ts", + "default": "./dist/integrations/index.js" } }, "scripts": { "build": "tsc --project tsconfig.build.json", "check-types": "tsc --noEmit", - "docs": "typedoc", "format-and-lint": "oxfmt --check . && oxlint --deny-warnings", "format-and-lint:fix": "oxfmt . && oxlint --fix", "knip": "knip", - "pgdelta": "bun src/cli/bin/cli.ts", - "sync-base-images": "bun scripts/sync-supabase-base-images.ts", - "test": "bun scripts/run-tests.ts", - "test:unit": "bun run test src/", - "test:integration": "bun run test tests/", - "update-empty-baseline": "bun scripts/update-empty-catalog-baseline.ts", - "version": "changeset version && bun install --no-frozen-lockfile && bun run format-and-lint:fix" + "pgdelta": "bun src/cli/main.ts", + "test": "bun scripts/run-tests.ts src/", + "test:integration": "bun scripts/run-tests.ts tests/", + "test:all": "bun scripts/run-tests.ts src/ tests/", + "docker:clean": "bun scripts/clean-testcontainers.ts", + "sync-base-images": "bun scripts/sync-supabase-base-images.ts" }, "dependencies": { - "@stricli/core": "^1.2.4", - "@supabase/pg-topo": "^1.0.0-alpha.3", - "@ts-safeql/sql-tag": "^0.2.0", - "chalk": "^5.6.2", "debug": "^4.3.7", - "pg": "^8.17.2", - "picomatch": "^4.0.3", - "zod": "^4.2.1" + "pg": "^8.17.2" }, "devDependencies": { "@supabase/bun-istanbul-coverage": "workspace:*", - "@tsconfig/node-ts": "^23.6.2", - "@tsconfig/node24": "^24.0.3", + "@supabase/pg-topo": "^1.0.0-alpha.3", "@types/bun": "^1.3.9", "@types/debug": "^4.1.12", "@types/node": "^24.10.4", "@types/pg": "^8.11.10", - "@types/picomatch": "^4.0.2", - "dedent": "^1.7.1", "knip": "^5.75.2", "testcontainers": "^11.10.0", - "typedoc": "^0.28.17", "typescript": "^5.9.3" }, + "peerDependencies": { + "@supabase/pg-topo": "^1.0.0-alpha.3" + }, + "peerDependenciesMeta": { + "@supabase/pg-topo": { + "optional": true + } + }, "engines": { "node": ">=20.0.0" } diff --git a/packages/pg-delta/scripts/benchmark.ts b/packages/pg-delta/scripts/benchmark.ts new file mode 100644 index 000000000..166cfb06a --- /dev/null +++ b/packages/pg-delta/scripts/benchmark.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env bun +/** + * The benchmark fixture + timing harness (stage 5 deliverable 8): a + * ≥10k-object schema and extract/diff/plan wall-times. Stage 10's + * performance-parity bar reads these numbers; run it in CI so regressions + * surface long before cutover. + * + * bun scripts/benchmark.ts # spins a disposable container + * bun scripts/benchmark.ts # uses an existing server + * + * Set PGDELTA_BENCH_PER_QUERY=1 to additionally attribute the cold extract's + * wall-time per SQL round-trip (milestone A "profile first") — it wraps the + * pooled client's `query` for the duration of that one extract, then restores + * it, so the rest of the run is unaffected. + */ +import pg from "pg"; +import { diff } from "../src/core/diff.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; + +const PER_QUERY = process.env["PGDELTA_BENCH_PER_QUERY"] === "1"; + +/** Identify which extractor a SQL string belongs to: the first FROM relation + * plus a short head. Good enough to rank the ~36 extraction queries. */ +function queryLabel(sql: string): string { + const flat = sql.replace(/\s+/g, " ").trim(); + const from = /\bFROM\s+(?:pg_catalog\.)?(\w+)/i.exec(flat); + return `${(from?.[1] ?? "?").padEnd(20)} ${flat.slice(0, 44)}`; +} + +/** Wrap the next-checked-out client's `query` to time each call, run `fn`, then + * restore `pool.connect`. Prints a sorted breakdown. Measurement only — never + * touches the library. */ +async function withPerQueryTiming( + pool: pg.Pool, + fn: () => Promise, +): Promise { + const timings: { ms: number; rows: number; label: string }[] = []; + const origConnect = pool.connect.bind(pool); + (pool as { connect: unknown }).connect = async (...args: unknown[]) => { + const client = await ( + origConnect as (...a: unknown[]) => Promise + )(...args); + const origQuery = client.query.bind(client) as ( + ...a: unknown[] + ) => Promise<{ rows: unknown[] }>; + (client as { query: unknown }).query = (...qa: unknown[]) => { + const sql = typeof qa[0] === "string" ? qa[0] : String(qa[0]); + const start = performance.now(); + const ret = origQuery(...qa) as unknown; + // pg's client.query has a callback overload that returns void, not a + // promise (pg-pool uses it internally) — only time the promise form. + if ( + ret == null || + typeof (ret as { then?: unknown }).then !== "function" + ) { + return ret; + } + return (ret as Promise<{ rows: unknown[] }>).then((r) => { + timings.push({ + ms: performance.now() - start, + rows: r.rows.length, + label: queryLabel(sql), + }); + return r; + }); + }; + return client; + }; + try { + return await fn(); + } finally { + (pool as { connect: unknown }).connect = origConnect; + timings.sort((a, b) => b.ms - a.ms); + let sum = 0; + console.log(`\nper-query breakdown (${timings.length} queries):`); + console.log(`${"ms".padStart(8)} ${"rows".padStart(7)} query`); + for (const t of timings) { + sum += t.ms; + console.log( + `${t.ms.toFixed(1).padStart(8)} ${String(t.rows).padStart(7)} ${t.label}`, + ); + } + console.log(`sum of query time: ${sum.toFixed(0)} ms\n`); + } +} + +const SCHEMAS = 40; +const TABLES_PER_SCHEMA = 15; +const COLUMNS_PER_TABLE = 8; + +function fixtureSql(): string { + const parts: string[] = []; + for (let s = 0; s < SCHEMAS; s++) { + const schema = `bench_${String(s).padStart(2, "0")}`; + parts.push(`CREATE SCHEMA ${schema};`); + parts.push( + `CREATE TYPE ${schema}.status AS ENUM ('a', 'b', 'c');`, + `CREATE SEQUENCE ${schema}.ids;`, + `CREATE FUNCTION ${schema}.f(a integer) RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT a + 1';`, + ); + for (let t = 0; t < TABLES_PER_SCHEMA; t++) { + const table = `${schema}.t${String(t).padStart(2, "0")}`; + const cols = [ + "id integer NOT NULL DEFAULT nextval('" + schema + ".ids')", + ]; + for (let c = 0; c < COLUMNS_PER_TABLE; c++) { + cols.push( + c % 3 === 0 + ? `c${c} text` + : c % 3 === 1 + ? `c${c} numeric(12,2) DEFAULT 0` + : `c${c} timestamptz`, + ); + } + cols.push("PRIMARY KEY (id)"); + parts.push(`CREATE TABLE ${table} (${cols.join(", ")});`); + parts.push(`CREATE INDEX t${t}_c0_idx_${s} ON ${table} (c0);`); + if (t % 2 === 0) { + parts.push( + `CREATE VIEW ${schema}.v${t} AS SELECT id, c0 FROM ${table} WHERE id > 0;`, + ); + parts.push(`COMMENT ON TABLE ${table} IS 'bench table ${t}';`); + } + } + } + return parts.join("\n"); +} + +const MUTATIONS = ` + CREATE SCHEMA bench_new; + CREATE TABLE bench_new.extra (id integer PRIMARY KEY, note text); + ALTER TABLE bench_00.t00 ADD COLUMN added_col integer DEFAULT 5; + DROP VIEW bench_01.v0; + COMMENT ON SCHEMA bench_02 IS 'mutated'; +`; + +async function timed(label: string, fn: () => Promise | T): Promise { + const start = performance.now(); + const result = await fn(); + const ms = performance.now() - start; + console.log(`${label.padEnd(28)} ${ms.toFixed(0).padStart(8)} ms`); + return result; +} + +const url = process.argv[2]; +let pool: pg.Pool; +let cleanup = async (): Promise => {}; +if (url !== undefined) { + pool = new pg.Pool({ connectionString: url, max: 2 }); + cleanup = async () => { + await pool.end(); + }; +} else { + const { sharedCluster } = await import("../tests/containers.ts"); + const cluster = await sharedCluster(); + const db = await cluster.createDb("bench"); + pool = db.pool; + cleanup = async () => { + await db.drop(); + process.exit(0); + }; +} + +console.log( + `fixture: ${SCHEMAS} schemas x ${TABLES_PER_SCHEMA} tables x ${COLUMNS_PER_TABLE} columns`, +); +await timed("load fixture DDL", () => pool.query(fixtureSql())); + +const before = await timed("extract (cold)", () => + PER_QUERY ? withPerQueryTiming(pool, () => extract(pool)) : extract(pool), +); +console.log(`fact count: ${before.factBase.facts().length}`); + +await pool.query(MUTATIONS); +const after = await timed("extract (mutated)", () => extract(pool)); + +const deltas = await timed("diff", () => diff(before.factBase, after.factBase)); +console.log(`delta count: ${deltas.length}`); + +const thePlan = await timed("plan (incremental)", () => + plan(before.factBase, after.factBase), +); +console.log(`action count: ${thePlan.actions.length}`); + +await timed("plan (full materialize)", async () => { + const { buildFactBase } = await import("../src/core/fact.ts"); + return plan(buildFactBase([], []), after.factBase); +}); + +await cleanup(); diff --git a/packages/pg-delta/scripts/clean-testcontainers.ts b/packages/pg-delta/scripts/clean-testcontainers.ts new file mode 100644 index 000000000..2bfc38ea0 --- /dev/null +++ b/packages/pg-delta/scripts/clean-testcontainers.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env bun +/** + * Reclaim leaked testcontainers. + * + * testcontainers' Ryuk reaper normally removes a run's containers when the test + * process dies. Gaps still happen — the Docker daemon restarts (orphaning what + * Ryuk was tracking), Ryuk is disabled, or a run is killed before Ryuk connects + * — and the shared cluster singletons in tests/containers.ts are never stopped + * explicitly. This script reclaims those orphans. + * + * It targets ONLY containers carrying the `org.testcontainers=true` label, and + * is age-guarded: by default it removes only those older than --min-age minutes + * (default 60), so a run in flight is never touched. Run it anytime, or as a CI + * post-step. + * + * bun run docker:clean # remove testcontainers older than 60m + * bun run docker:clean --min-age 30 # ...older than 30m + * bun run docker:clean --all # remove ALL testcontainers (no tests running!) + * bun run docker:clean --dry-run # show what would be removed, remove nothing + * + * Keep Ryuk ENABLED (do not set TESTCONTAINERS_RYUK_DISABLED) — this script is a + * backstop, not a replacement for it. + */ + +interface Row { + id: string; + image: string; + runningFor: string; + names: string; +} + +const LABEL = "org.testcontainers=true"; + +function parseArgs(argv: string[]): { + minAgeMin: number; + all: boolean; + dryRun: boolean; +} { + let minAgeMin = 60; + let all = false; + let dryRun = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--all") all = true; + else if (a === "--dry-run") dryRun = true; + else if (a === "--min-age") minAgeMin = Number(argv[++i] ?? "60"); + } + return { minAgeMin, all, dryRun }; +} + +/** docker is spawned directly here (not via a shell), so it is unaffected by + * any shell command-rewriting; `--filter`/`--format` work as upstream docker. */ +async function docker(args: string[]): Promise { + const proc = Bun.spawn(["docker", ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const [out, code] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + if (code !== 0) { + const err = await new Response(proc.stderr).text(); + throw new Error(`docker ${args.join(" ")} failed: ${err.trim()}`); + } + return out; +} + +/** "Up 42 hours" / "10 minutes ago" → is this older than the threshold? Recent + * (seconds/minutes) is never reclaimed unless --all. */ +function isOlderThan(runningFor: string, minAgeMin: number): boolean { + if (/second|minute/.test(runningFor)) { + const m = /(\d+)\s+minute/.exec(runningFor); + return m ? Number(m[1]) >= minAgeMin : false; + } + // hours / days / weeks → always older than any minute threshold + return /hour|day|week|month/.test(runningFor); +} + +async function main(): Promise { + const { minAgeMin, all, dryRun } = parseArgs(Bun.argv.slice(2)); + + const raw = await docker([ + "ps", + "-a", + "--filter", + `label=${LABEL}`, + "--format", + "{{.ID}}|{{.Image}}|{{.RunningFor}}|{{.Names}}", + ]); + const rows: Row[] = raw + .split("\n") + .filter((l) => l.trim() !== "") + .map((l) => { + const [id, image, runningFor, names] = l.split("|"); + return { id, image, runningFor, names } as Row; + }); + + if (rows.length === 0) { + console.log("No testcontainers found. Nothing to reclaim."); + return; + } + + const victims = all + ? rows + : rows.filter((r) => isOlderThan(r.runningFor, minAgeMin)); + const kept = rows.length - victims.length; + + console.log( + `${rows.length} testcontainer(s); ${victims.length} to remove, ${kept} kept` + + (all ? " (--all)" : ` (older than ${minAgeMin}m)`) + + (dryRun ? " [dry-run]" : ""), + ); + for (const v of victims) { + console.log( + ` ${dryRun ? "would remove" : "removing"}: ${v.names} (${v.image}, ${v.runningFor})`, + ); + } + if (dryRun || victims.length === 0) return; + + await docker(["rm", "-f", ...victims.map((v) => v.id)]); + console.log(`Removed ${victims.length} container(s).`); +} + +main().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); +}); diff --git a/packages/pg-delta/scripts/generate-supabase-baseline.ts b/packages/pg-delta/scripts/generate-supabase-baseline.ts new file mode 100644 index 000000000..9a21e5984 --- /dev/null +++ b/packages/pg-delta/scripts/generate-supabase-baseline.ts @@ -0,0 +1,106 @@ +/** + * Supabase baseline snapshot generator (stage-08-policy). + * + * Connects to a database URL, extracts its fact base, and saves a snapshot + * to src/policy/baselines/supabase-.json. + * + * USAGE + * bun run scripts/generate-supabase-baseline.ts [] + * + * PostgreSQL connection URL + * e.g. postgres://postgres:postgres@localhost:54322/postgres + * Optional override for the PostgreSQL major version (e.g. 17). + * If omitted, detected automatically via SHOW server_version. + * + * WHEN TO RUN + * Run against a FRESH (just-started, user-schema-empty) supabase/postgres + * container. The snapshot captures platform-managed facts so they can be + * subtracted from user-DB extracts before diffing — replacing the old + * emptyCatalog mechanism. + * + * Example workflow: + * docker run -d --name supa-base \ + * -e POSTGRES_PASSWORD=postgres \ + * -p 54322:5432 \ + * supabase/postgres:15.8.1.106 + * docker exec supa-base /usr/bin/pg_bootstrap.sh # Supabase bootstrap + * bun run scripts/generate-supabase-baseline.ts \ + * postgres://supabase_admin:postgres@localhost:54322/postgres 15 + * docker stop supa-base && docker rm supa-base + * + * REGENERATE WHEN + * The supabase/postgres image tag pinned in tests/constants.ts changes. + * After regenerating, run the focused regression tests to verify no + * phantom deltas appear on a freshly bootstrapped Supabase DB. + * + * NOTE: Do NOT run this script in CI directly — it requires a running Supabase + * container and produces a committed artifact. Regenerate locally and commit. + */ + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import pg from "pg"; +import { extract } from "../src/extract/extract.ts"; +import { serializeSnapshot } from "../src/core/snapshot.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main(): Promise { + const dbUrl = process.argv[2]; + if (!dbUrl) { + console.error( + "Usage: bun run scripts/generate-supabase-baseline.ts []", + ); + process.exit(1); + } + + const pool = new pg.Pool({ connectionString: dbUrl, max: 3 }); + pool.on("error", () => {}); + + try { + console.log(`Connecting to ${dbUrl} ...`); + + // Detect pg major version (from argv or via SHOW server_version) + let pgMajor: number; + if (process.argv[3] !== undefined) { + pgMajor = parseInt(process.argv[3] as string, 10); + if (Number.isNaN(pgMajor) || pgMajor < 14) { + console.error( + `Invalid pg-major argument: ${process.argv[3]}. Must be an integer >= 14.`, + ); + process.exit(1); + } + } else { + const res = await pool.query( + `SELECT current_setting('server_version_num')::int AS v`, + ); + const vnum = (res.rows[0] as { v: number }).v; + pgMajor = Math.floor(vnum / 10000); + console.log(`Detected PostgreSQL major version: ${pgMajor}`); + } + + console.log("Extracting fact base ..."); + const { factBase, pgVersion } = await extract(pool); + console.log( + `Extracted ${factBase.facts().length} facts, ${factBase.edges.length} edges.`, + ); + console.log(`Fact base root hash: ${factBase.rootHash}`); + + const json = serializeSnapshot(factBase, { pgVersion }); + + const outDir = resolve(__dirname, "../src/policy/baselines"); + await mkdir(outDir, { recursive: true }); + + const outPath = join(outDir, `supabase-${pgMajor}.json`); + await writeFile(outPath, json, "utf-8"); + console.log(`Baseline saved to: ${outPath}`); + } finally { + await pool.end().catch(() => {}); + } +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/pg-delta/scripts/lib/bootstrap-dbdev-fixture.ts b/packages/pg-delta/scripts/lib/bootstrap-dbdev-fixture.ts new file mode 100644 index 000000000..49abce7bd --- /dev/null +++ b/packages/pg-delta/scripts/lib/bootstrap-dbdev-fixture.ts @@ -0,0 +1,338 @@ +/** + * Bootstrap Supabase testcontainers with the dbdev-migrations fixture. + * Used by tests/dbdev-roundtrip.test.ts. + */ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Pool } from "pg"; +import pg from "pg"; +import { startStandaloneSupabase } from "../../tests/containers.ts"; +import { applySupabaseBaseInit as replaySupabaseBaseInit } from "../../tests/supabase-base-init.ts"; + +const MIGRATIONS_DIR = new URL( + "../../tests/fixtures/dbdev-migrations/migrations/", + import.meta.url, +).pathname; + +export type DbdevMigrationScope = "core" | "all"; + +/** Handle for apply-check / prove: independent copy of the source database state. */ +export interface DbdevCloneSource { + clone(): Promise<{ pool: Pool; drop(): Promise }>; +} + +export interface DbdevFixture { + mainPool: Pool; + branchPool: Pool; + /** Clone main (supabase base-init only) for apply-check on roundtrip scenarios. */ + mainCloneSource: DbdevCloneSource; + /** Clone branch (base-init + dbdev migrations) for apply-check on zero-diff. */ + branchCloneSource: DbdevCloneSource; + /** Ephemeral DB on the main container for declarative shadow loading. */ + createMainShadowDb: ( + prefix: string, + ) => Promise<{ pool: Pool; drop(): Promise }>; + /** End main pools so CREATE DATABASE … TEMPLATE postgres can run. */ + prepareDeclarativeShadow: () => Promise; + mainUri: string; + branchUri: string; + migrationCount: number; + cleanup: () => Promise; +} + +function suppressShutdownError(err: Error & { code?: string }) { + if (err.code === "57P01" || err.code === "53100") return; + console.error("Pool error:", err); +} + +function createPostgresRolePool(connectionUri: string): Pool { + const pool = new pg.Pool({ connectionString: connectionUri, max: 5 }); + pool.on("error", suppressShutdownError); + pool.on("connect", (client) => { + void client.query("SET ROLE postgres").catch(() => {}); + }); + return pool; +} + +function createSetupPool(connectionUri: string): Pool { + const pool = new pg.Pool({ connectionString: connectionUri, max: 5 }); + pool.on("error", suppressShutdownError); + return pool; +} + +async function loadMigrations( + scope: DbdevMigrationScope, +): Promise<{ filename: string; sql: string }[]> { + const files = (await readdir(MIGRATIONS_DIR)) + .filter((f) => f.endsWith(".sql")) + .filter((f) => (scope === "core" ? f.startsWith("20220117") : true)) + .sort(); + return Promise.all( + files.map(async (filename) => ({ + filename, + sql: await readFile(join(MIGRATIONS_DIR, filename), "utf-8"), + })), + ); +} + +function uriForDatabase(baseUri: string, dbName: string): string { + return baseUri.replace(/\/[^/?]+(\?|$)/, `/${dbName}$1`); +} + +function makeTemplateCloneSource( + getAdminPool: () => Pool, + setAdminPool: (pool: Pool) => void, + getActivePool: () => Pool, + setActivePool: (pool: Pool) => void, + baseUri: string, + templateDb = "postgres", +): DbdevCloneSource { + let cloneCounter = 0; + return { + async clone() { + await getActivePool().end(); + await getAdminPool().end(); + const cloneName = `${templateDb}_clone_${cloneCounter++}`; + const quotedClone = `"${cloneName.replaceAll('"', '""')}"`; + const quotedTemplate = `"${templateDb.replaceAll('"', '""')}"`; + const admin = new pg.Client({ + connectionString: uriForDatabase(baseUri, "template1"), + }); + await admin.connect(); + try { + await admin.query( + ` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid() + `, + [templateDb], + ); + await admin.query( + `CREATE DATABASE ${quotedClone} TEMPLATE ${quotedTemplate}`, + ); + await admin.query(`ALTER DATABASE ${quotedClone} OWNER TO postgres`); + } finally { + await admin.end().catch(() => {}); + } + const adminPool = createSetupPool(baseUri); + setAdminPool(adminPool); + setActivePool(createPostgresRolePool(baseUri)); + + const clonePool = createPostgresRolePool( + uriForDatabase(baseUri, cloneName), + ); + return { + pool: clonePool, + drop: async () => { + await clonePool.end().catch(() => {}); + await adminPool + .query(`DROP DATABASE IF EXISTS ${quotedClone} WITH (FORCE)`) + .catch(() => {}); + }, + }; + }, + }; +} + +/** Fresh container with supabase base-init — required for main roundtrip apply-check + * because Supabase only allows CREATE EXTENSION in the `postgres` database. */ +function makeFreshMainCloneSource(): DbdevCloneSource { + return { + async clone() { + const container = await startStandaloneSupabase(); + const uri = container.connectionUri(); + const setupPool = createSetupPool(uri); + const pool = createPostgresRolePool(uri); + try { + await applySupabaseBaseInit(setupPool); + } catch (err) { + await Promise.all([ + setupPool.end().catch(() => {}), + pool.end().catch(() => {}), + container.stop().catch(() => {}), + ]); + throw err; + } + await setupPool.end().catch(() => {}); + return { + pool, + drop: async () => { + await pool.end().catch(() => {}); + await container.stop().catch(() => {}); + }, + }; + }, + }; +} + +async function waitForPool(pool: Pool): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + try { + await pool.query("SELECT 1"); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + throw new Error("Supabase container pool did not become ready"); +} + +async function applySupabaseBaseInit(pool: Pool): Promise { + await waitForPool(pool); + await replaySupabaseBaseInit(pool); +} + +export async function bootstrapDbdevFixture( + scope: DbdevMigrationScope = "all", +): Promise { + process.stderr.write( + `[bootstrap-dbdev] starting two Supabase containers (${scope} migrations)…\n`, + ); + + const [containerMain, containerBranch] = await Promise.all([ + startStandaloneSupabase(), + startStandaloneSupabase(), + ]); + + const mainUri = containerMain.connectionUri(); + const branchUri = containerBranch.connectionUri(); + + const setupMain = createSetupPool(mainUri); + const setupBranch = createSetupPool(branchUri); + const mainPool = createPostgresRolePool(mainUri); + const branchPool = createPostgresRolePool(branchUri); + + try { + await Promise.all([ + applySupabaseBaseInit(setupMain), + applySupabaseBaseInit(setupBranch), + ]); + + const migrations = await loadMigrations(scope); + for (const { filename, sql } of migrations) { + await branchPool.query(sql).catch((err) => { + throw new Error(`Migration ${filename} failed: ${String(err)}`, { + cause: err, + }); + }); + } + + process.stderr.write( + `[bootstrap-dbdev] applied ${migrations.length} migration(s) to branch\n`, + ); + + let activeBranchPool = branchPool; + let activeMainPool = mainPool; + let activeSetupMain = setupMain; + let activeSetupBranch = setupBranch; + + return { + get mainPool() { + return activeMainPool; + }, + get branchPool() { + return activeBranchPool; + }, + mainCloneSource: makeFreshMainCloneSource(), + branchCloneSource: makeTemplateCloneSource( + () => activeSetupBranch, + (pool) => { + activeSetupBranch = pool; + }, + () => activeBranchPool, + (pool) => { + activeBranchPool = pool; + }, + branchUri, + ), + createMainShadowDb: async (prefix) => { + const dbName = `shadow_${prefix}_${Date.now()}`.replace( + /[^a-zA-Z0-9_]/g, + "_", + ); + const quoted = `"${dbName.replaceAll('"', '""')}"`; + const admin = new pg.Client({ + connectionString: uriForDatabase(mainUri, "template1"), + }); + await admin.connect(); + try { + for (let attempt = 0; attempt < 10; attempt++) { + await admin.query(` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = 'postgres' AND pid <> pg_backend_pid() + `); + const remaining = await admin.query<{ n: number }>(` + SELECT count(*)::int AS n FROM pg_stat_activity + WHERE datname = 'postgres' AND pid <> pg_backend_pid() + `); + if ((remaining.rows[0]?.n ?? 0) === 0) break; + await new Promise((r) => setTimeout(r, 50)); + } + await admin.query(`CREATE DATABASE ${quoted} TEMPLATE postgres`); + } finally { + await admin.end().catch(() => {}); + } + activeSetupMain = createSetupPool(mainUri); + activeMainPool = createPostgresRolePool(mainUri); + const pool = createPostgresRolePool(uriForDatabase(mainUri, dbName)); + return { + pool, + drop: async () => { + await pool.end().catch(() => {}); + await activeSetupMain + .query(`DROP DATABASE IF EXISTS ${quoted} WITH (FORCE)`) + .catch(() => {}); + }, + }; + }, + prepareDeclarativeShadow: async () => { + await Promise.all([ + activeMainPool.end().catch(() => {}), + activeSetupMain.end().catch(() => {}), + ]); + }, + mainUri, + branchUri, + migrationCount: migrations.length, + cleanup: async () => { + await Promise.all([ + activeSetupMain.end().catch(() => {}), + activeSetupBranch.end().catch(() => {}), + activeMainPool.end().catch(() => {}), + activeBranchPool.end().catch(() => {}), + ]); + await Promise.all([containerMain.stop(), containerBranch.stop()]); + }, + }; + } catch (err) { + await Promise.all([ + setupMain.end().catch(() => {}), + setupBranch.end().catch(() => {}), + mainPool.end().catch(() => {}), + branchPool.end().catch(() => {}), + containerMain.stop().catch(() => {}), + containerBranch.stop().catch(() => {}), + ]); + throw err; + } +} + +/** Apply dbdev migrations to a single pool that already has supabase base-init. */ +export async function applyDbdevMigrations( + pool: Pool, + scope: DbdevMigrationScope = "all", +): Promise { + const migrations = await loadMigrations(scope); + for (const { filename, sql } of migrations) { + await pool.query(sql).catch((err) => { + throw new Error(`Migration ${filename} failed: ${String(err)}`, { + cause: err, + }); + }); + } + return migrations.length; +} + +export { MIGRATIONS_DIR }; diff --git a/packages/pg-delta/scripts/run-tests.ts b/packages/pg-delta/scripts/run-tests.ts index b30a628b4..88f4e7710 100644 --- a/packages/pg-delta/scripts/run-tests.ts +++ b/packages/pg-delta/scripts/run-tests.ts @@ -1,13 +1,17 @@ /** - * Test runner that resolves global-setup and test paths from this script's - * location, so `bun run test` works correctly whether invoked from the - * package directory or from the monorepo root (e.g. via `bun run --filter '*' test`). + * Test runner wrapper. With `BUN_COVERAGE` unset it is a transparent + * passthrough to `bun test `, so `bun run test` / `test:integration` / + * `test:all` behave exactly as a bare `bun test src/` etc. When `BUN_COVERAGE=1` + * it injects the `@supabase/bun-istanbul-coverage` preload so source files are + * Istanbul-instrumented and per-process coverage JSON is written to + * `NYC_OUTPUT_DIR` (consumed by `nyc report` via the root `bun run coverage`). + * + * This mirrors `packages/pg-topo/scripts/run-tests.ts`. pg-delta tests manage + * their own containers (`tests/containers.ts`) and need no global-setup preload, + * so the wrapper adds nothing else — CI keeps invoking `bun test` directly. */ -import { join } from "node:path"; import { fileURLToPath } from "node:url"; -const pkgRoot = join(import.meta.dir, ".."); -const globalSetup = join(pkgRoot, "tests", "global-setup.ts"); const args = process.argv.slice(2); const coveragePreload = fileURLToPath( @@ -17,30 +21,9 @@ const coverageArgs = process.env.BUN_COVERAGE === "1" ? ["--preload", coveragePreload] : []; const proc = Bun.spawn({ - cmd: [ - "bun", - "test", - ...coverageArgs, - "--preload", - globalSetup, - "--concurrent", - "--timeout", - "30000", - "--max-concurrency", - "3", - "--retry=5", - ...args, - ], - cwd: pkgRoot, + cmd: ["bun", "test", ...coverageArgs, ...args], + cwd: fileURLToPath(new URL("..", import.meta.url)), stdio: ["inherit", "inherit", "inherit"], - env: { - // Limit the number of pool connections to 1 to avoid overwhelming the alpine containers - // on local dev - PGDELTA_POOL_MAX: "1", - PGDELTA_CONNECTION_TIMEOUT_MS: "2000", - PGDELTA_CONNECT_TIMEOUT_MS: "2000", - ...process.env, - }, }); const exitCode = await proc.exited; diff --git a/packages/pg-delta/scripts/sync-supabase-base-images.test.ts b/packages/pg-delta/scripts/sync-supabase-base-images.test.ts deleted file mode 100644 index 4da168d92..000000000 --- a/packages/pg-delta/scripts/sync-supabase-base-images.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - buildPgdeltaPlanCommand, - ensureSupabaseDbMajorVersion, - getSupabaseBaseInitFixtureRelativePath, -} from "./sync-supabase-base-images.ts"; - -describe("ensureSupabaseDbMajorVersion", () => { - test("updates the db major version in an existing db section", () => { - const config = ` -[api] -enabled = true - -[db] -port = 54322 -major_version = 15 -shadow_port = 54320 - -[studio] -enabled = true -`.trim(); - - expect(ensureSupabaseDbMajorVersion(config, 17)).toMatchInlineSnapshot(` - "[api] - enabled = true - - [db] - port = 54322 - major_version = 17 - shadow_port = 54320 - - [studio] - enabled = true" - `); - }); - - test("adds the db major version when the db section does not declare one", () => { - const config = ` -[db] -port = 54322 -shadow_port = 54320 - -[studio] -enabled = true -`.trim(); - - expect(ensureSupabaseDbMajorVersion(config, 15)).toMatchInlineSnapshot(` - "[db] - major_version = 15 - port = 54322 - shadow_port = 54320 - - [studio] - enabled = true" - `); - }); -}); - -describe("buildPgdeltaPlanCommand", () => { - test("builds a sql plan command with formatting and output", () => { - expect( - buildPgdeltaPlanCommand({ - source: "postgres://source", - target: "postgres://target", - output: - "tests/integration/fixtures/supabase-base-init/17_fullstack_container_init.sql", - sqlFormat: true, - }), - ).toMatchInlineSnapshot(` - [ - "bun", - "run", - "pgdelta", - "plan", - "--source", - "postgres://source", - "--target", - "postgres://target", - "--output", - "tests/integration/fixtures/supabase-base-init/17_fullstack_container_init.sql", - "--sql-format", - ] - `); - }); - - test("builds a validation command without an output file", () => { - expect( - buildPgdeltaPlanCommand({ - source: "postgres://validated", - target: "postgres://fullstack", - format: "sql", - sqlFormat: true, - }), - ).toMatchInlineSnapshot(` - [ - "bun", - "run", - "pgdelta", - "plan", - "--source", - "postgres://validated", - "--target", - "postgres://fullstack", - "--format", - "sql", - "--sql-format", - ] - `); - }); -}); - -describe("getSupabaseBaseInitFixtureRelativePath", () => { - test("stores generated fixtures in the dedicated versioned directory", () => { - expect(getSupabaseBaseInitFixtureRelativePath(17)).toBe( - "tests/integration/fixtures/supabase-base-init/17_fullstack_container_init.sql", - ); - }); -}); diff --git a/packages/pg-delta/scripts/sync-supabase-base-images.ts b/packages/pg-delta/scripts/sync-supabase-base-images.ts index d512ac3af..d6eb868c2 100644 --- a/packages/pg-delta/scripts/sync-supabase-base-images.ts +++ b/packages/pg-delta/scripts/sync-supabase-base-images.ts @@ -1,3 +1,31 @@ +/** + * Maintainer workflow: regenerate the Supabase baseline fixture replayed by + * integration tests that need a realistic post-`supabase start` target. + * + * For the pinned Supabase image (tests/containers.ts `SUPABASE_IMAGE`) we: + * 1. stop any running local Supabase stacks (free the default ports) + * 2. boot a BARE `supabase/postgres:` container (the "before" — just the + * image, before the service stack bootstraps its own schemas) + * 3. `supabase start` a temp project pinned to the SAME tag (the "after" — + * every service ran its init/migrations) + * 4. diff bare -> full with pg-delta-next ITSELF (raw plan: cluster-global + * roles + memberships + default privileges + auth/storage/realtime schemas + * all captured), render it to SQL, and write + * tests/fixtures/supabase-base-init/.sql + * 5. ZERO-DIFF GATE: replay the fixture into a FRESH bare container, re-extract, + * and require a subsequent bare->full plan to be empty. A non-empty plan + * means the fixture is incomplete and the script fails. + * + * Dogfooding the diff (rather than a pg_dump delta) gets redaction and every + * ACL/role/default-privilege edge case the engine already handles for free. + * + * USAGE + * cd packages/pg-delta-next + * DOCKER_HOST=unix:///.../docker.sock bun run sync-base-images + * + * NOTE: not for CI — it needs Docker + the Supabase CLI and produces a committed + * artifact. Regenerate locally when SUPABASE_IMAGE changes and commit the result. + */ import { access, mkdir, @@ -8,130 +36,60 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import type { Pool } from "pg"; -import { GenericContainer, Wait } from "testcontainers"; -import { createPool, endPool } from "../src/core/postgres-config.ts"; +import pg from "pg"; import { - POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG, - SUPABASE_POSTGRES_VERSIONS, - type SupabasePostgresVersion, -} from "../tests/constants.ts"; -import { SupabasePostgreSqlContainer } from "../tests/supabase-postgres.js"; -import { applySupabaseBaseInit } from "../tests/utils.ts"; - -/** - * Maintainer workflow for regenerating the "base init" SQL replayed by - * Supabase-isolated integration tests. - * - * For each supported Postgres major version we: - * 1. start a temporary `supabase start` project pinned to the exact image tag - * 2. start a bare `supabase/postgres` container for the same tag - * 3. diff bare -> full stack with `pgdelta plan` and persist that SQL - * 4. replay the generated SQL into a fresh test-style Supabase container - * 5. require a final zero-diff validation against the full stack - * - * This keeps the committed fixtures in sync with image upgrades and proves that - * the same SQL our tests replay is sufficient to reach the full-stack schema. - */ -const SUPABASE_BASE_INIT_FIXTURE_DIRECTORY = - "tests/integration/fixtures/supabase-base-init"; - -const POSTGRES_PORT = 5432; -// `supabase start` always exposes the database on 54322 inside the temporary -// local project; we patch the project config to control which major version/tag -// that local stack boots with. -const SUPABASE_LOCAL_DB_URL = - "postgres://postgres:postgres@127.0.0.1:54322/postgres"; + GenericContainer, + Wait, + type StartedTestContainer, +} from "testcontainers"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { renderPlanSql } from "../src/plan/render-sql.ts"; +import { SUPABASE_BARE_MAJOR, SUPABASE_IMAGE } from "../tests/containers.ts"; +import { supabaseBaseInitFixturePath } from "../tests/supabase-base-init.ts"; + +const SUPABASE_BIN = process.env["SUPABASE_BIN"] ?? "supabase"; +const SUPABASE_TAG = SUPABASE_IMAGE.split(":")[1] ?? "17.6.1.135"; +// `supabase start` exposes the local stack DB on 54322 by default; we free that +// port (step 1) before starting the temp project. Connect as `supabase_admin` +// on both sides — pg-delta-next extract is current_user-sensitive for owner +// edges / grants / default privileges, so the diff must be symmetric. +const FULL_DB_URL = `postgres://supabase_admin:postgres@127.0.0.1:54322/postgres`; const pkgRoot = join(import.meta.dir, ".."); -const supabaseBin = join( - pkgRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "supabase.cmd" : "supabase", -); -export function ensureSupabaseDbMajorVersion( +/** Patch only `[db].major_version` in a Supabase config.toml, preserving the + * rest (the CLI owns the surrounding TOML). Ported from the old package. */ +function ensureSupabaseDbMajorVersion( configToml: string, majorVersion: number, ): string { - // Supabase CLI owns the surrounding TOML, so keep the patch deliberately - // small: replace or inject only `[db].major_version` and preserve the rest. const newline = configToml.includes("\r\n") ? "\r\n" : "\n"; const lines = configToml.split(/\r?\n/); const dbSectionIndex = lines.findIndex((line) => line.trim() === "[db]"); - if (dbSectionIndex === -1) { throw new Error("Supabase config is missing a [db] section"); } - let nextSectionIndex = lines.findIndex( (line, index) => index > dbSectionIndex && line.trim().startsWith("[") && line.trim().endsWith("]"), ); - - if (nextSectionIndex === -1) { - nextSectionIndex = lines.length; - } - + if (nextSectionIndex === -1) nextSectionIndex = lines.length; const majorVersionLineIndex = lines.findIndex( (line, index) => index > dbSectionIndex && index < nextSectionIndex && line.trim().startsWith("major_version"), ); - if (majorVersionLineIndex === -1) { lines.splice(dbSectionIndex + 1, 0, `major_version = ${majorVersion}`); } else { lines[majorVersionLineIndex] = `major_version = ${majorVersion}`; } - return lines.join(newline); } -export function buildPgdeltaPlanCommand(options: { - source: string; - target: string; - format?: "sql" | "json"; - output?: string; - sqlFormat?: boolean; -}): string[] { - // Use the public CLI entrypoint even though this script lives inside the repo: - // the goal is to validate the real maintainer workflow, not just the internals. - const command = [ - "bun", - "run", - "pgdelta", - "plan", - "--source", - options.source, - "--target", - options.target, - ]; - - if (options.format) { - command.push("--format", options.format); - } - - if (options.output) { - command.push("--output", options.output); - } - - if (options.sqlFormat) { - command.push("--sql-format"); - } - - return command; -} - -export function getSupabaseBaseInitFixtureRelativePath( - version: number, -): string { - return `${SUPABASE_BASE_INIT_FIXTURE_DIRECTORY}/${version}_fullstack_container_init.sql`; -} - async function runCommand(options: { cmd: string[]; cwd: string; @@ -144,35 +102,25 @@ async function runCommand(options: { stderr: "pipe", env: process.env, }); - const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); - - const allowedExitCodes = options.allowedExitCodes ?? [0]; - if (!allowedExitCodes.includes(exitCode)) { - // Bubble up full stdout/stderr because most failures here come from external - // tools (Supabase CLI, Docker, pgdelta) where the captured command output is - // the primary debugging signal. - const commandLabel = options.cmd.join(" "); + const allowed = options.allowedExitCodes ?? [0]; + if (!allowed.includes(exitCode)) { throw new Error( - `Command failed with exit code ${exitCode}: ${commandLabel}\nstdout:\n${stdout}\nstderr:\n${stderr}`, + `Command failed (${exitCode}): ${options.cmd.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`, ); } - return { stdout, stderr, exitCode }; } async function waitForPool( - pool: Pool, - retries = 30, + pool: pg.Pool, + retries = 40, delayMs = 2_000, ): Promise { - // Container/CLI health checks can turn green before the database is actually - // ready to accept application connections. Poll the real connection path we - // will use for diffing/replay before moving to the next phase. for (let attempt = 0; attempt < retries; attempt++) { try { const client = await pool.connect(); @@ -189,322 +137,276 @@ async function waitForPool( } } -function createManagedPool(connectionString: string): Pool { - // The sync workflow starts and stops a lot of ephemeral containers. These two - // errors are expected during teardown and shouldn't hide the real failure. - return createPool(connectionString, { +function managedPool(connectionString: string): pg.Pool { + const pool = new pg.Pool({ + connectionString, + max: 3, connectionTimeoutMillis: 20_000, - onError: (err: Error & { code?: string }) => { - if (err.code === "57P01" || err.code === "53100") return; - console.error("Pool error:", err); - }, }); + // 57P01 (admin shutdown) / 53100 (disk full) are expected during the many + // ephemeral container teardowns; don't let them crash the process. + pool.on("error", (err: Error & { code?: string }) => { + if (err.code === "57P01" || err.code === "53100") return; + console.error("Pool error:", err); + }); + return pool; } -async function stopSupabaseStack(workdir: string): Promise { - try { - await runCommand({ - cmd: [supabaseBin, "stop", "--yes", "--no-backup", "--workdir", workdir], - cwd: pkgRoot, - allowedExitCodes: [0], - }); - } catch (error) { - // Cleanup should not mask the original generation/validation error. - console.warn( - `[sync-base-images] Failed to stop Supabase stack for ${workdir}: ${error instanceof Error ? error.message : String(error)}`, - ); - } +async function stopAllSupabaseStacks(): Promise { + console.log("[sync] Stopping any running Supabase stacks (stop --all)..."); + await runCommand({ + cmd: [SUPABASE_BIN, "stop", "--all", "--no-backup"], + cwd: pkgRoot, + // `stop --all` exits non-zero when nothing is running on some CLI versions. + allowedExitCodes: [0, 1], + }); } async function prepareSupabaseProject( workdir: string, - postgresVersion: SupabasePostgresVersion, + major: number, ): Promise { await runCommand({ - cmd: [supabaseBin, "init", "--yes", "--workdir", workdir], + cmd: [SUPABASE_BIN, "init", "--yes", "--workdir", workdir], cwd: pkgRoot, }); - const supabaseDir = join(workdir, "supabase"); const configPath = join(supabaseDir, "config.toml"); const configToml = await readFile(configPath, "utf-8"); - await writeFile( configPath, - ensureSupabaseDbMajorVersion(configToml, postgresVersion), + ensureSupabaseDbMajorVersion(configToml, major), "utf-8", ); - - // The CLI uses this temp file to pick the exact Postgres image tag. Without - // it we would only pin the major version, not the concrete image build our - // tests use from `tests/constants.ts`. + // Pin the EXACT image tag (not just the major) the way the CLI expects, so the + // full stack boots the same build as the bare container we diff against. await mkdir(join(supabaseDir, ".temp"), { recursive: true }); await writeFile( join(supabaseDir, ".temp", "postgres-version"), - `${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[postgresVersion]}\n`, + `${SUPABASE_TAG}\n`, "utf-8", ); } -type BareSupabaseContainer = { - connectionUri: string; +async function startBareContainer(): Promise<{ + uri: string; stop: () => Promise; -}; - -/** - * Build the connection URL for the local `supabase start` stack as a specific - * database role. - * - * This matters because pg-delta diffs are sensitive to `current_user` for some - * Supabase-managed grants/comments/default-privilege cases. - */ -function buildLocalSupabaseUrl(username: string): string { - // `current_user` affects grants/comments/default privilege diffs for Supabase - // objects, so both generation and validation must use the same login role as - // the path they are comparing against. - const url = new URL(SUPABASE_LOCAL_DB_URL); - url.username = username; - url.password = "postgres"; - return url.toString(); -} - -/** - * Rewrite a connection string to log in as the requested role. - * - * We use this for the bare comparison container because the container itself is - * started with the stock `postgres` bootstrap user, but the diff must run as - * the same role that the full local Supabase stack exposes to us during tests - * and validation (`supabase_admin`). - */ -function buildConnectionUrlForUser( - connectionUri: string, - username: string, -): string { - // The bare comparison container starts as `postgres`, but we diff it as - // `supabase_admin` to match the local Supabase stack and the test runtime. - const url = new URL(connectionUri); - url.username = username; - url.password = "postgres"; - return url.toString(); -} - -async function startBareSupabaseContainer( - postgresVersion: SupabasePostgresVersion, -): Promise { - const tag = POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[postgresVersion]; - // Start the raw image with the stock Docker entrypoint. This is the "before" - // side of the diff: just the base Postgres image, before the rest of the - // Supabase services have bootstrapped their own schemas and tables. - const startedContainer = await new GenericContainer( - `supabase/postgres:${tag}`, +}> { + console.log(`[sync] Booting bare ${SUPABASE_IMAGE}...`); + const container: StartedTestContainer = await new GenericContainer( + SUPABASE_IMAGE, ) - .withLabels({ "pg-toolbelt.package": "pg-delta" }) - .withExposedPorts(POSTGRES_PORT) - .withStartupTimeout(120_000) - .withWaitStrategy( - Wait.forLogMessage("database system is ready to accept connections"), - ) .withEnvironment({ + POSTGRES_USER: "supabase_admin", POSTGRES_PASSWORD: "postgres", + POSTGRES_DB: "postgres", }) + .withExposedPorts(5432) + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(180_000) + .withTmpFs({ "/var/lib/postgresql/data": "rw,noexec,nosuid,size=1024m" }) .start(); - - const url = new URL("", "postgres://"); - url.hostname = "127.0.0.1"; - url.port = startedContainer.getMappedPort(POSTGRES_PORT).toString(); - url.pathname = "postgres"; - url.username = "postgres"; - url.password = "postgres"; - - return { - connectionUri: url.toString(), - stop: async () => { - // Normalize teardown to `Promise` so the orchestration code can - // treat both bare and validated containers the same way. - await startedContainer.stop(); - }, - }; + const uri = `postgres://supabase_admin:postgres@${container.getHost()}:${container.getMappedPort(5432)}/postgres`; + return { uri, stop: () => container.stop().then(() => undefined) }; } -/** - * Start a Supabase container using the same wrapper as the test suite. - * - * Validation uses this container shape, not the raw GenericContainer above, so - * that the generated SQL is proven against the exact startup path used by - * `withDbSupabaseIsolated(...)`. - */ -async function startValidatedSupabaseContainer( - postgresVersion: SupabasePostgresVersion, -): Promise { - const tag = POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[postgresVersion]; - // Validation intentionally uses the same container wrapper as the test suite. - // If zero-diff passes here, we know the generated SQL is valid for the actual - // runtime path used by `withDbSupabaseIsolated(...)`, not just for a custom - // one-off container shape in this script. - const startedContainer = await new SupabasePostgreSqlContainer( - `supabase/postgres:${tag}`, - ).start(); +/** Fail loudly if `supabase start` booted a different image than the bare + * container — the fixture would otherwise bake in version-skewed schema. */ +async function assertFullStackTag(): Promise { + const { stdout } = await runCommand({ + cmd: [ + "docker", + "ps", + "--filter", + "name=supabase_db_", + "--format", + "{{.Image}}", + ], + cwd: pkgRoot, + }); + const image = stdout.trim().split("\n")[0] ?? ""; + if (!image.endsWith(`:${SUPABASE_TAG}`)) { + throw new Error( + `Full stack DB image is "${image}", expected tag "${SUPABASE_TAG}". ` + + `The .temp/postgres-version pin did not take (Supabase CLI ${SUPABASE_TAG} mismatch); ` + + `the fixture would be version-skewed. Aborting.`, + ); + } + console.log(`[sync] Full stack DB image confirmed: ${image}`); +} - return { - connectionUri: startedContainer.getConnectionUri(), - stop: async () => { - // Match the bare-container helper above: callers only care that teardown - // completes, not about the stopped-container object that testcontainers - // returns. - await startedContainer.stop(); - }, - }; +/** Apply each action on its own (autocommit, continue-on-error) to surface + * EVERY action that fails to apply, not just the first. `check_function_bodies` + * is disabled once on the session so forward-referencing bodies elaborate, the + * same as the batch replay. Cascade victims (an action failing because an + * earlier one did) are included — the list sizes the convergence gaps. */ +async function enumerateReplayFailures( + pool: pg.Pool, + actions: ReadonlyArray<{ sql: string }>, +): Promise> { + const failures: Array<{ i: number; sql: string; message: string }> = []; + const client = await pool.connect(); + try { + await client.query("SET check_function_bodies = off"); + for (let i = 0; i < actions.length; i++) { + const sql = actions[i]!.sql; + try { + await client.query(sql); + } catch (e) { + failures.push({ + i, + sql: (sql.split("\n")[0] ?? sql).slice(0, 160), + message: e instanceof Error ? e.message : String(e), + }); + } + } + } finally { + client.release(); + } + return failures; } -/** - * Generate and validate the replay SQL for one Postgres major version. - * - * The important distinction in this flow is: - * - bare container: "just the image" - * - full stack: `supabase start` after the other services have initialized it - * - validated container: a fresh test-style container after replaying the SQL - * - * If the validated container still diffs against the full stack, the generated - * fixture is incomplete and the script must fail. - */ -async function generateFixtureForVersion( - postgresVersion: SupabasePostgresVersion, -): Promise { +async function generateFixture(major: number): Promise { const workdir = await mkdtemp( - join(tmpdir(), `pg-delta-supabase-sync-pg${postgresVersion}-`), + join(tmpdir(), `pgdelta-supabase-sync-pg${major}-`), ); - const fixtureRelativePath = - getSupabaseBaseInitFixtureRelativePath(postgresVersion); - const fixturePath = join(pkgRoot, fixtureRelativePath); + const fixturePath = supabaseBaseInitFixturePath(major); - let fullstackPool: Pool | undefined; - let fullstackValidationPool: Pool | undefined; - let barePool: Pool | undefined; - let validatedPool: Pool | undefined; - let bareContainer: - | Awaited> - | undefined; - let validatedContainer: - | Awaited> - | undefined; + let fullPool: pg.Pool | undefined; + let barePool: pg.Pool | undefined; + let validatedPool: pg.Pool | undefined; + let bare: Awaited> | undefined; + let validated: Awaited> | undefined; try { - console.log( - `[sync-base-images] Preparing Supabase project for pg${postgresVersion}`, - ); - await prepareSupabaseProject(workdir, postgresVersion); - - // Bring up the full local stack first so the target side of the diff reflects - // every service-owned migration that `supabase start` applies on boot. - console.log( - `[sync-base-images] Starting full stack for pg${postgresVersion}`, - ); + // ── Full stack (after) ───────────────────────────────────────────────── + await prepareSupabaseProject(workdir, major); + console.log(`[sync] supabase start (pg${major}, tag ${SUPABASE_TAG})...`); await runCommand({ - cmd: [supabaseBin, "start", "--yes", "--workdir", workdir], + cmd: [SUPABASE_BIN, "start", "--workdir", workdir], cwd: pkgRoot, }); + await assertFullStackTag(); + fullPool = managedPool(FULL_DB_URL); + await waitForPool(fullPool); - fullstackPool = createManagedPool(SUPABASE_LOCAL_DB_URL); - console.log(`[sync-base-images] Waiting for full stack database readiness`); - await waitForPool(fullstackPool); - - console.log( - `[sync-base-images] Starting bare supabase/postgres container for pg${postgresVersion}`, - ); - bareContainer = await startBareSupabaseContainer(postgresVersion); - barePool = createManagedPool(bareContainer.connectionUri); - console.log( - `[sync-base-images] Waiting for bare container readiness at ${bareContainer.connectionUri}`, - ); + // ── Bare image (before) ──────────────────────────────────────────────── + bare = await startBareContainer(); + barePool = managedPool(bare.uri); await waitForPool(barePool); - await mkdir(dirname(fixturePath), { recursive: true }); - console.log(`[sync-base-images] Generating ${fixtureRelativePath}`); - // Generate the replay SQL by diffing the bare image against the fully - // bootstrapped local stack. Allow exit code 2 here because `pgdelta plan` - // uses it to signal "changes detected", which is exactly what we want. - const bareDiffUrl = buildConnectionUrlForUser( - bareContainer.connectionUri, - "supabase_admin", - ); - await runCommand({ - cmd: buildPgdeltaPlanCommand({ - source: bareDiffUrl, - target: buildLocalSupabaseUrl("supabase_admin"), - output: fixturePath, - sqlFormat: true, - }), - cwd: pkgRoot, - allowedExitCodes: [0, 2], + // ── Diff bare -> full, render, persist ────────────────────────────────── + console.log(`[sync] Extracting bare + full and planning delta...`); + const [base, full] = await Promise.all([ + extract(barePool, { redactSecrets: true }), + extract(fullPool, { redactSecrets: true }), + ]); + const thePlan = plan(base.factBase, full.factBase, { + renames: "off", + compact: true, }); + console.log(`[sync] Delta: ${thePlan.actions.length} action(s).`); + const body = renderPlanSql(thePlan); + const header = + `-- Supabase baseline: delta from bare ${SUPABASE_IMAGE} to \`supabase start\`.\n` + + `-- Generated by scripts/sync-supabase-base-images.ts — DO NOT EDIT BY HAND.\n` + + `-- Regenerate with: bun run sync-base-images\n\n`; + await mkdir(dirname(fixturePath), { recursive: true }); + await writeFile(fixturePath, header + body, "utf-8"); + console.log(`[sync] Wrote ${fixturePath}`); + // ── Zero-diff gate ────────────────────────────────────────────────────── console.log( - `[sync-base-images] Validating generated fixture for pg${postgresVersion}`, - ); - validatedContainer = await startValidatedSupabaseContainer(postgresVersion); - validatedPool = createManagedPool(validatedContainer.connectionUri); - console.log( - `[sync-base-images] Waiting for validated container readiness at ${validatedContainer.connectionUri}`, + `[sync] Validating fixture (replay -> re-diff must be empty)...`, ); + validated = await startBareContainer(); + validatedPool = managedPool(validated.uri); await waitForPool(validatedPool); - // Replay the committed fixture through the same helper exported to tests. - // This guarantees that script validation and test runtime share the exact - // same "post-start" setup behavior. - await applySupabaseBaseInit(validatedPool, postgresVersion); - - // Compare as `supabase_admin` on both sides. The remaining diff here is the - // exact signal we care about: "does the test runtime baseline match the - // stack that `supabase start` produced?" - fullstackValidationPool = createManagedPool( - buildLocalSupabaseUrl("supabase_admin"), + // Replay exactly as `applySupabaseBaseInit` does: one multi-statement batch + // on a single connection (implicit transaction). On failure the whole batch + // rolls back, so the DB is clean again — re-apply action-by-action to + // enumerate EVERY failing action (not just the first), which is what sizes + // the remaining convergence gaps. + if (body.trim() !== "") { + try { + await validatedPool.query(body); + } catch (batchErr) { + const failures = await enumerateReplayFailures( + validatedPool, + thePlan.actions, + ); + const detail = failures + .map((f) => ` [action ${f.i}] ${f.message}\n ${f.sql}`) + .join("\n"); + throw new Error( + `Fixture replay FAILED — ${failures.length} action(s) do not apply ` + + `(first batch error: ${batchErr instanceof Error ? batchErr.message : String(batchErr)}).\n${detail}`, + ); + } + } + const replayed = await extract(validatedPool, { redactSecrets: true }); + const gate = plan(replayed.factBase, full.factBase, { + renames: "off", + compact: true, + }); + // PGDELTA_SYNC_DEBUG_DIR=: dump both gate-side snapshots for offline + // analysis of residuals (avoids re-running `supabase start` per hypothesis). + const debugDir = process.env["PGDELTA_SYNC_DEBUG_DIR"]; + if (debugDir) { + await mkdir(debugDir, { recursive: true }); + const { serializeSnapshot } = await import("../src/core/snapshot.ts"); + await writeFile( + join(debugDir, `replayed-${major}.json`), + serializeSnapshot(replayed.factBase, { pgVersion: replayed.pgVersion }), + "utf-8", + ); + await writeFile( + join(debugDir, `full-${major}.json`), + serializeSnapshot(full.factBase, { pgVersion: full.pgVersion }), + "utf-8", + ); + console.log(`[sync] Debug snapshots written to ${debugDir}`); + } + if (gate.actions.length !== 0) { + const residual = gate.actions.map((a) => ` ${a.sql};`).join("\n"); + throw new Error( + `Zero-diff gate FAILED: ${gate.actions.length} residual action(s) after replay.\n${residual}`, + ); + } + console.log(`[sync] Zero-diff gate passed for pg${major}. ✅`); + } finally { + await Promise.all( + [fullPool, barePool, validatedPool] + .filter((p): p is pg.Pool => p !== undefined) + .map((p) => p.end().catch(() => {})), + ); + await Promise.all( + [bare, validated] + .filter( + (c): c is { uri: string; stop: () => Promise } => + c !== undefined, + ) + .map((c) => c.stop().catch(() => {})), ); - await waitForPool(fullstackValidationPool); - - // Final contract of this script: after replaying the generated SQL into a - // fresh test-style container, `pgdelta plan` must report no remaining diff. - // If this exits 2, the fixture is incomplete and the script fails. await runCommand({ - cmd: buildPgdeltaPlanCommand({ - source: validatedContainer.connectionUri, - target: buildLocalSupabaseUrl("supabase_admin"), - format: "sql", - sqlFormat: true, - }), + cmd: [SUPABASE_BIN, "stop", "--workdir", workdir, "--no-backup"], cwd: pkgRoot, - allowedExitCodes: [0], - }); - } finally { - await Promise.all([ - barePool ? endPool(barePool) : Promise.resolve(), - validatedPool ? endPool(validatedPool) : Promise.resolve(), - fullstackValidationPool - ? endPool(fullstackValidationPool) - : Promise.resolve(), - fullstackPool ? endPool(fullstackPool) : Promise.resolve(), - ]); - await Promise.all([ - bareContainer ? bareContainer.stop() : Promise.resolve(), - validatedContainer ? validatedContainer.stop() : Promise.resolve(), - ]); - // Always tear down the temporary CLI project, even after generation or - // validation failures, so reruns start from a clean slate. - await stopSupabaseStack(workdir); + allowedExitCodes: [0, 1], + }).catch((e) => + console.warn(`[sync] stop failed: ${e instanceof Error ? e.message : e}`), + ); await rm(workdir, { recursive: true, force: true }); } } -export async function syncSupabaseBaseImages(): Promise { - await access(supabaseBin); - - // Keep fixture generation serialized by version to avoid multiple local - // Supabase stacks fighting over the CLI's fixed localhost ports. - for (const postgresVersion of SUPABASE_POSTGRES_VERSIONS) { - await generateFixtureForVersion(postgresVersion); - } +async function main(): Promise { + await access(pkgRoot); + await stopAllSupabaseStacks(); + await generateFixture(SUPABASE_BARE_MAJOR); } if (import.meta.main) { - await syncSupabaseBaseImages().catch((error) => { + main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); diff --git a/packages/pg-delta/scripts/update-empty-catalog-baseline.ts b/packages/pg-delta/scripts/update-empty-catalog-baseline.ts deleted file mode 100644 index 69e2e2ff9..000000000 --- a/packages/pg-delta/scripts/update-empty-catalog-baseline.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Update the empty-catalogs baseline by exporting the catalog from a fresh - * Postgres 15 testcontainer. - * - * The baseline JSON is used as the "empty database" reference for declarative - * export and plan commands when comparing against a live DB. This script - * ensures the baseline matches the exact catalog of a vanilla Postgres 15 - * (Alpine) instance so diffs are stable and reproducible. - * - * Usage (from package root): - * bun run update-empty-baseline - * - * Requirements: Docker running (testcontainers starts a postgres:15.14-alpine - * container). The script writes to src/core/fixtures/empty-catalogs/ - * postgres-15-16-baseline.json and then exits; container stop is capped so - * the process does not hang. - */ - -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { extractCatalog } from "../src/core/catalog.model.ts"; -import { - serializeCatalog, - stringifyCatalogSnapshot, -} from "../src/core/catalog.snapshot.ts"; -import { createPool, endPool } from "../src/core/postgres-config.ts"; -import { POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG } from "../tests/constants.ts"; -import { PostgresAlpineContainer } from "../tests/postgres-alpine.ts"; - -/** Postgres major version used for the baseline (must match fixture naming). */ -const PG_VERSION = 15; - -/** Output path relative to package root; shared by declarative/plan code. */ -const OUTPUT_RELATIVE = - "src/core/fixtures/empty-catalogs/postgres-15-16-baseline.json"; - -const pkgRoot = join(import.meta.dir, ".."); -const outputPath = join(pkgRoot, OUTPUT_RELATIVE); - -/** Same image as integration tests for consistency. */ -const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[PG_VERSION]}`; - -console.log("Starting Postgres 15 container..."); -const container = await new PostgresAlpineContainer(image).start(); - -try { - const uri = container.getConnectionUri(); - const pool = createPool(uri, { - onError: (err: Error & { code?: string }) => { - if (err.code !== "57P01") console.error("Pool error:", err); - }, - }); - - try { - console.log("Exporting catalog..."); - const catalog = await extractCatalog(pool); - const snapshot = serializeCatalog(catalog); - const json = stringifyCatalogSnapshot(snapshot); - await writeFile(outputPath, json, "utf-8"); - console.log(`Done. Baseline written to ${OUTPUT_RELATIVE}`); - } finally { - await endPool(pool); - } -} finally { - // Don't block on stop(); Docker's graceful shutdown can hang. Give it a short - // timeout then exit so the process doesn't hang. - const stopTimeoutMs = 5_000; - await Promise.race([ - container.stop(), - new Promise((r) => setTimeout(r, stopTimeoutMs)), - ]); - process.exit(0); -} diff --git a/packages/pg-delta/src/apply/apply.test.ts b/packages/pg-delta/src/apply/apply.test.ts new file mode 100644 index 000000000..e8eb561d4 --- /dev/null +++ b/packages/pg-delta/src/apply/apply.test.ts @@ -0,0 +1,66 @@ +/** + * Segmentation algorithm (stage 6 deliverable 2), pure: hand-built action + * lists exercise maximal transactional runs, lone nonTransactional + * actions, and commitBoundaryAfter boundaries. + */ +import { describe, expect, test } from "bun:test"; +import { segmentActions } from "./apply.ts"; + +const txn = (newSegmentBefore = false) => ({ + transactionality: "transactional" as const, + newSegmentBefore, +}); +const nonTxn = () => ({ + transactionality: "nonTransactional" as const, + newSegmentBefore: false, +}); +const boundary = () => ({ + transactionality: "commitBoundaryAfter" as const, + newSegmentBefore: false, +}); + +describe("segmentActions", () => { + test("all-transactional plans run as one segment", () => { + expect(segmentActions([txn(), txn(), txn()])).toEqual([ + { start: 0, end: 3, transactional: true }, + ]); + }); + + test("a nonTransactional action runs alone between transaction runs", () => { + expect(segmentActions([txn(), nonTxn(), txn(), txn()])).toEqual([ + { start: 0, end: 1, transactional: true }, + { start: 1, end: 2, transactional: false }, + { start: 2, end: 4, transactional: true }, + ]); + }); + + test("leading and trailing nonTransactional actions", () => { + expect(segmentActions([nonTxn(), txn(), nonTxn()])).toEqual([ + { start: 0, end: 1, transactional: false }, + { start: 1, end: 2, transactional: true }, + { start: 2, end: 3, transactional: false }, + ]); + }); + + test("a commitBoundaryAfter action unconditionally ends its segment (review #6)", () => { + // ADD VALUE at 1 closes its segment immediately — no reliance on a graph + // successor being marked. A later newSegmentBefore (at 3) splits again. + expect( + segmentActions([txn(), boundary(), txn(), txn(true), txn()]), + ).toEqual([ + { start: 0, end: 2, transactional: true }, + { start: 2, end: 3, transactional: true }, + { start: 3, end: 5, transactional: true }, + ]); + }); + + test("a boundary at the very first action opens no empty segment", () => { + expect(segmentActions([txn(true), txn()])).toEqual([ + { start: 0, end: 2, transactional: true }, + ]); + }); + + test("empty plans yield no segments", () => { + expect(segmentActions([])).toEqual([]); + }); +}); diff --git a/packages/pg-delta/src/apply/apply.ts b/packages/pg-delta/src/apply/apply.ts new file mode 100644 index 000000000..61908403d --- /dev/null +++ b/packages/pg-delta/src/apply/apply.ts @@ -0,0 +1,285 @@ +/** + * Execution (target-architecture §3.8, stage 6): sequential, lock-aware, + * segmented. Actions self-declare transactionality; the executor groups + * maximal transactional runs, isolates nonTransactional actions, and + * honors the planner's commitBoundaryAfter segment boundaries. + * Segmentation changes transaction boundaries only, never order. + * + * Mid-plan failure semantics are explicit: every action is reported + * applied / unapplied / inDoubt. A failure inside a transaction segment + * rolls that segment back (its actions report unapplied); earlier + * segments are committed (applied); a failure AT commit reports the + * segment inDoubt. + */ +import type { Pool } from "pg"; +import type { FactBase } from "../core/fact.ts"; +import { extract } from "../extract/extract.ts"; +import { ENGINE_VERSION, type Plan } from "../plan/plan.ts"; +import { reconstructManagedView } from "../policy/reconstruct.ts"; + +export type ActionStatus = "applied" | "unapplied" | "inDoubt"; + +export interface ApplyReport { + status: "applied" | "failed"; + /** count of actions in committed segments */ + appliedActions: number; + /** one entry per plan action, in plan order */ + actionStatuses: ActionStatus[]; + error?: { actionIndex: number; sql: string; message: string }; +} + +export interface ApplyOptions { + /** re-extract the target and require its fingerprint to equal the + * plan's source fingerprint (stage 6 deliverable 3). Defaults to ON; + * harnesses that just proved the fingerprint may skip it. */ + fingerprintGate?: boolean; + /** per-segment lock/statement timeouts (operational policy) */ + lockTimeoutMs?: number; + statementTimeoutMs?: number; + /** resolved platform baseline (§3.9), required to reconstruct the fingerprint + * gate for a baseline-shaped plan — the baseline is NOT carried in the plan + * artifact. If the plan's policy declares a baseline and this is absent, the + * gate fails loudly rather than mis-comparing. */ + baseline?: FactBase; + /** how to re-extract the target for the fingerprint gate. Defaults to the core + * `extract`. An integration with extension handlers MUST pass a handler-aware + * re-extractor — `extract(pool, { handlers })`, which the resolved profile + * supplies as `applyOptions.reextract` — so the gate emits the same + * `managedBy` edges and `resolveView` reconstructs the SAME managed view the + * plan was fingerprinted from; otherwise operationally-managed objects present + * on the real target read as drift and reject a valid managed plan. */ + reextract?: (pool: Pool) => Promise<{ factBase: FactBase }>; +} + +interface Segment { + /** indexes into plan.actions, contiguous and in order */ + start: number; + end: number; // exclusive + transactional: boolean; +} + +/** Group actions into maximal transactional runs; nonTransactional actions + * run alone; a commitBoundaryAfter action UNCONDITIONALLY ends its + * transactional segment (its effect is unusable before COMMIT — ALTER TYPE … + * ADD VALUE — so nothing after it may share its transaction, regardless of + * graph-successor shape, review #6); newSegmentBefore forces a commit between + * two runs (used by compaction protection). */ +export function segmentActions( + actions: ReadonlyArray<{ + transactionality: + | "transactional" + | "nonTransactional" + | "commitBoundaryAfter"; + newSegmentBefore: boolean; + }>, +): Segment[] { + const segments: Segment[] = []; + let start = 0; + for (let i = 0; i < actions.length; i++) { + const action = actions[i]!; + if (action.transactionality === "nonTransactional") { + if (i > start) segments.push({ start, end: i, transactional: true }); + segments.push({ start: i, end: i + 1, transactional: false }); + start = i + 1; + } else if (action.transactionality === "commitBoundaryAfter") { + // close the transactional segment AFTER this action, unconditionally + segments.push({ start, end: i + 1, transactional: true }); + start = i + 1; + } else if (action.newSegmentBefore && i > start) { + segments.push({ start, end: i, transactional: true }); + start = i; + } + } + if (start < actions.length) { + segments.push({ start, end: actions.length, transactional: true }); + } + return segments; +} + +function errorEntry( + actionIndex: number, + sql: string, + error: unknown, +): NonNullable { + return { + actionIndex, + sql, + message: error instanceof Error ? error.message : String(error), + }; +} + +export async function apply( + thePlan: Plan, + target: Pool, + options?: ApplyOptions, +): Promise { + if (thePlan.formatVersion !== 1) { + throw new Error( + `apply: unsupported plan formatVersion ${String(thePlan.formatVersion)}`, + ); + } + if (thePlan.engineVersion !== ENGINE_VERSION) { + throw new Error( + `apply: plan was produced by engine ${thePlan.engineVersion}, this engine is ${ENGINE_VERSION} — re-plan`, + ); + } + if (options?.fingerprintGate !== false) { + // Gate against the SAME managed view the plan was produced from (P0-2). + // plan() fingerprints the resolveView'd source (extension-member + policy + + // capability + baseline projection), so the raw re-extract must be resolved + // the same way before comparing — otherwise an excluded object that is + // present on the real database (an extension's internals, a policy-scoped + // schema/role) reads as drift and rejects a valid scoped plan. + if ( + thePlan.policy?.baseline !== undefined && + options?.baseline === undefined + ) { + throw new Error( + `apply: plan was produced with policy "${thePlan.policy.id}" declaring baseline ` + + `"${thePlan.policy.baseline}", but no baseline was supplied to reconstruct the ` + + `fingerprint gate. Pass the resolved baseline as options.baseline, or skip the ` + + `gate with fingerprintGate:false if convergence was already proven.`, + ); + } + // re-extract the target with the SAME redaction mode the plan was + // fingerprinted with (Plan.redactSecrets, default true) — a custom + // `reextract` is trusted to already bake in the right mode (the CLI's + // profile-aware reextractors do); the bare default must be told + // explicitly, or a plan built from `extract({ redactSecrets: false })` + // state is spuriously rejected here (placeholder vs real secret hashes). + const current = await (options?.reextract + ? options.reextract(target) + : extract(target, { redactSecrets: thePlan.redactSecrets ?? true })); + // reconstruct the SAME managed-view-under-scope the plan fingerprinted + // (`reconstructManagedView` seals resolveView → scope; defaults cluster). + const view = reconstructManagedView(current.factBase, { + policy: thePlan.policy, + capability: thePlan.capability, + baseline: options?.baseline, + scope: thePlan.scope, + defaultOwner: thePlan.defaultOwner, + }); + // KNOWN PITFALL (acknowledged, by design): the fingerprint folds the WHOLE + // resolved view, INCLUDING `referenceOnly` assumed-schema facts (e.g. + // `auth.users` kept so a managed dependent resolves its parent). Those facts + // never produce a diff delta, but they DO move the fingerprint. So if the + // platform mutates an unmanaged assumed-schema object between plan and apply, + // this gate trips and asks for a re-plan even though the managed delta is + // unchanged. That is intentional: plan and apply must run against the SAME + // baseline for the plan to be provably applicable; if the baseline shifted, + // regenerating the plan is the correct, safe response (use fingerprintGate: + // false / --force only when convergence was already proven). Excluding + // referenceOnly facts from the fingerprint was considered (PR #307) and + // declined to keep this guarantee. See the same note on FactBase.rootHash. + if (view.rootHash !== thePlan.source.fingerprint) { + throw new Error( + `apply: fingerprint gate failed — the target's resolved state (${view.rootHash.slice(0, 12)}…) is not the plan's source (${thePlan.source.fingerprint.slice(0, 12)}…); re-plan against the current state`, + ); + } + } + + const statuses: ActionStatus[] = thePlan.actions.map(() => "unapplied"); + const segments = segmentActions(thePlan.actions); + let appliedActions = 0; + + const client = await target.connect(); + try { + const preamble = (local: boolean): string[] => [ + ...(options?.lockTimeoutMs !== undefined + ? [ + `SET ${local ? "LOCAL " : ""}lock_timeout = ${options.lockTimeoutMs}`, + ] + : []), + ...(options?.statementTimeoutMs !== undefined + ? [ + `SET ${local ? "LOCAL " : ""}statement_timeout = ${options.statementTimeoutMs}`, + ] + : []), + ...thePlan.preamble.map( + (s) => `SET ${local ? "LOCAL " : ""}${s.name} = ${s.value}`, + ), + ]; + + for (const segment of segments) { + if (!segment.transactional) { + // a lone non-transactional action; session-level settings, reset after + const index = segment.start; + const action = thePlan.actions[index]!; + try { + for (const sql of preamble(false)) await client.query(sql); + await client.query(action.sql); + } catch (error) { + // a failed non-transactional DDL is NOT safely unapplied — it can + // leave durable side effects (e.g. an INVALID index from a cancelled + // CREATE INDEX CONCURRENTLY). Report it inDoubt so the caller knows + // the database must be re-extracted before retry (review P1). + statuses[index] = "inDoubt"; + return { + status: "failed", + appliedActions, + actionStatuses: statuses, + error: errorEntry(index, action.sql, error), + }; + } finally { + // ALWAYS restore session state before the client returns to the pool, + // on success or failure — RESET ALL must not be skipped by the catch's + // early return, and a reset failure must not flip the action's outcome. + await client.query("RESET ALL").catch(() => {}); + } + statuses[index] = "applied"; + appliedActions += 1; + continue; + } + + try { + await client.query("BEGIN"); + for (const sql of preamble(true)) await client.query(sql); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + return { + status: "failed", + appliedActions, + actionStatuses: statuses, + error: errorEntry(segment.start, "BEGIN", error), + }; + } + for (let i = segment.start; i < segment.end; i++) { + const action = thePlan.actions[i]!; + try { + await client.query(action.sql); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + return { + status: "failed", + appliedActions, + actionStatuses: statuses, + error: errorEntry(i, action.sql, error), + }; + } + } + try { + await client.query("COMMIT"); + } catch (error) { + // the commit itself failed: the segment's fate is unknown + for (let i = segment.start; i < segment.end; i++) + statuses[i] = "inDoubt"; + await client.query("ROLLBACK").catch(() => {}); + return { + status: "failed", + appliedActions, + actionStatuses: statuses, + error: errorEntry(segment.start, "COMMIT", error), + }; + } + for (let i = segment.start; i < segment.end; i++) statuses[i] = "applied"; + appliedActions += segment.end - segment.start; + } + } finally { + client.release(); + } + return { + status: "applied", + appliedActions, + actionStatuses: statuses, + }; +} diff --git a/packages/pg-delta/src/apply/commit-boundary.test.ts b/packages/pg-delta/src/apply/commit-boundary.test.ts new file mode 100644 index 000000000..9fe66003e --- /dev/null +++ b/packages/pg-delta/src/apply/commit-boundary.test.ts @@ -0,0 +1,50 @@ +/** + * Unit tests for commitBoundaryAfter segmentation (hardening Item 5 / review + * #6). No Docker / database required. + * + * A `commitBoundaryAfter` action's effect is not usable before COMMIT (ALTER + * TYPE … ADD VALUE), so NOTHING after it may share its transaction — + * unconditionally, regardless of whether a consumer happens to be a graph + * successor (the fragile assumption the review flagged). + */ +import { describe, expect, test } from "bun:test"; +import { segmentActions } from "./apply.ts"; + +type A = { + transactionality: + | "transactional" + | "nonTransactional" + | "commitBoundaryAfter"; + newSegmentBefore: boolean; +}; +const t: A = { transactionality: "transactional", newSegmentBefore: false }; +const boundary: A = { + transactionality: "commitBoundaryAfter", + newSegmentBefore: false, +}; + +describe("segmentActions — commitBoundaryAfter closes its segment", () => { + const segOf = (segs: ReturnType, i: number) => + segs.findIndex((s) => i >= s.start && i < s.end); + + test("closes the segment even with no newSegmentBefore successor", () => { + const segs = segmentActions([t, boundary, t]); + // the boundary action ends the segment it shares with action 0; + // action 2 lands in a strictly later segment (a commit separates them) + expect(segOf(segs, 1)).toBe(segOf(segs, 0)); + expect(segOf(segs, 2)).not.toBe(segOf(segs, 1)); + expect(segs.every((s) => s.transactional)).toBe(true); + }); + + test("a trailing commitBoundaryAfter still gets a transactional segment", () => { + const segs = segmentActions([t, boundary]); + expect(segOf(segs, 1)).toBe(segOf(segs, 0)); + expect(segs).toHaveLength(1); + }); + + test("two boundaries in a row each close their own segment", () => { + const segs = segmentActions([boundary, boundary, t]); + expect(segOf(segs, 0)).not.toBe(segOf(segs, 1)); + expect(segOf(segs, 1)).not.toBe(segOf(segs, 2)); + }); +}); diff --git a/packages/pg-delta/src/cli/app.ts b/packages/pg-delta/src/cli/app.ts deleted file mode 100644 index ab60f0707..000000000 --- a/packages/pg-delta/src/cli/app.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { buildApplication, buildRouteMap } from "@stricli/core"; -import { applyCommand } from "./commands/apply.ts"; -import { catalogExportCommand } from "./commands/catalog-export.ts"; -import { declarativeApplyCommand } from "./commands/declarative-apply.ts"; -import { declarativeExportCommand } from "./commands/declarative-export.ts"; -import { planCommand } from "./commands/plan.ts"; -import { syncCommand } from "./commands/sync.ts"; - -const declarativeRouteMap = buildRouteMap({ - routes: { - apply: declarativeApplyCommand, - export: declarativeExportCommand, - }, - docs: { - brief: "Declarative schema management", - fullDescription: ` -Manage declarative SQL schemas. - -Commands: - apply - Apply a declarative SQL schema to a database - export - Export a declarative schema from a database diff - `.trim(), - }, -}); - -const root = buildRouteMap({ - routes: { - plan: planCommand, - apply: applyCommand, - sync: syncCommand, - declarative: declarativeRouteMap, - "catalog-export": catalogExportCommand, - }, - defaultCommand: "sync", - docs: { - brief: "PostgreSQL migrations made easy", - fullDescription: ` -pgdelta generates migration scripts by comparing two PostgreSQL databases. - -Commands: - plan - Compute schema diff and preview changes - apply - Apply a plan's migration script to a database - sync - Plan and apply changes in one go - declarative - Declarative schema (apply | export) - catalog-export - Export a database catalog as a snapshot JSON file - `.trim(), - }, -}); - -export const app = buildApplication(root, { - name: "pgdelta", -}); diff --git a/packages/pg-delta/src/cli/bin/cli.ts b/packages/pg-delta/src/cli/bin/cli.ts deleted file mode 100755 index f8c25c985..000000000 --- a/packages/pg-delta/src/cli/bin/cli.ts +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node - -import { run } from "@stricli/core"; -import { UnorderableCycleError } from "../../core/sort/unorderable-cycle-error.ts"; -import { app } from "../app.ts"; -import { getCommandExitCode } from "../exit-code.ts"; - -await run(app, process.argv.slice(2), { process }).catch((error) => { - if (error instanceof UnorderableCycleError) { - console.error(error.message); - console.error( - "pg-delta could not find a valid execution order for these changes. Please report this plan at https://github.com/supabase/pg-toolbelt/issues.", - ); - } else { - console.error(error); - } - process.exit(1); -}); - -const code = getCommandExitCode(); -if (code !== undefined) { - process.exitCode = code; -} diff --git a/packages/pg-delta/src/cli/commands/apply.ts b/packages/pg-delta/src/cli/commands/apply.ts index 4d126e309..d2667bfdb 100644 --- a/packages/pg-delta/src/cli/commands/apply.ts +++ b/packages/pg-delta/src/cli/commands/apply.ts @@ -1,101 +1,123 @@ /** - * Apply command - apply a plan's migration script to a target database. + * apply --plan --target [--force] + * + * Parse the plan artifact and apply it to the target database. + * --force disables the fingerprint gate. + * On failure, print the per-action failure report. */ +import { readFileSync } from "node:fs"; +import { parsePlan } from "../../plan/artifact.ts"; +import { apply } from "../../apply/apply.ts"; +import { makePool } from "../pool.ts"; +import { CliExit, parseFlags, UsageError } from "../flags.ts"; +import { + effectiveProfileId, + PROFILE_IDS, + reconcileBaselineDigest, + resolveCliProfile, +} from "../profile.ts"; -import { readFile } from "node:fs/promises"; -import { buildCommand, type CommandContext } from "@stricli/core"; -import { applyPlan } from "../../core/plan/apply.ts"; -import { deserializePlan, type Plan } from "../../core/plan/index.ts"; -import { handleApplyResult, validatePlanRisk } from "../utils.ts"; - -export const applyCommand = buildCommand({ - parameters: { - flags: { - plan: { - kind: "parsed", - brief: "Path to plan file (JSON format)", - parse: String, - }, - source: { - kind: "parsed", - brief: "Source database connection URL (current state)", - parse: String, - }, - target: { - kind: "parsed", - brief: "Target database connection URL (desired state)", - parse: String, - }, - unsafe: { - kind: "boolean", - brief: "Allow data-loss operations (unsafe mode)", - optional: true, - }, - }, - aliases: { - p: "plan", - s: "source", - t: "target", - u: "unsafe", - }, - }, - docs: { - brief: "Apply a plan's migration script to a database", - fullDescription: ` -Apply changes from a plan file to a target database. +export async function cmdApply(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + plan: { type: "value", required: true }, + target: { type: "value", required: true }, + profile: { type: "value" }, + force: { type: "boolean" }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError( + `${err.message}\nUsage: pgdelta apply --plan --target [--profile ${PROFILE_IDS}] [--force]`, + ); + } + throw err; + } -The plan file should be a JSON file created with "pgdelta plan --output .plan.json" (or any .plan/.json path). + const { flags } = parsed; + const planPath = flags["plan"]; + const targetUrl = flags["target"]; + const force = flags["force"]; -Safe by default: will refuse plans containing data-loss unless --unsafe is set. + const json = readFileSync(planPath, "utf8"); + const thePlan = parsePlan(json); -Exit codes: - 0 - Success (changes applied) - 1 - Error occurred - `.trim(), - }, - async func( - this: CommandContext, - flags: { - plan: string; - source: string; - target: string; - unsafe?: boolean; - }, - ) { - // Read and parse plan file - let planJson: string; - try { - planJson = await readFile(flags.plan, "utf-8"); - } catch (error) { - this.process.stderr.write( - `Error reading plan file: ${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; - return; - } + // The profile MUST match the one used to plan: it supplies the handler-aware + // re-extractor + baseline the fingerprint gate needs to reconstruct the same + // managed view (otherwise operational children on the target read as drift). + // Default to the profile stamped on the plan artifact; reject a contradicting + // --profile up front (before opening a connection) rather than failing + // indirectly through the gate. + // effectiveProfileId throws UsageError on a --profile that contradicts the + // plan artifact; it propagates to main() (→ message + exit 2). + const profileId: string | undefined = effectiveProfileId( + flags["profile"], + thePlan.profile?.id, + ); - let plan: Plan; - try { - plan = deserializePlan(planJson); - } catch (error) { - this.process.stderr.write( - `Error parsing plan file: ${error instanceof Error ? error.message : String(error)}\n`, + const tgt = makePool(targetUrl); + try { + if (force) { + process.stderr.write( + "WARNING: --force disables the fingerprint gate. Applying without state verification.\n", ); - process.exitCode = 1; - return; } + // Reconstruct the fingerprint with the SAME redaction mode the plan used + // (stamped on the artifact). Without this, an `--unsafe-show-secrets` plan + // fingerprinted over unredacted secrets is gated against a default-redacted + // re-extract and aborts unless `--force`. Absent on direct library plans → + // the extract default (redacted), matching the profile's default reextract. + // Passed into profile resolution so a profile-declared baseline captured in + // the other mode is rejected. + const redactSecrets = thePlan.redactSecrets ?? true; + const ctx = await resolveCliProfile(tgt.pool, profileId, { redactSecrets }); + // The baseline the profile resolves MUST match the one the plan was produced + // with, or apply reconstructs a different managed view and the fingerprint + // gate fails opaquely. Fail loud with a precise message instead (Codex #323 + // finding 1). --force still skips the fingerprint gate but not this check — + // a wrong baseline is a profile/artifact contradiction, not target drift. + reconcileBaselineDigest( + thePlan.baseline?.digest, + ctx.baseline?.digest, + "plan artifact", + ); + process.stderr.write(`Applying ${thePlan.actions.length} action(s)...\n`); - const validation = validatePlanRisk(plan, !!flags.unsafe, this); - if (!validation.valid) { - process.exitCode = validation.exitCode ?? 1; - return; - } - - const result = await applyPlan(plan, flags.source, flags.target, { - verifyPostApply: true, + const report = await apply(thePlan, tgt.pool, { + fingerprintGate: !force, + ...ctx.applyOptions, // reextract (handler-aware) + baseline + reextract: (p) => ctx.extract(p, { redactSecrets }), }); - const { exitCode } = handleApplyResult(result, this); - process.exitCode = exitCode; - }, -}); + if (report.status === "applied") { + process.stderr.write( + `Applied ${report.appliedActions} action(s) successfully.\n`, + ); + } else { + process.stderr.write(`Apply failed!\n`); + if (report.error) { + process.stderr.write( + ` action[${report.error.actionIndex}]: ${report.error.message}\n`, + ); + process.stderr.write(` sql: ${report.error.sql}\n`); + } + const applied = report.actionStatuses.filter( + (s) => s === "applied", + ).length; + const unapplied = report.actionStatuses.filter( + (s) => s === "unapplied", + ).length; + const inDoubt = report.actionStatuses.filter( + (s) => s === "inDoubt", + ).length; + process.stderr.write( + ` applied: ${applied} unapplied: ${unapplied} inDoubt: ${inDoubt}\n`, + ); + // main() maps CliExit(1) → exit 1; the finally still closes the pool. + throw new CliExit(1); + } + } finally { + await tgt.end(); + } +} diff --git a/packages/pg-delta/src/cli/commands/catalog-export.ts b/packages/pg-delta/src/cli/commands/catalog-export.ts deleted file mode 100644 index a43061c81..000000000 --- a/packages/pg-delta/src/cli/commands/catalog-export.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Catalog export command - extract a database catalog and save as a snapshot JSON file. - */ - -import { writeFile } from "node:fs/promises"; -import { buildCommand, type CommandContext } from "@stricli/core"; -import { filterCatalog } from "../../core/catalog.filter.ts"; -import { extractCatalog } from "../../core/catalog.model.ts"; -import { - serializeCatalog, - stringifyCatalogSnapshot, -} from "../../core/catalog.snapshot.ts"; -import type { FilterDSL } from "../../core/integrations/filter/dsl.ts"; -import { createManagedPool } from "../../core/postgres-config.ts"; - -export const catalogExportCommand = buildCommand({ - parameters: { - flags: { - target: { - kind: "parsed", - brief: "Target database connection URL to extract the catalog from", - parse: String, - }, - output: { - kind: "parsed", - brief: "Output file path for the catalog snapshot JSON", - parse: String, - }, - role: { - kind: "parsed", - brief: "Role to use when extracting the catalog (SET ROLE)", - parse: String, - optional: true, - }, - filter: { - kind: "parsed", - brief: - 'Filter DSL as inline JSON to filter changes (e.g., \'{"*/schema": "app"}\').', - parse: (value: string): FilterDSL => { - try { - return JSON.parse(value) as FilterDSL; - } catch (error) { - throw new Error( - `Invalid filter JSON: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - optional: true, - }, - }, - aliases: { - t: "target", - o: "output", - }, - }, - docs: { - brief: "Export a database catalog as a snapshot JSON file", - fullDescription: ` -Extract the full catalog from a live PostgreSQL database and save it -as a JSON snapshot file. The snapshot can later be used as --source or ---target for the plan and declarative export commands, enabling -offline diffing without a live database connection. - -Use cases: - - Snapshot template1 for use as an empty-database baseline - - Snapshot a production database to generate revert migrations - - Snapshot any state for reproducible offline diffs - -Pass --filter to scope the snapshot to a subset of the catalog (same -Filter DSL accepted by plan/sync). Useful when committing a baseline -snapshot to a repo and only one schema's drift is interesting. - `.trim(), - }, - async func( - this: CommandContext, - flags: { - target: string; - output: string; - role?: string; - filter?: FilterDSL; - }, - ) { - const { pool, close } = await createManagedPool(flags.target, { - role: flags.role, - label: "target", - }); - - try { - const catalog = await extractCatalog(pool); - const scoped = flags.filter - ? await filterCatalog(catalog, flags.filter) - : catalog; - const snapshot = serializeCatalog(scoped); - const json = stringifyCatalogSnapshot(snapshot); - await writeFile(flags.output, json, "utf-8"); - this.process.stdout.write( - `Catalog snapshot written to ${flags.output}\n`, - ); - } finally { - await close(); - } - }, -}); diff --git a/packages/pg-delta/src/cli/commands/collect-sql-files.test.ts b/packages/pg-delta/src/cli/commands/collect-sql-files.test.ts new file mode 100644 index 000000000..c558b3efa --- /dev/null +++ b/packages/pg-delta/src/cli/commands/collect-sql-files.test.ts @@ -0,0 +1,266 @@ +/** + * collectSqlFiles must derive relative names from the NORMALIZED root, so a + * trailing slash (or other non-normalized --dir) does not drop the first + * character of every name and corrupt the raw loader's lexicographic order + * (PR #307 review P2). No DB. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { readExportManifest } from "../../frontends/export-manifest.ts"; +import { + collectSqlFiles, + prepareApplyFiles, + writeExportFiles, +} from "./schema.ts"; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "pgdn-collect-")); + mkdirSync(join(root, "schemas", "app"), { recursive: true }); + writeFileSync(join(root, "01_schema.sql"), "CREATE SCHEMA app;\n"); + writeFileSync(join(root, "10_table.sql"), "CREATE TABLE app.t (id int);\n"); + writeFileSync(join(root, "schemas", "app", "x.sql"), "SELECT 1;\n"); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("collectSqlFiles", () => { + for (const suffix of ["", "/"]) { + test(`derives correct names with dir suffix ${JSON.stringify(suffix)}`, () => { + const names = collectSqlFiles(root + suffix) + .map((f) => f.name) + .sort(); + // RED before the fix (trailing slash): names lost their first char, e.g. + // "1_schema.sql", reordering 01_ vs 10_ against 0_table.sql. + expect(names).toEqual([ + "01_schema.sql", + "10_table.sql", + join("schemas", "app", "x.sql"), + ]); + }); + } +}); + +describe("writeExportFiles", () => { + test("creates a brand-new root and writes the manifest (with an empty files list) even with zero files", () => { + // a DB with no managed objects yields zero files; the per-file loop would + // never create outRoot, so the manifest write must create it first (review P2). + const target = join(root, "nested", "brand-new"); + const { removed, unmanaged } = writeExportFiles( + target, + [], + { + redactSecrets: true, + profile: "raw", + }, + false, + ); + expect(removed).toEqual([]); + expect(unmanaged).toEqual([]); + expect(existsSync(join(target, ".pgdelta-export.json"))).toBe(true); + expect(readExportManifest(target)).toEqual({ + redactSecrets: true, + profile: "raw", + files: [], + }); + }); + + test("records the owned files list as sorted POSIX relative paths", () => { + const target = join(root, "sorted"); + const { removed } = writeExportFiles( + target, + [ + { name: join("schemas", "app", "b.sql"), sql: "-- b\n" }, + { name: join("schemas", "app", "a.sql"), sql: "-- a\n" }, + { name: join("cluster", "roles.sql"), sql: "-- roles\n" }, + ], + { redactSecrets: false }, + false, + ); + expect(removed).toEqual([]); + expect(readExportManifest(target)?.files).toEqual([ + "cluster/roles.sql", + "schemas/app/a.sql", + "schemas/app/b.sql", + ]); + }); + + test("prunes a previously-owned file that dropped out of the new set", () => { + const target = join(root, "reexport"); + // first export owns t.sql AND gone.sql + writeExportFiles( + target, + [ + { + name: join("schemas", "app", "t.sql"), + sql: "CREATE TABLE app.t ();\n", + }, + { name: join("schemas", "app", "gone.sql"), sql: "-- gone\n" }, + ], + { redactSecrets: false }, + false, + ); + // re-export drops gone.sql: it was owned, so it is pruned with no error + const { removed, unmanaged } = writeExportFiles( + target, + [ + { + name: join("schemas", "app", "t.sql"), + sql: "CREATE TABLE app.t ();\n", + }, + ], + { redactSecrets: false }, + false, + ); + expect(removed).toEqual([join(target, "schemas", "app", "gone.sql")]); + expect(unmanaged).toEqual([]); + expect(existsSync(join(target, "schemas", "app", "t.sql"))).toBe(true); + expect(existsSync(join(target, "schemas", "app", "gone.sql"))).toBe(false); + }); + + test("throws (and preserves the file) on an unmanaged .sql not previously owned", () => { + const target = join(root, "handauthored"); + mkdirSync(join(target, "schemas", "app"), { recursive: true }); + writeFileSync( + join(target, "schemas", "app", "handwritten.sql"), + "-- hand-authored\n", + ); + expect(() => + writeExportFiles( + target, + [ + { + name: join("schemas", "app", "t.sql"), + sql: "CREATE TABLE app.t ();\n", + }, + ], + { redactSecrets: false }, + false, + ), + ).toThrow(/handwritten\.sql[\s\S]*--prune-unmanaged/); + // the unmanaged file survives, and no new file / manifest was written + expect(existsSync(join(target, "schemas", "app", "handwritten.sql"))).toBe( + true, + ); + expect(existsSync(join(target, "schemas", "app", "t.sql"))).toBe(false); + expect(existsSync(join(target, ".pgdelta-export.json"))).toBe(false); + }); + + test("--prune-unmanaged deletes the unmanaged file and proceeds", () => { + const target = join(root, "handauthored2"); + mkdirSync(join(target, "schemas", "app"), { recursive: true }); + writeFileSync( + join(target, "schemas", "app", "handwritten.sql"), + "-- hand-authored\n", + ); + const { removed, unmanaged } = writeExportFiles( + target, + [ + { + name: join("schemas", "app", "t.sql"), + sql: "CREATE TABLE app.t ();\n", + }, + ], + { redactSecrets: false }, + true, + ); + expect(removed).toEqual([ + join(target, "schemas", "app", "handwritten.sql"), + ]); + expect(unmanaged).toEqual([]); + expect(existsSync(join(target, "schemas", "app", "handwritten.sql"))).toBe( + false, + ); + expect(existsSync(join(target, "schemas", "app", "t.sql"))).toBe(true); + expect(existsSync(join(target, ".pgdelta-export.json"))).toBe(true); + }); +}); + +describe("prepareApplyFiles", () => { + function dirWith(files: Record): string { + const d = mkdtempSync(join(tmpdir(), "pgdn-prepare-")); + for (const [name, sql] of Object.entries(files)) { + const p = join(d, name); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, sql); + } + return d; + } + + test("refuses an all-cluster-DDL dir after --skip-cluster-ddl (would drop everything)", () => { + // The up-front executable-SQL guard passes on the ORIGINAL role DDL, but + // --skip-cluster-ddl strips it to nothing → empty shadow → destructive + // drop-all of every managed object. It must be refused after stripping. + const d = dirWith({ + "roles.sql": "CREATE ROLE app;\nALTER ROLE app WITH LOGIN;\n", + }); + try { + const r = prepareApplyFiles(d, "database", true); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.message).toContain("no executable database-scope SQL remains"); + } + } finally { + rmSync(d, { recursive: true, force: true }); + } + }); + + test("keeps the non-cluster SQL when --skip-cluster-ddl leaves real statements", () => { + const d = dirWith({ + "1.sql": "CREATE ROLE app;\nCREATE TABLE public.t (id int);\n", + }); + try { + const r = prepareApplyFiles(d, "database", true); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.skipped.length).toBeGreaterThan(0); + expect(r.files.map((f) => f.sql).join("")).toContain("CREATE TABLE"); + } + } finally { + rmSync(d, { recursive: true, force: true }); + } + }); + + test("refuses cluster DDL in database scope without --skip-cluster-ddl", () => { + const d = dirWith({ + "roles.sql": "CREATE ROLE app;\n", + "t.sql": "CREATE TABLE public.t (id int);\n", + }); + try { + const r = prepareApplyFiles(d, "database", false); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toContain("cluster DDL"); + } finally { + rmSync(d, { recursive: true, force: true }); + } + }); + + test("refuses an empty / comment-only dir", () => { + const d = dirWith({ "c.sql": "-- just a comment\n" }); + try { + const r = prepareApplyFiles(d, "database", false); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toContain("no executable SQL found"); + } finally { + rmSync(d, { recursive: true, force: true }); + } + }); + + test("accepts a normal database-scope dir", () => { + const d = dirWith({ "t.sql": "CREATE TABLE public.t (id int);\n" }); + try { + expect(prepareApplyFiles(d, "database", false).ok).toBe(true); + } finally { + rmSync(d, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/pg-delta/src/cli/commands/declarative-apply.diagnostics.test.ts b/packages/pg-delta/src/cli/commands/declarative-apply.diagnostics.test.ts deleted file mode 100644 index 1da406225..000000000 --- a/packages/pg-delta/src/cli/commands/declarative-apply.diagnostics.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Diagnostic } from "@supabase/pg-topo"; -import { - buildDiagnosticDisplayItems, - type DiagnosticDisplayEntry, -} from "../utils/apply-display.ts"; - -const unresolvedDiagnostic = (message: string): Diagnostic => ({ - code: "UNRESOLVED_DEPENDENCY", - message, - details: { - requiredObjectKey: "function:public:json_build_object:(unknown,unknown)", - }, - suggestedFix: "Add the missing statement.", -}); - -describe("declarative apply diagnostic display grouping", () => { - test("grouped mode collapses repeated unresolved diagnostics", () => { - const entries: DiagnosticDisplayEntry[] = [ - { - diagnostic: unresolvedDiagnostic( - "No producer found for 'function:public:json_build_object:(unknown,unknown)'.", - ), - location: "schemas/public/tables/user.sql:38:1", - requiredObjectKey: - "function:public:json_build_object:(unknown,unknown)", - }, - { - diagnostic: unresolvedDiagnostic( - "No producer found for 'function:public:json_build_object:(unknown,unknown)'.", - ), - location: "schemas/public/tables/user.sql:45:1", - requiredObjectKey: - "function:public:json_build_object:(unknown,unknown)", - }, - ]; - - const displayItems = buildDiagnosticDisplayItems(entries, true); - - expect(displayItems).toHaveLength(1); - expect(displayItems[0]?.locations).toEqual([ - "schemas/public/tables/user.sql:38:1", - "schemas/public/tables/user.sql:45:1", - ]); - }); - - test("ungrouped mode keeps full per-diagnostic detail", () => { - const entries: DiagnosticDisplayEntry[] = [ - { - diagnostic: unresolvedDiagnostic( - "No producer found for 'function:public:json_build_object:(unknown,unknown)'.", - ), - location: "schemas/public/tables/user.sql:38:1", - requiredObjectKey: - "function:public:json_build_object:(unknown,unknown)", - }, - { - diagnostic: unresolvedDiagnostic( - "No producer found for 'function:public:json_build_object:(unknown,unknown)'.", - ), - location: "schemas/public/tables/user.sql:45:1", - requiredObjectKey: - "function:public:json_build_object:(unknown,unknown)", - }, - ]; - - const displayItems = buildDiagnosticDisplayItems(entries, false); - - expect(displayItems).toHaveLength(2); - expect(displayItems[0]?.locations).toEqual([ - "schemas/public/tables/user.sql:38:1", - ]); - expect(displayItems[1]?.locations).toEqual([ - "schemas/public/tables/user.sql:45:1", - ]); - }); -}); diff --git a/packages/pg-delta/src/cli/commands/declarative-apply.ts b/packages/pg-delta/src/cli/commands/declarative-apply.ts deleted file mode 100644 index 8fdb8ce6b..000000000 --- a/packages/pg-delta/src/cli/commands/declarative-apply.ts +++ /dev/null @@ -1,380 +0,0 @@ -/** - * Declarative-apply command - apply a declarative SQL schema to a database - * using pg-topo static analysis + round-based execution. - */ - -import { readFile } from "node:fs/promises"; -import { buildCommand, type CommandContext } from "@stricli/core"; -import chalk from "chalk"; -import { loadDeclarativeSchema } from "../../core/declarative-apply/discover-sql.ts"; -import { - applyDeclarativeSchema, - type DeclarativeApplyResult, - type RoundResult, -} from "../../core/declarative-apply/index.ts"; -import { - buildDiagnosticDisplayItems, - type DiagnosticDisplayEntry, - formatStatementError, - positionToLineColumn, - requiredObjectKeyFromDiagnostic, - resolveSqlFilePath, -} from "../utils/apply-display.ts"; - -export const declarativeApplyCommand = buildCommand({ - parameters: { - flags: { - path: { - kind: "parsed", - brief: - "Path to the declarative schema directory (containing .sql files) or a single .sql file", - parse: String, - }, - target: { - kind: "parsed", - brief: "Target database connection URL to apply the schema to", - parse: String, - }, - "max-rounds": { - kind: "parsed", - brief: - "Maximum number of application rounds before giving up (default: 100)", - parse: Number, - optional: true, - }, - "skip-function-validation": { - kind: "boolean", - brief: "Skip final function body validation pass", - optional: true, - }, - verbose: { - kind: "boolean", - brief: "Show detailed per-round progress", - optional: true, - }, - "ungroup-diagnostics": { - kind: "boolean", - brief: - "Show full per-diagnostic detail instead of grouped summary output", - optional: true, - }, - }, - aliases: { - p: "path", - t: "target", - v: "verbose", - }, - }, - docs: { - brief: "Apply a declarative SQL schema to a database", - fullDescription: ` -Apply SQL files from a declarative schema directory to a target database. - -Uses pg-topo for static dependency analysis and topological ordering, -then applies statements round-by-round to handle any remaining -dependency gaps. Statements that fail with dependency errors are -deferred to subsequent rounds until all succeed or no progress is made. - -Function body checks are disabled during rounds to avoid false failures -from functions referencing not-yet-created objects. A final validation -pass re-runs all function/procedure definitions with body checks enabled. - -Exit codes: - 0 - Success (all statements applied) - 1 - Error (hard failures or validation errors) - 2 - Stuck (dependency cycle or unresolvable ordering) - -Tip: Use DEBUG=pg-delta:declarative-apply for detailed defer/skip/fail logs (which statements are deferred and why). - `.trim(), - }, - async func( - this: CommandContext, - flags: { - path: string; - target: string; - "max-rounds"?: number; - "skip-function-validation"?: boolean; - verbose?: boolean; - "ungroup-diagnostics"?: boolean; - }, - ) { - const verbose = !!flags.verbose; - const ungroupDiagnostics = !!flags["ungroup-diagnostics"]; - - const onRoundComplete = verbose - ? (round: RoundResult) => { - const parts = [ - `Round ${round.round}:`, - chalk.green(`${round.applied} applied`), - ]; - if (round.deferred > 0) { - parts.push(chalk.yellow(`${round.deferred} deferred`)); - } - if (round.failed > 0) { - parts.push(chalk.red(`${round.failed} failed`)); - } - this.process.stdout.write(`${parts.join(" ")}\n`); - } - : undefined; - - this.process.stdout.write(`Analyzing SQL files in ${flags.path}...\n`); - - let content: Array<{ filePath: string; sql: string }>; - try { - content = await loadDeclarativeSchema(flags.path); - } catch (error) { - this.process.stderr.write( - `Error: ${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; - return; - } - - if (content.length === 0) { - this.process.stderr.write( - `No .sql files found in '${flags.path}'. Pass a directory containing .sql files or a single .sql file.\n`, - ); - process.exitCode = 1; - return; - } - - let result: DeclarativeApplyResult; - try { - result = await applyDeclarativeSchema({ - content, - targetUrl: flags.target, - maxRounds: flags["max-rounds"], - validateFunctionBodies: !flags["skip-function-validation"], - onRoundComplete, - }); - } catch (error) { - this.process.stderr.write( - `Error: ${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; - return; - } - - // Report pg-topo diagnostics grouped by severity (least noisy first). - // UNKNOWN_STATEMENT_CLASS is always hidden; DUPLICATE_PRODUCER, - // CYCLE_EDGE_SKIPPED, and UNRESOLVED_DEPENDENCY are verbose-only. - const diagnosticDisplayOrder: Record = { - UNKNOWN_STATEMENT_CLASS: 0, - DUPLICATE_PRODUCER: 1, - CYCLE_EDGE_SKIPPED: 2, - UNRESOLVED_DEPENDENCY: 3, - }; - const diagnosticColor: Record string> = { - DUPLICATE_PRODUCER: chalk.yellow, - CYCLE_EDGE_SKIPPED: chalk.red, - UNRESOLVED_DEPENDENCY: chalk.dim, - }; - const verboseOnlyCodes = new Set([ - "UNRESOLVED_DEPENDENCY", - "DUPLICATE_PRODUCER", - "CYCLE_EDGE_SKIPPED", - ]); - const warnings = result.diagnostics - .filter( - (d) => - d.code !== "UNKNOWN_STATEMENT_CLASS" && - (verbose || !verboseOnlyCodes.has(d.code)), - ) - .sort( - (a, b) => - (diagnosticDisplayOrder[a.code] ?? 99) - - (diagnosticDisplayOrder[b.code] ?? 99), - ); - if (warnings.length > 0 && verbose) { - const fileContentCache = new Map(); - for (const diag of warnings) { - const id = diag.statementId; - if ( - id && - id.sourceOffset != null && - id.filePath && - !fileContentCache.has(id.filePath) - ) { - // Try to resolve the exact file path of the statement to get the exact location of the error - try { - const fullPath = await resolveSqlFilePath(flags.path, id.filePath); - const content = await readFile(fullPath, "utf-8"); - fileContentCache.set(id.filePath, content); - } catch { - // Fall back to statementIndex display - } - } - } - - this.process.stderr.write( - chalk.yellow( - `\n${warnings.length} diagnostic(s) from static analysis:\n`, - ), - ); - const entries: DiagnosticDisplayEntry[] = warnings.map((diag) => { - let location: string | undefined; - if (diag.statementId) { - const id = diag.statementId; - const offset = id.sourceOffset; - const content = - offset != null ? fileContentCache.get(id.filePath) : undefined; - if (content != null && offset != null) { - const { line, column } = positionToLineColumn(content, offset + 1); - location = `${id.filePath}:${line}:${column}`; - } else { - location = `${id.filePath}:${id.statementIndex}`; - } - } - return { - diagnostic: diag, - location, - requiredObjectKey: requiredObjectKeyFromDiagnostic(diag), - }; - }); - const displayItems = buildDiagnosticDisplayItems( - entries, - !ungroupDiagnostics, - ); - - let lastCode = ""; - const previewLimit = 5; - for (const item of displayItems) { - if (item.code !== lastCode) { - if (lastCode !== "") { - this.process.stderr.write("\n"); - } - lastCode = item.code; - } - const colorFn = diagnosticColor[item.code] ?? chalk.yellow; - const location = - item.locations.length > 0 ? ` (${item.locations[0]})` : ""; - const occurrences = - !ungroupDiagnostics && item.locations.length > 1 - ? ` x${item.locations.length}` - : ""; - this.process.stderr.write( - colorFn( - ` [${item.code}]${location}${occurrences} ${item.message}\n`, - ), - ); - if (!ungroupDiagnostics && item.requiredObjectKey) { - this.process.stderr.write( - colorFn(` -> Object: ${item.requiredObjectKey}\n`), - ); - } - if (!ungroupDiagnostics && item.locations.length > 1) { - for (const locationEntry of item.locations.slice(0, previewLimit)) { - this.process.stderr.write(colorFn(` at ${locationEntry}\n`)); - } - const remaining = item.locations.length - previewLimit; - if (remaining > 0) { - this.process.stderr.write( - colorFn(` ... and ${remaining} more location(s)\n`), - ); - } - } - if (item.suggestedFix) { - this.process.stderr.write( - colorFn(` -> Fix: ${item.suggestedFix}\n`), - ); - } - } - this.process.stderr.write("\n"); - } - - const { apply } = result; - - // Summary - this.process.stdout.write("\n"); - this.process.stdout.write( - `Statements: ${result.totalStatements} total, ${apply.totalApplied} applied`, - ); - if (apply.totalSkipped > 0) { - this.process.stdout.write(`, ${apply.totalSkipped} skipped`); - } - this.process.stdout.write("\n"); - this.process.stdout.write(`Rounds: ${apply.totalRounds}\n`); - - switch (apply.status) { - case "success": { - this.process.stdout.write( - chalk.green("All statements applied successfully.\n"), - ); - if (apply.validationErrors && apply.validationErrors.length > 0) { - this.process.stderr.write( - chalk.yellow( - `\n${apply.validationErrors.length} function body validation error(s):\n`, - ), - ); - for (const err of apply.validationErrors) { - const formatted = await formatStatementError(err, flags.path); - this.process.stderr.write(chalk.yellow(formatted)); - this.process.stderr.write("\n\n"); - } - process.exitCode = 1; - } else { - process.exitCode = 0; - } - break; - } - - case "stuck": { - this.process.stderr.write( - chalk.red( - `\nStuck after ${apply.totalRounds} round(s). ${apply.stuckStatements?.length ?? 0} statement(s) could not be applied:\n`, - ), - ); - if (apply.stuckStatements) { - for (const stuck of apply.stuckStatements) { - const formatted = await formatStatementError(stuck, flags.path); - this.process.stderr.write(chalk.red(formatted)); - this.process.stderr.write("\n\n"); - } - } - if (apply.errors && apply.errors.length > 0) { - this.process.stderr.write( - chalk.red( - `\nAdditionally, ${apply.errors.length} statement(s) had non-dependency errors:\n`, - ), - ); - for (const err of apply.errors) { - const formatted = await formatStatementError(err, flags.path); - this.process.stderr.write(chalk.red(formatted)); - this.process.stderr.write("\n\n"); - } - } - process.exitCode = 2; - break; - } - - case "error": { - this.process.stderr.write( - chalk.red( - `\nCompleted with errors. ${apply.errors?.length ?? 0} statement(s) failed:\n`, - ), - ); - if (apply.errors) { - for (const err of apply.errors) { - const formatted = await formatStatementError(err, flags.path); - this.process.stderr.write(chalk.red(formatted)); - this.process.stderr.write("\n\n"); - } - } - if (apply.validationErrors && apply.validationErrors.length > 0) { - this.process.stderr.write( - chalk.yellow( - `\n${apply.validationErrors.length} function body validation error(s):\n`, - ), - ); - for (const err of apply.validationErrors) { - const formatted = await formatStatementError(err, flags.path); - this.process.stderr.write(chalk.yellow(formatted)); - this.process.stderr.write("\n\n"); - } - } - process.exitCode = 1; - break; - } - } - }, -}); diff --git a/packages/pg-delta/src/cli/commands/declarative-export.ts b/packages/pg-delta/src/cli/commands/declarative-export.ts deleted file mode 100644 index ab7970f12..000000000 --- a/packages/pg-delta/src/cli/commands/declarative-export.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Declarative export command - export a declarative SQL schema from a database diff. - */ - -import { mkdir, rm, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { buildCommand, type CommandContext } from "@stricli/core"; -import chalk from "chalk"; -import { deserializeCatalog } from "../../core/catalog.snapshot.ts"; -import { exportDeclarativeSchema } from "../../core/export/index.ts"; -import type { Grouping, GroupingPattern } from "../../core/export/types.ts"; -import type { FilterDSL } from "../../core/integrations/filter/dsl.ts"; -import { - compileSerializeDSL, - type SerializeDSL, -} from "../../core/integrations/serialize/dsl.ts"; -import { createPlan } from "../../core/plan/index.ts"; -import type { SqlFormatOptions } from "../../core/plan/sql-format.ts"; -import { - assertSafePath, - buildFileTree, - computeFileDiff, - formatExportSummary, -} from "../utils/export-display.ts"; -import { resolveIntegrationOptions } from "../utils/integrations.ts"; -import { isPostgresUrl, loadCatalogFromFile } from "../utils/resolve-input.ts"; - -function parseJsonFlag(label: string, value: string): T { - try { - return JSON.parse(value) as T; - } catch (error) { - throw new Error( - `Invalid ${label} JSON: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -export const declarativeExportCommand = buildCommand({ - parameters: { - flags: { - source: { - kind: "parsed", - brief: - "Source (current state): postgres URL or catalog snapshot file path. Omit to export all objects from target.", - parse: String, - optional: true, - }, - target: { - kind: "parsed", - brief: - "Target (desired state): postgres URL or catalog snapshot file path", - parse: String, - }, - output: { - kind: "parsed", - brief: "Output directory path for declarative schema files", - parse: String, - }, - integration: { - kind: "parsed", - brief: - "Integration name (e.g., 'supabase') or path to integration JSON file", - parse: String, - optional: true, - }, - filter: { - kind: "parsed", - brief: 'Filter DSL as inline JSON (e.g., \'{"schema":"public"}\')', - parse: (v: string) => parseJsonFlag("filter", v), - optional: true, - }, - serialize: { - kind: "parsed", - brief: - 'Serialize DSL as inline JSON array (e.g., \'[{"when":{"type":"schema"},"options":{"skipAuthorization":true}}]\')', - parse: (v: string) => parseJsonFlag("serialize", v), - optional: true, - }, - "grouping-mode": { - kind: "enum", - brief: "How grouped entities are organized on disk", - values: ["single-file", "subdirectory"] as const, - optional: true, - }, - "group-patterns": { - kind: "parsed", - brief: - 'JSON array of {pattern, name} objects (e.g., \'[{"pattern":"^auth","name":"auth"}]\')', - parse: (v: string) => { - const parsed = parseJsonFlag("group-patterns", v); - if (!Array.isArray(parsed)) { - throw new Error("group-patterns must be a JSON array"); - } - return parsed; - }, - optional: true, - }, - "flat-schemas": { - kind: "parsed", - brief: - "Comma-separated list of schemas to flatten (e.g., partman,pgboss,audit)", - parse: String, - optional: true, - }, - "format-options": { - kind: "parsed", - brief: - 'SQL format options as inline JSON (e.g., \'{"keywordCase":"lower","maxWidth":180}\')', - parse: (v: string) => - parseJsonFlag("format-options", v), - optional: true, - }, - force: { - kind: "boolean", - brief: "Remove entire output directory before writing", - optional: true, - }, - "dry-run": { - kind: "boolean", - brief: "Show tree and summary without writing files", - optional: true, - }, - "diff-focus": { - kind: "boolean", - brief: - "Show only files that changed (created/updated/deleted) in the tree", - optional: true, - }, - verbose: { - kind: "boolean", - brief: "Show detailed output", - optional: true, - }, - }, - aliases: { - s: "source", - t: "target", - o: "output", - }, - }, - docs: { - brief: "Export a declarative schema from a database diff", - fullDescription: ` -Export a declarative SQL schema by comparing two databases (source → target). -Writes .sql files to the output directory, grouped by object type and optional -grouping rules. - -When --source is omitted, all objects from the target database are exported -(equivalent to diffing from an empty database). - -Flags: - source - Source database connection URL (optional; omit for full export) - target - Target database connection URL (desired state) - output - Directory path for generated .sql files - integration - Integration name or path (e.g., supabase) for filter/serialize - filter - Filter DSL as JSON to include/exclude changes - serialize - Serialize DSL as JSON array for custom SQL generation - grouping-mode - single-file or subdirectory for grouped entities - group-patterns - JSON array of {pattern, name} for name-based grouping - flat-schemas - Comma-separated schemas to merge into one file per category - format-options - SQL format options as JSON - force - Remove output directory before writing (full replace) - dry-run - Show tree and summary only, do not write files - diff-focus - Show only changed files (created/updated/deleted) in the tree - verbose - Show detailed output - -After export, a tip is printed with the command to apply the schema to an empty database. - `.trim(), - }, - async func( - this: CommandContext, - flags: { - source?: string; - target: string; - output: string; - integration?: string; - filter?: FilterDSL; - serialize?: SerializeDSL; - "grouping-mode"?: "single-file" | "subdirectory"; - "group-patterns"?: GroupingPattern[]; - "flat-schemas"?: string; - "format-options"?: SqlFormatOptions; - force?: boolean; - "dry-run"?: boolean; - "diff-focus"?: boolean; - verbose?: boolean; - }, - ) { - const { - filter, - serialize, - emptyCatalog: integrationEmptyCatalog, - } = await resolveIntegrationOptions({ - filter: flags.filter, - serialize: flags.serialize, - integration: flags.integration, - }); - - const resolvedSource = flags.source - ? isPostgresUrl(flags.source) - ? flags.source - : await loadCatalogFromFile(flags.source) - : integrationEmptyCatalog - ? deserializeCatalog(integrationEmptyCatalog) - : null; - - const resolvedTarget = isPostgresUrl(flags.target) - ? flags.target - : await loadCatalogFromFile(flags.target); - - // Pass raw DSL to createPlan (not pre-compiled functions). - // createPlan compiles them internally and uses the DSL type to correctly - // determine cascade behavior: DSL filters disable cascading by default - // (unless cascade:true is set), preventing unintended exclusion of - // changes that depend on filtered objects (e.g. RLS policies that - // reference auth.uid() when the auth schema is filtered out). - const planResult = await createPlan(resolvedSource, resolvedTarget, { - filter, - serialize, - skipDefaultPrivilegeSubtraction: true, - }); - - if (!planResult) { - this.process.stdout.write("No changes detected.\n"); - return; - } - - const hasGrouping = - flags["grouping-mode"] !== undefined || - (flags["group-patterns"] !== undefined && - flags["group-patterns"].length > 0) || - (flags["flat-schemas"] !== undefined && flags["flat-schemas"].length > 0); - - let grouping: Grouping | undefined; - if (hasGrouping) { - grouping = { - mode: flags["grouping-mode"] ?? "single-file", - groupPatterns: flags["group-patterns"], - autoGroupPartitions: true, - flatSchemas: - flags["flat-schemas"] !== undefined - ? flags["flat-schemas"] - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - : undefined, - }; - } - - const serializeFn = - serialize !== undefined ? compileSerializeDSL(serialize) : undefined; - - const output = exportDeclarativeSchema(planResult, { - integration: - serializeFn !== undefined ? { serialize: serializeFn } : undefined, - formatOptions: flags["format-options"] ?? undefined, - grouping, - onWarning: (msg) => { - this.process.stderr.write(chalk.yellow(`Warning: ${msg}\n`)); - }, - }); - - const outputDir = path.resolve(flags.output); - const applyTip = (dir: string) => - `\nTip: To apply this schema to an empty database, run:\n pgdelta declarative apply --path ${dir} --target \n`; - const diff = await computeFileDiff(outputDir, output.files); - - this.process.stdout.write("\n"); - this.process.stdout.write( - `${buildFileTree( - output.files.map((f) => f.path), - path.basename(outputDir) || outputDir, - { diff, diffFocus: !!flags["diff-focus"] }, - )}\n`, - ); - this.process.stdout.write("\n"); - this.process.stdout.write( - `${chalk.green("+")} created ${chalk.yellow("~")} updated ${chalk.red("-")} deleted\n`, - ); - this.process.stdout.write("\n"); - - const summary = formatExportSummary(diff, !!flags["dry-run"]); - if (summary) { - this.process.stdout.write(`${summary}\n`); - } - - const totalChanges = planResult.sortedChanges.length; - const totalStatements = output.files.reduce((s, f) => s + f.statements, 0); - this.process.stdout.write( - `Changes: ${totalChanges} | Files: ${output.files.length} | Statements: ${totalStatements}\n`, - ); - - if (flags["dry-run"]) { - this.process.stdout.write(chalk.dim("\n(dry-run: no files written)\n")); - this.process.stdout.write(chalk.cyan(applyTip(outputDir))); - return; - } - - if (flags.force) { - await rm(outputDir, { recursive: true, force: true }); - await mkdir(outputDir, { recursive: true }); - } else if (diff.deleted.length > 0) { - this.process.stderr.write( - chalk.yellow( - `Warning: ${diff.deleted.length} existing file(s) will no longer be present. Use --force to replace the output directory.\n`, - ), - ); - } - - for (const file of output.files) { - assertSafePath(file.path, outputDir); - const filePath = path.join(outputDir, file.path); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, file.sql); - } - - this.process.stdout.write( - chalk.green(`Wrote ${output.files.length} file(s) to ${outputDir}\n`), - ); - this.process.stdout.write(chalk.cyan(applyTip(outputDir))); - }, -}); diff --git a/packages/pg-delta/src/cli/commands/diff.ts b/packages/pg-delta/src/cli/commands/diff.ts new file mode 100644 index 000000000..742c111de --- /dev/null +++ b/packages/pg-delta/src/cli/commands/diff.ts @@ -0,0 +1,137 @@ +/** + * diff --source --desired + * Print a delta summary grouped by verb and kind. + */ +import { diff } from "../../core/diff.ts"; +import { encodeId } from "../../core/stable-id.ts"; +import { resolveView } from "../../policy/policy.ts"; +import { exitIfBlocking, printDiagnostics } from "../diagnostics.ts"; +import { makePool } from "../pool.ts"; +import { parseFlags, UsageError } from "../flags.ts"; +import { PROFILE_IDS, resolveCliProfile } from "../profile.ts"; +import type { Delta } from "../../core/diff.ts"; + +function subjectKind(d: Delta): string { + switch (d.verb) { + case "add": + case "remove": + return d.fact.id.kind; + case "set": + return d.id.kind; + case "link": + case "unlink": + return d.edge.from.kind; + } +} + +function subjectId(d: Delta): string { + switch (d.verb) { + case "add": + case "remove": + return encodeId(d.fact.id); + case "set": + return encodeId(d.id); + case "link": + case "unlink": + return encodeId(d.edge.from); + } +} + +export async function cmdDiff(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + source: { type: "value", required: true }, + desired: { type: "value", required: true }, + profile: { type: "value" }, + "strict-coverage": { type: "boolean" }, + "unsafe-show-secrets": { type: "boolean" }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError( + `${err.message}\nUsage: pgdelta diff --source --desired [--profile ${PROFILE_IDS}] [--strict-coverage] [--unsafe-show-secrets]`, + ); + } + throw err; + } + + const { flags } = parsed; + const sourceUrl = flags["source"]; + const desiredUrl = flags["desired"]; + const redactSecrets = !flags["unsafe-show-secrets"]; + + const src = makePool(sourceUrl); + const dst = makePool(desiredUrl); + try { + // Resolve the profile against the source: handler-aware extraction + the + // policy / capability / baseline that define the managed view, so a + // profile-declared baseline's platform objects are subtracted and not shown + // as ordinary differences (raw when --profile is omitted). + const ctx = await resolveCliProfile(src.pool, flags["profile"], { + redactSecrets, + }); + process.stderr.write("Extracting source...\n"); + const [sourceResult, desiredResult] = await Promise.all([ + ctx.extract(src.pool, { redactSecrets }), + ctx.extract(dst.pool, { redactSecrets }), + ]); + process.stderr.write("Extracting desired...\n"); + + printDiagnostics(sourceResult.diagnostics, { label: "source" }); + printDiagnostics(desiredResult.diagnostics, { label: "desired" }); + exitIfBlocking( + [...sourceResult.diagnostics, ...desiredResult.diagnostics], + { + strictCoverage: flags["strict-coverage"], + action: "diff", + }, + ); + + // project BOTH sides through the managed view (policy scope + baseline + // subtraction + capability) so the diff reflects only what the profile + // manages — same lens the DB-to-DB `plan` uses. For the raw profile this is + // an identity projection, so the default `diff` is unchanged. + const projected = (fb: typeof sourceResult.factBase) => + resolveView( + fb, + ctx.planOptions.policy, + ctx.planOptions.capability, + ctx.planOptions.baseline, + ); + const deltas = diff( + projected(sourceResult.factBase), + projected(desiredResult.factBase), + ); + + if (deltas.length === 0) { + process.stdout.write("No differences found.\n"); + return; + } + + // group by verb then kind + const grouped = new Map>(); + for (const d of deltas) { + const verb = d.verb; + const kind = subjectKind(d); + const id = subjectId(d); + if (!grouped.has(verb)) grouped.set(verb, new Map()); + const byKind = grouped.get(verb)!; + if (!byKind.has(kind)) byKind.set(kind, []); + byKind.get(kind)!.push(id); + } + + for (const [verb, byKind] of grouped) { + process.stdout.write(`\n${verb.toUpperCase()}\n`); + for (const [kind, ids] of byKind) { + process.stdout.write(` ${kind} (${ids.length})\n`); + for (const id of ids) { + process.stdout.write(` ${id}\n`); + } + } + } + process.stdout.write(`\nTotal: ${deltas.length} delta(s)\n`); + } finally { + await Promise.all([src.end(), dst.end()]); + } +} diff --git a/packages/pg-delta/src/cli/commands/drift.ts b/packages/pg-delta/src/cli/commands/drift.ts new file mode 100644 index 000000000..29e0a968c --- /dev/null +++ b/packages/pg-delta/src/cli/commands/drift.ts @@ -0,0 +1,148 @@ +/** + * drift --env --snapshot + * Diff the live environment against a saved snapshot. + * Exit 0 = no drift; exit 1 = drift found. + * Stage-9 deliverable 7. + */ +import { diff } from "../../core/diff.ts"; +import { encodeId } from "../../core/stable-id.ts"; +import { loadSnapshot } from "../../frontends/snapshot-file.ts"; +import { exitIfBlocking, printDiagnostics } from "../diagnostics.ts"; +import { makePool } from "../pool.ts"; +import { CliExit, parseFlags, UsageError } from "../flags.ts"; +import { + PROFILE_IDS, + reconcileSnapshotProfile, + resolveCliProfile, +} from "../profile.ts"; + +export async function cmdDrift(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + env: { type: "value", required: true }, + snapshot: { type: "value", required: true }, + profile: { type: "value" }, + "strict-coverage": { type: "boolean" }, + "unsafe-show-secrets": { type: "boolean" }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError( + `${err.message}\nUsage: pgdelta drift --env --snapshot [--profile ${PROFILE_IDS}] [--strict-coverage] [--unsafe-show-secrets]`, + ); + } + throw err; + } + + const { flags } = parsed; + const envUrl = flags["env"]; + const snapshotPath = flags["snapshot"]; + + const env = makePool(envUrl); + try { + const { + factBase: snapshotFb, + pgVersion: snapshotPgVersion, + redactSecrets: snapshotRedactSecrets, + profile: snapshotProfile, + } = loadSnapshot(snapshotPath); + process.stderr.write( + `Snapshot: ${snapshotFb.facts().length} facts (pg ${snapshotPgVersion})\n`, + ); + // The snapshot side carries its own diagnostics (Codex P2, PR #338) — + // e.g. a USER_MAPPING_UNREADABLE that plan()'s gate needs, or the + // pre-existing unmodeled_kind/INTENT_UNKEYED findings from whenever the + // snapshot was captured. Surfaced with the same labeled + combined-set + // gating pattern every multi-source command uses (plan.ts, diff.ts): + // print with a "snapshot" label, then include in the blocking check + // below alongside the live extraction's diagnostics. + printDiagnostics(snapshotFb.diagnostics, { label: "snapshot" }); + + // Match the snapshot's redaction mode so a snapshot saved with + // --unsafe-show-secrets is compared against an equally-unredacted live + // extract (otherwise unchanged FDW/subscription secrets read as + // placeholder-vs-real drift). Prefer the mode stamped on the snapshot; + // fall back to the flag for snapshots written before it was recorded. + const redactSecrets = + snapshotRedactSecrets ?? !flags["unsafe-show-secrets"]; + + // Match the extractor to the snapshot: a snapshot captured with + // `--profile` carries handler-aware facts (pg_cron intent, pg_partman + // provenance), so the live re-extract must run the SAME handlers or those + // facts read as spurious drift. Reconcile the snapshot's STAMPED profile with + // any `--profile` flag: omit the flag → adopt the stamp; a contradicting flag + // fails closed; a legacy (un-stamped) snapshot lets the flag win. `skipBaseline` + // — drift is a raw snapshot-vs-live comparison, and the profile may declare a + // baseline that is irrelevant here (and need not exist). + if (flags["profile"] === undefined && snapshotProfile === undefined) { + process.stderr.write( + "Note: this snapshot predates profile stamping; re-extracting with the default profile. " + + "Re-capture it with `pgdelta snapshot --profile …` to pin the profile.\n", + ); + } + const profileId = reconcileSnapshotProfile( + flags["profile"], + snapshotProfile, + ); + const ctx = await resolveCliProfile(env.pool, profileId, { + redactSecrets, + skipBaseline: true, + }); + + process.stderr.write("Extracting live environment...\n"); + const { + factBase: liveFb, + pgVersion: livePgVersion, + diagnostics, + } = await ctx.extract(env.pool, { redactSecrets }); + printDiagnostics(diagnostics); + // Combined set (snapshot + live), same as every other multi-source + // command — blocking semantics/exit codes are unchanged: this only + // widens WHICH diagnostics are considered, not what counts as blocking + // (hasBlockingDiagnostics still only blocks error severity / the + // strict-coverage unmodeled_kind case). + exitIfBlocking([...snapshotFb.diagnostics, ...diagnostics], { + strictCoverage: flags["strict-coverage"], + action: "report drift", + }); + process.stderr.write( + `Live: ${liveFb.facts().length} facts (pg ${livePgVersion})\n`, + ); + + // diff(snapshot, live): adds = live has extra, removes = live is missing + const deltas = diff(snapshotFb, liveFb); + + if (deltas.length === 0) { + process.stdout.write("No drift detected.\n"); + return; // no drift → normal completion (exit 0) + } + + process.stdout.write(`Drift detected: ${deltas.length} delta(s)\n\n`); + for (const d of deltas) { + let line: string; + switch (d.verb) { + case "add": + line = `+ ${encodeId(d.fact.id)}`; + break; + case "remove": + line = `- ${encodeId(d.fact.id)}`; + break; + case "set": + line = `~ ${encodeId(d.id)} .${d.attr}`; + break; + case "link": + line = `+ link ${encodeId(d.edge.from)} -> ${encodeId(d.edge.to)}`; + break; + case "unlink": + line = `- link ${encodeId(d.edge.from)} -> ${encodeId(d.edge.to)}`; + break; + } + process.stdout.write(`${line}\n`); + } + // drift detected → exit 1 (main maps CliExit); the finally still closes the pool. + throw new CliExit(1); + } finally { + await env.end(); + } +} diff --git a/packages/pg-delta/src/cli/commands/plan.ts b/packages/pg-delta/src/cli/commands/plan.ts index 14150dc8b..b2752709f 100644 --- a/packages/pg-delta/src/cli/commands/plan.ts +++ b/packages/pg-delta/src/cli/commands/plan.ts @@ -1,256 +1,194 @@ /** - * Plan command - compute schema diff and preview changes. + * plan --source --desired + * [--renames auto|prompt|off] [--no-compact] [--out ] + * [--accept-rename =] (repeatable) + * + * Extract both databases, plan, write serializePlan to --out (default stdout). + * Print a human summary to stderr: action count, safety report, rename + * candidates (prompt-mode candidates listed as questions with from/to), + * filtered-delta count. + * + * --accept-rename = + * Confirm one rename candidate identified during a prior --renames prompt run. + * and are the encoded stable-ids printed in the prompt output + * (e.g. table:public.users). Repeatable; each flag names one confirmed rename. + * In prompt mode, accepted renames become real renames; unconfirmed unambiguous + * candidates are treated as drop+create. */ +import { plan } from "../../plan/plan.ts"; +import { serializePlan } from "../../plan/artifact.ts"; +import { encodeId, parseId, type StableId } from "../../core/stable-id.ts"; +import { exitIfBlocking, printDiagnostics } from "../diagnostics.ts"; +import { makePool } from "../pool.ts"; +import { parseFlags, UsageError } from "../flags.ts"; +import { PROFILE_IDS, resolveCliProfile } from "../profile.ts"; +import type { RenameMode } from "../../plan/renames.ts"; +import { writeFileSync } from "node:fs"; -import { mkdir, readdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { buildCommand, type CommandContext } from "@stricli/core"; -import { deserializeCatalog } from "../../core/catalog.snapshot.ts"; -import type { FilterDSL } from "../../core/integrations/filter/dsl.ts"; -import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts"; -import { createPlan } from "../../core/plan/index.ts"; -import { renderPlanFiles } from "../../core/plan/render.ts"; -import type { SqlFormatOptions } from "../../core/plan/sql-format.ts"; -import { setCommandExitCode } from "../exit-code.ts"; -import { resolveIntegrationOptions } from "../utils/integrations.ts"; -import { isPostgresUrl, loadCatalogFromFile } from "../utils/resolve-input.ts"; -import { formatPlanForDisplay } from "../utils.ts"; +const USAGE = + "Usage: pgdelta plan --source --desired " + + `[--profile ${PROFILE_IDS}] ` + + "[--renames auto|prompt|off] [--no-compact] [--out ] " + + "[--accept-rename =] ... [--restrict-to-applier] [--strict-coverage] " + + "[--unsafe-show-secrets]\n"; -export const planCommand = buildCommand({ - parameters: { - flags: { - source: { - kind: "parsed", - brief: - "Source (current state): postgres URL or catalog snapshot file path. Omit for empty baseline.", - parse: String, - optional: true, - }, - target: { - kind: "parsed", - brief: - "Target (desired state): postgres URL or catalog snapshot file path", - parse: String, - }, - format: { - kind: "enum", - brief: "Output format override: json (plan) or sql (script).", - values: ["json", "sql"] as const, - optional: true, - }, - output: { - kind: "parsed", - brief: - "Write output to file (stdout by default). If format is not set: .sql infers sql, .json infers json, otherwise uses human output.", - parse: String, - optional: true, - }, - "output-dir": { - kind: "parsed", - brief: - "Write numbered SQL migration files to a directory using transaction-aware plan units.", - parse: String, - optional: true, - }, - role: { - kind: "parsed", - brief: - "Role to use when executing the migration (SET ROLE will be added to statements).", - parse: String, - optional: true, - }, - filter: { - kind: "parsed", - brief: - 'Filter DSL as inline JSON to filter changes (e.g., \'{"schema":"public"}\').', - parse: (value: string): FilterDSL => { - try { - return JSON.parse(value) as FilterDSL; - } catch (error) { - throw new Error( - `Invalid filter JSON: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - optional: true, - }, - serialize: { - kind: "parsed", - brief: - 'Serialize DSL as inline JSON array (e.g., \'[{"when":{"type":"schema"},"options":{"skipAuthorization":true}}]\').', - parse: (value: string): SerializeDSL => { - try { - return JSON.parse(value) as SerializeDSL; - } catch (error) { - throw new Error( - `Invalid serialize JSON: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - optional: true, - }, - integration: { - kind: "parsed", - brief: - "Integration name (e.g., 'supabase') or path to integration JSON file (must end with .json). Loads from core/integrations/ or file path.", - parse: String, - optional: true, - }, - "sql-format": { - kind: "boolean", - brief: "Format SQL output (opt-in for --format sql or .sql output).", - optional: true, - }, - "sql-format-options": { - kind: "parsed", - brief: - 'SQL format options as inline JSON (e.g., \'{"keywordCase":"upper","maxWidth":100}\').', - parse: (value: string): SqlFormatOptions => { - try { - return JSON.parse(value) as SqlFormatOptions; - } catch (error) { - throw new Error( - `Invalid SQL format JSON: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - optional: true, - }, - }, - aliases: { - s: "source", - t: "target", - o: "output", - }, - }, - docs: { - brief: "Compute schema diff and preview changes", - fullDescription: ` -Compute the schema diff between two PostgreSQL databases (source → target), -and preview it for review or scripting. Defaults to tree display; -json/sql outputs are available for artifacts or piping. - `.trim(), - }, - async func( - this: CommandContext, - flags: { - source?: string; - target: string; - format?: "json" | "sql"; - output?: string; - "output-dir"?: string; - role?: string; - filter?: FilterDSL; - serialize?: SerializeDSL; - integration?: string; - "sql-format"?: boolean; - "sql-format-options"?: SqlFormatOptions; - }, - ) { - const { - filter, - serialize, - emptyCatalog: integrationEmptyCatalog, - } = await resolveIntegrationOptions({ - filter: flags.filter, - serialize: flags.serialize, - integration: flags.integration, +export async function cmdPlan(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + source: { type: "value", required: true }, + desired: { type: "value", required: true }, + profile: { type: "value" }, + renames: { type: "value" }, + "no-compact": { type: "boolean" }, + out: { type: "value" }, + "accept-rename": { type: "multi" }, + "restrict-to-applier": { type: "boolean" }, + "strict-coverage": { type: "boolean" }, + "unsafe-show-secrets": { type: "boolean" }, }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError(`${err.message}\n${USAGE.trimEnd()}`); + } + throw err; + } - const resolvedSource = flags.source - ? isPostgresUrl(flags.source) - ? flags.source - : await loadCatalogFromFile(flags.source) - : integrationEmptyCatalog - ? deserializeCatalog(integrationEmptyCatalog) - : null; - - const resolvedTarget = isPostgresUrl(flags.target) - ? flags.target - : await loadCatalogFromFile(flags.target); + const { flags } = parsed; + const sourceUrl = flags["source"]; + const desiredUrl = flags["desired"]; + const compact = !flags["no-compact"]; + const outPath = flags["out"]; + const acceptRenameRaw = flags["accept-rename"]; // string[] - const planResult = await createPlan(resolvedSource, resolvedTarget, { - role: flags.role, - filter, - serialize, - }); - if (!planResult) { - this.process.stdout.write("No changes detected.\n"); - return; + // --renames default for CLI is "prompt" + let renames: RenameMode = "prompt"; + if (flags["renames"] !== undefined) { + const v = flags["renames"]; + if (v !== "auto" && v !== "prompt" && v !== "off") { + throw new UsageError( + `--renames must be auto, prompt, or off (got: ${v})`, + ); } + renames = v; + } - if (flags.output && flags["output-dir"]) { - throw new Error("Use either --output or --output-dir, not both."); + // parse --accept-rename = entries + const acceptRenames: Array<{ from: StableId; to: StableId }> = []; + for (const entry of acceptRenameRaw) { + const eqIdx = entry.indexOf("="); + if (eqIdx === -1) { + throw new UsageError( + `--accept-rename value must be in = form (got: ${entry})`, + ); } - - if (flags["output-dir"]) { - await prepareOutputDirectory(flags["output-dir"]); - const files = renderPlanFiles(planResult.plan, { - sqlFormatOptions: - flags["sql-format"] || flags["sql-format-options"] - ? (flags["sql-format-options"] ?? {}) - : undefined, - }); - for (const file of files) { - await writeFile( - path.join(flags["output-dir"], file.path), - file.sql, - "utf-8", - ); - } - this.process.stdout.write( - `${files.length} migration file${files.length === 1 ? "" : "s"} written to ${flags["output-dir"]}\n`, + const fromStr = entry.slice(0, eqIdx); + const toStr = entry.slice(eqIdx + 1); + try { + acceptRenames.push({ from: parseId(fromStr), to: parseId(toStr) }); + } catch (e) { + throw new UsageError( + `--accept-rename: invalid stable-id in "${entry}": ${e instanceof Error ? e.message : String(e)}`, ); - setCommandExitCode(2); - return; } + } - const outputPath = flags.output; - let effectiveFormat: "tree" | "json" | "sql"; - if (flags.format) { - effectiveFormat = flags.format; - } else if (outputPath?.endsWith(".sql")) { - effectiveFormat = "sql"; - } else if (outputPath?.endsWith(".json")) { - effectiveFormat = "json"; - } else { - effectiveFormat = "tree"; - } + const src = makePool(sourceUrl); + const dst = makePool(desiredUrl); + try { + // redaction mode is passed into profile resolution so a profile-declared + // baseline captured in the OTHER mode is rejected (its secret-bearing facts + // would hash differently and silently stop subtracting). + const redactSecrets = !flags["unsafe-show-secrets"]; + // Resolve the profile against the SOURCE pool (the source is the apply + // target): this composes handler-aware extraction, the profile's policy + + // baseline, and — with --restrict-to-applier — the applier capability. All + // three flow into planOptions so plan == prove == apply (P0/P2). + const ctx = await resolveCliProfile(src.pool, flags["profile"], { + restrictToApplier: flags["restrict-to-applier"], + redactSecrets, + }); + + process.stderr.write("Extracting source...\n"); + process.stderr.write("Extracting desired...\n"); + const [sourceResult, desiredResult] = await Promise.all([ + ctx.extract(src.pool, { redactSecrets }), + ctx.extract(dst.pool, { redactSecrets }), + ]); - const { content, label } = formatPlanForDisplay( - planResult, - effectiveFormat, + // surface extraction diagnostics (review finding 2); --strict-coverage + // refuses to plan while user objects the engine cannot manage exist + printDiagnostics(sourceResult.diagnostics, { label: "source" }); + printDiagnostics(desiredResult.diagnostics, { label: "desired" }); + exitIfBlocking( + [...sourceResult.diagnostics, ...desiredResult.diagnostics], { - disableColors: !!outputPath, - showUnsafeFlagSuggestion: false, - sqlFormatOptions: - flags["sql-format"] || flags["sql-format-options"] - ? (flags["sql-format-options"] ?? {}) - : undefined, + strictCoverage: flags["strict-coverage"], + action: "plan", }, ); - if (outputPath) { - await writeFile(outputPath, content, "utf-8"); - this.process.stdout.write(`${label} written to ${outputPath}\n`); - } else { - this.process.stdout.write(content); - if (!content.endsWith("\n")) { - this.process.stdout.write("\n"); + const planOptions = { + renames, + compact, + // stamp the redaction mode on the artifact so apply/prove re-extract the + // target identically for the fingerprint gate (an unsafe plan fingerprinted + // over unredacted secrets must not be gated against a redacted re-extract). + redactSecrets, + ...(acceptRenames.length > 0 ? { acceptRenames } : {}), + ...ctx.planOptions, // policy, capability, baseline (from the profile) + }; + const thePlan = plan( + sourceResult.factBase, + desiredResult.factBase, + planOptions, + ); + + // human summary → stderr + process.stderr.write(`\nPlan summary:\n`); + process.stderr.write(` actions: ${thePlan.actions.length}\n`); + process.stderr.write( + ` filtered deltas: ${thePlan.filteredDeltas.length}\n`, + ); + process.stderr.write( + ` destructive: ${thePlan.safetyReport.destructiveActions}\n`, + ); + process.stderr.write( + ` rewrite risk: ${thePlan.safetyReport.rewriteRiskActions}\n`, + ); + process.stderr.write( + ` non-transactional:${thePlan.safetyReport.nonTransactionalActions}\n`, + ); + + if (thePlan.renameCandidates.length > 0) { + process.stderr.write(`\nRename candidates:\n`); + for (const c of thePlan.renameCandidates) { + const fromStr = encodeId(c.from); + const toStr = encodeId(c.to); + if (renames === "prompt" && c.status === "unambiguous") { + process.stderr.write( + ` ? Rename ${fromStr} -> ${toStr}? (${c.status})\n`, + ); + process.stderr.write( + ` To confirm, rerun with: --accept-rename ${fromStr}=${toStr}\n`, + ); + } else { + process.stderr.write( + ` ${c.status}: ${fromStr} -> ${toStr}${c.reason ? ` (${c.reason})` : ""}\n`, + ); + } } } - // Exit code 2 indicates changes were detected - setCommandExitCode(2); - }, -}); + const json = serializePlan(thePlan); -async function prepareOutputDirectory(outputDir: string): Promise { - await mkdir(outputDir, { recursive: true }); - const entries = await readdir(outputDir); - if (entries.length > 0) { - throw new Error( - `Output directory is not empty: ${outputDir}. Choose an empty directory to avoid stale migration files.`, - ); + if (outPath) { + writeFileSync(outPath, json, "utf8"); + process.stderr.write(`\nPlan written to ${outPath}\n`); + } else { + process.stdout.write(json + "\n"); + } + } finally { + await Promise.all([src.end(), dst.end()]); } } diff --git a/packages/pg-delta/src/cli/commands/prove.test.ts b/packages/pg-delta/src/cli/commands/prove.test.ts new file mode 100644 index 000000000..e2e72b82d --- /dev/null +++ b/packages/pg-delta/src/cli/commands/prove.test.ts @@ -0,0 +1,211 @@ +/** + * Unit test for the `prove` CLI failure formatter (second follow-up review + * 2026-06-15, P2). No database required. + * + * A proof can fail on rewrite violations ALONE (a kept table's relfilenode + * changed under an action that did not declare rewriteRisk). The CLI used to + * print only "Proof FAILED." for that case, hiding the offending table. The + * formatter must surface every failure category, mirroring the corpus runner. + */ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + cmdProve, + formatProofFailure, + formatProofPassCaveat, + formatProofPassCoverage, +} from "./prove.ts"; +import type { ProofCoverage } from "../../proof/prove.ts"; +import { buildFactBase } from "../../core/fact.ts"; +import { serializeSnapshot } from "../../core/snapshot.ts"; +import { ENGINE_VERSION } from "../../plan/plan.ts"; +import { UsageError } from "../flags.ts"; +import type { ProofVerdict } from "../../proof/prove.ts"; + +const baseVerdict = (): ProofVerdict => ({ + ok: false, + driftDeltas: [], + dataViolations: [], + rewriteViolations: [], + coverage: { tablesChecked: 0, tablesSkipped: [], perTable: [] }, +}); + +describe("formatProofFailure (review P2)", () => { + test("renders a rewrite-only failure with the offending table", () => { + const verdict: ProofVerdict = { + ...baseVerdict(), + rewriteViolations: [{ table: { schema: "app", name: "t" } }], + }; + + const out = formatProofFailure(verdict); + + expect(out).toContain("rewrite violations (1):"); + expect(out).toContain( + ` "app"."t": relfilenode changed, no rewriteRisk declared`, + ); + }); + + test("quotes identifiers with dots collision-free", () => { + const verdict: ProofVerdict = { + ...baseVerdict(), + rewriteViolations: [{ table: { schema: "a.b", name: "c" } }], + }; + // render.ts rel() must quote each part — not split a dotted string + expect(formatProofFailure(verdict)).toContain(`"a.b"."c"`); + }); +}); + +describe("formatProofPassCaveat (PR #338 comment 3603601155, drift parity)", () => { + test("no diagnostics on the desired snapshot — no suffix", () => { + expect(formatProofPassCaveat(0)).toBe(""); + }); + + test("one diagnostic — singular, with count", () => { + expect(formatProofPassCaveat(1)).toBe( + " (1 diagnostic on the desired snapshot — see above)", + ); + }); + + test("multiple diagnostics — plural, with count", () => { + expect(formatProofPassCaveat(3)).toBe( + " (3 diagnostics on the desired snapshot — see above)", + ); + }); +}); + +describe("formatProofPassCoverage (honest data-preservation coverage)", () => { + const ref = (schema: string, name: string) => ({ schema, name }); + + test("everything content-verified — no qualifier (keeps the plain message)", () => { + const coverage: ProofCoverage = { + tablesChecked: 2, + tablesSkipped: [], + perTable: [ + { + table: ref("app", "a"), + contentMode: "fingerprint", + recreated: false, + rewriteDeclared: false, + rowsBefore: 3, + rowsAfter: 3, + }, + { + table: ref("app", "b"), + contentMode: "fingerprint", + recreated: false, + rewriteDeclared: false, + rowsBefore: 1, + rowsAfter: 1, + }, + ], + }; + expect(formatProofPassCoverage(coverage)).toBe(""); + }); + + test("a recreated table is not content-verified — qualifies and names the table", () => { + const coverage: ProofCoverage = { + tablesChecked: 1, + tablesSkipped: [ + { table: ref("app", "orders"), reason: "recreated by the plan" }, + ], + perTable: [ + { + table: ref("app", "kept"), + contentMode: "fingerprint", + recreated: false, + rewriteDeclared: false, + rowsBefore: 2, + rowsAfter: 2, + }, + ], + }; + const out = formatProofPassCoverage(coverage); + expect(out).toContain("content-verified"); + expect(out).toContain("not compared"); + expect(out).toContain(`"app"."orders"`); + }); + + test("a count-only (schema changed) table qualifies and names the table", () => { + const coverage: ProofCoverage = { + tablesChecked: 1, + tablesSkipped: [], + perTable: [ + { + table: ref("app", "widened"), + contentMode: "count", + recreated: false, + rewriteDeclared: false, + rowsBefore: 5, + rowsAfter: 5, + }, + ], + }; + const out = formatProofPassCoverage(coverage); + expect(out).toContain("count-only"); + expect(out).toContain(`"app"."widened"`); + }); +}); + +describe("cmdProve — desired-snapshot profile reconciliation", () => { + const fb = buildFactBase( + [{ id: { kind: "schema", name: "public" }, payload: {} }], + [], + ); + + function writeArtifacts( + planProfileId: string, + snapshotProfile: string | null, + ): { planPath: string; snapPath: string } { + const dir = mkdtempSync(join(tmpdir(), "pgdelta-prove-prof-")); + const planPath = join(dir, "plan.json"); + const snapPath = join(dir, "desired.json"); + // a minimal, parse-valid plan artifact stamping the plan's profile id + writeFileSync( + planPath, + JSON.stringify({ + formatVersion: 1, + engineVersion: ENGINE_VERSION, + actions: [], + deltas: [], + renameCandidates: [], + safetyReport: { level: "safe", findings: [] }, + redactSecrets: true, + profile: { id: planProfileId }, + source: { fingerprint: "aaa" }, + target: { fingerprint: "bbb" }, + }), + "utf8", + ); + writeFileSync( + snapPath, + serializeSnapshot(fb, { pgVersion: "17.6", profile: snapshotProfile }), + "utf8", + ); + return { planPath, snapPath }; + } + + test("a desired snapshot captured under a DIFFERENT profile fails closed before touching the clone", async () => { + // plan produced under raw, snapshot captured under supabase → the proof + // would compare a different managed view; reject up front (UsageError), so + // the clone URL is never even opened. + const { planPath, snapPath } = writeArtifacts("raw", "supabase"); + let error: unknown; + try { + await cmdProve([ + "--plan", + planPath, + "--clone", + "postgres://invalid.invalid:1/none", + "--desired-snapshot", + snapPath, + ]); + } catch (e) { + error = e; + } + // fails closed with a UsageError, NOT a connection error — the guard runs + // before makePool opens the clone. + expect(error).toBeInstanceOf(UsageError); + }); +}); diff --git a/packages/pg-delta/src/cli/commands/prove.ts b/packages/pg-delta/src/cli/commands/prove.ts new file mode 100644 index 000000000..538174d6a --- /dev/null +++ b/packages/pg-delta/src/cli/commands/prove.ts @@ -0,0 +1,269 @@ +/** + * prove --plan --clone --desired-snapshot + * + * Run the proof loop against a sacrificial clone of the source. + * WARNING: the clone is mutated and will no longer reflect the source. + */ +import { readFileSync } from "node:fs"; +import { parsePlan } from "../../plan/artifact.ts"; +import { rel } from "../../plan/render.ts"; +import { + provePlan, + type ProofCoverage, + type ProofVerdict, + type TableRef, +} from "../../proof/prove.ts"; +import { loadSnapshot } from "../../frontends/snapshot-file.ts"; +import { encodeId } from "../../core/stable-id.ts"; +import { exitIfBlocking, printDiagnostics } from "../diagnostics.ts"; +import { makePool } from "../pool.ts"; +import { CliExit, parseFlags, UsageError } from "../flags.ts"; +import { + effectiveProfileId, + isProfilePath, + loadProfile, + PROFILE_IDS, + reconcileBaselineDigest, + resolveCliProfile, +} from "../profile.ts"; + +/** + * Render a failing `ProofVerdict` as an indented, human-readable report (the + * lines printed after "Proof FAILED."). Pure + exported so the CLI output is + * testable without a database. Every category the verdict can fail on gets a + * block — apply error, drift, data violations, AND rewrite violations — so a + * proof failure is always self-explanatory (review P2: rewrite-only failures + * used to print just "Proof FAILED."). + */ +export function formatProofFailure(verdict: ProofVerdict): string { + const lines: string[] = []; + if (verdict.applyError) { + lines.push( + ` apply error at action[${verdict.applyError.actionIndex}]: ${verdict.applyError.message}`, + ); + } + if (verdict.driftDeltas.length > 0) { + lines.push(` drift deltas (${verdict.driftDeltas.length}):`); + for (const d of verdict.driftDeltas) { + const id = + d.verb === "add" || d.verb === "remove" + ? encodeId(d.fact.id) + : d.verb === "set" + ? encodeId(d.id) + : encodeId(d.edge.from); + lines.push(` ${d.verb} ${id}`); + } + } + if (verdict.dataViolations.length > 0) { + lines.push(` data violations (${verdict.dataViolations.length}):`); + for (const v of verdict.dataViolations) { + lines.push( + ` ${rel(v.table.schema, v.table.name)}: before=${v.before} after=${v.after}`, + ); + } + } + if (verdict.rewriteViolations.length > 0) { + lines.push(` rewrite violations (${verdict.rewriteViolations.length}):`); + for (const v of verdict.rewriteViolations) { + lines.push( + ` ${rel(v.table.schema, v.table.name)}: relfilenode changed, no rewriteRisk declared`, + ); + } + } + return lines.length > 0 ? `${lines.join("\n")}\n` : ""; +} + +/** + * The suffix appended to the "Proof passed" line when the desired snapshot + * carried diagnostics (e.g. a skipped-as-unreadable user-mapping fact) — an + * honest caveat that a syntactically-clean proof doesn't mean the desired + * state was fully known (drift parity with the `prove`/`drift` diagnostics + * fix, PR #338 comment 3603601155). Pure + exported so it's testable without + * a database, alongside {@link formatProofFailure}. Empty when there's + * nothing to caveat. + */ +export function formatProofPassCaveat(diagnosticsCount: number): string { + if (diagnosticsCount === 0) return ""; + return ` (${diagnosticsCount} diagnostic${diagnosticsCount === 1 ? "" : "s"} on the desired snapshot — see above)`; +} + +/** + * A coverage caveat appended to the "Proof passed" line so a passing proof never + * over-claims "data preservation verified" when it could not actually compare + * every kept table's content. The proof's `coverage` (see `ProofCoverage`) is + * honest per-table; this renders the parts a bare success line would hide: + * count-only tables (schema changed, so only the row count was trusted) and + * tables not compared at all (recreated/dropped by the plan). Pure + exported so + * it's testable without a database, alongside {@link formatProofFailure}. Empty + * when every kept, non-empty table was content-verified (keeps the plain + * message). Does NOT change ok/exit semantics — reporting honesty only. + */ +export function formatProofPassCoverage(coverage: ProofCoverage): string { + const contentVerified = coverage.perTable.filter( + (t) => t.contentMode === "fingerprint", + ); + const countOnly = coverage.perTable.filter((t) => t.contentMode === "count"); + const notCompared = coverage.tablesSkipped; + if (countOnly.length === 0 && notCompared.length === 0) return ""; + const sample = (refs: TableRef[]): string => { + const shown = refs.slice(0, 3).map((t) => rel(t.schema, t.name)); + const extra = refs.length - shown.length; + return extra > 0 + ? `${shown.join(", ")} (+${extra} more)` + : shown.join(", "); + }; + const segments = [`${contentVerified.length} content-verified`]; + if (countOnly.length > 0) { + segments.push( + `${countOnly.length} count-only (schema changed): ${sample(countOnly.map((t) => t.table))}`, + ); + } + if (notCompared.length > 0) { + segments.push( + `${notCompared.length} not compared (recreated/dropped): ${sample( + notCompared.map((t) => t.table), + )}`, + ); + } + return ` — ${segments.join(", ")}`; +} + +export async function cmdProve(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + plan: { type: "value", required: true }, + clone: { type: "value", required: true }, + "desired-snapshot": { type: "value", required: true }, + profile: { type: "value" }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError( + `${err.message}\nUsage: pgdelta prove --plan --clone --desired-snapshot [--profile ${PROFILE_IDS}]`, + ); + } + throw err; + } + + const { flags } = parsed; + const planPath = flags["plan"]; + const cloneUrl = flags["clone"]; + const snapshotPath = flags["desired-snapshot"]; + + process.stderr.write( + "WARNING: The --clone database will be mutated and can no longer be used as a source.\n", + ); + + const json = readFileSync(planPath, "utf8"); + const thePlan = parsePlan(json); + const { + factBase: desiredFb, + redactSecrets: snapshotRedactSecrets, + profile: snapshotProfile, + } = loadSnapshot(snapshotPath); + // Drift parity (PR #338 comment 3603601155): `drift` already surfaces its + // snapshot's diagnostics (src/cli/commands/drift.ts) — `prove`'s desired + // snapshot can carry the same kind (e.g. a skipped-as-unreadable user + // mapping) and previously went unread. Blocking stays error-severity only: + // a USER_MAPPING_UNREADABLE warning must NOT become fatal here (declined — + // see plan.ts's gate for where that variant actually matters); it prints + // and the proof proceeds, with a caveat appended if it later passes. + printDiagnostics(desiredFb.diagnostics, { label: "desired snapshot" }); + exitIfBlocking(desiredFb.diagnostics, { action: "prove" }); + + // The proof re-extracts the (mutated) clone with the PLAN's redaction mode and + // compares it to the desired snapshot. If the snapshot was captured with a + // different mode, FDW/subscription secrets would compare placeholder-vs-real + // and fail the proof spuriously — and only AFTER the clone is destroyed. + // Reject a mismatch up front so the operator re-generates a consistent pair + // instead of getting a false failure (review P2). + // Both default to redacted (true) when unstamped: a snapshot written before the + // redactSecrets field existed is a default-redacted extract, so it must still be + // caught as a mismatch against an --unsafe-show-secrets plan (review P2). + const planRedactSecrets = thePlan.redactSecrets ?? true; + const snapRedactSecrets = snapshotRedactSecrets ?? true; + if (snapRedactSecrets !== planRedactSecrets) { + throw new UsageError( + `prove: the desired snapshot's redaction mode (redactSecrets=${snapRedactSecrets}) does not match the plan's (redactSecrets=${planRedactSecrets}). ` + + `Re-generate both with the same --unsafe-show-secrets setting; a mismatch would compare placeholder-vs-real secrets and fail the proof spuriously.`, + ); + } + + // The profile MUST match the one used to plan: it supplies the handler-aware + // re-extractor + baseline so the proof reconstructs the SAME managed view it + // diffed (otherwise operational children reappear as drift). Default to the + // plan's stamped profile; reject a contradicting --profile before opening the + // clone. Policy and capability fall back to the plan artifact inside provePlan. + // effectiveProfileId throws UsageError on a --profile that contradicts the + // plan artifact; it propagates to main() (→ message + exit 2). + const profileId: string | undefined = effectiveProfileId( + flags["profile"], + thePlan.profile?.id, + ); + + // The desired snapshot must ALSO have been captured under that profile, or the + // proof reconstructs a different managed view than it diffed (the re-extract + // runs different handlers than the snapshot's facts were produced with). + // Reject a mismatch up front — before the clone is opened/mutated — mirroring + // the redaction-mode guard above. A `null` stamp = captured raw (reconciled as + // "raw"); an ABSENT stamp is a pre-stamping legacy snapshot and is exempt. + const resolvedDeclaredId = + profileId !== undefined && isProfilePath(profileId) + ? loadProfile(profileId).id + : profileId; + const snapshotDeclaredId = snapshotProfile === null ? "raw" : snapshotProfile; + if ( + snapshotDeclaredId !== undefined && + resolvedDeclaredId !== undefined && + snapshotDeclaredId !== resolvedDeclaredId + ) { + throw new UsageError( + `prove: the desired snapshot was captured under profile "${snapshotDeclaredId}", but the plan/prove ` + + `profile resolves to "${resolvedDeclaredId}". A proof must compare against a snapshot captured with the ` + + `same profile — re-capture the snapshot with a matching --profile, or prove with the snapshot's profile.`, + ); + } + + const clone = makePool(cloneUrl); + try { + process.stderr.write( + `Proving plan (${thePlan.actions.length} action(s))...\n`, + ); + const ctx = await resolveCliProfile(clone.pool, profileId, { + redactSecrets: planRedactSecrets, + }); + // The baseline the profile resolves MUST match the plan's, or the proof + // reconstructs a different managed view than the plan diffed. Fail loud with + // a precise message (Codex #323 finding 1) — prove never got the old + // `--baseline` flag, so a baselined plan silently proved a different view. + reconcileBaselineDigest( + thePlan.baseline?.digest, + ctx.baseline?.digest, + "plan artifact", + ); + // Re-extract the post-apply clone with the SAME redaction mode the plan used + // (stamped on the artifact), so the proof compares like-for-like against the + // desired snapshot — an unredacted (`--unsafe-show-secrets`) plan must not be + // proven against a default-redacted re-extract. Absent → the extract default. + const verdict = await provePlan(thePlan, clone.pool, desiredFb, { + ...ctx.proveOptions, + reextract: (p) => ctx.extract(p, { redactSecrets: planRedactSecrets }), + }); + + if (verdict.ok) { + process.stderr.write( + `Proof passed: state and data preservation verified.` + + `${formatProofPassCoverage(verdict.coverage)}` + + `${formatProofPassCaveat(desiredFb.diagnostics.length)}\n`, + ); + } else { + process.stderr.write("Proof FAILED.\n"); + process.stderr.write(formatProofFailure(verdict)); + // main() maps CliExit(1) → exit 1; the finally still closes the pool. + throw new CliExit(1); + } + } finally { + await clone.end(); + } +} diff --git a/packages/pg-delta/src/cli/commands/render.test.ts b/packages/pg-delta/src/cli/commands/render.test.ts new file mode 100644 index 000000000..693fa76e8 --- /dev/null +++ b/packages/pg-delta/src/cli/commands/render.test.ts @@ -0,0 +1,112 @@ +/** + * `render` writes a plan's SQL as `.sql` / `_.sql`. Re-rendering + * to the same base must PRUNE the segment files the previous render owned but + * this one no longer produces — otherwise a runner scanning the directory + * replays obsolete (possibly destructive) segments. The naming scheme is the + * ownership ledger: only files matching `.sql` / `_.sql` are + * render-owned; any other file in the directory is left untouched. + */ +import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { Action, Plan } from "../../plan/plan.ts"; +import { ENGINE_VERSION } from "../../plan/plan.ts"; +import { serializePlan } from "../../plan/artifact.ts"; +import { cmdRender } from "./render.ts"; + +function action(overrides: Partial): Action { + return { + sql: "SELECT 1", + verb: "create", + produces: [], + consumes: [], + destroys: [], + releases: [], + transactionality: "transactional", + lockClass: "none", + newSegmentBefore: false, + dataLoss: "none", + rewriteRisk: false, + ...overrides, + } as Action; +} + +function makePlan(actions: Action[]): Plan { + return { + formatVersion: 1, + engineVersion: ENGINE_VERSION, + source: { fingerprint: "a" }, + target: { fingerprint: "b" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions, + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + } as Plan; +} + +/** A 3-segment plan: two commitBoundaryAfter actions force `_1`/`_2`/`_3`. */ +const threeSegments = makePlan([ + action({ + sql: "ALTER TYPE color ADD VALUE 'a'", + transactionality: "commitBoundaryAfter", + }), + action({ + sql: "ALTER TYPE color ADD VALUE 'b'", + transactionality: "commitBoundaryAfter", + }), + action({ sql: "CREATE TABLE t (c color)" }), +]); +/** A single-segment plan → `.sql` only. */ +const oneSegment = makePlan([ + action({ sql: "CREATE TABLE only (id integer)" }), +]); + +describe("cmdRender segment pruning", () => { + let dir: string; + const origStdout = process.stdout.write.bind(process.stdout); + const origStderr = process.stderr.write.bind(process.stderr); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "pgdelta-render-")); + // silence the command's stdout/stderr summary during the test + process.stdout.write = (() => true) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + }); + afterEach(() => { + process.stdout.write = origStdout; + process.stderr.write = origStderr; + }); + + async function render(plan: Plan, outBase: string): Promise { + const planPath = join(dir, "plan.json"); + writeFileSync(planPath, serializePlan(plan), "utf8"); + await cmdRender(["--plan", planPath, "--out", join(dir, outBase)]); + } + + test("re-render to the same base removes stale segment files, keeps foreign ones", async () => { + // a foreign file that render does NOT own (name doesn't match the pattern) + writeFileSync(join(dir, "mig_notes.sql"), "-- hand authored\n", "utf8"); + writeFileSync(join(dir, "other.sql"), "-- unrelated\n", "utf8"); + + await render(threeSegments, "mig.sql"); + expect(existsSync(join(dir, "mig_1.sql"))).toBe(true); + expect(existsSync(join(dir, "mig_2.sql"))).toBe(true); + expect(existsSync(join(dir, "mig_3.sql"))).toBe(true); + + // re-render a 1-segment plan → mig.sql; the old mig_1/2/3.sql must be gone + await render(oneSegment, "mig.sql"); + + const sqlFiles = readdirSync(dir) + .filter((f) => f.endsWith(".sql") && f !== "plan.json") + .sort(); + expect(sqlFiles).toEqual(["mig.sql", "mig_notes.sql", "other.sql"]); + }); +}); diff --git a/packages/pg-delta/src/cli/commands/render.ts b/packages/pg-delta/src/cli/commands/render.ts new file mode 100644 index 000000000..655b9c19d --- /dev/null +++ b/packages/pg-delta/src/cli/commands/render.ts @@ -0,0 +1,129 @@ +/** + * render --plan --out .sql [--allow-drops] + * + * Read a plan artifact and write its SQL as one or more dbmate-friendly + * `.sql` files, split on the same segment boundaries `apply()` uses at + * execution time (src/apply/apply.ts::segmentActions). Argv parsing, fs + * writes, and the stdout summary live here; all rendering logic is in the + * pure `renderPlan` (../render.ts) so it is unit-testable without fs/process. + * + * Exit codes: 0 = files written, 1 = error (no files written), 2 = usage + * error, 3 = plan has no actions (no files written, not an error). + */ +import { readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { parsePlan } from "../../plan/artifact.ts"; +import { CliExit, parseFlags, UsageError } from "../flags.ts"; +import { renderPlan } from "../render.ts"; + +const USAGE = + "Usage: pgdelta render --plan --out .sql [--allow-drops]\n"; + +/** Given "--out" value, split into base + ".sql" ext. Strips a trailing + * ".sql" if present; otherwise treats the whole value as the base. */ +function splitBase(outPath: string): string { + return outPath.endsWith(".sql") ? outPath.slice(0, -".sql".length) : outPath; +} + +/** True when `fileName` is a render-owned segment file for `baseName`: + * `.sql` or `_.sql` (n = one or more digits). The + * naming scheme is the ownership ledger — anything else in the directory + * (hand-authored `_notes.sql`, unrelated files) is NOT matched and + * is never touched. */ +function isOwnedSegmentFile(fileName: string, baseName: string): boolean { + if (fileName === `${baseName}.sql`) return true; + const prefix = `${baseName}_`; + if (!fileName.startsWith(prefix) || !fileName.endsWith(".sql")) return false; + const middle = fileName.slice(prefix.length, -".sql".length); + return middle.length > 0 && /^\d+$/.test(middle); +} + +/** Remove segment files from a previous render to `base` that this render's + * file set (`keep`) no longer produces, so a runner scanning the directory + * never replays an obsolete (possibly destructive) segment. Only files + * matching the `.sql` / `_.sql` pattern are candidates — the + * scheme guarantees render owns them; foreign files are left in place. */ +function pruneStaleSegments(base: string, keep: ReadonlySet): void { + const dir = dirname(base); + const baseName = basename(base); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; // directory does not exist yet — nothing to prune + } + for (const entry of entries) { + if (!isOwnedSegmentFile(entry, baseName)) continue; + const full = join(dir, entry); + if (!keep.has(full)) rmSync(full, { force: true }); + } +} + +export async function cmdRender(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + plan: { type: "value", required: true }, + out: { type: "value", required: true }, + "allow-drops": { type: "boolean" }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError(`${err.message}\n${USAGE.trimEnd()}`); + } + throw err; + } + + const { flags } = parsed; + const planPath = flags["plan"]; + const outPath = flags["out"]; + const allowDrops = flags["allow-drops"]; + + const json = readFileSync(planPath, "utf8"); + const thePlan = parsePlan(json); + + let result; + try { + result = renderPlan(thePlan, { allowDrops }); + } catch (err) { + process.stderr.write( + `Error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + throw new CliExit(1); + } + + if (!result.changes) { + process.stderr.write( + "No changes: plan has no actions. Writing no files.\n", + ); + process.stdout.write(`${JSON.stringify({ changes: false, files: [] })}\n`); + throw new CliExit(3); + } + + const base = splitBase(outPath); + // Prune segment files a previous render to this base left behind but this + // render no longer produces (the naming scheme is the ownership ledger). + const keep = new Set( + result.files.map((file) => `${base}${file.suffix ?? ""}.sql`), + ); + pruneStaleSegments(base, keep); + const writtenFiles: { + path: string; + transactional: boolean; + actionCount: number; + }[] = []; + for (const file of result.files) { + const path = `${base}${file.suffix ?? ""}.sql`; + writeFileSync(path, file.contents, "utf8"); + writtenFiles.push({ + path, + transactional: file.transactional, + actionCount: file.actionCount, + }); + } + + process.stderr.write(`Wrote ${writtenFiles.length} file(s).\n`); + process.stdout.write( + `${JSON.stringify({ changes: true, files: writtenFiles })}\n`, + ); +} diff --git a/packages/pg-delta/src/cli/commands/schema.ts b/packages/pg-delta/src/cli/commands/schema.ts new file mode 100644 index 000000000..07d61b664 --- /dev/null +++ b/packages/pg-delta/src/cli/commands/schema.ts @@ -0,0 +1,808 @@ +/** + * schema export --source --out-dir [--layout by-object|ordered|grouped] + * Export the source database as SQL files written to disk. + * Maps to old `declarative-export`. + * + * Layouts: + * by-object (default) — the familiar tree (schemas//tables/.sql, …), + * files in dependency/plan order. + * ordered — numbered files in plan order; the loader converges in one pass. + * grouped — the old engine's "nice" export: files ordered by semantic + * category (cluster → schema → types → tables → views → …), statements + * sorted within a file for readability, plus opt-in grouping: + * --grouping-mode single-file|subdirectory (default subdirectory) + * --group-patterns '[{"pattern":"^auth_","name":"auth"}]' (first match wins) + * --flat-schemas partman,audit (collapse a schema to one file/category) + * --no-group-partitions (keep partition children in their own files) + * + * --format-options '' (any layout) — pretty-print each file's SQL with + * the formatter (frontends/sql-format), e.g. '{"keywordCase":"upper","maxWidth":180}'. + * Off by default (raw renderer output). Cosmetic — load(export) ≡ db still holds. + * + * schema apply --dir --shadow --target + * [--renames auto|prompt|off] [--force] + * [--accept-rename =] (repeatable) [--no-reorder] + * Read .sql files recursively (lexicographic), load into shadow, extract + * target, plan, apply. Maps to old `declarative-apply` / `sync`. + * + * By default the SQL files are passed through the statement-reordering assist + * (target-architecture §4.4.1): each file is split into one-statement units + * and topologically pre-sorted before loading, so authoring order within a + * file no longer matters and the shadow loader converges in fewer rounds. The + * assist is advisory — Postgres still elaborates the shadow — so it can only + * fail to BUILD the shadow (a visible error), never corrupt the desired state. + * + * --no-reorder + * Skip the reordering assist and load the raw files at file granularity + * (the original behavior). Useful for debugging a stuck load. + * + * --accept-rename = + * Confirm one rename candidate by the encoded stable-ids shown in a prior + * --renames prompt run. Repeatable; each flag names one confirmed rename. + */ +import { + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join, dirname, relative, resolve, sep } from "node:path"; +import type { + ExportGrouping, + ExportGroupingPattern, +} from "../../frontends/export-sql-files.ts"; +import type { SqlFormatOptions } from "../../frontends/sql-format/index.ts"; +import { pruneStaleSqlFiles } from "../../frontends/prune-sql-files.ts"; +import { + readExportManifest, + writeExportManifest, +} from "../../frontends/export-manifest.ts"; +import { type SqlFile } from "../../frontends/load-sql-files.ts"; +import { analyzeForShadow } from "../../frontends/sql-order.ts"; +import { buildSchemaExport } from "../../frontends/schema-export.ts"; +import { + planSchemaFiles, + prepareSchemaFiles, +} from "../../frontends/schema-plan.ts"; +import { + appendShadowCycleHint, + formatLintReport, + rewriteReorderedShadowError, +} from "../reorder-display.ts"; +import type { ManagementScope } from "../../policy/view.ts"; +import { apply } from "../../apply/apply.ts"; +import { encodeId, parseId, type StableId } from "../../core/stable-id.ts"; +import { exitIfBlocking, printDiagnostics } from "../diagnostics.ts"; +import { makePool } from "../pool.ts"; +import { + type CoLocatedShadow, + isShadowProvisionError, + provisionCoLocatedShadow, +} from "../shadow.ts"; +import { CliExit, parseFlags, UsageError } from "../flags.ts"; +import { effectiveProfileId, PROFILE_IDS, profileById } from "../profile.ts"; +import type { RenameMode } from "../../plan/renames.ts"; + +/** Recursively collect *.sql files in lexicographic order. Exported for tests. */ +export function collectSqlFiles(dir: string): SqlFile[] { + // Derive names from the NORMALIZED root, not by slicing the raw `--dir` string: + // a trailing slash or non-normalized segment would make `dir.length + 1` drop + // the first character of every relative path (`01_schema.sql` → `1_schema.sql`), + // corrupting the lexicographic order the raw loader relies on (review P2). + const root = resolve(dir); + const result: SqlFile[] = []; + const recurse = (current: string): void => { + const entries = readdirSync(current).sort(); + for (const entry of entries) { + const full = join(current, entry); + const st = statSync(full); + if (st.isDirectory()) { + recurse(full); + } else if (entry.endsWith(".sql")) { + result.push({ + name: relative(root, full), // relative path from the normalized dir + sql: readFileSync(full, "utf8"), + }); + } + } + }; + recurse(root); + return result; +} + +/** + * Write the exported SQL files and the `.pgdelta-export.json` manifest under + * `outRoot`, returning the stale files pruned. Exported for tests. + * + * Creates `outRoot` up front: a database with no managed objects legitimately + * yields zero files, and the per-file loop (which only mkdirs each file's parent) + * would then never create the root, so the manifest write would ENOENT (review + * P2). Stale `.sql` files the PREVIOUS export owned (recorded in that export's + * manifest `files` list) are pruned first so a dropped object's file can't + * linger and be reloaded. A `.sql` file the previous export did NOT own — hand + * authored SQL, or every `.sql` in a pre-feature / manifest-less dir — is + * UNMANAGED: rather than silently deleting it (`schema apply --dir` loads the + * whole tree, so a lingering unmanaged file is a real hazard), the export is + * refused with an error naming each such file and telling the operator to pass + * `--prune-unmanaged` (threaded here as `pruneUnmanaged`) to delete them. The + * new manifest records the owned files as SORTED relative POSIX paths so the + * next re-export knows what it may prune. + */ +export function writeExportFiles( + outRoot: string, + files: SqlFile[], + manifest: { + redactSecrets: boolean; + profile?: string; + scope?: "database" | "cluster"; + baselineDigest?: string; + defaultOwner?: string | null; + }, + pruneUnmanaged: boolean, +): { removed: string[]; unmanaged: string[] } { + // The PREVIOUS export's owned-file list, read before we write anything. Absent + // manifest / no `files` field → undefined → every out-of-set `.sql` is + // unmanaged (pre-feature or hand-authored dir). + const previous = readExportManifest(outRoot); + const previouslyOwned = + previous?.files !== undefined + ? new Set(previous.files.map((rel) => resolve(outRoot, rel))) + : undefined; + const keep = new Set(files.map((file) => join(outRoot, file.name))); + const { removed, unmanaged } = pruneStaleSqlFiles( + outRoot, + keep, + previouslyOwned, + pruneUnmanaged, + ); + // Refuse BEFORE writing any file: deleting hand-authored SQL is irreversible. + if (unmanaged.length > 0) { + throw new Error( + `export: refusing to overwrite ${outRoot}: it contains ${unmanaged.length} ` + + `unmanaged .sql file(s) this export does not own:\n` + + unmanaged.map((f) => ` ${f}`).join("\n") + + `\nPass --prune-unmanaged to delete them and take ownership of the directory.`, + ); + } + mkdirSync(outRoot, { recursive: true }); + for (const file of files) { + const full = join(outRoot, file.name); + // defense-in-depth (review P2): even with per-segment encoding in + // exportSqlFiles, never let a database identifier escape the output dir. + if (full !== outRoot && !full.startsWith(outRoot + sep)) { + throw new Error( + `export: refusing to write outside ${outRoot}: ${file.name}`, + ); + } + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, file.sql, "utf8"); + } + const ownedFiles = files.map((file) => file.name.split(sep).join("/")).sort(); + writeExportManifest(outRoot, { ...manifest, files: ownedFiles }); + return { removed, unmanaged }; +} + +export async function cmdSchemaExport(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + source: { type: "value", required: true }, + "out-dir": { type: "value", required: true }, + layout: { type: "value" }, + profile: { type: "value" }, + "strict-coverage": { type: "boolean" }, + "unsafe-show-secrets": { type: "boolean" }, + "grouping-mode": { type: "value" }, + "group-patterns": { type: "value" }, + "flat-schemas": { type: "value" }, + "no-group-partitions": { type: "boolean" }, + "format-options": { type: "value" }, + "no-format": { type: "boolean" }, + scope: { type: "value" }, + "default-owner": { type: "value" }, + "prune-unmanaged": { type: "boolean" }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError( + `${err.message}\nUsage: pgdelta schema export --source --out-dir ` + + `[--layout by-object|ordered|grouped] [--profile ${PROFILE_IDS}] [--strict-coverage] [--unsafe-show-secrets] [--scope database|cluster] [--prune-unmanaged]\n` + + ` [--default-owner ] (which owner stays implicit; default: profile default or the database owner; "none" emits every OWNER TO)\n` + + ` [--prune-unmanaged] (delete .sql files in --out-dir this export does not own; refuse otherwise)\n` + + ` [--format-options '{"keywordCase":"upper","maxWidth":180}'] [--no-format]\n` + + ` (SQL is pretty-printed by default: lowercase keywords, width 180; any layout)\n` + + ` Grouped-layout options (only with --layout grouped):\n` + + ` [--grouping-mode single-file|subdirectory] [--group-patterns ] [--flat-schemas ] [--no-group-partitions]`, + ); + } + throw err; + } + + const { flags } = parsed; + const sourceUrl = flags["source"]; + const outDir = flags["out-dir"]; + // Management scope of the export (default database-local). `database` omits + // cluster-global roles/memberships so the directory reloads on any cluster; + // `cluster` includes them. Stamped in the manifest so `schema apply` matches. + let exportScope: ManagementScope = "database"; + const exportScopeFlag = flags["scope"]; + if (exportScopeFlag === "database" || exportScopeFlag === "cluster") { + exportScope = exportScopeFlag; + } else if (exportScopeFlag !== undefined) { + throw new UsageError( + `--scope must be database or cluster (got: ${exportScopeFlag})`, + ); + } + let layout: "by-object" | "ordered" | "grouped" = "by-object"; + if (flags["layout"] !== undefined) { + const v = flags["layout"]; + if (v !== "by-object" && v !== "ordered" && v !== "grouped") { + throw new UsageError( + `--layout must be by-object, ordered, or grouped (got: ${v})`, + ); + } + layout = v; + } + + // Grouping options apply only to the grouped layout. Parse them up front so + // a malformed value fails before connecting to the database. + let grouping: ExportGrouping | undefined; + if (layout === "grouped") { + const mode = flags["grouping-mode"]; + if ( + mode !== undefined && + mode !== "single-file" && + mode !== "subdirectory" + ) { + throw new UsageError( + `--grouping-mode must be single-file or subdirectory (got: ${mode})`, + ); + } + let groupPatterns: ExportGroupingPattern[] | undefined; + if (flags["group-patterns"] !== undefined) { + try { + const raw = JSON.parse(flags["group-patterns"]) as unknown; + if ( + !Array.isArray(raw) || + !raw.every( + (p): p is ExportGroupingPattern => + typeof p === "object" && + p !== null && + typeof (p as { pattern?: unknown }).pattern === "string" && + typeof (p as { name?: unknown }).name === "string", + ) + ) { + throw new Error("expected an array of { pattern, name } objects"); + } + groupPatterns = raw; + } catch (e) { + throw new UsageError( + `--group-patterns must be JSON array of { pattern, name }: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + const flatSchemas = flags["flat-schemas"] + ?.split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""); + grouping = { + ...(mode !== undefined ? { mode } : {}), + ...(groupPatterns !== undefined ? { groupPatterns } : {}), + ...(flatSchemas !== undefined && flatSchemas.length > 0 + ? { flatSchemas } + : {}), + ...(flags["no-group-partitions"] ? { autoGroupPartitions: false } : {}), + }; + } + + // SQL formatting is ON by default — the export is a human-facing artifact, so + // it pretty-prints with lowercase keywords and a 180-char width (formatter + // defaults otherwise: aligned columns). --format-options overrides every + // knob; --no-format restores the raw renderer output. Layout-agnostic, and + // purely cosmetic by contract: the fidelity gate (load(export) ≡ fb) covers + // the formatter. Parsed up front so a malformed value fails before connecting. + let format: SqlFormatOptions | undefined = flags["no-format"] + ? undefined + : { keywordCase: "lower", maxWidth: 180 }; + if (flags["format-options"] !== undefined) { + if (flags["no-format"]) { + throw new UsageError( + "--format-options and --no-format are mutually exclusive", + ); + } + try { + const raw = JSON.parse(flags["format-options"]) as unknown; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error("expected a JSON object"); + } + format = raw as SqlFormatOptions; + } catch (e) { + throw new UsageError( + `--format-options must be a JSON object (e.g. '{"keywordCase":"upper","maxWidth":180}'): ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + const src = makePool(sourceUrl); + try { + const redactSecrets = !flags["unsafe-show-secrets"]; + const profile = profileById(flags["profile"]); + process.stderr.write("Extracting...\n"); + const result = await buildSchemaExport(src.pool, { + profile, + scope: exportScope, + redactSecrets, + layout, + ...(grouping !== undefined ? { grouping } : {}), + ...(format !== undefined ? { format } : {}), + ...(flags["default-owner"] === "none" + ? { defaultOwner: null } + : flags["default-owner"] !== undefined && flags["default-owner"] !== "" + ? { defaultOwner: flags["default-owner"] } + : {}), + onWarning: (message) => process.stderr.write(` WARNING: ${message}\n`), + }); + printDiagnostics(result.diagnostics); + exitIfBlocking(result.diagnostics, { + strictCoverage: flags["strict-coverage"], + action: "export", + }); + + const outRoot = resolve(outDir); + const { removed } = writeExportFiles( + outRoot, + result.files, + { + redactSecrets: result.manifest.redactSecrets, + scope: result.manifest.scope, + ...(result.manifest.profile !== undefined + ? { profile: result.manifest.profile } + : {}), + ...(result.manifest.baselineDigest !== undefined + ? { baselineDigest: result.manifest.baselineDigest } + : {}), + ...(result.manifest.scope === "database" && + "defaultOwner" in result.manifest + ? { defaultOwner: result.manifest.defaultOwner } + : {}), + }, + flags["prune-unmanaged"] === true, + ); + if (removed.length > 0) { + process.stderr.write( + `Removed ${removed.length} stale .sql file(s) from ${outDir}\n`, + ); + } + process.stderr.write( + `Exported ${result.files.length} file(s) to ${outDir} (layout: ${layout})\n`, + ); + } finally { + await src.end(); + } +} + +/** Discriminated result of {@link prepareApplyFiles}. */ +type PreparedApplyFiles = + | { ok: true; files: SqlFile[]; skipped: { file: string; stmt: string }[] } + | { ok: false; message: string }; + +/** + * Collect and validate the declarative SQL files for `schema apply`, applying the + * database-scope cluster-DDL policy. Delegates to {@link prepareSchemaFiles}. + */ +export function prepareApplyFiles( + dir: string, + scope: "database" | "cluster", + skipClusterDdl: boolean, +): PreparedApplyFiles { + const files = collectSqlFiles(dir); + const prepared = prepareSchemaFiles(files, { + scope, + skipClusterDdl, + label: dir, + }); + if (!prepared.ok) { + let message = prepared.message + .replace( + /^scope database does not manage/, + "--scope database does not manage", + ) + .replace( + /Use scope cluster \(with an isolated shadow\) to manage roles, or skipClusterDdl to skip these statements\./, + "Use --scope cluster (with --isolated-shadow) to manage roles, or --skip-cluster-ddl to skip these statements.", + ) + .replace(/after skipClusterDdl,/, "after --skip-cluster-ddl,"); + if ( + message.includes("no executable SQL found") && + !message.includes("Check the --dir path") + ) { + message = `${message} Check the --dir path.`; + } + return { ok: false, message }; + } + return prepared; +} + +export async function cmdSchemaApply(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + dir: { type: "value", required: true }, + shadow: { type: "value" }, + target: { type: "value", required: true }, + renames: { type: "value" }, + force: { type: "boolean" }, + "accept-rename": { type: "multi" }, + profile: { type: "value" }, + "restrict-to-applier": { type: "boolean" }, + "strict-coverage": { type: "boolean" }, + "strict-function-bodies": { type: "boolean" }, + "no-reorder": { type: "boolean" }, + "unsafe-show-secrets": { type: "boolean" }, + "isolated-shadow": { type: "boolean" }, + scope: { type: "value" }, + "skip-cluster-ddl": { type: "boolean" }, + "keep-shadow": { type: "boolean" }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError( + `${err.message}\nUsage: pgdelta schema apply --dir --target [--shadow ] ` + + `[--renames auto|prompt|off] [--force] [--accept-rename =] ... ` + + `[--profile ${PROFILE_IDS}] [--restrict-to-applier] [--strict-coverage] [--strict-function-bodies] [--no-reorder] [--unsafe-show-secrets] [--isolated-shadow] [--scope database|cluster] [--skip-cluster-ddl] [--keep-shadow]\n` + + ` --shadow omitted: a co-located shadow database is created on the target's cluster (database scope only) and dropped after.`, + ); + } + throw err; + } + + const { flags } = parsed; + const dir = flags["dir"]; + const shadowFlag = flags["shadow"]; + const targetUrl = flags["target"]; + const force = flags["force"]; + const acceptRenameRaw = flags["accept-rename"]; + + // The export directory's manifest (redaction mode, profile, scope), consulted + // once and reused. Absent for hand-authored dirs / older exports. + const manifest = readExportManifest(dir); + + // Management scope (declarative default: database-local). `cluster` scope + // manages roles/memberships/ownership and therefore REQUIRES an isolated + // shadow — loading cluster-global role DDL onto a shared shadow cluster would + // mutate roles other databases use. `database` scope treats roles as ambient + // (assumed to exist at apply time) and never diffs them (§scope). Prefer the + // flag, else the manifest's scope, else database; reject a flag that + // contradicts the manifest (mirrors the profile reconciliation). + const scopeFlag = flags["scope"]; + if ( + scopeFlag !== undefined && + scopeFlag !== "database" && + scopeFlag !== "cluster" + ) { + throw new UsageError( + `--scope must be database or cluster (got: ${scopeFlag})`, + ); + } + if ( + (scopeFlag === "database" || scopeFlag === "cluster") && + manifest?.scope !== undefined && + scopeFlag !== manifest.scope + ) { + throw new UsageError( + `--scope ${scopeFlag} contradicts the export manifest scope (${manifest.scope}); re-export or drop --scope.`, + ); + } + let scope: ManagementScope = "database"; + if (scopeFlag === "database" || scopeFlag === "cluster") { + scope = scopeFlag; + } else if (manifest?.scope !== undefined) { + scope = manifest.scope; + } + if (scope === "cluster" && !flags["isolated-shadow"]) { + throw new UsageError( + `--scope cluster manages cluster-global roles and must run against a dedicated shadow cluster; pass --isolated-shadow.`, + ); + } + + // --renames default for CLI is "prompt" + let renames: RenameMode = "prompt"; + if (flags["renames"] !== undefined) { + const v = flags["renames"]; + if (v !== "auto" && v !== "prompt" && v !== "off") { + throw new UsageError( + `--renames must be auto, prompt, or off (got: ${v})`, + ); + } + renames = v; + } + + // parse --accept-rename = entries + const acceptRenames: Array<{ from: StableId; to: StableId }> = []; + for (const entry of acceptRenameRaw) { + const eqIdx = entry.indexOf("="); + if (eqIdx === -1) { + throw new UsageError( + `--accept-rename value must be in = form (got: ${entry})`, + ); + } + const fromStr = entry.slice(0, eqIdx); + const toStr = entry.slice(eqIdx + 1); + try { + acceptRenames.push({ from: parseId(fromStr), to: parseId(toStr) }); + } catch (e) { + throw new UsageError( + `--accept-rename: invalid stable-id in "${entry}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + // Collect + validate the declarative SQL files: refuse an empty/comment-only + // dir (would build an empty shadow and drop every managed object), and enforce + // the database-scope cluster-DDL policy (refuse, or --skip-cluster-ddl and log + // each skip). Extracted to prepareApplyFiles so the guards — including the + // re-check that a --skip-cluster-ddl strip did not empty the input — are unit + // tested. + const prepared = prepareApplyFiles( + dir, + scope, + flags["skip-cluster-ddl"] === true, + ); + if (!prepared.ok) { + throw new UsageError(`schema apply: ${prepared.message}`); + } + for (const s of prepared.skipped) { + process.stderr.write( + ` SKIP cluster DDL (--skip-cluster-ddl) in ${s.file}: ${s.stmt}\n`, + ); + } + let files = prepared.files; + + // The profile MUST match the one the directory was exported with: `schema + // export --profile supabase` projects out platform schemas/roles, so applying + // that directory under the default (raw) profile would extract the target's + // platform state as drift and plan destructive drops. Default to the profile + // stamped in the export manifest and reject a contradicting --profile before + // opening any connection, exactly as `apply`/`prove` reconcile plan artifacts + // (review P1). + const manifestProfile = manifest?.profile; + // effectiveProfileId throws UsageError on a --profile that contradicts the + // manifest; it propagates to main() (→ message + exit 2). + const profileId: string | undefined = effectiveProfileId( + flags["profile"], + manifestProfile, + ); + + // Resolve the shadow: an explicit --shadow, else a co-located throwaway + // database created on the TARGET's own cluster (quick mode). Co-located is + // database scope only — it shares the target's cluster, so it must never carry + // cluster-global role DDL. The created database is dropped in the finally. + let coLocated: CoLocatedShadow | undefined; + let shadowUrl: string; + if (shadowFlag !== undefined) { + shadowUrl = shadowFlag; + } else { + if (scope === "cluster") { + throw new UsageError( + `schema apply --scope cluster needs an explicit --shadow to a dedicated cluster; a co-located shadow (no --shadow) is database scope only.`, + ); + } + process.stderr.write( + `No --shadow given; creating a co-located shadow database on the target's cluster...\n`, + ); + try { + coLocated = await provisionCoLocatedShadow(targetUrl, { + keep: flags["keep-shadow"], + }); + } catch (e) { + if (isShadowProvisionError(e)) { + // Operational failure (couldn't create the shadow DB): keep the exit-2 + // channel. No pools exist yet, so nothing to release. + process.stderr.write(`schema apply: ${e.message}\n`); + throw new CliExit(2); + } + throw e; + } + shadowUrl = coLocated.url; + process.stderr.write(` Created shadow database ${coLocated.name}\n`); + } + + const shadow = makePool(shadowUrl); + const tgt = makePool(targetUrl); + // Close the pools and drop the co-located throwaway database. Called by the + // `finally` below, which always runs — the early-exit paths inside the try now + // THROW (CliExit / SchemaFrontendError / UsageError) instead of calling + // process.exit, so cleanup happens on every exit and the co-located shadow is + // never leaked (`--keep-shadow` keeps it). + const releaseResources = async (): Promise => { + await Promise.all([shadow.end(), tgt.end()]); + if (coLocated !== undefined) { + if (flags["keep-shadow"]) { + process.stderr.write(` Kept shadow database ${coLocated.name}\n`); + } + await coLocated.cleanup(); + } + }; + try { + const redactSecrets = + manifest?.redactSecrets ?? !flags["unsafe-show-secrets"]; + const profile = profileById(profileId); + + // Hand-authored / pre-feature dirs: surface the same NOTE the old path did. + if ( + scope === "database" && + (manifest === undefined || !("defaultOwner" in manifest)) + ) { + process.stderr.write( + ` NOTE: the directory records no default owner, so it is applied verbose ` + + `(all ownership honored as written). Re-export with the current pg-delta to ` + + `record a default owner.\n`, + ); + } + + process.stderr.write("Extracting target / loading shadow...\n"); + // planSchemaFiles throws SchemaFrontendError (baseline mismatch, cron + // precheck, …) / UsageError; both propagate to main() (→ message + exit 2). + // A ShadowLoadError propagates too (main's generic path → details + exit 1). + // The finally below still runs, dropping any co-located shadow. + const planned = await planSchemaFiles(tgt.pool, shadow.pool, files, { + profile, + scope, + ...(manifest !== undefined ? { manifest } : {}), + redactSecrets, + skipClusterDdl: flags["skip-cluster-ddl"] === true, + isolatedShadow: flags["isolated-shadow"] === true, + seedAssumedSchemas: coLocated !== undefined, + renames, + ...(acceptRenames.length > 0 ? { acceptRenames } : {}), + resolveOptions: { + restrictToApplier: flags["restrict-to-applier"], + }, + strictFunctionBodies: flags["strict-function-bodies"] === true, + reorder: !flags["no-reorder"], + onWarning: (message) => { + if (message.startsWith("the directory records no default owner")) { + // already printed above for CLI parity + return; + } + process.stderr.write(` WARNING: ${message}\n`); + }, + onShadowLoadError: (error, ctx) => { + let enriched = rewriteReorderedShadowError( + error, + ctx.orderedFiles!, + ctx.originalSqlByName, + ); + const nonConverging = error.details.some( + (d) => + d.code === "stuck_statement" || d.code === "max_rounds_exceeded", + ); + if (nonConverging) { + enriched = appendShadowCycleHint( + enriched, + ctx.cycles, + ctx.originalSqlByName, + ); + } + return enriched; + }, + }); + + printDiagnostics(planned.loadDiagnostics, { label: "shadow" }); + printDiagnostics(planned.targetDiagnostics, { label: "target" }); + exitIfBlocking([...planned.loadDiagnostics, ...planned.targetDiagnostics], { + strictCoverage: flags["strict-coverage"], + action: "apply", + }); + + const thePlan = planned.plan; + process.stderr.write(`Planning: ${thePlan.actions.length} action(s)\n`); + + if (renames === "prompt" && thePlan.renameCandidates.length > 0) { + process.stderr.write(`\nRename candidates:\n`); + for (const c of thePlan.renameCandidates) { + const fromStr = encodeId(c.from); + const toStr = encodeId(c.to); + if (c.status === "unambiguous") { + process.stderr.write( + ` ? Rename ${fromStr} -> ${toStr}? (${c.status})\n`, + ); + process.stderr.write( + ` To confirm, rerun with: --accept-rename ${fromStr}=${toStr}\n`, + ); + } else { + process.stderr.write( + ` ${c.status}: ${fromStr} -> ${toStr}${c.reason ? ` (${c.reason})` : ""}\n`, + ); + } + } + process.stderr.write("\n"); + } + + if (thePlan.actions.length === 0) { + process.stderr.write("Target is already up to date.\n"); + return; + } + + if (force) { + process.stderr.write("WARNING: --force disables the fingerprint gate.\n"); + } + + const report = await apply(thePlan, tgt.pool, { + ...planned.applyOptions, + reextract: (p) => + planned.extract(p, { redactSecrets: planned.redactSecrets }), + fingerprintGate: !force, + }); + + if (report.status === "applied") { + process.stderr.write( + `Applied ${report.appliedActions} action(s) successfully.\n`, + ); + } else { + process.stderr.write("Apply failed!\n"); + if (report.error) { + process.stderr.write( + ` action[${report.error.actionIndex}]: ${report.error.message}\n`, + ); + process.stderr.write(` sql: ${report.error.sql}\n`); + } + // The finally below releases resources (drops any co-located shadow); + // main() maps CliExit(1) → exit 1 with no extra banner. + throw new CliExit(1); + } + } finally { + // drop the co-located throwaway database (after our pools close so nothing + // holds a connection to it); --keep-shadow makes cleanup a no-op. + await releaseResources(); + } +} + +export async function cmdSchemaLint(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + dir: { type: "value", required: true }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError( + `${err.message}\nUsage: pgdelta schema lint --dir `, + ); + } + throw err; + } + + const { flags } = parsed; + const dir = flags["dir"]; + const files = collectSqlFiles(dir); + if (files.length === 0) { + process.stderr.write(`No .sql files found in ${dir}.\n`); + return; + } + + // Pure static analysis — no shadow/target database. Surfaces pg-topo + // diagnostics (cycles, unknown statements, duplicate producers, …) for + // proactive authoring; deliberately kept OUT of the apply path so apply stays + // Postgres-truth. Throws ReorderUnavailableError (with an install hint) when + // @supabase/pg-topo is absent. + const { cycles, diagnostics } = await analyzeForShadow(files); + const originalSqlByName = new Map(files.map((f) => [f.name, f.sql])); + const report = formatLintReport({ cycles, diagnostics }, originalSqlByName); + + process.stderr.write(`Linted ${files.length} file(s) in ${dir}.\n`); + for (const line of report.lines) { + process.stderr.write(` ${line}\n`); + } + if (report.lines.length === 0) { + process.stderr.write("No issues found.\n"); + } else { + process.stderr.write( + `\n${report.errorCount} error(s), ${report.warningCount} warning(s).\n`, + ); + } + if (report.blocking) { + throw new CliExit(1); + } +} diff --git a/packages/pg-delta/src/cli/commands/snapshot.ts b/packages/pg-delta/src/cli/commands/snapshot.ts new file mode 100644 index 000000000..eeae9639b --- /dev/null +++ b/packages/pg-delta/src/cli/commands/snapshot.ts @@ -0,0 +1,81 @@ +/** + * snapshot --source --out [--profile ] + * Extract from the source database and write a snapshot file. + * Replaces the old `catalog-export` command. + * + * `--profile` uses the profile's handler-aware extractor, so a snapshot intended + * as a baseline captures the SAME facts (extension-intent rows like pg_cron jobs, + * `managedBy` edges) that a profile-aware plan/export/apply extraction produces — + * otherwise those handler facts would never hash-match and the baseline would not + * subtract them. A profile's policy/baseline projection is deliberately NOT + * applied: a baseline is the raw handler-aware capture, not a managed view. + * + * Baseline resolution is SKIPPED (`skipBaseline`): a profile that declares a + * `"baseline"` is very often being used to CAPTURE that very file, so requiring + * it to already exist would be a chicken-and-egg — the first capture (or a + * regeneration after the file is deleted / the base image changes) must not fail + * loading a baseline it is about to write. + */ +import { saveSnapshot } from "../../frontends/snapshot-file.ts"; +import { exitIfBlocking, printDiagnostics } from "../diagnostics.ts"; +import { makePool } from "../pool.ts"; +import { parseFlags, UsageError } from "../flags.ts"; +import { PROFILE_IDS, resolveCliProfile } from "../profile.ts"; + +export async function cmdSnapshot(args: string[]): Promise { + let parsed; + try { + parsed = parseFlags(args, { + source: { type: "value", required: true }, + out: { type: "value", required: true }, + profile: { type: "value" }, + "strict-coverage": { type: "boolean" }, + "unsafe-show-secrets": { type: "boolean" }, + }); + } catch (err) { + if (err instanceof UsageError) { + throw new UsageError( + `${err.message}\nUsage: pgdelta snapshot --source --out [--profile ${PROFILE_IDS}] [--strict-coverage] [--unsafe-show-secrets]`, + ); + } + throw err; + } + + const { flags } = parsed; + const sourceUrl = flags["source"]; + const outPath = flags["out"]; + const redactSecrets = !flags["unsafe-show-secrets"]; + + const src = makePool(sourceUrl); + try { + // handler-aware extraction (profile handlers only); no policy/baseline + // projection — a baseline snapshot is the raw capture. skipBaseline avoids + // the chicken-and-egg of a profile that declares the baseline this very + // command is capturing. + const ctx = await resolveCliProfile(src.pool, flags["profile"], { + redactSecrets, + skipBaseline: true, + }); + process.stderr.write("Extracting...\n"); + const { factBase, pgVersion, diagnostics } = await ctx.extract(src.pool, { + redactSecrets, + }); + printDiagnostics(diagnostics); + exitIfBlocking(diagnostics, { + strictCoverage: flags["strict-coverage"], + action: "snapshot", + }); + // record the redaction mode so `drift` re-extracts the live env identically, + // and stamp the capture profile so `drift`/`prove` re-extract with the SAME + // handler-aware profile the facts were produced with. `null` = captured raw + // (mirrors ExportManifest.defaultOwner's absent-vs-null precedent), so a + // later flagless drift adopts it and a contradicting --profile fails closed. + const profileStamp = ctx.id === "raw" ? null : ctx.id; + saveSnapshot(factBase, pgVersion, outPath, redactSecrets, profileStamp); + process.stderr.write( + `Snapshot saved to ${outPath} (${factBase.facts().length} facts, pg ${pgVersion})\n`, + ); + } finally { + await src.end(); + } +} diff --git a/packages/pg-delta/src/cli/commands/sync.ts b/packages/pg-delta/src/cli/commands/sync.ts deleted file mode 100644 index 6388338f8..000000000 --- a/packages/pg-delta/src/cli/commands/sync.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Sync command - plan and apply changes in one go with confirmation prompt. - */ - -import { buildCommand, type CommandContext } from "@stricli/core"; -import type { FilterDSL } from "../../core/integrations/filter/dsl.ts"; -import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts"; -import { applyPlan } from "../../core/plan/apply.ts"; -import { createPlan } from "../../core/plan/index.ts"; -import { resolveIntegrationOptions } from "../utils/integrations.ts"; -import { - formatPlanForDisplay, - handleApplyResult, - promptConfirmation, - validatePlanRisk, -} from "../utils.ts"; - -export const syncCommand = buildCommand({ - parameters: { - flags: { - source: { - kind: "parsed", - brief: "Source database connection URL (current state)", - parse: String, - }, - target: { - kind: "parsed", - brief: "Target database connection URL (desired state)", - parse: String, - }, - yes: { - kind: "boolean", - brief: "Skip confirmation prompt and apply changes automatically", - optional: true, - }, - unsafe: { - kind: "boolean", - brief: "Allow data-loss operations (unsafe mode)", - optional: true, - }, - role: { - kind: "parsed", - brief: - "Role to use when executing the migration (SET ROLE will be added to statements).", - parse: String, - optional: true, - }, - filter: { - kind: "parsed", - brief: - 'Filter DSL as inline JSON to filter changes (e.g., \'{"schema":"public"}\').', - parse: (value: string): FilterDSL => { - try { - return JSON.parse(value) as FilterDSL; - } catch (error) { - throw new Error( - `Invalid filter JSON: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - optional: true, - }, - serialize: { - kind: "parsed", - brief: - 'Serialize DSL as inline JSON array (e.g., \'[{"when":{"type":"schema"},"options":{"skipAuthorization":true}}]\').', - parse: (value: string): SerializeDSL => { - try { - return JSON.parse(value) as SerializeDSL; - } catch (error) { - throw new Error( - `Invalid serialize JSON: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - optional: true, - }, - integration: { - kind: "parsed", - brief: - "Integration name (e.g., 'supabase') or path to integration JSON file (must end with .json). Loads from core/integrations/ or file path.", - parse: String, - optional: true, - }, - }, - aliases: { - s: "source", - t: "target", - y: "yes", - u: "unsafe", - }, - }, - docs: { - brief: "Plan and apply schema changes in one go", - fullDescription: ` -Compute the schema diff between two PostgreSQL databases (source → target), -display the plan, prompt for confirmation, and apply changes if confirmed. - -Use --yes to skip the confirmation prompt and apply changes automatically. -Safe by default: refuses data-loss changes unless --unsafe is set. - -Exit codes: - 0 - Success (changes applied or no changes detected) - 1 - Error occurred - 2 - User cancelled or changes detected but not applied - `.trim(), - }, - async func( - this: CommandContext, - flags: { - source: string; - target: string; - yes?: boolean; - unsafe?: boolean; - role?: string; - filter?: FilterDSL; - serialize?: SerializeDSL; - integration?: string; - }, - ) { - const { filter, serialize } = await resolveIntegrationOptions({ - filter: flags.filter, - serialize: flags.serialize, - integration: flags.integration, - }); - - // 1. Create the plan - const planResult = await createPlan(flags.source, flags.target, { - role: flags.role, - filter, - serialize, - }); - if (!planResult) { - this.process.stdout.write("No changes detected.\n"); - process.exitCode = 0; - return; - } - - // 2. Display the plan - const { content } = formatPlanForDisplay(planResult, "tree"); - this.process.stdout.write(content); - - // 3. Validate risk (suppress warning since it's already shown in the plan) - const validation = validatePlanRisk(planResult.plan, !!flags.unsafe, this, { - suppressWarning: true, - }); - if (!validation.valid) { - process.exitCode = validation.exitCode ?? 1; - return; - } - - // 4. Prompt for confirmation (unless --yes) - if (!flags.yes) { - const confirmed = await promptConfirmation( - "Apply these changes? (y/N) ", - this, - ); - if (!confirmed) { - process.exitCode = 2; - return; - } - } - - // 5. Apply the plan - const result = await applyPlan( - planResult.plan, - flags.source, - flags.target, - { - verifyPostApply: true, - }, - ); - - // 6. Handle apply result - const { exitCode } = handleApplyResult(result, this); - process.exitCode = exitCode; - }, -}); diff --git a/packages/pg-delta/src/cli/diagnostics.test.ts b/packages/pg-delta/src/cli/diagnostics.test.ts new file mode 100644 index 000000000..2198cdab8 --- /dev/null +++ b/packages/pg-delta/src/cli/diagnostics.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import type { Diagnostic } from "../core/diagnostic.ts"; +import { hasBlockingDiagnostics } from "./diagnostics.ts"; + +const unmodeled: Diagnostic = { + code: "unmodeled_kind", + severity: "warning", + message: "1 unmodeled cast", +}; +const orphan: Diagnostic = { + code: "orphaned_satellite", + severity: "info", + message: "dropped", +}; +const unresolvedLabel: Diagnostic = { + code: "unresolved_security_label", + severity: "warning", + message: "1 security label on an unsupported object", +}; +const err: Diagnostic = { code: "boom", severity: "error", message: "fatal" }; + +describe("hasBlockingDiagnostics", () => { + test("an error-severity diagnostic always blocks", () => { + expect(hasBlockingDiagnostics([err])).toBe(true); + expect(hasBlockingDiagnostics([err], { strictCoverage: false })).toBe(true); + }); + + test("unmodeled_kind blocks ONLY in strict-coverage mode", () => { + expect(hasBlockingDiagnostics([unmodeled])).toBe(false); + expect(hasBlockingDiagnostics([unmodeled], { strictCoverage: true })).toBe( + true, + ); + }); + + test("unresolved_security_label blocks ONLY in strict-coverage mode", () => { + // a valid SECURITY LABEL on an unsupported object (language/database/large + // object/tablespace) would otherwise be silently absent from the artifact + expect(hasBlockingDiagnostics([unresolvedLabel])).toBe(false); + expect( + hasBlockingDiagnostics([unresolvedLabel], { strictCoverage: true }), + ).toBe(true); + }); + + test("info/warning diagnostics do not block in the default mode", () => { + expect(hasBlockingDiagnostics([orphan, unmodeled])).toBe(false); + expect(hasBlockingDiagnostics([])).toBe(false); + }); +}); diff --git a/packages/pg-delta/src/cli/diagnostics.ts b/packages/pg-delta/src/cli/diagnostics.ts new file mode 100644 index 000000000..07903143f --- /dev/null +++ b/packages/pg-delta/src/cli/diagnostics.ts @@ -0,0 +1,101 @@ +/** + * One renderer + one gate for the shared `Diagnostic` shape (core/diagnostic.ts). + * + * Extraction and the SQL-file loader both return diagnostics; before this + * module the CLI silently dropped them (review finding 2), so unmodeled-kind + * detection — and any other warning — was invisible. Every extracting command + * now prints diagnostics to STDERR (stdout carries machine output like the plan + * JSON) and, in strict-coverage mode, refuses to produce an apply artifact + * while the engine cannot manage every user object. + */ +import type { Diagnostic } from "../core/diagnostic.ts"; +import { encodeId } from "../core/stable-id.ts"; +import { CliExit } from "./flags.ts"; + +const SEVERITY_LABEL: Record = { + error: "ERROR", + warning: "WARNING", + info: "INFO", +}; + +/** + * Print diagnostics to stderr, one line each: `SEVERITY [code] subject: message`. + * No-op on an empty list. `label` prefixes the source (e.g. "source", "desired"). + */ +export function printDiagnostics( + diagnostics: readonly Diagnostic[], + options: { label?: string } = {}, +): void { + const prefix = options.label ? `[${options.label}] ` : ""; + for (const d of diagnostics) { + const subject = d.subject ? ` ${encodeId(d.subject)}:` : ""; + process.stderr.write( + `${prefix}${SEVERITY_LABEL[d.severity]} [${d.code}]${subject} ${d.message}\n`, + ); + } +} + +/** + * Diagnostic codes that escalate to blocking under `--strict-coverage`: each + * marks a user object the engine cannot faithfully carry into the artifact, so + * strict mode refuses rather than silently ship an incomplete migration. + * - `unmodeled_kind`: a user object of a kind the engine does not model. + * - `unresolved_security_label`: a valid SECURITY LABEL on an unsupported + * object (language / database / large object / tablespace) — it cannot + * resolve to a managed id, so the label would be silently missing. + */ +const STRICT_COVERAGE_CODES: ReadonlySet = new Set([ + "unmodeled_kind", + "unresolved_security_label", +]); + +/** + * Whether diagnostics should HALT a command before it produces something to + * apply: + * - an error-severity diagnostic always blocks; + * - in strict-coverage mode, a {@link STRICT_COVERAGE_CODES} warning blocks + * too — the engine refuses to act while user objects it cannot faithfully + * manage exist. + */ +export function hasBlockingDiagnostics( + diagnostics: readonly Diagnostic[], + options: { strictCoverage?: boolean } = {}, +): boolean { + return diagnostics.some( + (d) => + d.severity === "error" || + (options.strictCoverage === true && STRICT_COVERAGE_CODES.has(d.code)), + ); +} + +/** + * Throw {@link CliExit}(3) if the (already-printed) diagnostics are blocking — + * the guard every extracting CLI command applies after printing. Multi-source + * commands print each source with {@link printDiagnostics} and pass the + * COMBINED set here so the refusal message reflects the whole run. `action` + * names what is being refused (e.g. "plan", "apply"). Never returns when + * blocking (the throw propagates to main(), which maps CliExit → exit 3). + */ +export function exitIfBlocking( + diagnostics: readonly Diagnostic[], + options: { strictCoverage?: boolean; action?: string } = {}, +): void { + if (!hasBlockingDiagnostics(diagnostics, options)) return; + const coverageGaps = diagnostics.filter((d) => + STRICT_COVERAGE_CODES.has(d.code), + ); + const action = options.action ?? "continue"; + if (options.strictCoverage && coverageGaps.length > 0) { + process.stderr.write( + `\nRefusing to ${action}: --strict-coverage is set and ${coverageGaps.length} ` + + `object(s) cannot be faithfully managed by this engine (unmodeled kinds / ` + + `unresolved security labels — see above). ` + + `Drop them, or rerun without --strict-coverage to proceed with them unmanaged.\n`, + ); + } else { + process.stderr.write( + `\nRefusing to ${action}: blocking diagnostics present (see above).\n`, + ); + } + throw new CliExit(3); +} diff --git a/packages/pg-delta/src/cli/exit-code.test.ts b/packages/pg-delta/src/cli/exit-code.test.ts deleted file mode 100644 index af8e77913..000000000 --- a/packages/pg-delta/src/cli/exit-code.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { getCommandExitCode, setCommandExitCode } from "./exit-code.ts"; - -describe("exit-code", () => { - test("getCommandExitCode returns undefined when never set", () => { - expect(getCommandExitCode()).toBeUndefined(); - }); - - test("setCommandExitCode then getCommandExitCode returns the value", () => { - setCommandExitCode(2); - expect(getCommandExitCode()).toBe(2); - }); - - test("setCommandExitCode overwrites previous value", () => { - setCommandExitCode(2); - setCommandExitCode(0); - expect(getCommandExitCode()).toBe(0); - }); -}); diff --git a/packages/pg-delta/src/cli/exit-code.ts b/packages/pg-delta/src/cli/exit-code.ts deleted file mode 100644 index ec0980fc8..000000000 --- a/packages/pg-delta/src/cli/exit-code.ts +++ /dev/null @@ -1,7 +0,0 @@ -let _exitCode: number | undefined; -export function setCommandExitCode(code: number) { - _exitCode = code; -} -export function getCommandExitCode() { - return _exitCode; -} diff --git a/packages/pg-delta/src/cli/flags.ts b/packages/pg-delta/src/cli/flags.ts new file mode 100644 index 000000000..59c0e4d9f --- /dev/null +++ b/packages/pg-delta/src/cli/flags.ts @@ -0,0 +1,129 @@ +/** + * Minimal typed flag parser shared by all CLI command handlers. + * + * Usage: + * const { flags, positionals } = parseFlags(args, { + * source: { type: "value", required: true }, + * desired: { type: "value", required: true }, + * compact: { type: "boolean" }, + * out: { type: "value" }, + * }); + * + * - "value" flags consume the next argv token as their value. + * - "boolean" flags are true when present, absent = undefined. + * - "multi" flags are repeatable; each occurrence appends one value; result is string[]. + * - required: true on a "value" flag makes parseFlags throw a UsageError when absent. + * - Unknown flags throw a UsageError (exit code 2 semantics). + * - Positional args (non-flag tokens) are collected into `positionals`. + */ + +export class UsageError extends Error { + readonly exitCode = 2; + constructor(message: string) { + super(message); + this.name = "UsageError"; + } +} + +/** + * Request a specific process exit code from `main()` without a generic + * "Error:" banner. Command handlers use this for operation-result exits + * (apply failed → 1, drift detected → 1, render no-op → 3, blocking + * diagnostics → 3, …) so command bodies NEVER call `process.exit` directly — + * `main()` is the sole exiter. A command writes its own human output to + * stdout/stderr first, then throws `CliExit(code)`; `main()` just maps the + * code. Keeping command bodies exit-free is what makes them safe to call + * in-process (tests, embedders): a stray `process.exit` inside a command would + * otherwise tear down the whole host process (e.g. the bun test runner). + */ +export class CliExit extends Error { + constructor(readonly code: number) { + super(`process exit ${code}`); + this.name = "CliExit"; + } +} + +export type FlagSpec = + | { type: "value"; required?: boolean } + | { type: "boolean" } + | { type: "multi" }; + +export type FlagsDef = Record; + +/** Infer the result type from a FlagsDef. */ +export type ParsedFlags = { + [K in keyof T]: T[K] extends { type: "boolean" } + ? boolean + : T[K] extends { type: "multi" } + ? string[] + : T[K] extends { type: "value"; required: true } + ? string + : string | undefined; +}; + +export interface ParseResult { + flags: ParsedFlags; + positionals: string[]; +} + +export function parseFlags( + args: string[], + spec: T, +): ParseResult { + // initialise result with defaults + const result: Record = {}; + for (const [name, def] of Object.entries(spec)) { + if (def.type === "boolean") { + result[name] = false; + } else if (def.type === "multi") { + result[name] = []; + } else { + result[name] = undefined; + } + } + + const positionals: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i] as string; // i < args.length guarantees defined + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } + + const flagName = arg.slice(2); // strip "--" + const def = spec[flagName]; + + if (def === undefined) { + throw new UsageError(`Unknown flag: --${flagName}`); + } + + if (def.type === "boolean") { + result[flagName] = true; + } else if (def.type === "value") { + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new UsageError(`Flag --${flagName} requires a value`); + } + result[flagName] = next; + i++; + } else { + // multi + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new UsageError(`Flag --${flagName} requires a value`); + } + (result[flagName] as string[]).push(next); + i++; + } + } + + // check required value flags + for (const [name, def] of Object.entries(spec)) { + if (def.type === "value" && def.required && result[name] === undefined) { + throw new UsageError(`Missing required flag: --${name}`); + } + } + + return { flags: result as ParsedFlags, positionals }; +} diff --git a/packages/pg-delta/src/cli/formatters/index.ts b/packages/pg-delta/src/cli/formatters/index.ts deleted file mode 100644 index 7bc547a9e..000000000 --- a/packages/pg-delta/src/cli/formatters/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Formatters for displaying plans in various formats. - */ - -export { formatTree } from "./tree/tree.ts"; diff --git a/packages/pg-delta/src/cli/formatters/tree/tree-builder.ts b/packages/pg-delta/src/cli/formatters/tree/tree-builder.ts deleted file mode 100644 index 5b091e3e3..000000000 --- a/packages/pg-delta/src/cli/formatters/tree/tree-builder.ts +++ /dev/null @@ -1,380 +0,0 @@ -/** - * Builds a generic tree structure from a HierarchicalPlan. - * Shows only structural changes (scope === "object"), grouped for readability. - */ - -import { - AlterTableAddColumn, - AlterTableAlterColumnDropDefault, - AlterTableAlterColumnDropNotNull, - AlterTableAlterColumnSetDefault, - AlterTableAlterColumnSetNotNull, - AlterTableAlterColumnType, - AlterTableDropColumn, -} from "../../../core/objects/table/changes/table.alter.ts"; -import type { - ChangeEntry, - ChangeGroup, - HierarchicalPlan, -} from "../../../core/plan/index.ts"; -import { getObjectName } from "../../../core/plan/serialize.ts"; -import type { TreeGroup, TreeItem } from "./tree-renderer.ts"; - -function withCount(name: string, count: number): string { - return count > 0 ? `${name} ${count}` : name; -} - -function structural(group: ChangeGroup): ChangeGroup { - const onlyStructural = (entry: ChangeEntry) => - entry.original.scope === "object"; - return { - create: group.create.filter(onlyStructural), - alter: group.alter.filter(onlyStructural), - drop: group.drop.filter(onlyStructural), - }; -} - -function hasStructural(group: ChangeGroup): boolean { - const structuralGroup = structural(group); - return ( - structuralGroup.create.length + - structuralGroup.alter.length + - structuralGroup.drop.length > - 0 - ); -} - -function symbol(group: ChangeGroup): string { - const g = structural(group); - if (g.create.length > 0) return "+"; - if (g.alter.length > 0) return "~"; - if (g.drop.length > 0) return "-"; - return ""; -} - -function displayName(entry: ChangeEntry): string { - const { original } = entry; - if ( - original instanceof AlterTableAddColumn || - original instanceof AlterTableDropColumn || - original instanceof AlterTableAlterColumnType || - original instanceof AlterTableAlterColumnSetDefault || - original instanceof AlterTableAlterColumnDropDefault || - original instanceof AlterTableAlterColumnSetNotNull || - original instanceof AlterTableAlterColumnDropNotNull - ) { - return original.column.name; - } - return getObjectName(original); -} - -function toItems(group: ChangeGroup): TreeItem[] { - const items: TreeItem[] = []; - const s = structural(group); - for (const entry of s.create) { - items.push({ name: `+ ${displayName(entry)}` }); - } - for (const entry of s.alter) { - items.push({ name: `~ ${displayName(entry)}` }); - } - for (const entry of s.drop) { - items.push({ name: `- ${displayName(entry)}` }); - } - return items; -} - -function tableChildren( - table: HierarchicalPlan["schemas"][string]["tables"][string], -): TreeGroup[] { - const groups: TreeGroup[] = []; - - const pushGroup = (name: string, changeGroup: ChangeGroup) => { - if (hasStructural(changeGroup)) { - const items = toItems(changeGroup); - const label = items.length > 0 ? `${name} ${items.length}` : name; - groups.push({ name: label, items }); - } - }; - - pushGroup("columns", table.columns); - pushGroup("indexes", table.indexes); - pushGroup("triggers", table.triggers); - pushGroup("rules", table.rules); - pushGroup("policies", table.policies); - - const partitionNames = Object.keys(table.partitions).sort(); - if (partitionNames.length > 0) { - const partitionGroups = partitionNames - .map((name) => { - const part = table.partitions[name]; - const sym = tableSymbol(part); - const childGroups = tableChildren(part); - if (!hasStructural(part.changes) && childGroups.length === 0) { - return null; - } - return { - name: sym ? `${sym} ${name}` : name, - groups: childGroups, - }; - }) - .filter(Boolean) as TreeGroup[]; - if (partitionGroups.length > 0) { - groups.push({ - name: withCount("partitions", partitionGroups.length), - groups: partitionGroups, - }); - } - } - - return groups; -} - -function tableSymbol( - table: HierarchicalPlan["schemas"][string]["tables"][string], -): string { - const own = symbol(table.changes); - if (own) return own; - const childSymbols = [ - symbol(table.columns), - symbol(table.indexes), - symbol(table.triggers), - symbol(table.rules), - symbol(table.policies), - ]; - if (childSymbols.includes("+")) return "+"; - if (childSymbols.includes("~")) return "~"; - if (childSymbols.includes("-")) return "-"; - for (const part of Object.values(table.partitions)) { - const s = tableSymbol(part); - if (s) return s; - } - return ""; -} - -function matviewSymbol( - mv: HierarchicalPlan["schemas"][string]["materializedViews"][string], -): string { - const own = symbol(mv.changes); - if (own) return own; - const child = symbol(mv.indexes); - return child; -} - -function matviewChildren( - mv: HierarchicalPlan["schemas"][string]["materializedViews"][string], -): TreeGroup[] { - const groups: TreeGroup[] = []; - if (hasStructural(mv.indexes)) { - const items = toItems(mv.indexes); - groups.push({ - name: items.length > 0 ? `indexes (${items.length})` : "indexes", - items, - }); - } - return groups; -} - -function buildCluster(cluster: HierarchicalPlan["cluster"]): TreeGroup[] { - const groups: Array<[string, ChangeGroup]> = [ - ["roles", cluster.roles], - ["extensions", cluster.extensions], - ["event triggers", cluster.eventTriggers], - ["publications", cluster.publications], - ["subscriptions", cluster.subscriptions], - ["foreign-data-wrappers", cluster.foreignDataWrappers], - ["servers", cluster.servers], - ["user-mappings", cluster.userMappings], - ]; - - return groups - .filter(([, grp]) => hasStructural(grp)) - .map(([name, grp]) => { - const items = toItems(grp); - return { name: withCount(name, items.length), items }; - }); -} - -function buildSchema(schema: HierarchicalPlan["schemas"][string]): TreeGroup[] { - const groups: TreeGroup[] = []; - - const pushItems = (name: string, changeGroup: ChangeGroup) => { - if (hasStructural(changeGroup)) { - const items = toItems(changeGroup); - const label = items.length > 0 ? `${name} ${items.length}` : name; - groups.push({ name: label, items }); - } - }; - - const tableNames = Object.keys(schema.tables).sort(); - if (tableNames.length > 0) { - const tableGroups = tableNames - .map((name) => { - const table = schema.tables[name]; - const changeSymbol = tableSymbol(table); - const children = tableChildren(table); - if (!hasStructural(table.changes) && children.length === 0) return null; - return { - name: changeSymbol ? `${changeSymbol} ${name}` : name, - groups: children, - }; - }) - .filter(Boolean) as TreeGroup[]; - if (tableGroups.length > 0) { - groups.push({ - name: withCount("tables", tableGroups.length), - groups: tableGroups, - }); - } - } - - const viewNames = Object.keys(schema.views).sort(); - if (viewNames.length > 0) { - const viewGroups = viewNames - .map((name) => { - const view = schema.views[name]; - const changeSymbol = tableSymbol(view); - const children = tableChildren(view); - if (!hasStructural(view.changes) && children.length === 0) return null; - return { - name: changeSymbol ? `${changeSymbol} ${name}` : name, - groups: children, - }; - }) - .filter(Boolean) as TreeGroup[]; - if (viewGroups.length > 0) { - groups.push({ - name: withCount("views", viewGroups.length), - groups: viewGroups, - }); - } - } - - const mvNames = Object.keys(schema.materializedViews).sort(); - if (mvNames.length > 0) { - const mvGroups = mvNames - .map((name) => { - const mv = schema.materializedViews[name]; - const changeSymbol = matviewSymbol(mv); - const children = matviewChildren(mv); - if (!hasStructural(mv.changes) && children.length === 0) return null; - return { - name: changeSymbol ? `${changeSymbol} ${name}` : name, - groups: children, - }; - }) - .filter(Boolean) as TreeGroup[]; - if (mvGroups.length > 0) { - groups.push({ - name: withCount("materialized views", mvGroups.length), - groups: mvGroups, - }); - } - } - - pushItems("functions", schema.functions); - pushItems("procedures", schema.procedures); - pushItems("aggregates", schema.aggregates); - pushItems("sequences", schema.sequences); - - const typeGroups: TreeGroup[] = []; - if (hasStructural(schema.types.enums)) { - const items = toItems(schema.types.enums); - typeGroups.push({ - name: items.length > 0 ? `enums ${items.length}` : "enums", - items, - }); - } - if (hasStructural(schema.types.composites)) { - const items = toItems(schema.types.composites); - typeGroups.push({ - name: - items.length > 0 - ? `composite types ${items.length}` - : "composite types", - items, - }); - } - if (hasStructural(schema.types.ranges)) { - const items = toItems(schema.types.ranges); - typeGroups.push({ - name: items.length > 0 ? `ranges ${items.length}` : "ranges", - items, - }); - } - if (hasStructural(schema.types.domains)) { - const items = toItems(schema.types.domains); - typeGroups.push({ - name: items.length > 0 ? `domains ${items.length}` : "domains", - items, - }); - } - if (typeGroups.length > 0) { - groups.push({ name: `types ${typeGroups.length}`, groups: typeGroups }); - } - - pushItems("collations", schema.collations); - - const ftNames = Object.keys(schema.foreignTables).sort(); - if (ftNames.length > 0) { - const ftGroups = ftNames - .map((name) => { - const ft = schema.foreignTables[name]; - const changeSymbol = tableSymbol(ft); - const children = tableChildren(ft); - if (!hasStructural(ft.changes) && children.length === 0) return null; - return { - name: changeSymbol ? `${changeSymbol} ${name}` : name, - groups: children, - }; - }) - .filter(Boolean) as TreeGroup[]; - if (ftGroups.length > 0) { - groups.push({ - name: withCount("foreign tables", ftGroups.length), - groups: ftGroups, - }); - } - } - - return groups; -} - -/** - * Build a generic tree structure from a HierarchicalPlan. - */ -export function buildPlanTree(plan: HierarchicalPlan): TreeGroup { - const rootGroups: TreeGroup[] = []; - - const clusterGroups = buildCluster(plan.cluster); - if (clusterGroups.length > 0) { - rootGroups.push({ - name: withCount("cluster", clusterGroups.length), - groups: clusterGroups, - }); - } - - const schemaNames = Object.keys(plan.schemas).sort(); - if (schemaNames.length > 0) { - const schemaGroups = schemaNames - .map((schemaName) => { - const schema = plan.schemas[schemaName]; - const sym = symbol(schema.changes); - const children = buildSchema(schema); - if (!hasStructural(schema.changes) && children.length === 0) { - return null; - } - const label = sym ? `${sym} ${schemaName}` : schemaName; - return { name: label, groups: children }; - }) - .filter(Boolean) as TreeGroup[]; - - if (schemaGroups.length > 0) { - rootGroups.push({ - name: withCount("schemas", schemaGroups.length), - groups: schemaGroups, - }); - } - } - - return { name: "Plan", groups: rootGroups }; -} diff --git a/packages/pg-delta/src/cli/formatters/tree/tree-renderer.ts b/packages/pg-delta/src/cli/formatters/tree/tree-renderer.ts deleted file mode 100644 index a5dcb67e2..000000000 --- a/packages/pg-delta/src/cli/formatters/tree/tree-renderer.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Generic tree renderer for hierarchical structures. - * Uses dim guides and connectors (├/└) with no trailing rails past last children. - * Helper names favor readability over brevity. - */ - -import chalk from "chalk"; - -const GUIDE_UNIT = "│ "; -const colorCount = (count: string) => chalk.gray(count); -const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); -const stripAnsi = (value: string) => value.replace(ANSI_PATTERN, ""); -const visibleWidth = (value: string) => stripAnsi(value).length; -const splitNameCount = (name: string) => { - const m = name.match(/^(.*?)(\s+)(\d+)$/); - return m - ? { base: m[1], sep: m[2], count: m[3] } - : { base: name, sep: "", count: "" }; -}; -const GROUP_NAMES = [ - "cluster", - "database", - "schemas", - "tables", - "views", - "materialized views", - "functions", - "procedures", - "aggregates", - "sequences", - "types", - "enums", - "composite types", - "ranges", - "domains", - "collations", - "foreign tables", - "columns", - "indexes", - "triggers", - "rules", - "policies", - "partitions", - "roles", - "extensions", - "event triggers", - "publications", - "subscriptions", - "foreign data wrappers", - "servers", - "user mappings", -]; - -interface OperationCounts { - create: number; - alter: number; - drop: number; -} - -/** - * Increment operation counters based on a prefixed label. - */ -function tallyOperation(name: string, counts: OperationCounts): void { - if (name.startsWith("+")) counts.create += 1; - else if (name.startsWith("~")) counts.alter += 1; - else if (name.startsWith("-")) counts.drop += 1; -} - -/** - * Count operations for a single level (no recursion). - */ -function summarizeShallow( - groups?: TreeGroup[], - items?: TreeItem[], -): OperationCounts { - const counts: OperationCounts = { create: 0, alter: 0, drop: 0 }; - - if (items) { - for (const item of items) { - tallyOperation(item.name, counts); - } - } - if (groups) { - for (const group of groups) { - tallyOperation(group.name, counts); - } - } - - return counts; -} - -/** - * A single item in the tree (leaf node). - */ -export interface TreeItem { - /** The display name/label */ - name: string; -} - -/** - * A group in the tree (branch node with children). - */ -export interface TreeGroup { - /** The display name/label */ - name: string; - /** Child items (leaves) */ - items?: TreeItem[]; - /** Child groups (branches) */ - groups?: TreeGroup[]; -} - -/** - * Render a tree structure using plain lines style. - * - * @example - * ```ts - * const tree: TreeGroup = { - * name: "root", - * groups: [ - * { name: "src", items: [{ name: "index.ts" }] }, - * { name: "tests", items: [{ name: "test.ts" }] }, - * ], - * }; - * const output = renderTree(tree); - * // Output: - * // root - * // ├─ src - * // │ └─ index.ts - * // └─ tests - * // └─ test.ts - * ``` - */ -export function renderTree(root: TreeGroup): string { - interface TreeRow { - left: string; - counts?: OperationCounts; - } - const rows: TreeRow[] = []; - if (root.name) { - rows.push({ left: chalk.bold(root.name) }); - } - - const rootItems = root.items ?? []; - const rootGroups = root.groups ?? []; - - // Render root groups at top level (no extra wrapper indentation) - const clusterEntries: TreeGroup[] = []; - const otherGroups: TreeGroup[] = []; - let schemasGroup: TreeGroup | undefined; - - for (const group of rootGroups) { - const { base: label } = splitNameCount(group.name); - if (label === "cluster") { - if (group.items) { - clusterEntries.push(...group.items.map((it) => ({ name: it.name }))); - } - if (group.groups) { - clusterEntries.push(...group.groups); - } - } else if (label === "schemas") { - schemasGroup = group; - } else { - otherGroups.push(group); - } - } - - const orderedRoot: TreeGroup[] = [ - ...sortGroups(clusterEntries), - ...(schemasGroup ? [schemasGroup] : []), - ...sortGroups(otherGroups), - ]; - - const combinedRoot = [ - ...sortItems(rootItems).map((it) => ({ - kind: "item" as const, - name: it.name, - })), - ...orderedRoot.map((g) => ({ kind: "group" as const, group: g })), - ]; - - for (let i = 0; i < combinedRoot.length; i++) { - const node = combinedRoot[i]; - const isLast = i === combinedRoot.length - 1; - if (node.kind === "item") { - renderItem(node.name, [], isLast, rows); - } else { - renderGroup(node.group, [], isLast, rows); - } - } - - const maxLeftWidth = rows.reduce( - (max, row) => Math.max(max, visibleWidth(row.left)), - 0, - ); - - const lines = rows.map(({ left, counts }) => { - if (!counts) return left; - - const parts: string[] = []; - if (counts.create) parts.push(chalk.green.dim(`+${counts.create}`)); - if (counts.alter) parts.push(chalk.yellow.dim(`~${counts.alter}`)); - if (counts.drop) parts.push(chalk.red.dim(`-${counts.drop}`)); - - if (parts.length === 0) return left; - - const summary = parts.join(" "); - const gap = Math.max( - 1, - maxLeftWidth - visibleWidth(left) - visibleWidth(summary) - 1, - ); - const filler = gap > 0 ? " ".repeat(gap) : ""; - - return `${left}${filler} ${summary}`; - }); - - return lines.join("\n"); -} - -function sortItems(items: TreeItem[]): TreeItem[] { - return [...items].sort((firstItem, secondItem) => { - const operationOrder = (name: string) => - name.startsWith("+") - ? 0 - : name.startsWith("~") - ? 1 - : name.startsWith("-") - ? 2 - : 3; - const firstOrder = operationOrder(firstItem.name); - const secondOrder = operationOrder(secondItem.name); - if (firstOrder !== secondOrder) return firstOrder - secondOrder; - const firstLabel = firstItem.name.replace(/^[+~-]\s*/, "").toLowerCase(); - const secondLabel = secondItem.name.replace(/^[+~-]\s*/, "").toLowerCase(); - if (firstLabel < secondLabel) return -1; - if (firstLabel > secondLabel) return 1; - return 0; - }); -} - -function sortGroups(groups: TreeGroup[]): TreeGroup[] { - return [...groups].sort((firstGroup, secondGroup) => { - const operationOrder = (name: string) => - name.startsWith("+") - ? 0 - : name.startsWith("~") - ? 1 - : name.startsWith("-") - ? 2 - : 3; - const firstOrder = operationOrder(firstGroup.name); - const secondOrder = operationOrder(secondGroup.name); - if (firstOrder !== secondOrder) return firstOrder - secondOrder; - const firstLabel = firstGroup.name.replace(/^[+~-]\s*/, "").toLowerCase(); - const secondLabel = secondGroup.name.replace(/^[+~-]\s*/, "").toLowerCase(); - if (firstLabel < secondLabel) return -1; - if (firstLabel > secondLabel) return 1; - return 0; - }); -} - -/** - * Colorize a label; pads non-op labels to align with op-labeled rows. - */ -function colorizeName(name: string, isGroup = false): string { - const { base: rawBase, sep, count } = splitNameCount(name); - const hasOperationSymbol = /^[+~-]\s/.test(rawBase); - const baseName = rawBase.trim(); - - // Colorize items/entities with operation symbols (e.g., "+ customer", "+ customer_email_domain_idx") - if (hasOperationSymbol) { - const symbol = rawBase[0]; - const rest = rawBase.slice(2).trimStart(); - const coloredBase = - symbol === "+" - ? `${chalk.green(symbol)} ${rest}` - : symbol === "~" - ? `${chalk.yellow(symbol)} ${rest}` - : `${chalk.red(symbol)} ${rest}`; - return count ? `${coloredBase}${sep}${colorCount(count)}` : coloredBase; - } - - // Group names (like "tables", "schemas") - dim gray - const baseNameStripped = baseName.replace(/\s*\(\d+\)$/, ""); - - if (GROUP_NAMES.includes(baseNameStripped)) { - const coloredBase = chalk.gray(baseNameStripped); - return count ? `${coloredBase}${sep}${colorCount(count)}` : coloredBase; - } - - const padded = isGroup ? baseName : ` ${baseName}`; - return count ? `${padded}${sep}${colorCount(count)}` : padded; -} - -/** - * Render a group with bullet-style indentation. - */ -function buildPrefix(ancestors: boolean[]): string { - return ancestors - .map((hasSibling) => - hasSibling ? chalk.hex("#4a4a4a")(GUIDE_UNIT) : " ", - ) - .join(""); -} - -/** - * Render a group node (may have child groups/items). Avoids drawing guides past the last child. - */ -function renderGroup( - group: TreeGroup, - ancestors: boolean[], - isLast: boolean, - rows: { left: string; counts?: OperationCounts }[], -): void { - const { base } = splitNameCount(group.name); - const hasOperationSymbol = /^[+~-]\s/.test(base); - const baseNormalized = base - .replace(/^[+~-]\s*/, "") - .replace(/\s*\(\d+\)$/, ""); - const summary = - GROUP_NAMES.includes(base) && - baseNormalized !== "types" && - (group.items || group.groups) - ? summarizeShallow(group.groups, group.items) - : undefined; - const childItems = sortItems(group.items ?? []); - const childGroups = sortGroups(group.groups ?? []); - const children = [ - ...childItems.map((item) => ({ kind: "item" as const, name: item.name })), - ...childGroups.map((childGroup) => ({ - kind: "group" as const, - group: childGroup, - })), - ]; - - const prefix = buildPrefix(ancestors); - const connector = chalk.hex("#4a4a4a")(isLast ? "└ " : "├ "); - const extraGuide = hasOperationSymbol ? "" : connector; - const coloredName = colorizeName(base, true); - rows.push({ - left: `${prefix}${extraGuide}${coloredName}`, - counts: summary, - }); - - for (let i = 0; i < children.length; i++) { - const child = children[i]; - const childIsLast = i === children.length - 1; - const childAncestors = [...ancestors, !isLast]; - if (child.kind === "item") { - renderItem(child.name, childAncestors, childIsLast, rows); - } else { - renderGroup(child.group, childAncestors, childIsLast, rows); - } - } -} - -/** - * Render a leaf item. Non-op rows get a dim connector; op rows start at the prefix. - */ -function renderItem( - name: string, - ancestors: boolean[], - isLast: boolean, - rows: { left: string; counts?: OperationCounts }[], -): void { - const prefix = buildPrefix(ancestors); - const hasOperationSymbol = /^[+~-]\s/.test(name); - const connector = hasOperationSymbol - ? "" - : chalk.hex("#4a4a4a")(isLast ? "└ " : "├ "); - const coloredName = colorizeName(name); - rows.push({ left: `${prefix}${connector}${coloredName}` }); -} diff --git a/packages/pg-delta/src/cli/formatters/tree/tree.ts b/packages/pg-delta/src/cli/formatters/tree/tree.ts deleted file mode 100644 index 05c1ebd42..000000000 --- a/packages/pg-delta/src/cli/formatters/tree/tree.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Tree formatter for displaying plans hierarchically (compact mode). - */ - -import chalk from "chalk"; -import type { HierarchicalPlan } from "../../../core/plan/index.ts"; -import { buildPlanTree } from "./tree-builder.ts"; -import { renderTree } from "./tree-renderer.ts"; - -/** - * Format a plan as a tree structure (compact mode). - */ -export function formatTree(plan: HierarchicalPlan): string { - const lines: string[] = []; - - // Summary - const total = countTotalChanges(plan); - lines.push( - chalk.bold(`📋 Migration Plan: ${total} change${total !== 1 ? "s" : ""}`), - ); - const summary = buildPlanSummaryTable(plan); - if (summary) { - lines.push(""); - lines.push(summary); - } - lines.push(""); - - // Build generic tree structure and render it - const tree = buildPlanTree(plan); - const treeOutput = renderTree(tree); - lines.push(treeOutput); - - // Legend - lines.push(""); - lines.push( - `${chalk.green("+")} create ${chalk.yellow("~")} alter ${chalk.red("-")} drop`, - ); - - return lines.join("\n"); -} - -function countTotalChanges(plan: HierarchicalPlan): number { - const byType: Record< - string, - { create: number; alter: number; drop: number } - > = {}; - countFromHierarchy(plan, byType); - let total = 0; - for (const counts of Object.values(byType)) { - total += counts.create + counts.alter + counts.drop; - } - return total; -} - -/** - * Build summary as a table showing counts by entity type and operation. - * Exported for use by declarative-export to show the same summary style. - */ -function buildPlanSummaryTable(plan: HierarchicalPlan): string { - // Count by object type - const byType: Record< - string, - { create: number; alter: number; drop: number } - > = {}; - countFromHierarchy(plan, byType); - - // Filter to only types with changes - const entries = Object.entries(byType).filter( - ([, counts]) => counts.create + counts.alter + counts.drop > 0, - ); - - if (entries.length === 0) { - return ""; - } - - // Calculate column widths - let maxNameWidth = 0; - let maxCreateWidth = 0; - let maxAlterWidth = 0; - let maxDropWidth = 0; - - for (const [type, counts] of entries) { - const typeStr = type.replace(/_/g, "-"); - maxNameWidth = Math.max(maxNameWidth, typeStr.length); - // For width calculation, use "1" instead of "0" since we'll show "-" for zeros - maxCreateWidth = Math.max( - maxCreateWidth, - counts.create > 0 ? counts.create.toString().length : 1, - ); - maxAlterWidth = Math.max( - maxAlterWidth, - counts.alter > 0 ? counts.alter.toString().length : 1, - ); - maxDropWidth = Math.max( - maxDropWidth, - counts.drop > 0 ? counts.drop.toString().length : 1, - ); - } - - // Ensure minimum widths for headers - maxNameWidth = Math.max(maxNameWidth, "Entity".length); - maxCreateWidth = Math.max(maxCreateWidth, "Create".length); - maxAlterWidth = Math.max(maxAlterWidth, "Alter".length); - maxDropWidth = Math.max(maxDropWidth, "Drop".length); - - const lines: string[] = []; - - // Header - lines.push( - `${chalk.bold("Entity".padEnd(maxNameWidth))} ${chalk.bold("Create".padStart(maxCreateWidth))} ${chalk.bold("Alter".padStart(maxAlterWidth))} ${chalk.bold("Drop".padStart(maxDropWidth))}`, - ); - lines.push( - `${chalk.dim("-".repeat(maxNameWidth))} ${chalk.dim("-".repeat(maxCreateWidth))} ${chalk.dim("-".repeat(maxAlterWidth))} ${chalk.dim("-".repeat(maxDropWidth))}`, - ); - - // Rows - for (const [type, counts] of entries.sort(([a], [b]) => a.localeCompare(b))) { - const typeStr = type.replace(/_/g, "-"); - // Format numbers: show "-" for 0, pad and colorize - const createDisplay = counts.create > 0 ? counts.create.toString() : "-"; - const alterDisplay = counts.alter > 0 ? counts.alter.toString() : "-"; - const dropDisplay = counts.drop > 0 ? counts.drop.toString() : "-"; - - const createStr = - counts.create > 0 - ? chalk.green(createDisplay.padStart(maxCreateWidth)) - : chalk.dim(createDisplay.padStart(maxCreateWidth)); - const alterStr = - counts.alter > 0 - ? chalk.yellow(alterDisplay.padStart(maxAlterWidth)) - : chalk.dim(alterDisplay.padStart(maxAlterWidth)); - const dropStr = - counts.drop > 0 - ? chalk.red(dropDisplay.padStart(maxDropWidth)) - : chalk.dim(dropDisplay.padStart(maxDropWidth)); - - lines.push( - `${typeStr.padEnd(maxNameWidth)} ${createStr} ${alterStr} ${dropStr}`, - ); - } - - return lines.join("\n"); -} - -/** - * Count changes by type from hierarchy. - */ -function countFromHierarchy( - plan: HierarchicalPlan, - byType: Record, -): void { - const addCounts = ( - target: { create: number; alter: number; drop: number }, - source: { create: number; alter: number; drop: number }, - ) => { - target.create += source.create; - target.alter += source.alter; - target.drop += source.drop; - }; - - function countGroup( - group: { - create: ChangeEntry[]; - alter: ChangeEntry[]; - drop: ChangeEntry[]; - }, - type: string, - ) { - if (!byType[type]) { - byType[type] = { create: 0, alter: 0, drop: 0 }; - } - byType[type].create += group.create.length; - byType[type].alter += group.alter.length; - byType[type].drop += group.drop.length; - } - - // Cluster - countGroup(plan.cluster.roles, "role"); - countGroup(plan.cluster.extensions, "extension"); - countGroup(plan.cluster.eventTriggers, "event-trigger"); - countGroup(plan.cluster.publications, "publication"); - countGroup(plan.cluster.subscriptions, "subscription"); - - // Schemas - for (const schema of Object.values(plan.schemas)) { - countGroup(schema.changes, "schema"); - countGroup(schema.functions, "function"); - countGroup(schema.procedures, "procedure"); - countGroup(schema.aggregates, "aggregate"); - countGroup(schema.sequences, "sequence"); - countGroup(schema.collations, "collation"); - - // Tables - for (const table of Object.values(schema.tables)) { - const tableCounts = { - create: table.changes.create.length, - alter: table.changes.alter.length, - drop: table.changes.drop.length, - }; - - // Roll column counts into table totals (no separate column row) - addCounts(tableCounts, { - create: table.columns.create.length, - alter: table.columns.alter.length, - drop: table.columns.drop.length, - }); - - // Apply rolled-up counts to table totals - if (!byType.table) { - byType.table = { create: 0, alter: 0, drop: 0 }; - } - addCounts(byType.table, tableCounts); - - countGroup(table.indexes, "index"); - countGroup(table.triggers, "trigger"); - countGroup(table.rules, "rule"); - countGroup(table.policies, "policy"); - } - - // Views - for (const view of Object.values(schema.views)) { - countGroup(view.changes, "view"); - } - - // Materialized views - for (const matview of Object.values(schema.materializedViews)) { - countGroup(matview.changes, "materialized-view"); - } - - // Types - countGroup(schema.types.enums, "enum"); - countGroup(schema.types.composites, "composite-type"); - countGroup(schema.types.ranges, "range"); - countGroup(schema.types.domains, "domain"); - } -} - -import type { ChangeEntry } from "../../../core/plan/index.ts"; diff --git a/packages/pg-delta/src/cli/main.ts b/packages/pg-delta/src/cli/main.ts new file mode 100644 index 000000000..03be94e6f --- /dev/null +++ b/packages/pg-delta/src/cli/main.ts @@ -0,0 +1,271 @@ +#!/usr/bin/env node +/** + * pgdelta CLI — thin consumer of the public API. + * Zero new dependencies; manual argv parsing; exits 1 on failure, 2 on + * usage errors. + * + * ┌─────────────────────────────────────────────────────────────────────┐ + * │ Old → New command mapping (old commands from pg-delta/src/cli/) │ + * ├──────────────────────────┬──────────────────────────────────────────┤ + * │ plan │ plan │ + * │ apply │ apply │ + * │ sync │ plan + apply (or: schema apply) │ + * │ catalog-export │ snapshot │ + * │ declarative-apply │ schema apply │ + * │ declarative-export │ schema export │ + * └──────────────────────────┴──────────────────────────────────────────┘ + * + * Commands: + * plan --source --desired + * [--renames auto|prompt|off] [--no-compact] [--out ] + * [--accept-rename =] ... + * apply --plan --target [--force] + * render --plan --out .sql [--allow-drops] + * prove --plan --clone --desired-snapshot + * diff --source --desired + * drift --env --snapshot + * snapshot --source --out + * schema export --source --out-dir [--layout by-object|ordered|grouped] + * schema apply --dir --shadow --target + * [--renames auto|prompt|off] [--force] + * [--accept-rename =] ... [--no-reorder] + * schema lint --dir + * Statically check the SQL files (pg-topo) for shadow-load + * cycles and other issues, without touching a database. + * + * --renames default for the CLI is "prompt" (the library default is "off"). + * --accept-rename = + * Confirm one rename candidate using the encoded stable-ids printed during a + * prior --renames prompt run (e.g. --accept-rename table:public.old=table:public.new). + * Repeatable; each occurrence confirms one rename. Available on: plan, schema apply. + */ + +import { readFileSync } from "node:fs"; +import { cmdPlan } from "./commands/plan.ts"; +import { cmdApply } from "./commands/apply.ts"; +import { cmdRender } from "./commands/render.ts"; +import { cmdProve } from "./commands/prove.ts"; +import { cmdDiff } from "./commands/diff.ts"; +import { cmdDrift } from "./commands/drift.ts"; +import { cmdSnapshot } from "./commands/snapshot.ts"; +import { + cmdSchemaExport, + cmdSchemaApply, + cmdSchemaLint, +} from "./commands/schema.ts"; +import { CliExit, UsageError } from "./flags.ts"; +import { SchemaFrontendError } from "../frontends/index.ts"; + +const USAGE = ` +pgdelta [options] + +Commands: + plan --source --desired + [--renames auto|prompt|off] [--no-compact] [--out ] + [--accept-rename =] ... + apply --plan --target [--force] + render --plan --out .sql [--allow-drops] + prove --plan --clone --desired-snapshot + diff --source --desired + drift --env --snapshot + snapshot --source --out + schema export --source --out-dir [--layout by-object|ordered|grouped] + [--format-options ] (pretty-print SQL; any layout) + grouped adds: [--grouping-mode single-file|subdirectory] + [--group-patterns ] [--flat-schemas ] [--no-group-partitions] + schema apply --dir --shadow --target + [--renames auto|prompt|off] [--force] + [--accept-rename =] ... [--no-reorder] + schema lint --dir + +Notes: + --profile: raw | supabase, OR a path to a custom profile .json file + (a value containing "/" or ending in ".json" is loaded from disk). The + file is { "id": ..., "handlers": ["pg_partman", "pg_cron"], "policy"?: {…}, + "baseline"?: "./middleware-base.json" }, referencing bundled handlers by + name. Available on plan / diff / drift / snapshot / apply / prove / + schema export / schema apply. + A profile's "baseline" is a "pgdelta snapshot" file (path resolved relative + to the profile file) subtracted from both sides before diffing, so + platform-provided objects (base-image roles, extension-owned schemas) + captured in it are invisible to the managed view — no policy or per-command + flag needed. Its digest is stamped on plan artifacts / export manifests and + reconciled at apply/prove time, so a swapped/edited/missing baseline fails + loud (plan == prove == apply). Capture one with 'snapshot --profile ' + so it carries the same handler-aware facts. A baseline captured in a + different --unsafe-show-secrets mode than the command's is rejected. + render: writes the plan's SQL as one .sql file per executor segment, + split on the same boundaries "apply" uses at execution time. A single + segment writes .sql; multiple segments write _1.sql, + _2.sql, ... in execution order. A non-transactional segment's file + starts with a machine-readable "-- pg-delta: transaction=false" header. + render is migration-runner-agnostic: it emits ordered segment SQL and + leaves runner-specific packaging to a thin consumer. For dbmate, that + consumer writes one migration per segment with a DISTINCT version, wraps + each in -- migrate:up / -- migrate:down, and maps the transactionality + header to "-- migrate:up transaction:false". Refuses to render a + DESTRUCTIVE plan unless --allow-drops is given — any "drop"-verb action + OR any action marked dataLoss:"destructive" (e.g. an enum rewrite), per + the plan's safety metadata, not the verb alone. + Exit codes: 0 = files written, 1 = error (no files written), + 2 = usage error, 3 = plan has no actions (no files written, not an + error). Prints one JSON summary line to stdout; human/status output + goes to stderr only. + --renames defaults to "prompt" for the CLI (library default is "off"). + --accept-rename: confirm a rename from a prior prompt run; repeatable. + --no-reorder (schema apply): skip the statement-reordering assist and load + raw files at file granularity. Reorder is on by default — it splits files + into one-statement units and topologically pre-sorts them so authoring + order within a file no longer matters. + --unsafe-show-secrets (plan, diff, drift, snapshot, schema export, schema apply): + emit REAL foreign-data option values and subscription conninfo instead of + redacted placeholders. Off by default; raises a loud warning when set. + Only for output destined for a trusted target. An unredacted plan stamps its + redaction mode on the artifact; "apply" and "prove" re-extract the target with + that same mode, so the fingerprint gate passes without "--force". Snapshots + likewise record their mode so "drift" re-extracts identically. + +Old → New mapping: + plan -> plan + apply -> apply + sync -> plan + apply (or: schema apply) + catalog-export -> snapshot + declarative-apply -> schema apply + declarative-export-> schema export +`.trimStart(); + +const SCHEMA_USAGE = ` +pgdelta schema [options] + +Subcommands: + schema export --source --out-dir [--layout by-object|ordered|grouped] + [--format-options ] (pretty-print SQL; any layout) + grouped adds: [--grouping-mode single-file|subdirectory] + [--group-patterns ] [--flat-schemas ] [--no-group-partitions] + schema apply --dir --shadow --target + [--renames auto|prompt|off] [--force] + [--accept-rename =] ... [--no-reorder] + schema lint --dir + Statically check the SQL files (pg-topo) for shadow-load + cycles and other issues, without touching a database. + +Run 'pgdelta --help' for the full command reference and shared flags. +`.trimStart(); + +/** Package version, read from the bundled package.json at runtime. */ +function readVersion(): string { + try { + const pkg = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { version?: string }; + return pkg.version ?? "unknown"; + } catch { + return "unknown"; + } +} + +async function main(): Promise { + // Bun populates process.argv as: ["bun", "main.ts", ...userArgs] + const args = process.argv.slice(2); + const command = args[0]; + const rest = args.slice(1); + + try { + switch (command) { + case "plan": + await cmdPlan(rest); + break; + case "apply": + await cmdApply(rest); + break; + case "render": + await cmdRender(rest); + break; + case "prove": + await cmdProve(rest); + break; + case "diff": + await cmdDiff(rest); + break; + case "drift": + await cmdDrift(rest); + break; + case "snapshot": + await cmdSnapshot(rest); + break; + case "schema": { + const sub = rest[0]; + const subArgs = rest.slice(1); + if (sub === "export") { + await cmdSchemaExport(subArgs); + } else if (sub === "apply") { + await cmdSchemaApply(subArgs); + } else if (sub === "lint") { + await cmdSchemaLint(subArgs); + } else if (sub === "--help" || sub === "-h" || sub === "help") { + process.stdout.write(SCHEMA_USAGE); + } else { + process.stderr.write( + `Unknown schema subcommand: ${sub ?? "(none)"}\n` + + "Available: export, apply, lint\n", + ); + process.exit(2); + } + break; + } + case "--version": + case "-v": + case "version": + process.stdout.write(`${readVersion()}\n`); + break; + case "--help": + case "-h": + case "help": + process.stdout.write(USAGE); + break; + default: + process.stderr.write( + `Unknown command: ${command ?? "(none)"}\n\n${USAGE}`, + ); + process.exit(2); + } + } catch (error) { + // Command handlers never call process.exit themselves — main is the sole + // exiter. An operation-result exit arrives as CliExit (the command already + // wrote its own output); a usage/frontend error arrives as UsageError / + // SchemaFrontendError and maps to exit 2 with its message. Everything else + // is an unexpected failure → the generic "Error:" path + exit 1. + if (error instanceof CliExit) { + process.exit(error.code); + } + if (error instanceof UsageError || error instanceof SchemaFrontendError) { + process.stderr.write(`${error.message}\n`); + process.exit(2); + } + process.stderr.write( + `Error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + // Duck-type check: ShadowLoadError (and any similarly-shaped error) carries + // per-item `details` (Diagnostic[]) that named the actual failures — surface + // them instead of leaving the operator with only the summary line. + const details = (error as { details?: unknown } | null | undefined) + ?.details; + if (Array.isArray(details)) { + for (const detail of details) { + if ( + detail !== null && + typeof detail === "object" && + typeof (detail as { code?: unknown }).code === "string" && + typeof (detail as { message?: unknown }).message === "string" + ) { + process.stderr.write( + ` - ${(detail as { code: string; message: string }).code}: ${(detail as { code: string; message: string }).message}\n`, + ); + } + } + } + process.exit(1); + } +} + +void main(); diff --git a/packages/pg-delta/src/cli/no-process-exit-guard.test.ts b/packages/pg-delta/src/cli/no-process-exit-guard.test.ts new file mode 100644 index 000000000..95428a54d --- /dev/null +++ b/packages/pg-delta/src/cli/no-process-exit-guard.test.ts @@ -0,0 +1,19 @@ +/** + * Self-test for the test-harness guard (tests/no-process-exit-guard.ts, loaded + * via bunfig `[test].preload`): confirms that `process.exit` is intercepted and + * throws inside the test process instead of tearing the runner down. If this + * ever fails, the guard is not active and an in-process `process.exit` + * regression could once again abort the whole suite with no summary. + */ +import { describe, expect, test } from "bun:test"; + +describe("test-harness process.exit guard", () => { + test("process.exit throws instead of exiting the test process", () => { + expect(() => process.exit(2)).toThrow(/process\.exit\(2\) called inside/); + }); + + test("the guard reports whatever code was passed", () => { + expect(() => process.exit(0)).toThrow(/process\.exit\(0\) called inside/); + expect(() => process.exit(1)).toThrow(/process\.exit\(1\) called inside/); + }); +}); diff --git a/packages/pg-delta/src/cli/pool.ts b/packages/pg-delta/src/cli/pool.ts new file mode 100644 index 000000000..f2203494c --- /dev/null +++ b/packages/pg-delta/src/cli/pool.ts @@ -0,0 +1,43 @@ +/** + * Shared helper: create a pg.Pool from a connection URL and provide a + * dispose function so callers always end the pool. + */ +import createDebug from "debug"; +import pg from "pg"; + +const log = createDebug("pgdelta:pool"); + +export interface ManagedPool { + pool: pg.Pool; + end(): Promise; +} + +/** A credential-free label for a connection (host + database only). */ +function safeLabel(url: string): string { + try { + const u = new URL(url); + return `${u.host}${u.pathname}`; + } catch { + return "?"; + } +} + +export function makePool(url: string, label?: string): ManagedPool { + const pool = new pg.Pool({ connectionString: url, max: 5 }); + const lbl = label ?? safeLabel(url); + // Don't crash on an idle client error (server restart, network drop), but + // surface it under DEBUG=pgdelta:* instead of swallowing it silently — + // these are exactly the failures worth seeing when troubleshooting (P3). The + // label carries no credentials. + pool.on("error", (err: unknown) => { + log( + "idle client error [%s]: %s", + lbl, + err instanceof Error ? err.message : String(err), + ); + }); + return { + pool, + end: () => pool.end(), + }; +} diff --git a/packages/pg-delta/src/cli/profile.test.ts b/packages/pg-delta/src/cli/profile.test.ts new file mode 100644 index 000000000..c12ae05c0 --- /dev/null +++ b/packages/pg-delta/src/cli/profile.test.ts @@ -0,0 +1,285 @@ +/** + * Unit tests for CLI profile selection (src/cli/profile.ts). No DB. + */ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildFactBase } from "../core/fact.ts"; +import { serializeSnapshot } from "../core/snapshot.ts"; +import { rawProfile } from "../integrations/profile.ts"; +import { supabaseProfile } from "../integrations/supabase.ts"; +import { UsageError } from "./flags.ts"; +import { + effectiveProfileId, + isProfilePath, + parseProfileFile, + profileById, + reconcileBaselineDigest, + reconcileSnapshotProfile, +} from "./profile.ts"; + +function writeTempProfile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "pgdelta-profile-")); + const path = join(dir, "profile.json"); + writeFileSync(path, contents, "utf8"); + return path; +} + +function writeTempSnapshot(fb: ReturnType): string { + const dir = mkdtempSync(join(tmpdir(), "pgdelta-baseline-")); + const path = join(dir, "snapshot.json"); + writeFileSync(path, serializeSnapshot(fb, { pgVersion: "170000" }), "utf8"); + return path; +} + +describe("profileById", () => { + test("defaults to the raw profile when no id is given", () => { + expect(profileById(undefined)).toBe(rawProfile); + expect(profileById("raw")).toBe(rawProfile); + }); + + test("maps 'supabase' to the Supabase profile", () => { + expect(profileById("supabase")).toBe(supabaseProfile); + }); + + test("rejects an unknown profile id with a UsageError", () => { + expect(() => profileById("bogus")).toThrow(UsageError); + expect(() => profileById("bogus")).toThrow(/--profile must be one of/); + }); +}); + +describe("effectiveProfileId (apply/prove: flag vs plan-stamped profile)", () => { + test("uses the flag when given", () => { + expect(effectiveProfileId("supabase", undefined)).toBe("supabase"); + expect(effectiveProfileId("raw", "raw")).toBe("raw"); + }); + + test("defaults to the plan's stamped profile when the flag is omitted", () => { + expect(effectiveProfileId(undefined, "supabase")).toBe("supabase"); + }); + + test("profile-less plan (library plan()) + no flag → undefined (resolves to raw)", () => { + expect(effectiveProfileId(undefined, undefined)).toBeUndefined(); + }); + + test("rejects a flag that contradicts the plan's stamped profile", () => { + expect(() => effectiveProfileId("raw", "supabase")).toThrow(UsageError); + expect(() => effectiveProfileId("raw", "supabase")).toThrow( + /does not match the plan's profile/, + ); + }); + + test("a file-path flag reconciles against the file's declared id", () => { + const path = writeTempProfile( + JSON.stringify({ id: "platform-middleware", handlers: ["pg_partman"] }), + ); + // flag is the PATH, plan stamped the file's declared id → they agree + expect(effectiveProfileId(path, "platform-middleware")).toBe(path); + // a contradicting stamped id is rejected + expect(() => effectiveProfileId(path, "supabase")).toThrow( + /does not match/, + ); + }); +}); + +describe("reconcileSnapshotProfile (drift: flag vs snapshot-stamped profile)", () => { + test("adopts the snapshot's stamped profile when the flag is omitted", () => { + expect(reconcileSnapshotProfile(undefined, "supabase")).toBe("supabase"); + // an explicit-raw (null) stamp reconciles as the concrete "raw" id + expect(reconcileSnapshotProfile(undefined, null)).toBe("raw"); + }); + + test("uses the flag when it agrees with the stamp", () => { + expect(reconcileSnapshotProfile("supabase", "supabase")).toBe("supabase"); + expect(reconcileSnapshotProfile("raw", null)).toBe("raw"); + }); + + test("a --profile that contradicts the stamp fails closed (UsageError)", () => { + expect(() => reconcileSnapshotProfile("supabase", null)).toThrow( + UsageError, + ); + expect(() => reconcileSnapshotProfile("supabase", null)).toThrow( + /snapshot/i, + ); + expect(() => reconcileSnapshotProfile("raw", "supabase")).toThrow( + UsageError, + ); + }); + + test("a legacy snapshot (no stamp, undefined) lets the flag win with no contradiction", () => { + // pre-stamping snapshot: current behavior — flag wins, never a contradiction + expect(reconcileSnapshotProfile("supabase", undefined)).toBe("supabase"); + expect(reconcileSnapshotProfile(undefined, undefined)).toBeUndefined(); + }); + + test("a file-path flag reconciles against the file's declared id", () => { + const path = writeTempProfile( + JSON.stringify({ id: "platform-middleware", handlers: ["pg_partman"] }), + ); + expect(reconcileSnapshotProfile(path, "platform-middleware")).toBe(path); + expect(() => reconcileSnapshotProfile(path, "supabase")).toThrow( + /does not match/, + ); + }); +}); + +describe("isProfilePath", () => { + test("treats a value with a slash or a .json suffix as a path", () => { + expect(isProfilePath("./p.json")).toBe(true); + expect(isProfilePath("dir/p")).toBe(true); + expect(isProfilePath("profile.json")).toBe(true); + expect(isProfilePath("supabase")).toBe(false); + expect(isProfilePath("raw")).toBe(false); + }); +}); + +describe("parseProfileFile (custom file-based profiles)", () => { + test("resolves handler names against the bundled registry", () => { + const profile = parseProfileFile( + JSON.stringify({ + id: "platform-middleware", + handlers: ["pg_partman", "pg_cron"], + }), + "profile.json", + ); + expect(profile.id).toBe("platform-middleware"); + expect(profile.handlers.map((h) => h.extension)).toEqual([ + "pg_partman", + "pg_cron", + ]); + expect(profile.policy).toBeUndefined(); + }); + + test("passes a declared policy through", () => { + const profile = parseProfileFile( + JSON.stringify({ + id: "p", + handlers: [], + policy: { id: "pol", filter: [] }, + }), + "profile.json", + ); + expect(profile.policy?.id).toBe("pol"); + }); + + test("rejects an unknown handler name with a UsageError listing valid names", () => { + expect(() => + parseProfileFile( + JSON.stringify({ id: "p", handlers: ["pg_bogus"] }), + "profile.json", + ), + ).toThrow(UsageError); + expect(() => + parseProfileFile( + JSON.stringify({ id: "p", handlers: ["pg_bogus"] }), + "profile.json", + ), + ).toThrow(/unknown handler 'pg_bogus'/); + }); + + test("rejects a missing id / non-array handlers / bad JSON", () => { + expect(() => + parseProfileFile(JSON.stringify({ handlers: [] }), "p.json"), + ).toThrow(/id/); + expect(() => + parseProfileFile(JSON.stringify({ id: "p", handlers: "nope" }), "p.json"), + ).toThrow(/handlers/); + expect(() => parseProfileFile("{not json", "p.json")).toThrow(); + }); +}); + +describe("profileById with a file path", () => { + test("loads a profile from a .json path", () => { + const path = writeTempProfile( + JSON.stringify({ id: "platform-middleware", handlers: ["pg_cron"] }), + ); + const profile = profileById(path); + expect(profile.id).toBe("platform-middleware"); + expect(profile.handlers.map((h) => h.extension)).toEqual(["pg_cron"]); + }); +}); + +describe("parseProfileFile: baseline field", () => { + test("resolves a relative baseline path against the profile file's directory", () => { + const profile = parseProfileFile( + JSON.stringify({ + id: "mw", + handlers: [], + baseline: "./middleware-base.json", + }), + "/proj/db/pgdelta-profile.json", + { dir: "/proj/db" }, + ); + expect(profile.baselinePath).toBe("/proj/db/middleware-base.json"); + }); + + test("keeps an absolute baseline path as-is; omits when absent", () => { + expect( + parseProfileFile( + JSON.stringify({ id: "a", handlers: [], baseline: "/abs/base.json" }), + "p.json", + { dir: "/proj" }, + ).baselinePath, + ).toBe("/abs/base.json"); + expect( + parseProfileFile(JSON.stringify({ id: "b", handlers: [] }), "p.json") + .baselinePath, + ).toBeUndefined(); + }); + + test("rejects a non-string / empty baseline", () => { + expect(() => + parseProfileFile( + JSON.stringify({ id: "a", handlers: [], baseline: 5 }), + "p.json", + ), + ).toThrow(/"baseline" must be a non-empty string/); + expect(() => + parseProfileFile( + JSON.stringify({ id: "a", handlers: [], baseline: "" }), + "p.json", + ), + ).toThrow(/"baseline" must be a non-empty string/); + }); + + test("a profile .json with a relative baseline loads with an absolute baselinePath", () => { + const fb = buildFactBase( + [{ id: { kind: "schema", name: "platform" }, payload: {} }], + [], + ); + const snapPath = writeTempSnapshot(fb); + const dir = mkdtempSync(join(tmpdir(), "pgdelta-profile-baseline-")); + const profilePath = join(dir, "profile.json"); + // reference the snapshot by a path relative to the profile file's dir + writeFileSync( + profilePath, + JSON.stringify({ id: "mw", handlers: [], baseline: snapPath }), + "utf8", + ); + expect(profileById(profilePath).baselinePath).toBe(snapPath); + }); +}); + +describe("reconcileBaselineDigest", () => { + test("passes when the digests match (or both absent)", () => { + expect(() => + reconcileBaselineDigest("abc", "abc", "plan artifact"), + ).not.toThrow(); + expect(() => + reconcileBaselineDigest(undefined, undefined, "plan artifact"), + ).not.toThrow(); + }); + + test("throws on every asymmetry (mismatch / stamped-only / resolved-only)", () => { + expect(() => + reconcileBaselineDigest("aaaa", "bbbb", "plan artifact"), + ).toThrow(UsageError); + expect(() => + reconcileBaselineDigest("aaaa", undefined, "export manifest"), + ).toThrow(/declares NO baseline/); + expect(() => + reconcileBaselineDigest(undefined, "bbbb", "export manifest"), + ).toThrow(/was produced with NONE/); + }); +}); diff --git a/packages/pg-delta/src/cli/profile.ts b/packages/pg-delta/src/cli/profile.ts new file mode 100644 index 000000000..534367ea3 --- /dev/null +++ b/packages/pg-delta/src/cli/profile.ts @@ -0,0 +1,285 @@ +/** + * CLI profile selection (`--profile `). + * + * One flag is the safe, discoverable way to opt into integration semantics: + * `--profile supabase` composes handler-aware extraction, the Supabase policy, + * baseline resolution, proof re-extraction, and apply fingerprint reconstruction + * — instead of asking the operator to hand-assemble that recipe. `raw` (the + * default) is the unrestricted view for generic users and tests. + */ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import type { Pool } from "pg"; +import { + type ExtensionHandler, + type IntegrationProfile, + pgCronHandler, + pgPartmanHandler, + rawProfile, + type ResolvedProfile, + type ResolveProfileOptions, + resolveProfile, + supabaseProfile, +} from "../integrations/index.ts"; +import type { Policy } from "../policy/policy.ts"; +import { UsageError } from "./flags.ts"; + +const PROFILES: Record = { + raw: rawProfile, + supabase: supabaseProfile, +}; + +/** The `--profile` value shown in usage strings. */ +export const PROFILE_IDS = Object.keys(PROFILES).join(" | "); + +/** The bundled extension handlers a custom profile file may reference BY NAME + * (the handler's `extension` field). Extend this as new handlers ship. */ +const HANDLER_BY_NAME = new Map( + ([pgPartmanHandler, pgCronHandler] as const).map((h) => [h.extension, h]), +); + +/** A `--profile` value is a path (load from disk) rather than a built-in id when + * it looks like a path: contains a `/` or ends in `.json`. */ +export function isProfilePath(id: string): boolean { + return id.includes("/") || id.endsWith(".json"); +} + +/** + * Parse a custom profile file's JSON into an `IntegrationProfile`. The file + * mirrors `IntegrationProfile` but references handlers BY NAME (resolved against + * {@link HANDLER_BY_NAME}) so it stays plain, serializable data: + * + * { "id": "platform-middleware", "handlers": ["pg_partman", "pg_cron"], + * "policy"?: { ...a serializable Policy... }, + * "baseline"?: "./middleware-base.json" } + * + * `baseline` is a path to a `pgdelta snapshot` file; a relative path is resolved + * against `opts.dir` (the profile file's own directory) so a committed profile + + * baseline pair is portable. `source` is the path/label used in error messages. + * Pure (no disk) so it is unit-testable; {@link loadProfile} reads the file, + * passes its directory as `opts.dir`, and delegates here. + */ +export function parseProfileFile( + json: string, + source: string, + opts: { dir?: string } = {}, +): IntegrationProfile { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (err) { + throw new UsageError( + `profile ${source}: not valid JSON — ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (typeof parsed !== "object" || parsed === null) { + throw new UsageError(`profile ${source}: expected a JSON object`); + } + const obj = parsed as Record; + if (typeof obj["id"] !== "string" || obj["id"] === "") { + throw new UsageError(`profile ${source}: missing a non-empty string "id"`); + } + if (!Array.isArray(obj["handlers"])) { + throw new UsageError( + `profile ${source}: "handlers" must be an array of handler names`, + ); + } + const handlers: ExtensionHandler[] = []; + for (const name of obj["handlers"]) { + if (typeof name !== "string") { + throw new UsageError( + `profile ${source}: handler names must be strings (got ${typeof name})`, + ); + } + const handler = HANDLER_BY_NAME.get(name); + if (handler === undefined) { + throw new UsageError( + `profile ${source}: unknown handler '${name}' — available: ${[...HANDLER_BY_NAME.keys()].join(", ")}`, + ); + } + handlers.push(handler); + } + const policy = obj["policy"]; + const rawBaseline = obj["baseline"]; + if ( + rawBaseline !== undefined && + (typeof rawBaseline !== "string" || rawBaseline === "") + ) { + throw new UsageError( + `profile ${source}: "baseline" must be a non-empty string path to a snapshot file`, + ); + } + // resolve a relative baseline path against the profile file's own directory so + // a committed profile + baseline pair is portable; an absolute path is kept. + const baselinePath = + typeof rawBaseline === "string" + ? opts.dir !== undefined + ? resolve(opts.dir, rawBaseline) + : rawBaseline + : undefined; + return { + id: obj["id"], + handlers, + ...(policy !== undefined && policy !== null + ? { policy: policy as Policy } + : {}), + ...(baselinePath !== undefined ? { baselinePath } : {}), + }; +} + +/** Read and parse a custom profile file from disk. */ +export function loadProfile(path: string): IntegrationProfile { + let json: string; + try { + json = readFileSync(path, "utf8"); + } catch (err) { + throw new UsageError( + `profile ${path}: cannot read file — ${err instanceof Error ? err.message : String(err)}`, + ); + } + return parseProfileFile(json, path, { dir: dirname(path) }); +} + +/** Map a `--profile` value to its profile: a built-in id (`raw`/`supabase`, + * default `raw`), or a path to a custom profile `.json` file. Throws + * UsageError otherwise. */ +export function profileById(id: string | undefined): IntegrationProfile { + if (id !== undefined && isProfilePath(id)) return loadProfile(id); + const profile = PROFILES[id ?? "raw"]; + if (profile === undefined) { + throw new UsageError( + `--profile must be one of: ${PROFILE_IDS} (or a path to a profile .json) (got: ${id})`, + ); + } + return profile; +} + +/** Resolve the selected profile against a live pool (source / target / clone). */ +export function resolveCliProfile( + pool: Pool, + id: string | undefined, + options?: ResolveProfileOptions, +): Promise { + return resolveProfile(pool, profileById(id), options); +} + +/** + * Reconcile the baseline DIGEST stamped on a produced artifact (plan artifact / + * export manifest) against the digest the current command resolved from its + * profile. Throws a UsageError on ANY asymmetry: + * - both present but different → a swapped or edited baseline; + * - stamped but the command resolved none → the profile no longer declares the + * baseline the artifact was produced with; + * - the command resolved one but the artifact carries none → the profile now + * declares a baseline the artifact was NOT produced with. + * + * This turns the fingerprint gate's opaque "re-plan" into a precise diagnosis and + * enforces the plan == prove == apply invariant for profile-file baselines (which + * carry no policy-declared NAME the older name-based guard could check). `context` + * names the artifact for the message (e.g. "plan artifact", "export manifest"). + */ +export function reconcileBaselineDigest( + stamped: string | undefined, + resolved: string | undefined, + context: string, +): void { + if (stamped === resolved) return; + if (stamped !== undefined && resolved !== undefined) { + throw new UsageError( + `baseline mismatch: the ${context} was produced with baseline digest ${stamped.slice(0, 12)} ` + + `but the profile now resolves to ${resolved.slice(0, 12)}. The baseline changed since the ` + + `${context} was produced — regenerate it, or point the profile back at the original baseline.`, + ); + } + if (stamped !== undefined) { + throw new UsageError( + `baseline mismatch: the ${context} was produced with baseline digest ${stamped.slice(0, 12)} ` + + `but the profile now declares NO baseline. Restore the profile's baseline, or regenerate the ${context}.`, + ); + } + throw new UsageError( + `baseline mismatch: the profile declares a baseline (digest ${resolved?.slice(0, 12)}) but the ${context} ` + + `was produced with NONE. Regenerate the ${context} with this profile, or remove the profile's baseline.`, + ); +} + +/** + * Reconcile the `--profile` flag with the profile id stamped on a plan artifact + * (apply/prove). The apply/prove profile MUST match the plan's, so: + * + * - `--profile` omitted → use the plan's stamped id (or undefined → raw when the + * plan carries no profile, i.e. it came from a direct library `plan()` call + * with no integration); + * - `--profile` given → use it, but throw if it contradicts the plan's stamp. + * + * The returned id is fed to {@link resolveCliProfile} / {@link profileById}, + * which rejects an id unknown to this binary. + */ +export function effectiveProfileId( + flagId: string | undefined, + planProfileId: string | undefined, +): string | undefined { + // a file-path flag stamps its DECLARED id on the plan, so reconcile against + // that id (load the file), not the raw path string. The returned value stays + // the path so profileById can load it. (A file profile's plan can only be + // apply/prove-d by passing --profile : the artifact stamps the id, + // not the file location, so an omitted flag cannot recover the file.) + const flagComparisonId = + flagId !== undefined && isProfilePath(flagId) + ? loadProfile(flagId).id + : flagId; + if ( + flagComparisonId !== undefined && + planProfileId !== undefined && + flagComparisonId !== planProfileId + ) { + throw new UsageError( + `--profile ${flagId} (id "${flagComparisonId}") does not match the plan's profile "${planProfileId}"; ` + + `the apply/prove profile must match the plan profile — omit --profile to use the plan's, ` + + `or re-plan with --profile ${flagId}`, + ); + } + return flagId ?? planProfileId; +} + +/** + * Reconcile the `--profile` flag with the profile id STAMPED on a snapshot (the + * `drift` snapshot, or `prove`'s desired snapshot). A snapshot must be compared + * under the profile it was CAPTURED with, or the live re-extract runs different + * handlers than the snapshot's facts were produced with (handler-aware facts + * would read as spurious drift). Mirrors {@link effectiveProfileId} but for the + * snapshot's three-state stamp: + * + * - `--profile` omitted → adopt the stamp (a `null` stamp = captured raw → + * resolves as the concrete `"raw"` profile); + * - `--profile` given → use it, but throw if it contradicts the stamp. A `null` + * (captured-raw) stamp DOES contradict a non-raw `--profile` — it is treated + * as `"raw"`, not collapsed to "no stamp"; + * - an ABSENT (`undefined`) stamp is a pre-stamping legacy snapshot → the flag + * wins with no contradiction (the caller may note the snapshot predates + * stamping when the flag is omitted too). + */ +export function reconcileSnapshotProfile( + flagId: string | undefined, + stamped: string | null | undefined, +): string | undefined { + // legacy snapshot (pre-stamping): no reconciliation — the flag wins. + if (stamped === undefined) return flagId; + // captured-raw (null) reconciles as the concrete "raw" profile so a + // contradicting --profile fails closed rather than slipping through. + const stampedId = stamped === null ? "raw" : stamped; + // a file-path flag stamps its DECLARED id on the snapshot, so reconcile + // against that id (load the file), not the raw path string. + const flagComparisonId = + flagId !== undefined && isProfilePath(flagId) + ? loadProfile(flagId).id + : flagId; + if (flagComparisonId !== undefined && flagComparisonId !== stampedId) { + throw new UsageError( + `--profile ${flagId} (id "${flagComparisonId}") does not match the snapshot's captured profile "${stampedId}"; ` + + `a snapshot must be compared under the profile it was captured with — omit --profile to use the snapshot's, ` + + `or re-capture the snapshot with --profile ${flagId}`, + ); + } + return flagId ?? stampedId; +} diff --git a/packages/pg-delta/src/cli/render.test.ts b/packages/pg-delta/src/cli/render.test.ts new file mode 100644 index 000000000..87995011c --- /dev/null +++ b/packages/pg-delta/src/cli/render.test.ts @@ -0,0 +1,237 @@ +/** + * renderPlan (pure): reads a Plan artifact and produces one or more + * dbmate-friendly SQL file bodies, splitting on the SAME segment boundaries + * `apply()` uses at execution time (src/apply/apply.ts::segmentActions), so + * rendered files reflect exactly how the plan would be executed. + */ +import { describe, expect, test } from "bun:test"; +import type { Action, Plan } from "../plan/plan.ts"; +import { renderPlan } from "./render.ts"; + +function action(overrides: Partial): Action { + return { + sql: "SELECT 1", + verb: "create", + produces: [], + consumes: [], + destroys: [], + releases: [], + transactionality: "transactional", + lockClass: "none", + newSegmentBefore: false, + dataLoss: "none", + rewriteRisk: false, + ...overrides, + } as Action; +} + +function makePlan(overrides: Partial): Plan { + return { + formatVersion: 1, + engineVersion: "test", + source: { fingerprint: "a" }, + target: { fingerprint: "b" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + ...overrides, + } as Plan; +} + +describe("renderPlan", () => { + test("single segment: one file with preamble + statements", () => { + const plan = makePlan({ + preamble: [{ name: "check_function_bodies", value: "off" }], + actions: [ + action({ sql: "CREATE SCHEMA foo" }), + action({ sql: "CREATE TABLE foo.bar (id integer);" }), + ], + }); + + const result = renderPlan(plan, { allowDrops: false }); + + expect(result.changes).toBe(true); + expect(result.files).toHaveLength(1); + expect(result.files[0]!.suffix).toBeNull(); + expect(result.files[0]!.transactional).toBe(true); + expect(result.files[0]!.actionCount).toBe(2); + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "set local check_function_bodies = off; + + CREATE SCHEMA foo; + + CREATE TABLE foo.bar (id integer); + " + `); + }); + + test("multi segment: nonTransactional action splits into _1/_2 with transaction=false header", () => { + const plan = makePlan({ + preamble: [{ name: "check_function_bodies", value: "off" }], + actions: [ + action({ sql: "CREATE TABLE foo (id integer)" }), + action({ + sql: "CREATE INDEX CONCURRENTLY foo_idx ON foo (id)", + transactionality: "nonTransactional", + }), + ], + }); + + const result = renderPlan(plan, { allowDrops: false }); + + expect(result.changes).toBe(true); + expect(result.files).toHaveLength(2); + + expect(result.files[0]!.suffix).toBe("_1"); + expect(result.files[0]!.transactional).toBe(true); + expect(result.files[0]!.actionCount).toBe(1); + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "set local check_function_bodies = off; + + CREATE TABLE foo (id integer); + " + `); + + expect(result.files[1]!.suffix).toBe("_2"); + expect(result.files[1]!.transactional).toBe(false); + expect(result.files[1]!.actionCount).toBe(1); + expect(result.files[1]!.contents).toMatchInlineSnapshot(` + "-- pg-delta: transaction=false + set check_function_bodies = off; + + CREATE INDEX CONCURRENTLY foo_idx ON foo (id); + + reset all; + " + `); + }); + + test("commitBoundaryAfter closes a segment, starting a new one after it", () => { + const plan = makePlan({ + preamble: [], + actions: [ + action({ + sql: "ALTER TYPE color ADD VALUE 'blue'", + transactionality: "commitBoundaryAfter", + }), + action({ sql: "CREATE TABLE uses_color (c color)" }), + ], + }); + + const result = renderPlan(plan, { allowDrops: false }); + + expect(result.files).toHaveLength(2); + expect(result.files[0]!.suffix).toBe("_1"); + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "ALTER TYPE color ADD VALUE 'blue'; + " + `); + expect(result.files[1]!.suffix).toBe("_2"); + expect(result.files[1]!.contents).toMatchInlineSnapshot(` + "CREATE TABLE uses_color (c color); + " + `); + }); + + test("drop action without allowDrops throws naming the offending SQL", () => { + const plan = makePlan({ + actions: [action({ sql: "DROP TABLE foo", verb: "drop" })], + }); + + expect(() => renderPlan(plan, { allowDrops: false })).toThrow( + /DROP TABLE foo/, + ); + }); + + test("drop action with allowDrops renders normally", () => { + const plan = makePlan({ + actions: [action({ sql: "DROP TABLE foo", verb: "drop" })], + }); + + const result = renderPlan(plan, { allowDrops: true }); + + expect(result.changes).toBe(true); + expect(result.files).toHaveLength(1); + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "DROP TABLE foo; + " + `); + }); + + test("destructive non-drop action without allowDrops throws (gates on dataLoss, not just verb)", () => { + // an enum value-set migration rewrites dependent columns: verb `alter`, + // but dataLoss "destructive". The verb-only guard would let it through. + const plan = makePlan({ + actions: [ + action({ + sql: "ALTER TABLE foo ALTER COLUMN c TYPE new_enum USING c::text::new_enum", + verb: "alter", + dataLoss: "destructive", + }), + ], + }); + + expect(() => renderPlan(plan, { allowDrops: false })).toThrow( + /destructive action/, + ); + // and it renders once the caller opts in + expect(renderPlan(plan, { allowDrops: true }).files).toHaveLength(1); + }); + + test("non-destructive drop-verb action (e.g. cron unschedule) is still gated by allowDrops", () => { + // conservative union: a `drop`-verb action stays gated even when dataLoss + // is "none", so nothing that reads as a drop slips out silently. + const plan = makePlan({ + actions: [ + action({ + sql: "select cron.unschedule('nightly')", + verb: "drop", + dataLoss: "none", + }), + ], + }); + + expect(() => renderPlan(plan, { allowDrops: false })).toThrow( + /drop action/, + ); + expect(renderPlan(plan, { allowDrops: true }).files).toHaveLength(1); + }); + + test("empty plan: no changes, no files", () => { + const plan = makePlan({ actions: [] }); + + const result = renderPlan(plan, { allowDrops: false }); + + expect(result.changes).toBe(false); + expect(result.files).toHaveLength(0); + }); + + test("semicolon normalization: with and without trailing ; both yield exactly one", () => { + const plan = makePlan({ + actions: [ + action({ sql: "CREATE SCHEMA already_terminated;" }), + action({ sql: "CREATE SCHEMA not_terminated" }), + action({ sql: "CREATE SCHEMA trailing_whitespace; \n\n" }), + ], + }); + + const result = renderPlan(plan, { allowDrops: false }); + + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "CREATE SCHEMA already_terminated; + + CREATE SCHEMA not_terminated; + + CREATE SCHEMA trailing_whitespace; + " + `); + }); +}); diff --git a/packages/pg-delta/src/cli/render.ts b/packages/pg-delta/src/cli/render.ts new file mode 100644 index 000000000..143bcdd9c --- /dev/null +++ b/packages/pg-delta/src/cli/render.ts @@ -0,0 +1,6 @@ +/** + * Re-export the public plan renderer under the historical CLI names so existing + * CLI imports (`./render.ts`) keep working. New callers should prefer + * `renderPlanFiles` from `@supabase/pg-delta/frontends`. + */ +export { renderPlanFiles as renderPlan } from "../frontends/render-plan-files.ts"; diff --git a/packages/pg-delta/src/cli/reorder-display.test.ts b/packages/pg-delta/src/cli/reorder-display.test.ts new file mode 100644 index 000000000..094e19509 --- /dev/null +++ b/packages/pg-delta/src/cli/reorder-display.test.ts @@ -0,0 +1,275 @@ +/** + * Unit tests for the reorder display helpers. + * + * When the statement-reordering assist is on, the shadow loader sees synthetic + * one-statement file names (`0007__schema/users.sql`) and bakes them into its + * error strings. The CLI must present those back to the author as the real + * source location (`schema/users.sql:line:col`) — stripping the ordinal prefix + * and resolving the statement's offset against the original file content. The + * Postgres error text itself is preserved verbatim (D6: PG errors stay + * authoritative). + * + * No Docker required (pure string/offset functions). + */ +import { describe, expect, test } from "bun:test"; +import { + appendShadowCycleHint, + formatLintReport, + formatStatementLocation, + positionToLineColumn, + rewriteReorderedShadowError, + stripOrdinalPrefix, +} from "./reorder-display.ts"; +import { ShadowLoadError } from "../frontends/load-sql-files.ts"; +import type { + OrderedSqlFile, + ShadowLoadCycle, + ShadowOrderDiagnostic, +} from "../frontends/sql-order.ts"; + +describe("stripOrdinalPrefix", () => { + test("removes a zero-padded ordinal prefix", () => { + expect(stripOrdinalPrefix("0007__schema/users.sql")).toBe( + "schema/users.sql", + ); + expect(stripOrdinalPrefix("00__a.sql")).toBe("a.sql"); + }); + + test("leaves a name without an ordinal prefix unchanged", () => { + expect(stripOrdinalPrefix("schema/users.sql")).toBe("schema/users.sql"); + // a double underscore that is not an ordinal prefix is preserved + expect(stripOrdinalPrefix("my__file.sql")).toBe("my__file.sql"); + }); +}); + +describe("positionToLineColumn", () => { + test("1-based line/column for a single line", () => { + expect(positionToLineColumn("hello", 1)).toEqual({ line: 1, column: 1 }); + expect(positionToLineColumn("hello", 5)).toEqual({ line: 1, column: 5 }); + }); + + test("counts newlines", () => { + expect(positionToLineColumn("ab\ncd\nef", 5)).toEqual({ + line: 2, + column: 2, + }); + }); +}); + +describe("formatStatementLocation", () => { + test("renders file:line:col when sourceOffset + content are available", () => { + const content = "create table t(id int);\ncreate view v as select 1;"; + // the second statement starts at offset 24 (the char after the first ';\n') + const offset = content.indexOf("create view"); + expect( + formatStatementLocation( + { filePath: "schema.sql", statementIndex: 1, sourceOffset: offset }, + content, + ), + ).toBe("schema.sql:2:1"); + }); + + test("falls back to the bare file path when offset/content are missing", () => { + expect( + formatStatementLocation({ filePath: "schema.sql", statementIndex: 0 }), + ).toBe("schema.sql"); + }); +}); + +describe("rewriteReorderedShadowError", () => { + const orderedFile = ( + name: string, + filePath: string, + statementIndex: number, + sourceOffset: number, + ): OrderedSqlFile => ({ + name, + sql: "", + provenance: { filePath, statementIndex, sourceOffset }, + }); + + test("replaces synthetic ordinal names with file:line:col in message + details, preserving PG text", () => { + const content = + "create view public.v as select id from public.t;\n" + + "create table public.t(id int primary key);"; + const ordered = [ + orderedFile( + "0__schema.sql", + "schema.sql", + 1, + content.indexOf("create table"), + ), + orderedFile("1__schema.sql", "schema.sql", 0, 0), + ]; + const originalSqlByName = new Map([["schema.sql", content]]); + + const error = new ShadowLoadError( + "shadow load stuck after 1 round(s): 1 file(s) cannot apply", + [ + { + code: "stuck_statement", + severity: "error", + message: '1__schema.sql: relation "public.t" does not exist', + }, + ], + ); + + const rewritten = rewriteReorderedShadowError( + error, + ordered, + originalSqlByName, + ); + + // the synthetic name is gone, replaced by the real source location… + expect(rewritten.details[0]?.message).toContain("schema.sql:1:1:"); + expect(rewritten.details[0]?.message).not.toMatch(/\d+__schema\.sql/); + // …and the Postgres text is preserved verbatim + expect(rewritten.details[0]?.message).toContain( + 'relation "public.t" does not exist', + ); + // it is still a ShadowLoadError + expect(rewritten).toBeInstanceOf(ShadowLoadError); + }); + + test("is a no-op for names that carry no ordinal/provenance", () => { + const error = new ShadowLoadError("shadow database is not empty", []); + const rewritten = rewriteReorderedShadowError(error, [], new Map()); + expect(rewritten.message).toBe("shadow database is not empty"); + }); +}); + +describe("appendShadowCycleHint (D6)", () => { + const content = + "create table public.a(id int primary key, b_id int references public.b(id));\n" + + "create table public.b(id int primary key, a_id int references public.a(id));"; + const originalSqlByName = new Map([["schema.sql", content]]); + const cycle: ShadowLoadCycle = { + members: [ + { filePath: "schema.sql", statementIndex: 0, sourceOffset: 0 }, + { + filePath: "schema.sql", + statementIndex: 1, + sourceOffset: content.indexOf("create table public.b"), + }, + ], + objectKeys: ["table:public.a", "table:public.b"], + }; + + const stuckError = (): ShadowLoadError => + new ShadowLoadError( + "shadow load stuck after 1 round(s): 2 file(s) cannot apply", + [ + { + code: "stuck_statement", + severity: "error", + message: 'schema.sql:1:1: relation "public.b" does not exist', + }, + ], + ); + + test("appends a clearly-labeled, advisory static-analysis hint", () => { + const enriched = appendShadowCycleHint( + stuckError(), + [cycle], + originalSqlByName, + ); + + // the message keeps the authoritative PG-driven stuck text… + expect(enriched.message).toContain("shadow load stuck"); + // …and gains a labeled, advisory cycle hint + expect(enriched.message.toLowerCase()).toContain("suspected"); + expect(enriched.message.toLowerCase()).toMatch( + /advisory|static analysis|authoritative/, + ); + // rendered as the cycle chain with real file:line:col + a back-edge + expect(enriched.message).toContain("schema.sql:1:1"); + expect(enriched.message).toContain("schema.sql:2:1"); + expect(enriched.message).toContain("→"); + // object keys surfaced + expect(enriched.message).toContain("table:public.a"); + + // a structured hint diagnostic is added without disturbing the PG one + const codes = enriched.details.map((d) => d.code); + expect(codes).toContain("stuck_statement"); + expect(codes).toContain("suspected_shadow_load_cycle"); + const hint = enriched.details.find( + (d) => d.code === "suspected_shadow_load_cycle", + ); + expect(hint?.severity).toBe("warning"); + }); + + test("is a no-op when there are no cycles", () => { + const error = stuckError(); + const same = appendShadowCycleHint(error, [], originalSqlByName); + expect(same.message).toBe(error.message); + expect(same.details).toHaveLength(error.details.length); + }); +}); + +describe("formatLintReport (schema lint)", () => { + const diag = ( + code: string, + message: string, + location?: ShadowOrderDiagnostic["location"], + ): ShadowOrderDiagnostic => + location ? { code, message, location } : { code, message }; + + test("a clean schema yields no findings and is not blocking", () => { + const report = formatLintReport({ diagnostics: [], cycles: [] }, new Map()); + expect(report.blocking).toBe(false); + expect(report.errorCount).toBe(0); + expect(report.lines).toHaveLength(0); + }); + + test("a cycle is a blocking ERROR rendered as a chain", () => { + const cycle: ShadowLoadCycle = { + members: [ + { filePath: "a.sql", statementIndex: 0 }, + { filePath: "b.sql", statementIndex: 0 }, + ], + objectKeys: ["view:public.v1", "view:public.v2"], + }; + const report = formatLintReport( + { diagnostics: [], cycles: [cycle] }, + new Map(), + ); + expect(report.blocking).toBe(true); + expect(report.errorCount).toBe(1); + const text = report.lines.join("\n"); + expect(text).toContain("a.sql"); + expect(text).toContain("→"); + expect(text.toUpperCase()).toContain("ERROR"); + }); + + test("UNKNOWN_STATEMENT_CLASS is a non-blocking WARNING with its location", () => { + const report = formatLintReport( + { + diagnostics: [ + diag("UNKNOWN_STATEMENT_CLASS", "Unsupported statement", { + filePath: "vacuum.sql", + statementIndex: 0, + }), + ], + cycles: [], + }, + new Map(), + ); + expect(report.blocking).toBe(false); + expect(report.warningCount).toBe(1); + const text = report.lines.join("\n"); + expect(text).toContain("vacuum.sql"); + expect(text.toUpperCase()).toContain("WARNING"); + }); + + test("DUPLICATE_PRODUCER is a blocking ERROR", () => { + const report = formatLintReport( + { + diagnostics: [diag("DUPLICATE_PRODUCER", "defined twice")], + cycles: [], + }, + new Map(), + ); + expect(report.blocking).toBe(true); + expect(report.errorCount).toBe(1); + }); +}); diff --git a/packages/pg-delta/src/cli/reorder-display.ts b/packages/pg-delta/src/cli/reorder-display.ts new file mode 100644 index 000000000..78ff2e84f --- /dev/null +++ b/packages/pg-delta/src/cli/reorder-display.ts @@ -0,0 +1,241 @@ +/** + * Display helpers for the statement-reordering assist (`schema apply`). + * + * With reorder on, the shadow loader operates on synthetic one-statement files + * named `__` (e.g. `0007__schema/users.sql`) and bakes + * those names into its error strings. These helpers map the synthetic name back + * to the real authored location — `schema/users.sql:line:col` — so the author + * sees where the offending statement lives, not an internal ordinal name. + * + * Pure formatting / offset resolution — no CLI-framework or fs dependency. The + * Postgres error text is never altered (D6: PG errors remain authoritative); + * only the synthetic file name is rewritten. + */ +import { ShadowLoadError } from "../frontends/load-sql-files.ts"; +import type { + OrderedSqlFile, + ShadowLoadCycle, + ShadowOrderDiagnostic, + StatementProvenance, +} from "../frontends/sql-order.ts"; + +/** The ordinal prefix `orderForShadow` prepends: digits then `__`. */ +const ORDINAL_PREFIX = /^\d+__/; + +/** Strip the `__` prefix from a synthetic reorder name. Names that do + * not start with the ordinal pattern are returned unchanged. */ +export function stripOrdinalPrefix(name: string): string { + return name.replace(ORDINAL_PREFIX, ""); +} + +/** + * Convert a 1-based character offset in `content` to 1-based line and column. + * (Ported from pg-delta's apply-display.) Used to resolve a statement's byte + * offset to a human file location. + */ +export function positionToLineColumn( + content: string, + position: number, +): { line: number; column: number } { + const lines = content.split("\n"); + let offset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLen = + (lines[i] as string).length + (i < lines.length - 1 ? 1 : 0); + if (position <= offset + lineLen) { + return { line: i + 1, column: position - offset }; + } + offset += lineLen; + } + const last = lines.length; + const lastLineLen = lines[last - 1]?.length ?? 0; + return { line: last, column: lastLineLen + 1 }; +} + +/** + * Render a statement's provenance as `file:line:col` when its source offset and + * the original file content are both known, else the bare `file` path. + */ +export function formatStatementLocation( + provenance: StatementProvenance, + originalContent?: string, +): string { + if (provenance.sourceOffset !== undefined && originalContent !== undefined) { + const { line, column } = positionToLineColumn( + originalContent, + provenance.sourceOffset + 1, + ); + return `${provenance.filePath}:${line}:${column}`; + } + return provenance.filePath; +} + +/** + * Rewrite a {@link ShadowLoadError} produced while loading reordered files so + * every synthetic `__` name is replaced by the real source + * location (`file:line:col`). The Postgres message text is preserved verbatim. + * + * `originalSqlByName` maps each ORIGINAL file path (provenance.filePath) to its + * full content, so statement offsets can be resolved to line:column. + */ +export function rewriteReorderedShadowError( + error: ShadowLoadError, + ordered: readonly OrderedSqlFile[], + originalSqlByName: ReadonlyMap, +): ShadowLoadError { + // longest synthetic names first, so one name can't partially match inside + // another before the full replacement runs. + const replacements = [...ordered] + .sort((a, b) => b.name.length - a.name.length) + .map((file) => ({ + from: file.name, + to: formatStatementLocation( + file.provenance, + originalSqlByName.get(file.provenance.filePath), + ), + })); + + const rewrite = (text: string): string => + replacements.reduce((acc, { from, to }) => acc.split(from).join(to), text); + + return new ShadowLoadError( + rewrite(error.message), + error.details.map((diagnostic) => ({ + ...diagnostic, + message: rewrite(diagnostic.message), + })), + ); +} + +/** Render one cycle as `loc0 → loc1 → (back to loc0)` using real source + * locations when resolvable, plus the involved object keys. */ +function formatCycleChain( + cycle: ShadowLoadCycle, + originalSqlByName: ReadonlyMap, +): string { + const chain = cycle.members.map((member) => + formatStatementLocation(member, originalSqlByName.get(member.filePath)), + ); + const arrow = + chain.length > 0 + ? [...chain, `(back to ${chain[0]})`].join(" → ") + : "(empty cycle)"; + const objects = + cycle.objectKeys.length > 0 + ? ` [objects: ${cycle.objectKeys.join(", ")}]` + : ""; + return `${arrow}${objects}`; +} + +/** + * Attach statically-detected shadow-load cycles to a stuck {@link ShadowLoadError} + * as a clearly-labeled, advisory hint (D6). The Postgres-driven message and + * details remain first and authoritative — the assist only annotates a failure + * Postgres already produced, it never decides the load failed. + * + * No-op when `cycles` is empty. Call this only for a genuinely non-converging + * load (stuck / max-rounds); attaching a cycle hint to an unrelated rejection + * (transaction control, data statements, …) would mislead. + */ +export function appendShadowCycleHint( + error: ShadowLoadError, + cycles: readonly ShadowLoadCycle[], + originalSqlByName: ReadonlyMap, +): ShadowLoadError { + if (cycles.length === 0) { + return error; + } + + const chains = cycles.map((cycle) => + formatCycleChain(cycle, originalSqlByName), + ); + const hintBlock = [ + "", + "Suspected shadow-load cycle(s) detected by the reordering assist " + + "(static analysis — advisory; the PostgreSQL errors above are authoritative):", + ...chains.map((chain) => ` ${chain}`), + "If two objects reference each other, break the cycle by splitting one " + + "reference into a separate ALTER statement.", + ].join("\n"); + + const hintDetails = cycles.map((cycle) => ({ + code: "suspected_shadow_load_cycle", + severity: "warning" as const, + message: `suspected cycle (static analysis, advisory): ${formatCycleChain(cycle, originalSqlByName)}`, + })); + + return new ShadowLoadError(`${error.message}\n${hintBlock}`, [ + ...error.details, + ...hintDetails, + ]); +} + +/** pg-topo diagnostic codes that make a `schema lint` run fail (genuine + * authoring bugs). `CYCLE_DETECTED` is handled separately via the structured + * cycle list. Everything else is advisory (warning) — unresolved references, + * for instance, are commonly external (extension-provided) in a partial dir. */ +const LINT_ERROR_CODES: ReadonlySet = new Set([ + "PARSE_ERROR", + "DISCOVERY_ERROR", + "DUPLICATE_PRODUCER", +]); + +/** Result of {@link formatLintReport}: human-readable lines plus counts and a + * blocking flag the CLI maps to its exit code. */ +export interface LintReport { + lines: string[]; + errorCount: number; + warningCount: number; + blocking: boolean; +} + +/** + * Turn a static-analysis result (`analyzeForShadow`) into a lint report for + * `schema lint`: one labeled line per finding, with cycles rendered as their + * `a → b → (back to a)` chain and other pg-topo diagnostics shown at their + * source location. Purely static — no shadow database is involved (apply stays + * Postgres-truth). Cycles, parse errors and duplicate producers are blocking; + * other diagnostics are advisory warnings. + */ +export function formatLintReport( + result: { + diagnostics: readonly ShadowOrderDiagnostic[]; + cycles: readonly ShadowLoadCycle[]; + }, + originalSqlByName: ReadonlyMap, +): LintReport { + const lines: string[] = []; + let errorCount = 0; + let warningCount = 0; + + for (const cycle of result.cycles) { + errorCount += 1; + lines.push( + `ERROR [CYCLE_DETECTED] ${formatCycleChain(cycle, originalSqlByName)}`, + ); + } + + for (const diagnostic of result.diagnostics) { + // cycles are already rendered above from the structured list + if (diagnostic.code === "CYCLE_DETECTED") { + continue; + } + const isError = LINT_ERROR_CODES.has(diagnostic.code); + if (isError) { + errorCount += 1; + } else { + warningCount += 1; + } + const location = diagnostic.location + ? formatStatementLocation( + diagnostic.location, + originalSqlByName.get(diagnostic.location.filePath), + ) + : "(no location)"; + lines.push( + `${isError ? "ERROR" : "WARNING"} [${diagnostic.code}] ${location}: ${diagnostic.message}`, + ); + } + + return { lines, errorCount, warningCount, blocking: errorCount > 0 }; +} diff --git a/packages/pg-delta/src/cli/shadow.test.ts b/packages/pg-delta/src/cli/shadow.test.ts new file mode 100644 index 000000000..e02ed2f95 --- /dev/null +++ b/packages/pg-delta/src/cli/shadow.test.ts @@ -0,0 +1,29 @@ +/** + * withDatabaseName swaps only the database (path) segment of a connection URL, + * preserving credentials, host, port, and query params. + */ +import { describe, expect, test } from "bun:test"; +import { withDatabaseName } from "./shadow.ts"; + +describe("withDatabaseName", () => { + test("swaps the dbname, keeps everything else", () => { + expect( + withDatabaseName( + "postgres://u:p@host:5432/app?sslmode=require", + "shadow", + ), + ).toBe("postgres://u:p@host:5432/shadow?sslmode=require"); + }); + + test("handles a URL with no explicit database", () => { + expect(withDatabaseName("postgres://u:p@host:5432", "shadow")).toBe( + "postgres://u:p@host:5432/shadow", + ); + }); + + test("percent-encodes an unusual database name", () => { + expect(withDatabaseName("postgres://host/app", "pgdelta_shadow_x")).toBe( + "postgres://host/pgdelta_shadow_x", + ); + }); +}); diff --git a/packages/pg-delta/src/cli/shadow.ts b/packages/pg-delta/src/cli/shadow.ts new file mode 100644 index 000000000..00bacb865 --- /dev/null +++ b/packages/pg-delta/src/cli/shadow.ts @@ -0,0 +1,10 @@ +/** + * Re-export co-located shadow provisioning from the public frontend module so + * existing CLI imports (`./shadow.ts`) keep working. + */ +export { + provisionCoLocatedShadow, + isShadowProvisionError, + withDatabaseName, + type CoLocatedShadow, +} from "../frontends/shadow.ts"; diff --git a/packages/pg-delta/src/cli/utils.ts b/packages/pg-delta/src/cli/utils.ts deleted file mode 100644 index 0cec0a1c0..000000000 --- a/packages/pg-delta/src/cli/utils.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Shared utility functions for CLI commands. - */ - -import { createInterface } from "node:readline"; -import type { CommandContext } from "@stricli/core"; -import chalk from "chalk"; -import type { Change } from "../core/change.types.ts"; -import type { DiffContext } from "../core/context.ts"; -import { groupChangesHierarchically } from "../core/plan/hierarchy.ts"; -import { type Plan, serializePlan } from "../core/plan/index.ts"; -import { renderPlanSql } from "../core/plan/render.ts"; -import { classifyChangesRisk } from "../core/plan/risk.ts"; -import type { SqlFormatOptions } from "../core/plan/sql-format.ts"; -import { formatTree } from "./formatters/index.ts"; - -// Re-export ApplyPlanResult type for convenience -type ApplyPlanResult = - | { status: "invalid_plan"; message: string } - | { status: "fingerprint_mismatch"; current: string; expected: string } - | { status: "already_applied" } - | { - status: "applied"; - statements: number; - units: number; - warnings?: string[]; - } - | { - status: "failed"; - error: unknown; - script: string; - failedUnitIndex?: number; - completedUnits: number; - }; - -type PlanResult = { - plan: Plan; - sortedChanges: Change[]; - ctx: DiffContext; -}; - -interface FormatPlanOptions { - disableColors?: boolean; - showUnsafeFlagSuggestion?: boolean; - sqlFormatOptions?: SqlFormatOptions; -} - -/** - * Formats a plan result for display in various formats. - */ -export function formatPlanForDisplay( - planResult: PlanResult, - format: "tree" | "json" | "sql", - options: FormatPlanOptions = {}, -): { content: string; label: string } { - const { plan, sortedChanges, ctx } = planResult; - const risk = plan.risk ?? classifyChangesRisk(sortedChanges); - const planWithRisk = plan.risk ? plan : { ...plan, risk }; - - switch (format) { - case "sql": { - const content = [ - `-- Risk: ${risk.level === "data_loss" ? `data-loss (${risk.statements.length})` : "safe"}`, - renderPlanSql(plan, { sqlFormatOptions: options.sqlFormatOptions }), - ].join("\n"); - return { content, label: "Migration script" }; - } - case "json": { - const content = serializePlan(planWithRisk); - return { content, label: "Plan" }; - } - default: { - const hierarchy = groupChangesHierarchically(ctx, sortedChanges); - const previousLevel = chalk.level; - if (options.disableColors) { - chalk.level = 0; - } - try { - let treeContent = formatTree(hierarchy); - if (risk.level === "data_loss") { - const warningLines = [ - "", - chalk.yellow("⚠ Data-loss operations detected:"), - ...risk.statements.map((statement: string) => - chalk.yellow(`- ${statement}`), - ), - ]; - if (options.showUnsafeFlagSuggestion !== false) { - warningLines.push( - chalk.yellow( - "Use `--unsafe` to allow applying these operations.", - ), - ); - } - const treeLines = treeContent.split("\n"); - // Insert warning after the legend (at the end of the output) - // Find the legend line which contains "create", "alter", and "drop" - let insertIndex = treeLines.length; - const ansiPattern = new RegExp( - `${String.fromCharCode(27)}\\[[0-9;]*m`, - "g", - ); - // Search from the end backwards for the legend - for (let i = treeLines.length - 1; i >= 0; i--) { - const line = treeLines[i]; - const stripped = line.replace(ansiPattern, "").trim(); - // Legend format: "+ create ~ alter - drop" (or variations) - if ( - stripped.includes("create") && - stripped.includes("alter") && - stripped.includes("drop") - ) { - insertIndex = i + 1; - break; - } - } - // Fallback: if legend not found, append at the end - if (insertIndex === treeLines.length) { - insertIndex = treeLines.length; - } - treeLines.splice(insertIndex, 0, ...warningLines); - treeContent = treeLines.join("\n"); - } - // add newline for nicer stdout when in tree mode - if (!treeContent.endsWith("\n")) { - treeContent = `${treeContent}\n`; - } - return { content: treeContent, label: "Human-readable plan" }; - } finally { - if (options.disableColors) { - chalk.level = previousLevel; - } - } - } - } -} - -/** - * Validates plan risk and handles unsafe operations. - * Returns validation result with optional exit code. - */ -export function validatePlanRisk( - plan: Plan, - unsafe: boolean, - context: CommandContext, - options?: { suppressWarning?: boolean }, -): { valid: boolean; exitCode?: number } { - if (!unsafe) { - if (!plan.risk) { - context.process.stderr.write( - "Plan is missing risk metadata. Regenerate the plan with the current pgdelta or re-run with --unsafe to apply anyway.\n", - ); - return { valid: false, exitCode: 1 }; - } - if (plan.risk.level === "data_loss") { - if (!options?.suppressWarning) { - const warningLines = [ - chalk.yellow("⚠ Data-loss operations detected:"), - ...plan.risk.statements.map((statement: string) => - chalk.yellow(`- ${statement}`), - ), - chalk.yellow("Use `--unsafe` to allow applying these operations."), - ]; - context.process.stderr.write(`${warningLines.join("\n")}\n`); - } - return { valid: false, exitCode: 1 }; - } - } - return { valid: true }; -} - -/** - * Handles applyPlan results and writes appropriate output. - * Returns the exit code that should be set. - */ -export function handleApplyResult( - result: ApplyPlanResult, - context: CommandContext, -): { exitCode: number } { - switch (result.status) { - case "invalid_plan": - context.process.stderr.write(`${result.message}\n`); - return { exitCode: 1 }; - case "fingerprint_mismatch": - context.process.stderr.write( - "Target database does not match plan source fingerprint. Aborting.\n", - ); - return { exitCode: 1 }; - case "already_applied": - context.process.stdout.write( - "Plan already applied (target fingerprint matches desired state).\n", - ); - return { exitCode: 0 }; - case "failed": { - context.process.stderr.write( - `Failed to apply changes: ${result.error instanceof Error ? result.error.message : String(result.error)}\n`, - ); - if (result.failedUnitIndex !== undefined) { - context.process.stderr.write( - `Failed in migration unit ${result.failedUnitIndex + 1} (${result.completedUnits} unit${result.completedUnits === 1 ? "" : "s"} already committed).\n`, - ); - } - context.process.stderr.write(`Migration script:\n${result.script}\n`); - return { exitCode: 1 }; - } - case "applied": { - context.process.stdout.write( - `Applied ${result.statements} change${result.statements === 1 ? "" : "s"} across ${result.units} migration unit${result.units === 1 ? "" : "s"}.\n`, - ); - context.process.stdout.write("Successfully applied all changes.\n"); - if (result.warnings?.length) { - for (const warning of result.warnings) { - context.process.stderr.write(`Warning: ${warning}\n`); - } - } - return { exitCode: 0 }; - } - } -} - -/** - * Prompts user for confirmation using readline. - * Returns true for 'y'/'yes', false otherwise. - */ -export function promptConfirmation( - question: string, - context: CommandContext, -): Promise { - return new Promise((resolve) => { - // Access stdin/stdout from the process object - // Type assertion needed because CommandContext.process may not expose stdin in its type - const process = context.process as unknown as { - stdin: NodeJS.ReadableStream; - stdout: NodeJS.WritableStream; - }; - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - rl.question(question, (answer) => { - rl.close(); - const normalized = answer.trim().toLowerCase(); - resolve(normalized === "y" || normalized === "yes"); - }); - }); -} diff --git a/packages/pg-delta/src/cli/utils/apply-display.test.ts b/packages/pg-delta/src/cli/utils/apply-display.test.ts deleted file mode 100644 index 8d7f4d7a0..000000000 --- a/packages/pg-delta/src/cli/utils/apply-display.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import type { Diagnostic, DiagnosticCode } from "@supabase/pg-topo"; -import type { StatementError } from "../../core/declarative-apply/round-apply.ts"; -import { - buildDiagnosticDisplayItems, - type DiagnosticDisplayEntry, - formatStatementError, - positionToLineColumn, - requiredObjectKeyFromDiagnostic, - resolveSqlFilePath, -} from "./apply-display.ts"; - -describe("positionToLineColumn", () => { - test("single line, position at start", () => { - expect(positionToLineColumn("hello", 1)).toEqual({ line: 1, column: 1 }); - }); - - test("single line, position in the middle", () => { - expect(positionToLineColumn("hello", 3)).toEqual({ line: 1, column: 3 }); - }); - - test("single line, position at end", () => { - expect(positionToLineColumn("hello", 5)).toEqual({ line: 1, column: 5 }); - }); - - test("multi-line, first line", () => { - expect(positionToLineColumn("ab\ncd\nef", 2)).toEqual({ - line: 1, - column: 2, - }); - }); - - test("multi-line, second line start", () => { - expect(positionToLineColumn("ab\ncd\nef", 4)).toEqual({ - line: 2, - column: 1, - }); - }); - - test("multi-line, third line", () => { - expect(positionToLineColumn("ab\ncd\nef", 7)).toEqual({ - line: 3, - column: 1, - }); - }); - - test("position past end falls back to last line", () => { - expect(positionToLineColumn("ab\ncd", 100)).toEqual({ - line: 2, - column: 3, - }); - }); - - test("empty string", () => { - expect(positionToLineColumn("", 1)).toEqual({ line: 1, column: 1 }); - }); -}); - -describe("requiredObjectKeyFromDiagnostic", () => { - test("returns value when present and non-empty", () => { - const diag: Diagnostic = { - code: "UNRESOLVED_DEPENDENCY", - message: "warning", - details: { requiredObjectKey: "public.users" }, - }; - expect(requiredObjectKeyFromDiagnostic(diag)).toBe("public.users"); - }); - - test("returns undefined for empty string", () => { - const diag: Diagnostic = { - code: "UNRESOLVED_DEPENDENCY", - message: "warning", - details: { requiredObjectKey: "" }, - }; - expect(requiredObjectKeyFromDiagnostic(diag)).toBeUndefined(); - }); - - test("returns undefined when details is undefined", () => { - const diag: Diagnostic = { - code: "UNRESOLVED_DEPENDENCY", - message: "warning", - }; - expect(requiredObjectKeyFromDiagnostic(diag)).toBeUndefined(); - }); - - test("returns undefined for non-string value", () => { - const diag: Diagnostic = { - code: "UNRESOLVED_DEPENDENCY", - message: "warning", - details: { requiredObjectKey: 42 }, - }; - expect(requiredObjectKeyFromDiagnostic(diag)).toBeUndefined(); - }); -}); - -describe("buildDiagnosticDisplayItems", () => { - const makeDiag = ( - code: DiagnosticCode, - message: string, - suggestedFix?: string, - ): Diagnostic => ({ code, message, suggestedFix }); - - test("ungrouped mode returns one item per entry", () => { - const entries: DiagnosticDisplayEntry[] = [ - { - diagnostic: makeDiag("PARSE_ERROR", "err1"), - location: "file1.sql:1", - }, - { - diagnostic: makeDiag("PARSE_ERROR", "err1"), - location: "file2.sql:5", - }, - ]; - const items = buildDiagnosticDisplayItems(entries, false); - expect(items).toHaveLength(2); - expect(items[0].locations).toEqual(["file1.sql:1"]); - expect(items[1].locations).toEqual(["file2.sql:5"]); - }); - - test("ungrouped mode: entry without location gets empty locations array", () => { - const entries: DiagnosticDisplayEntry[] = [ - { diagnostic: makeDiag("PARSE_ERROR", "err1") }, - ]; - const items = buildDiagnosticDisplayItems(entries, false); - expect(items[0].locations).toEqual([]); - }); - - test("grouped mode merges same code/message entries", () => { - const entries: DiagnosticDisplayEntry[] = [ - { diagnostic: makeDiag("PARSE_ERROR", "err1"), location: "a.sql:1" }, - { diagnostic: makeDiag("PARSE_ERROR", "err1"), location: "b.sql:2" }, - { - diagnostic: makeDiag("UNRESOLVED_DEPENDENCY", "err2", "fix it"), - location: "c.sql:3", - }, - ]; - const items = buildDiagnosticDisplayItems(entries, true); - expect(items).toHaveLength(2); - expect(items[0].locations).toEqual(["a.sql:1", "b.sql:2"]); - expect(items[1].code).toBe("UNRESOLVED_DEPENDENCY"); - expect(items[1].suggestedFix).toBe("fix it"); - }); - - test("grouped mode: entry without location gets empty locations", () => { - const entries: DiagnosticDisplayEntry[] = [ - { diagnostic: makeDiag("PARSE_ERROR", "err1") }, - ]; - const items = buildDiagnosticDisplayItems(entries, true); - expect(items[0].locations).toEqual([]); - }); - - test("grouped mode deduplicates identical locations", () => { - const entries: DiagnosticDisplayEntry[] = [ - { diagnostic: makeDiag("PARSE_ERROR", "err1"), location: "a.sql:1" }, - { diagnostic: makeDiag("PARSE_ERROR", "err1"), location: "a.sql:1" }, - ]; - const items = buildDiagnosticDisplayItems(entries, true); - expect(items[0].locations).toEqual(["a.sql:1"]); - }); - - test("grouped mode preserves requiredObjectKey in group key", () => { - const entries: DiagnosticDisplayEntry[] = [ - { - diagnostic: makeDiag("PARSE_ERROR", "err1"), - location: "a.sql:1", - requiredObjectKey: "key1", - }, - { - diagnostic: makeDiag("PARSE_ERROR", "err1"), - location: "b.sql:2", - requiredObjectKey: "key2", - }, - ]; - const items = buildDiagnosticDisplayItems(entries, true); - expect(items).toHaveLength(2); - expect(items[0].requiredObjectKey).toBe("key1"); - expect(items[1].requiredObjectKey).toBe("key2"); - }); -}); - -describe("resolveSqlFilePath", () => { - test("schemaPath is a directory", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-apply-test-")); - try { - const result = await resolveSqlFilePath(dir, "schemas/table.sql"); - expect(result).toBe(path.join(dir, "schemas/table.sql")); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("schemaPath is a file uses dirname", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-apply-test-")); - try { - const filePath = path.join(dir, "schema.sql"); - await writeFile(filePath, ""); - const result = await resolveSqlFilePath(filePath, "schemas/table.sql"); - expect(result).toBe(path.join(dir, "schemas/table.sql")); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("stat throws falls back to joining schemaPath as dir", async () => { - const result = await resolveSqlFilePath("/nonexistent/path", "table.sql"); - expect(result).toBe(path.join("/nonexistent/path", "table.sql")); - }); -}); - -describe("formatStatementError", () => { - function makeErr( - overrides: Partial & { - id?: string; - sql?: string; - } = {}, - ): StatementError { - const { id, sql, ...rest } = overrides; - return { - message: "something failed", - code: "42601", - isDependencyError: false, - statement: { id: id ?? "raw-statement", sql: sql ?? "" }, - ...rest, - }; - } - - test("minimal error with unparseable id", async () => { - const result = await formatStatementError( - makeErr({ id: "no-colon-here" }), - "/tmp", - ); - expect(result).toContain("ERROR: something failed"); - expect(result).toContain("SQL state: 42601"); - expect(result).toContain("Location: no-colon-here"); - expect(result).not.toContain("Detail:"); - expect(result).not.toContain("Hint:"); - expect(result).not.toContain("Character:"); - }); - - test("includes detail and hint when present", async () => { - const result = await formatStatementError( - makeErr({ detail: "some detail", hint: "try this" }), - "/tmp", - ); - expect(result).toContain("Detail: some detail"); - expect(result).toContain("Hint: try this"); - }); - - test("includes Character and Context when position is set", async () => { - const sql = "SELECT * FROM missing_table WHERE id = 1"; - const result = await formatStatementError( - makeErr({ position: 15, sql, id: "raw-id" }), - "/tmp", - ); - expect(result).toContain("Character: 15"); - expect(result).toContain("Context:"); - }); - - test("parseable id with file containing the SQL resolves line:col", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-apply-test-")); - try { - const sql = "CREATE TABLE foo (id int);"; - const fileContent = `-- header\n\n${sql}\n`; - await writeFile(path.join(dir, "tables.sql"), fileContent); - - const result = await formatStatementError( - makeErr({ id: "tables.sql:0", sql, position: 14 }), - dir, - ); - expect(result).toMatch(/Location: tables\.sql:\d+:\d+/); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("parseable id with file containing SQL but no position resolves line only", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-apply-test-")); - try { - const sql = "CREATE TABLE bar (id int);"; - const fileContent = `-- header\n${sql}\n`; - await writeFile(path.join(dir, "tables.sql"), fileContent); - - const result = await formatStatementError( - makeErr({ id: "tables.sql:0", sql }), - dir, - ); - expect(result).toMatch(/Location: tables\.sql:\d+$/m); - expect(result).not.toMatch(/Location: tables\.sql:\d+:\d+/); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("parseable id with file but SQL not found falls back to statement index", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-apply-test-")); - try { - await writeFile( - path.join(dir, "tables.sql"), - "-- completely different content", - ); - - const result = await formatStatementError( - makeErr({ - id: "tables.sql:2", - sql: "DROP TABLE nonexistent;", - }), - dir, - ); - expect(result).toContain("Location: tables.sql (statement 2)"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("parseable id with position but file read fails uses SQL-based line/col", async () => { - const sql = "SELECT\n 1\n + bad_col"; - const result = await formatStatementError( - makeErr({ id: "missing/file.sql:0", sql, position: 14 }), - "/nonexistent", - ); - expect(result).toMatch( - /Location: missing\/file\.sql \(statement 0, line \d+, column \d+\)/, - ); - }); - - test("parseable id without position and file read fails shows statement index only", async () => { - const result = await formatStatementError( - makeErr({ id: "missing/file.sql:1" }), - "/nonexistent", - ); - expect(result).toContain("Location: missing/file.sql (statement 1)"); - expect(result).not.toContain("line"); - expect(result).not.toContain("column"); - }); - - test("all output lines are indented with two spaces", async () => { - const result = await formatStatementError( - makeErr({ detail: "d", hint: "h", position: 1, sql: "SELECT 1" }), - "/tmp", - ); - for (const line of result.split("\n")) { - expect(line).toMatch(/^ {2}/); - } - }); -}); diff --git a/packages/pg-delta/src/cli/utils/apply-display.ts b/packages/pg-delta/src/cli/utils/apply-display.ts deleted file mode 100644 index e3f03c891..000000000 --- a/packages/pg-delta/src/cli/utils/apply-display.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Display utilities for the declarative-apply command. - * - * Pure formatting and location-resolution functions — no CLI framework dependency. - * Used to: - * - Map pg-topo diagnostics into display items (optionally grouped by message/code). - * - Resolve statement IDs to file paths and line/column for error output. - * - Format StatementErrors in a pgAdmin-style multi-line block. - */ - -import { readFile, stat } from "node:fs/promises"; -import path from "node:path"; -import type { Diagnostic } from "@supabase/pg-topo"; -import type { StatementError } from "../../core/declarative-apply/round-apply.ts"; - -/** - * Convert a 1-based character offset in a string to 1-based line and column. - * Used when mapping PostgreSQL error positions (in SQL) to file locations. - */ -export function positionToLineColumn( - sql: string, - position: number, -): { line: number; column: number } { - const lines = sql.split("\n"); - let offset = 0; - for (let i = 0; i < lines.length; i++) { - const lineLen = lines[i].length + (i < lines.length - 1 ? 1 : 0); - if (position <= offset + lineLen) { - return { line: i + 1, column: position - offset }; - } - offset += lineLen; - } - const last = lines.length; - const lastLineLen = lines[last - 1]?.length ?? 0; - return { line: last, column: lastLineLen + 1 }; -} - -/** - * Parse a statement id in the form "filePath:statementIndex" into components. - * The last colon separates path from index (paths may contain colons). - * Returns null if the format is invalid. - */ -function parseStatementId( - id: string, -): { filePath: string; statementIndex: number } | null { - const lastColon = id.lastIndexOf(":"); - if (lastColon === -1) return null; - const filePath = id.slice(0, lastColon); - const n = Number.parseInt(id.slice(lastColon + 1), 10); - if (!Number.isInteger(n) || n < 0) return null; - return { filePath, statementIndex: n }; -} - -/** Input to buildDiagnosticDisplayItems: a pg-topo diagnostic plus optional location and object key. */ -export type DiagnosticDisplayEntry = { - diagnostic: Diagnostic; - location?: string; - requiredObjectKey?: string; -}; - -/** One display row for a diagnostic (or a group of same-code diagnostics with multiple locations). */ -type DiagnosticDisplayItem = { - code: string; - message: string; - suggestedFix?: string; - requiredObjectKey?: string; - locations: string[]; -}; - -/** Extract requiredObjectKey from a pg-topo diagnostic if present and non-empty. */ -export const requiredObjectKeyFromDiagnostic = ( - diagnostic: Diagnostic, -): string | undefined => { - const value = diagnostic.details?.requiredObjectKey; - return typeof value === "string" && value.length > 0 ? value : undefined; -}; - -/** Build a stable key for grouping diagnostics with the same code, message, and suggested fix. */ -const diagnosticDisplayGroupKey = (entry: DiagnosticDisplayEntry): string => - [ - entry.diagnostic.code, - entry.diagnostic.message, - entry.diagnostic.suggestedFix ?? "", - entry.requiredObjectKey ?? "", - ].join("\u0000"); - -/** - * Turn diagnostic entries into display items. If grouped is true, entries with - * the same code/message/suggestedFix are merged into one item with multiple locations. - */ -export const buildDiagnosticDisplayItems = ( - entries: DiagnosticDisplayEntry[], - grouped: boolean, -): DiagnosticDisplayItem[] => { - if (!grouped) { - return entries.map((entry) => ({ - code: entry.diagnostic.code, - message: entry.diagnostic.message, - suggestedFix: entry.diagnostic.suggestedFix, - requiredObjectKey: entry.requiredObjectKey, - locations: entry.location ? [entry.location] : [], - })); - } - - const groupedItems = new Map(); - for (const entry of entries) { - const key = diagnosticDisplayGroupKey(entry); - const existing = groupedItems.get(key); - if (!existing) { - groupedItems.set(key, { - code: entry.diagnostic.code, - message: entry.diagnostic.message, - suggestedFix: entry.diagnostic.suggestedFix, - requiredObjectKey: entry.requiredObjectKey, - locations: entry.location ? [entry.location] : [], - }); - continue; - } - if (entry.location && !existing.locations.includes(entry.location)) { - existing.locations.push(entry.location); - } - } - return [...groupedItems.values()]; -}; - -/** - * Resolve the full path to a .sql file from the schema path (directory or single file) - * and a relative file path (e.g. from a statement id). If schemaPath is a file, its - * directory is used as the base. - */ -export async function resolveSqlFilePath( - schemaPath: string, - relativeFilePath: string, -): Promise { - try { - const statResult = await stat(schemaPath); - const baseDir = statResult.isFile() ? path.dirname(schemaPath) : schemaPath; - return path.join(baseDir, relativeFilePath); - } catch { - return path.join(schemaPath, relativeFilePath); - } -} - -/** - * Find the 0-based start offset of statementSql in fileContent. - * Tries exact match first, then trimmed match. Returns -1 if not found. - */ -function findStatementStartInFile( - fileContent: string, - statementSql: string, -): number { - const exact = fileContent.indexOf(statementSql); - if (exact !== -1) return exact; - const trimmedStmt = statementSql.trim(); - if (!trimmedStmt) return -1; - const trimmed = fileContent.indexOf(trimmedStmt); - if (trimmed !== -1) return trimmed; - return -1; -} - -/** - * Format a StatementError in pgAdmin-style: ERROR, Detail, SQL state, optional - * Context, Hint, and Location (resolving the .sql file and line/column when possible). - */ -export async function formatStatementError( - err: StatementError, - schemaPath: string, -): Promise { - const lines: string[] = []; - lines.push(`ERROR: ${err.message}`); - if (err.detail) { - lines.push(`Detail: ${err.detail}`); - } - lines.push(`SQL state: ${err.code}`); - if (err.position !== undefined && err.statement.sql.length > 0) { - lines.push(`Character: ${err.position}`); - const pos = Math.max( - 0, - Math.min(err.position - 1, err.statement.sql.length), - ); - const contextStart = Math.max(0, pos - 40); - const contextEnd = Math.min(err.statement.sql.length, pos + 40); - const snippet = err.statement.sql.slice(contextStart, contextEnd); - const oneLine = snippet.replace(/\s+/g, " ").trim(); - lines.push(`Context: ${oneLine || "(empty)"}`); - } - if (err.hint) { - lines.push(`Hint: ${err.hint}`); - } - const parsed = parseStatementId(err.statement.id); - if (parsed) { - let locationLine: string; - try { - const fullPath = await resolveSqlFilePath(schemaPath, parsed.filePath); - const fileContent = await readFile(fullPath, "utf-8"); - const statementStart = findStatementStartInFile( - fileContent, - err.statement.sql, - ); - if (statementStart !== -1) { - if (err.position !== undefined && err.statement.sql.length > 0) { - const fileErrorOffset = statementStart + (err.position - 1); - const fileErrorPosition = Math.min( - fileErrorOffset + 1, - fileContent.length, - ); - const { line, column } = positionToLineColumn( - fileContent, - Math.max(1, fileErrorPosition), - ); - locationLine = `Location: ${parsed.filePath}:${line}:${column}`; - } else { - const { line } = positionToLineColumn( - fileContent, - statementStart + 1, - ); - locationLine = `Location: ${parsed.filePath}:${line}`; - } - } else { - locationLine = `Location: ${parsed.filePath} (statement ${parsed.statementIndex})`; - } - } catch { - if (err.position !== undefined && err.statement.sql.length > 0) { - const { line, column } = positionToLineColumn( - err.statement.sql, - err.position, - ); - locationLine = `Location: ${parsed.filePath} (statement ${parsed.statementIndex}, line ${line}, column ${column})`; - } else { - locationLine = `Location: ${parsed.filePath} (statement ${parsed.statementIndex})`; - } - } - lines.push(locationLine); - } else { - lines.push(`Location: ${err.statement.id}`); - } - return lines.map((l) => ` ${l}`).join("\n"); -} diff --git a/packages/pg-delta/src/cli/utils/export-display.test.ts b/packages/pg-delta/src/cli/utils/export-display.test.ts deleted file mode 100644 index 79fba5eed..000000000 --- a/packages/pg-delta/src/cli/utils/export-display.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import type { FileEntry } from "../../core/export/types.ts"; -import { assertSafePath, computeFileDiff } from "./export-display.ts"; - -// ============================================================================ -// assertSafePath -// ============================================================================ - -describe("assertSafePath", () => { - test("allows normal relative paths", () => { - expect(() => - assertSafePath("schemas/public/tables/users.sql", "/tmp/out"), - ).not.toThrow(); - }); - - test("allows nested paths", () => { - expect(() => - assertSafePath("cluster/extensions/pgcrypto.sql", "/tmp/out"), - ).not.toThrow(); - }); - - test("rejects path traversal with ..", () => { - expect(() => assertSafePath("../../etc/passwd", "/tmp/out")).toThrow( - "traversal", - ); - }); - - test("rejects path traversal embedded in path", () => { - expect(() => - assertSafePath("schemas/../../../etc/passwd", "/tmp/out"), - ).toThrow("traversal"); - }); - - test("rejects absolute paths", () => { - expect(() => assertSafePath("/etc/passwd", "/tmp/out")).toThrow( - "traversal", - ); - }); -}); - -// ============================================================================ -// computeFileDiff – SQL-only filtering -// ============================================================================ - -describe("computeFileDiff", () => { - function makeFileEntry(relPath: string, sql = "-- content"): FileEntry { - return { - path: relPath, - order: 0, - statements: 1, - sql, - metadata: { objectType: "table" }, - }; - } - - test("non-SQL files in output dir are not marked as deleted", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-export-test-")); - try { - await mkdir(path.join(dir, "schemas/public/tables"), { recursive: true }); - await writeFile( - path.join(dir, "schemas/public/tables/users.sql"), - "-- users", - ); - await writeFile(path.join(dir, "README.md"), "# readme"); - await writeFile(path.join(dir, ".gitkeep"), ""); - - const newFiles = [makeFileEntry("schemas/public/tables/users.sql")]; - const diff = await computeFileDiff(dir, newFiles); - - expect(diff.deleted).not.toContain("README.md"); - expect(diff.deleted).not.toContain(".gitkeep"); - expect(diff.deleted).toHaveLength(0); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("stale SQL files are still marked as deleted", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-export-test-")); - try { - await mkdir(path.join(dir, "schemas/public/tables"), { recursive: true }); - await writeFile( - path.join(dir, "schemas/public/tables/users.sql"), - "-- users", - ); - await writeFile( - path.join(dir, "schemas/public/tables/old_table.sql"), - "-- old", - ); - - const newFiles = [makeFileEntry("schemas/public/tables/users.sql")]; - const diff = await computeFileDiff(dir, newFiles); - - expect(diff.deleted).toContain("schemas/public/tables/old_table.sql"); - expect(diff.deleted).toHaveLength(1); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/pg-delta/src/cli/utils/export-display.ts b/packages/pg-delta/src/cli/utils/export-display.ts deleted file mode 100644 index 4ee6b21fc..000000000 --- a/packages/pg-delta/src/cli/utils/export-display.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * CLI helpers for declarative export: file tree, diff, and summary formatting. - */ - -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import chalk from "chalk"; -import type { FileEntry } from "../../core/export/types.ts"; - -// ============================================================================ -// Path safety -// ============================================================================ - -/** - * Ensure a relative file path does not escape the output directory. - * Uses Node.js path.resolve + startsWith as the canonical traversal check. - */ -export function assertSafePath(filePath: string, outputDir: string): void { - const resolvedOutput = path.resolve(outputDir); - const resolvedFile = path.resolve(outputDir, filePath); - if ( - resolvedFile !== resolvedOutput && - !resolvedFile.startsWith(resolvedOutput + path.sep) - ) { - throw new Error( - `Export path traversal detected: '${filePath}' resolves outside output directory`, - ); - } -} - -// ============================================================================ -// Types -// ============================================================================ - -interface FileDiffResult { - created: string[]; - updated: string[]; - deleted: string[]; - unchanged: string[]; -} - -// ============================================================================ -// File tree -// ============================================================================ - -interface BuildFileTreeOptions { - /** When provided, leaf paths are prefixed with + / ~ / - and colorized (created / updated / deleted). */ - diff?: FileDiffResult; - /** When true, only paths that are created, updated, or deleted are shown; unchanged are omitted. Includes diff.deleted in the tree. */ - diffFocus?: boolean; -} - -type FileStatus = "created" | "updated" | "deleted" | "unchanged"; - -function getFileStatus(path: string, diff: FileDiffResult): FileStatus { - if (diff.created.includes(path)) return "created"; - if (diff.updated.includes(path)) return "updated"; - if (diff.deleted.includes(path)) return "deleted"; - return "unchanged"; -} - -function formatLeafSegment(segment: string, status: FileStatus): string { - switch (status) { - case "created": - return chalk.green(`+ ${segment}`); - case "updated": - return chalk.yellow(`~ ${segment}`); - case "deleted": - return chalk.red(`- ${segment}`); - default: - return chalk.dim(segment); - } -} - -/** - * Build a directory tree string from file paths. - * Groups by directory, shows files as leaves with indentation. - * When options.diff is provided, leaf names are prefixed with + (created), ~ (updated), - (deleted) and colorized. - * When options.diffFocus is true, only changed paths (and their ancestors) are shown; unchanged files are omitted. - * - * @param files - Array of relative file paths (e.g. ["schemas/public/schema.sql", "schemas/public/tables/users.sql"]) - * @param outputDir - Display name for the root (e.g. "declarative-schemas") - * @param options - Optional diff and diffFocus for symbols and filtering - */ -export function buildFileTree( - files: string[], - outputDir: string, - options?: BuildFileTreeOptions, -): string { - const { diff, diffFocus } = options ?? {}; - let pathsToShow = files; - - if (diffFocus && diff) { - const changed = new Set([ - ...diff.created, - ...diff.updated, - ...diff.deleted, - ]); - pathsToShow = [...changed]; - if (pathsToShow.length === 0) { - return chalk.dim("(no file changes)"); - } - } - - const tree = new Map>(); - - for (const filePath of pathsToShow) { - const segments = filePath.split("/"); - let parent = ""; - for (let i = 0; i < segments.length; i++) { - const segment = segments[i]; - const fullPath = parent ? `${parent}/${segment}` : segment; - let children = tree.get(parent); - if (!children) { - children = new Set(); - tree.set(parent, children); - } - children.add(fullPath); - parent = fullPath; - } - } - - const lines: string[] = []; - - function emit(relPath: string, indent: number, isLast: boolean): void { - const segment = relPath ? path.basename(relPath) : outputDir; - const prefix = - indent === 0 ? "" : " ".repeat(indent) + (isLast ? "└── " : "├── "); - const isLeaf = !tree.has(relPath); - const displaySegment = - diff && isLeaf - ? formatLeafSegment(segment, getFileStatus(relPath, diff)) - : segment; - lines.push(prefix + displaySegment); - const children = tree.get(relPath); - if (children) { - const sorted = [...children].sort((a, b) => - path.basename(a).localeCompare(path.basename(b)), - ); - for (let i = 0; i < sorted.length; i++) { - emit(sorted[i], indent + 1, i === sorted.length - 1); - } - } - } - - emit("", 0, false); - return lines.join("\n"); -} - -// ============================================================================ -// File diff -// ============================================================================ - -/** - * Recursively collect relative paths of managed (.sql) files under a directory. - * Non-SQL files (README, .gitkeep, etc.) are intentionally excluded so they - * are never flagged as "deleted" during diff. - */ -async function collectExistingFiles(dir: string, base = ""): Promise { - const entries = await readdir(path.join(dir, base), { withFileTypes: true }); - const files: string[] = []; - for (const e of entries) { - const rel = base ? `${base}/${e.name}` : e.name; - if (e.isFile() && e.name.endsWith(".sql")) { - files.push(rel); - } else if (e.isDirectory()) { - files.push(...(await collectExistingFiles(dir, rel))); - } - } - return files; -} - -/** - * Compare existing output directory with new file set. - * Returns created, updated, deleted, and unchanged paths. - */ -export async function computeFileDiff( - outputDir: string, - newFiles: FileEntry[], -): Promise { - const newPaths = new Set(newFiles.map((f) => f.path)); - const newByPath = new Map(newFiles.map((f) => [f.path, f])); - - let existingPaths: string[] = []; - try { - existingPaths = await collectExistingFiles(outputDir); - } catch { - // Directory doesn't exist or not readable - return { - created: [...newPaths], - updated: [], - deleted: [], - unchanged: [], - }; - } - - const existingSet = new Set(existingPaths); - const created: string[] = []; - const updated: string[] = []; - const deleted: string[] = []; - const unchanged: string[] = []; - - for (const p of newPaths) { - if (!existingSet.has(p)) { - created.push(p); - } else { - const entry = newByPath.get(p); - if (!entry) continue; - try { - const existingContent = await readFile( - path.join(outputDir, p), - "utf-8", - ); - if (existingContent.trim() !== entry.sql.trim()) { - updated.push(p); - } else { - unchanged.push(p); - } - } catch { - updated.push(p); - } - } - } - - for (const p of existingPaths) { - if (!newPaths.has(p)) { - deleted.push(p); - } - } - - return { created, updated, deleted, unchanged }; -} - -// ============================================================================ -// Summary formatting -// ============================================================================ - -/** - * Format the created/deleted/updated summary with colors. - * In dry-run mode, uses "would create/delete/update" phrasing. - */ -export function formatExportSummary( - diff: FileDiffResult, - dryRun: boolean, -): string { - const lines: string[] = []; - const verb = dryRun ? "Would" : ""; - - if (diff.created.length > 0) { - lines.push( - chalk.green( - `${verb ? `${verb} create` : "Created"}: ${diff.created.length} file(s)`, - ), - ); - } - if (diff.updated.length > 0) { - lines.push( - chalk.yellow( - `${verb ? `${verb} update` : "Updated"}: ${diff.updated.length} file(s)`, - ), - ); - } - if (diff.deleted.length > 0) { - lines.push( - chalk.red( - `${verb ? `${verb} delete` : "Deleted"}: ${diff.deleted.length} file(s)`, - ), - ); - } - if (diff.unchanged.length > 0 && !dryRun) { - lines.push(chalk.dim(`Unchanged: ${diff.unchanged.length} file(s)`)); - } - - return lines.length > 0 ? lines.join("\n") : ""; -} diff --git a/packages/pg-delta/src/cli/utils/integrations.test.ts b/packages/pg-delta/src/cli/utils/integrations.test.ts deleted file mode 100644 index 77a7f3377..000000000 --- a/packages/pg-delta/src/cli/utils/integrations.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import type { FilterDSL } from "../../core/integrations/filter/dsl.ts"; -import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts"; -import { - loadIntegrationDSL, - resolveIntegrationOptions, -} from "./integrations.ts"; - -describe("loadIntegrationDSL", () => { - test("loads from .json file path", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-integration-")); - const jsonPath = path.join(dir, "custom.json"); - try { - await writeFile( - jsonPath, - JSON.stringify({ - filter: { "*/schema": "app" }, - }), - ); - const dsl = await loadIntegrationDSL(jsonPath); - expect(dsl).toBeDefined(); - expect(dsl.filter).toEqual({ "*/schema": "app" }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("loads core integration by name (supabase)", async () => { - const dsl = await loadIntegrationDSL("supabase"); - expect(dsl).toBeDefined(); - expect(dsl.filter).toBeDefined(); - expect(dsl.serialize).toBeDefined(); - }); - - test("fallback to file path when core module not found", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-integration-")); - const filePath = path.join(dir, "custom-dsl"); - await writeFile(filePath, JSON.stringify({ serialize: [] })); - try { - const dsl = await loadIntegrationDSL(filePath); - expect(dsl).toEqual({ serialize: [] }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); - -describe("extends resolution", () => { - test('extends: "supabase" → resolves and merges successfully', async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-extends-")); - const jsonPath = path.join(dir, "custom.json"); - try { - await writeFile( - jsonPath, - JSON.stringify({ - extends: "supabase", - serialize: [ - { - when: { objectType: "table" }, - options: { skipAuthorization: true }, - }, - ], - }), - ); - const dsl = await loadIntegrationDSL(jsonPath); - expect(dsl).toBeDefined(); - // Should have the supabase filter merged in - expect(dsl.filter).toBeDefined(); - // Should have both supabase serialize rules and our custom one - expect(dsl.serialize).toBeDefined(); - expect(dsl.serialize?.length).toBeGreaterThan(1); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test('extends: "./some-file.json" → throws error about core integrations only', async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-extends-")); - const filePath = path.join(dir, "child.json"); - const parentPath = path.join(dir, "parent.json"); - try { - await writeFile( - parentPath, - JSON.stringify({ filter: { "*/schema": "app" } }), - ); - await writeFile( - filePath, - JSON.stringify({ - extends: parentPath, - filter: { "*/schema": "public" }, - }), - ); - expect(loadIntegrationDSL(filePath)).rejects.toThrow( - /extends only supports core integration names/, - ); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test('extends: "nonexistent" → throws error about unknown core integration', async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-extends-")); - const jsonPath = path.join(dir, "custom.json"); - try { - await writeFile(jsonPath, JSON.stringify({ extends: "nonexistent" })); - expect(loadIntegrationDSL(jsonPath)).rejects.toThrow( - /Unknown core integration: "nonexistent"/, - ); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); - -describe("resolveIntegrationOptions", () => { - test("no integration, no CLI flags → all undefined", async () => { - const result = await resolveIntegrationOptions({}); - expect(result).toEqual({ - filter: undefined, - serialize: undefined, - }); - }); - - test("CLI flags only → passed through unchanged", async () => { - const filter: FilterDSL = { "*/schema": "public" }; - const serialize: SerializeDSL = [ - { when: { objectType: "schema" }, options: { skipAuthorization: true } }, - ]; - const result = await resolveIntegrationOptions({ filter, serialize }); - expect(result.filter).toEqual(filter); - expect(result.serialize).toEqual(serialize); - expect(result.emptyCatalog).toBeUndefined(); - }); - - test("integration only → integration values returned", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-resolve-")); - const jsonPath = path.join(dir, "int.json"); - try { - await writeFile( - jsonPath, - JSON.stringify({ - filter: { "*/schema": "app" }, - serialize: [{ when: { objectType: "table" }, options: {} }], - }), - ); - const result = await resolveIntegrationOptions({ - integration: jsonPath, - }); - expect(result.filter).toEqual({ "*/schema": "app" }); - expect(result.serialize).toEqual([ - { when: { objectType: "table" }, options: {} }, - ]); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("both filter + integration filter → AND-combined", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-resolve-")); - const jsonPath = path.join(dir, "int.json"); - try { - await writeFile( - jsonPath, - JSON.stringify({ - filter: { "*/schema": "app" }, - }), - ); - const cliFilter: FilterDSL = { objectType: "table" }; - const result = await resolveIntegrationOptions({ - filter: cliFilter, - integration: jsonPath, - }); - expect(result.filter).toEqual({ - and: [{ "*/schema": "app" }, { objectType: "table" }], - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("both serialize + integration serialize → concatenated (integration first)", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-resolve-")); - const jsonPath = path.join(dir, "int.json"); - try { - const intSerialize: SerializeDSL = [ - { - when: { objectType: "schema" }, - options: { skipAuthorization: true }, - }, - ]; - const cliSerialize: SerializeDSL = [ - { when: { objectType: "table" }, options: {} }, - ]; - await writeFile(jsonPath, JSON.stringify({ serialize: intSerialize })); - const result = await resolveIntegrationOptions({ - serialize: cliSerialize, - integration: jsonPath, - }); - expect(result.serialize).toEqual([...intSerialize, ...cliSerialize]); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("emptyCatalog returned from integration", async () => { - const dir = await mkdtemp(path.join(tmpdir(), "pgd-resolve-")); - const jsonPath = path.join(dir, "int.json"); - try { - const emptyCatalog = { - version: 1, - currentUser: "postgres", - aggregates: {}, - collations: {}, - compositeTypes: {}, - domains: {}, - enums: {}, - extensions: {}, - procedures: {}, - indexes: {}, - materializedViews: {}, - subscriptions: {}, - publications: {}, - rlsPolicies: {}, - roles: {}, - schemas: {}, - sequences: {}, - tables: {}, - triggers: {}, - eventTriggers: {}, - rules: {}, - ranges: {}, - views: {}, - foreignDataWrappers: {}, - servers: {}, - userMappings: {}, - foreignTables: {}, - depends: [], - }; - await writeFile(jsonPath, JSON.stringify({ emptyCatalog })); - const result = await resolveIntegrationOptions({ - integration: jsonPath, - }); - expect(result.emptyCatalog).toEqual(emptyCatalog); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/pg-delta/src/cli/utils/integrations.ts b/packages/pg-delta/src/cli/utils/integrations.ts deleted file mode 100644 index bfeeb71a8..000000000 --- a/packages/pg-delta/src/cli/utils/integrations.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Utilities for loading integrations from files. - */ - -import { readFile } from "node:fs/promises"; -import type { CatalogSnapshot } from "../../core/catalog.snapshot.ts"; -import type { FilterDSL } from "../../core/integrations/filter/dsl.ts"; -import type { IntegrationDSL } from "../../core/integrations/integration-dsl.ts"; -import { mergeIntegrations } from "../../core/integrations/merge.ts"; -import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts"; - -/** - * Load a raw integration DSL from a file or core integration (without resolving extends). - * - * @param nameOrPath - Integration name (e.g., "supabase") or file path (e.g., "./my-integration.json") - * @returns The loaded IntegrationDSL (unresolved) - */ -async function loadRawIntegrationDSL( - nameOrPath: string, -): Promise { - // If path ends with .json, treat it as a JSON file path directly - if (nameOrPath.endsWith(".json")) { - const content = await readFile(nameOrPath, "utf-8"); - return JSON.parse(content) as IntegrationDSL; - } - - // Try loading from core integrations (TypeScript) first - try { - const module = await import(`../../core/integrations/${nameOrPath}.ts`); - // Core integrations export using the integration name directly (e.g., "supabase") - const integrationName = nameOrPath; - if (integrationName in module) { - return module[integrationName] as IntegrationDSL; - } - // If no matching export, fall through to JSON file loading - } catch { - // Module not found or not a core integration, fall through to JSON file loading - } - - // Fallback to treating as JSON file path - const content = await readFile(nameOrPath, "utf-8"); - return JSON.parse(content) as IntegrationDSL; -} - -/** - * Load a core integration DSL by name only (no file path fallback). - * - * Used for resolving `extends` chains, which only support core integration names - * (e.g., "supabase"), not file paths. - * - * @param name - Core integration name (e.g., "supabase") - * @returns The loaded IntegrationDSL (unresolved) - */ -async function loadCoreIntegrationDSL(name: string): Promise { - try { - const module = await import(`../../core/integrations/${name}.ts`); - if (name in module) { - return module[name] as IntegrationDSL; - } - } catch { - // Module not found - } - throw new Error( - `Unknown core integration: "${name}". extends only supports core integration names (e.g., "supabase").`, - ); -} - -/** - * Load an integration DSL, recursively resolving `extends` chains. - * - * When an integration has `extends`, the referenced integration(s) are loaded - * and merged: filters are AND-combined, serialize rules concatenated (base first), - * and emptyCatalog uses the most-specific value. - * - * Circular extends are detected and rejected with a descriptive error. - * - * @param nameOrPath - Integration name (e.g., "supabase") or file path - * @returns The fully resolved IntegrationDSL - */ -export async function loadIntegrationDSL( - nameOrPath: string, -): Promise { - return resolveIntegration(nameOrPath, new Set()); -} - -async function resolveIntegration( - nameOrPath: string, - visited: Set, - preloadedRaw?: IntegrationDSL, -): Promise { - if (visited.has(nameOrPath)) { - throw new Error( - `Circular extends detected: ${[...visited, nameOrPath].join(" → ")}`, - ); - } - visited.add(nameOrPath); - - const raw = preloadedRaw ?? (await loadRawIntegrationDSL(nameOrPath)); - - if (!raw.extends) { - return raw; - } - - // Resolve base integrations (extends only supports core integration names) - const extendsArray = Array.isArray(raw.extends) ? raw.extends : [raw.extends]; - - const baseIntegrations: IntegrationDSL[] = []; - for (const baseName of extendsArray) { - const baseRaw = await loadCoreIntegrationDSL(baseName); - baseIntegrations.push( - await resolveIntegration(baseName, new Set(visited), baseRaw), - ); - } - - // Remove extends from the current integration before merging - const { extends: _, ...current } = raw; - - // Merge: bases first (higher priority serialize), then current (most-specific) - return mergeIntegrations([...baseIntegrations, current]); -} - -interface ResolvedIntegrationOptions { - filter?: FilterDSL; - serialize?: SerializeDSL; - emptyCatalog?: CatalogSnapshot; -} - -/** - * Load an integration (if provided) and merge its filter/serialize with CLI flags. - * - * - Filters are AND-combined (integration ∧ CLI flag) - * - Serialize rules are concatenated (integration first = higher priority) - * - emptyCatalog is extracted from the integration - */ -export async function resolveIntegrationOptions(options: { - filter?: FilterDSL; - serialize?: SerializeDSL; - integration?: string; -}): Promise { - if (!options.integration) { - return { - filter: options.filter, - serialize: options.serialize, - }; - } - - const integrationDSL = await loadIntegrationDSL(options.integration); - - // AND-combine integration filter with CLI --filter - let filter: FilterDSL | undefined; - if (integrationDSL.filter && options.filter) { - filter = { and: [integrationDSL.filter, options.filter] }; - } else { - filter = options.filter ?? integrationDSL.filter; - } - - // Concatenate serialize rules (integration first = higher priority) - let serialize: SerializeDSL | undefined; - if (integrationDSL.serialize && options.serialize) { - serialize = [...integrationDSL.serialize, ...options.serialize]; - } else { - serialize = options.serialize ?? integrationDSL.serialize; - } - - return { - filter, - serialize, - emptyCatalog: integrationDSL.emptyCatalog, - }; -} diff --git a/packages/pg-delta/src/cli/utils/resolve-input.test.ts b/packages/pg-delta/src/cli/utils/resolve-input.test.ts deleted file mode 100644 index a7648b656..000000000 --- a/packages/pg-delta/src/cli/utils/resolve-input.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import path from "node:path"; -import { isPostgresUrl, loadCatalogFromFile } from "./resolve-input.ts"; - -describe("isPostgresUrl", () => { - test("returns true for postgres:// URL", () => { - expect(isPostgresUrl("postgres://user:pass@localhost:5432/db")).toBe(true); - }); - - test("returns true for postgresql:// URL", () => { - expect(isPostgresUrl("postgresql://localhost/db")).toBe(true); - }); - - test("returns false for file path", () => { - expect(isPostgresUrl("/path/to/catalog.json")).toBe(false); - expect(isPostgresUrl("catalog.json")).toBe(false); - }); - - test("returns false for other strings", () => { - expect(isPostgresUrl("")).toBe(false); - expect(isPostgresUrl("postgres")).toBe(false); - }); -}); - -describe("loadCatalogFromFile", () => { - test("loads and deserializes catalog from JSON file", async () => { - const fixturePath = path.join( - import.meta.dir, - "../../core/fixtures/empty-catalogs/postgres-15-16-baseline.json", - ); - const catalog = await loadCatalogFromFile(fixturePath); - expect(catalog).toBeDefined(); - expect(catalog.version).toBeGreaterThan(0); - expect(typeof catalog.currentUser).toBe("string"); - expect(catalog.schemas).toBeDefined(); - expect(catalog.depends).toEqual(expect.any(Array)); - }); -}); diff --git a/packages/pg-delta/src/cli/utils/resolve-input.ts b/packages/pg-delta/src/cli/utils/resolve-input.ts deleted file mode 100644 index 06f6da3f9..000000000 --- a/packages/pg-delta/src/cli/utils/resolve-input.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Shared utilities for resolving CLI --source/--target inputs that - * can be either a PostgreSQL connection URL or a catalog snapshot file path. - */ - -import { readFile } from "node:fs/promises"; -import type { Catalog } from "../../core/catalog.model.ts"; -import { deserializeCatalog } from "../../core/catalog.snapshot.ts"; - -export function isPostgresUrl(input: string): boolean { - return input.startsWith("postgres://") || input.startsWith("postgresql://"); -} - -export async function loadCatalogFromFile(path: string): Promise { - const json = await readFile(path, "utf-8"); - return deserializeCatalog(JSON.parse(json)); -} diff --git a/packages/pg-delta/src/core/catalog-export/index.ts b/packages/pg-delta/src/core/catalog-export/index.ts deleted file mode 100644 index 7aeb1414d..000000000 --- a/packages/pg-delta/src/core/catalog-export/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Catalog export – programmatic API for extracting a database catalog - * and serializing it to a JSON snapshot. - * - * Use this subpath when you only need catalog export (e.g. Supabase CLI - * edge-runtime templates) without pulling in the full pg-delta API. - */ - -export { - Catalog, - createEmptyCatalog, - extractCatalog, -} from "../catalog.model.ts"; -export type { CatalogSnapshot } from "../catalog.snapshot.ts"; -export { - deserializeCatalog, - serializeCatalog, - stringifyCatalogSnapshot, -} from "../catalog.snapshot.ts"; -export { createManagedPool } from "../postgres-config.ts"; diff --git a/packages/pg-delta/src/core/catalog.diff.test.ts b/packages/pg-delta/src/core/catalog.diff.test.ts deleted file mode 100644 index c3a6be4fa..000000000 --- a/packages/pg-delta/src/core/catalog.diff.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { diffCatalogs } from "./catalog.diff.ts"; -import { Catalog, createEmptyCatalog } from "./catalog.model.ts"; -import { Role, type RoleProps } from "./objects/role/role.model.ts"; -import { - GrantViewPrivileges, - RevokeViewPrivileges, -} from "./objects/view/changes/view.privilege.ts"; -import { View, type ViewProps } from "./objects/view/view.model.ts"; - -const idColumn: ViewProps["columns"][number] = { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, -}; - -const nameColumn: ViewProps["columns"][number] = { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, -}; - -const baseView: ViewProps = { - schema: "public", - name: "replaced_view", - definition: "SELECT id FROM source_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "postgres", - comment: null, - columns: [idColumn], - privileges: [], -}; - -const makeView = (override: Partial = {}) => - new View({ - ...baseView, - ...override, - columns: override.columns ?? [...baseView.columns], - privileges: override.privileges ?? [...baseView.privileges], - }); - -const makeRole = (name: string, override: Partial = {}) => - new Role({ - name, - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: true, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - security_labels: [], - ...override, - }); - -describe("catalog.diff", () => { - test("keeps replacement-created view grants through dropped-target privilege filtering", async () => { - const baseline = await createEmptyCatalog(170000, "postgres"); - const mainView = makeView(); - const branchView = makeView({ - definition: "SELECT id, name FROM source_table", - columns: [...mainView.columns, nameColumn], - privileges: [ - { grantee: "view_reader", privilege: "SELECT", grantable: false }, - ], - }); - - const main = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - views: { [mainView.stableId]: mainView }, - }); - const branch = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - views: { [branchView.stableId]: branchView }, - }); - - const changes = diffCatalogs(main, branch); - - expect( - changes.some((change) => change instanceof GrantViewPrivileges), - ).toBe(true); - }); - - test("keeps replacement-created view revokes through dropped-target privilege filtering", async () => { - const baseline = await createEmptyCatalog(170000, "postgres"); - const mainView = makeView(); - const branchView = makeView({ - definition: "SELECT id, name FROM source_table", - columns: [...mainView.columns, nameColumn], - }); - const postgres = makeRole("postgres", { - default_privileges: [ - { - in_schema: "public", - objtype: "r", - grantee: "view_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - is_implicit: false, - }, - ], - }); - const viewReader = makeRole("view_reader"); - - const roles = { - [postgres.stableId]: postgres, - [viewReader.stableId]: viewReader, - }; - const main = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - roles, - views: { [mainView.stableId]: mainView }, - }); - const branch = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - roles, - views: { [branchView.stableId]: branchView }, - }); - - // The recreated view inherits SELECT from default privileges, but the - // branch model wants no explicit reader ACL. The replacement filter must - // keep the generated REVOKE even though the old view stable id is dropped. - const changes = diffCatalogs(main, branch); - - expect( - changes.some((change) => change instanceof RevokeViewPrivileges), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/catalog.diff.ts b/packages/pg-delta/src/core/catalog.diff.ts deleted file mode 100644 index c8d29d3b6..000000000 --- a/packages/pg-delta/src/core/catalog.diff.ts +++ /dev/null @@ -1,275 +0,0 @@ -import debug from "debug"; -import type { Catalog } from "./catalog.model.ts"; -import { expandReplaceDependencies } from "./expand-replace-dependencies.ts"; -import { normalizePostDiffChanges } from "./post-diff-normalization.ts"; - -const debugCatalog = debug("pg-delta:catalog"); - -import type { Change } from "./change.types.ts"; -import { diffAggregates } from "./objects/aggregate/aggregate.diff.ts"; -import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts"; -import { diffCollations } from "./objects/collation/collation.diff.ts"; -import { diffDomains } from "./objects/domain/domain.diff.ts"; -import { diffEventTriggers } from "./objects/event-trigger/event-trigger.diff.ts"; -import { diffExtensions } from "./objects/extension/extension.diff.ts"; -import { diffForeignDataWrappers } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.diff.ts"; -import { diffForeignTables } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.diff.ts"; -import { diffServers } from "./objects/foreign-data-wrapper/server/server.diff.ts"; -import { diffUserMappings } from "./objects/foreign-data-wrapper/user-mapping/user-mapping.diff.ts"; -import { diffIndexes } from "./objects/index/index.diff.ts"; -import { diffMaterializedViews } from "./objects/materialized-view/materialized-view.diff.ts"; -import { diffProcedures } from "./objects/procedure/procedure.diff.ts"; -import { diffPublications } from "./objects/publication/publication.diff.ts"; -import { diffRlsPolicies } from "./objects/rls-policy/rls-policy.diff.ts"; -import { - GrantRoleDefaultPrivileges, - RevokeRoleDefaultPrivileges, -} from "./objects/role/changes/role.privilege.ts"; -import { diffRoles } from "./objects/role/role.diff.ts"; -import { diffRules } from "./objects/rule/rule.diff.ts"; -import { diffSchemas } from "./objects/schema/schema.diff.ts"; -import { diffSequences } from "./objects/sequence/sequence.diff.ts"; -import { diffSubscriptions } from "./objects/subscription/subscription.diff.ts"; -import { diffTables } from "./objects/table/table.diff.ts"; -import { diffTriggers } from "./objects/trigger/trigger.diff.ts"; -import { diffCompositeTypes } from "./objects/type/composite-type/composite-type.diff.ts"; -import { diffEnums } from "./objects/type/enum/enum.diff.ts"; -import { diffRanges } from "./objects/type/range/range.diff.ts"; -import { stringifyWithBigInt } from "./objects/utils.ts"; -import { diffViews } from "./objects/view/view.diff.ts"; - -type PrivilegeChange = Extract; - -/** - * Get the stableId of the target object for a privilege change. - * Used to filter out redundant REVOKE statements for dropped objects. - */ -function getPrivilegeTargetStableId(change: PrivilegeChange): string { - switch (change.objectType) { - case "composite_type": - return change.compositeType.stableId; - case "domain": - return change.domain.stableId; - case "enum": - return change.enum.stableId; - case "language": - return change.language.stableId; - case "materialized_view": - return change.materializedView.stableId; - case "aggregate": - return change.aggregate.stableId; - case "procedure": - return change.procedure.stableId; - case "range": - return change.range.stableId; - case "schema": - return change.schema.stableId; - case "sequence": - return change.sequence.stableId; - case "table": - return change.table.stableId; - case "view": - return change.view.stableId; - case "foreign_data_wrapper": - return change.foreignDataWrapper.stableId; - case "server": - return change.server.stableId; - case "foreign_table": - return change.foreignTable.stableId; - default: { - const _exhaustive: never = change; - return _exhaustive; - } - } -} - -export function diffCatalogs( - main: Catalog, - branch: Catalog, - options?: { role?: string; skipDefaultPrivilegeSubtraction?: boolean }, -) { - const changes: Change[] = []; - - // Step 1: Diff roles first to get default privilege changes - const roleChanges = diffRoles( - { version: main.version }, - main.roles, - branch.roles, - ); - changes.push(...roleChanges); - - // Step 2: Compute default privileges state from role changes - // This represents what defaults will be in effect after all ALTER DEFAULT PRIVILEGES - // Since ALTER DEFAULT PRIVILEGES runs before CREATE (via constraint spec), - // all created objects will use these final defaults. - // - // When skipDefaultPrivilegeSubtraction is true, we use an empty state so that - // getEffectiveDefaults always returns [] -- no privileges are subtracted and - // every GRANT is emitted explicitly. This is needed for declarative export - // where the output must be self-contained regardless of statement execution order. - const defaultPrivilegeState = options?.skipDefaultPrivilegeSubtraction - ? new DefaultPrivilegeState({}) - : new DefaultPrivilegeState(main.roles); - if (!options?.skipDefaultPrivilegeSubtraction) { - for (const change of roleChanges) { - if (change instanceof GrantRoleDefaultPrivileges) { - defaultPrivilegeState.applyGrant( - change.role.name, - change.objtype, - change.inSchema, - change.grantee, - change.privileges, - ); - } else if (change instanceof RevokeRoleDefaultPrivileges) { - defaultPrivilegeState.applyRevoke( - change.role.name, - change.objtype, - change.inSchema, - change.grantee, - change.privileges, - ); - } - } - } - - // Step 3: Create context with default privileges state for object diffing - // Use the specified role for both default privilege lookups and owner comparisons if provided, - // otherwise use the login role (main.currentUser). This ensures that when SET ROLE is used - // in the migration, both default privileges and owner comparisons match what will actually - // happen during execution (objects will be created with the effective role as owner). - const effectiveUser = options?.role ?? main.currentUser; - const diffContext = { - version: main.version, - currentUser: effectiveUser, - defaultPrivilegeState, - mainRoles: main.roles, - skipDefaultPrivilegeSubtraction: options?.skipDefaultPrivilegeSubtraction, - }; - - // Step 4: Diff all other objects with default privileges context - changes.push( - ...diffAggregates(diffContext, main.aggregates, branch.aggregates), - ); - changes.push( - ...diffCollations(diffContext, main.collations, branch.collations), - ); - changes.push( - ...diffCompositeTypes( - diffContext, - main.compositeTypes, - branch.compositeTypes, - ), - ); - changes.push(...diffDomains(diffContext, main.domains, branch.domains)); - changes.push(...diffEnums(diffContext, main.enums, branch.enums)); - changes.push(...diffExtensions(main.extensions, branch.extensions)); - changes.push( - ...diffIndexes(main.indexes, branch.indexes, branch.indexableObjects), - ); - changes.push( - ...diffMaterializedViews( - diffContext, - main.materializedViews, - branch.materializedViews, - ), - ); - changes.push( - ...diffSubscriptions(diffContext, main.subscriptions, branch.subscriptions), - ); - changes.push( - ...diffPublications(diffContext, main.publications, branch.publications), - ); - changes.push( - ...diffProcedures(diffContext, main.procedures, branch.procedures), - ); - changes.push(...diffRlsPolicies(main.rlsPolicies, branch.rlsPolicies)); - changes.push(...diffSchemas(diffContext, main.schemas, branch.schemas)); - changes.push( - ...diffSequences( - diffContext, - main.sequences, - branch.sequences, - branch.tables, - main.tables, - ), - ); - changes.push(...diffTables(diffContext, main.tables, branch.tables)); - changes.push( - ...diffTriggers(main.triggers, branch.triggers, branch.indexableObjects), - ); - changes.push( - ...diffEventTriggers(diffContext, main.eventTriggers, branch.eventTriggers), - ); - changes.push(...diffRules(main.rules, branch.rules)); - changes.push(...diffRanges(diffContext, main.ranges, branch.ranges)); - changes.push(...diffViews(diffContext, main.views, branch.views)); - // Foreign Data Wrapper objects (in dependency order) - changes.push( - ...diffForeignDataWrappers( - diffContext, - main.foreignDataWrappers, - branch.foreignDataWrappers, - ), - ); - changes.push(...diffServers(diffContext, main.servers, branch.servers)); - changes.push(...diffUserMappings(main.userMappings, branch.userMappings)); - changes.push( - ...diffForeignTables(diffContext, main.foreignTables, branch.foreignTables), - ); - - // Filter privilege changes for objects that are only being dropped. - // Avoid emitting redundant ACL statements for targets that will no longer exist. - const droppedObjectStableIds = new Set(); - const createdStableIds = new Set(); - for (const change of changes) { - if (change.operation === "drop" && change.scope === "object") { - for (const dep of change.requires) { - droppedObjectStableIds.add(dep); - } - } - if (change.operation === "create" && change.scope === "object") { - for (const dep of change.creates) { - createdStableIds.add(dep); - } - } - } - // A pure DROP does not need ACL cleanup: the target object is going away. - // A replacement is different: it has both DROP and CREATE for the same stable - // id, and its privilege ALTERs describe the ACL state of the newly created - // object. Keep all of them, including REVOKE/REVOKE GRANT OPTION generated to - // subtract privileges inherited from ALTER DEFAULT PRIVILEGES at create time. - const replacementStableIds = new Set( - [...droppedObjectStableIds].filter((id) => createdStableIds.has(id)), - ); - let filteredChanges = changes.filter((change) => { - if (change.operation === "alter" && change.scope === "privilege") { - const targetStableId = getPrivilegeTargetStableId(change); - // Checking only privilege creates would keep replacement GRANTs but drop - // replacement REVOKEs, so preserve by replacement target stable id instead. - if (replacementStableIds.has(targetStableId)) { - return true; - } - return !droppedObjectStableIds.has(targetStableId); - } - return true; - }); - - const expandedDependencies = expandReplaceDependencies({ - changes: filteredChanges, - mainCatalog: main, - branchCatalog: branch, - diffContext, - }); - filteredChanges = normalizePostDiffChanges({ - changes: expandedDependencies.changes, - replacedTableIds: expandedDependencies.replacedTableIds, - branchTables: branch.tables, - }); - - debugCatalog( - "changes catalog diff: %O", - stringifyWithBigInt(filteredChanges, 2), - ); - - return filteredChanges; -} diff --git a/packages/pg-delta/src/core/catalog.filter.ts b/packages/pg-delta/src/core/catalog.filter.ts deleted file mode 100644 index 11da7a74e..000000000 --- a/packages/pg-delta/src/core/catalog.filter.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Prune a catalog to the objects that match a Filter DSL expression. - * - * The Filter DSL is defined over Change objects, so the catalog is - * diffed against an empty baseline first to materialize one CREATE - * change per object. The filter then evaluates against the same shape - * it would at plan time, and the surviving stableIds drive the prune. - * - * Dependency cascade is not applied. A scoped snapshot is partial by - * design: out-of-scope owners, roles, and types must exist on the - * target DB at apply time. Cascading would expand the filter beyond - * what the caller asked for and, in practice, collapse schema-scoped - * exports whose kept objects reference cluster-scoped owners. - */ - -import { diffCatalogs } from "./catalog.diff.ts"; -import { Catalog, createEmptyCatalog } from "./catalog.model.ts"; -import { compileFilterDSL, type FilterDSL } from "./integrations/filter/dsl.ts"; - -export async function filterCatalog( - catalog: Catalog, - filter: FilterDSL, -): Promise { - if ( - typeof filter === "object" && - filter !== null && - (filter as Record).cascade === true - ) { - throw new Error( - "Filter DSL `cascade: true` is not supported by catalog-export: " + - "scoped snapshots are intentionally partial. Out-of-scope owners, " + - "roles, and types must exist on the target DB at apply time.", - ); - } - - const empty = await createEmptyCatalog(catalog.version, catalog.currentUser); - const changes = diffCatalogs(empty, catalog); - const filterFn = compileFilterDSL(filter); - - const keep = new Set(); - for (const change of changes) { - if (!filterFn(change)) continue; - for (const id of change.creates ?? []) keep.add(id); - } - - return pruneCatalog(catalog, keep); -} - -function filterRecord( - record: Record, - keep: ReadonlySet, -): Record { - return Object.fromEntries( - Object.entries(record).filter(([id]) => keep.has(id)), - ); -} - -function pruneCatalog(catalog: Catalog, keep: ReadonlySet): Catalog { - const tables = filterRecord(catalog.tables, keep); - const materializedViews = filterRecord(catalog.materializedViews, keep); - - return new Catalog({ - aggregates: filterRecord(catalog.aggregates, keep), - collations: filterRecord(catalog.collations, keep), - compositeTypes: filterRecord(catalog.compositeTypes, keep), - domains: filterRecord(catalog.domains, keep), - enums: filterRecord(catalog.enums, keep), - extensions: filterRecord(catalog.extensions, keep), - procedures: filterRecord(catalog.procedures, keep), - indexes: filterRecord(catalog.indexes, keep), - materializedViews, - subscriptions: filterRecord(catalog.subscriptions, keep), - publications: filterRecord(catalog.publications, keep), - rlsPolicies: filterRecord(catalog.rlsPolicies, keep), - roles: filterRecord(catalog.roles, keep), - schemas: filterRecord(catalog.schemas, keep), - sequences: filterRecord(catalog.sequences, keep), - tables, - triggers: filterRecord(catalog.triggers, keep), - eventTriggers: filterRecord(catalog.eventTriggers, keep), - rules: filterRecord(catalog.rules, keep), - ranges: filterRecord(catalog.ranges, keep), - views: filterRecord(catalog.views, keep), - foreignDataWrappers: filterRecord(catalog.foreignDataWrappers, keep), - servers: filterRecord(catalog.servers, keep), - userMappings: filterRecord(catalog.userMappings, keep), - foreignTables: filterRecord(catalog.foreignTables, keep), - depends: catalog.depends.filter( - (d) => - keep.has(d.dependent_stable_id) && keep.has(d.referenced_stable_id), - ), - indexableObjects: { ...tables, ...materializedViews }, - version: catalog.version, - currentUser: catalog.currentUser, - }); -} diff --git a/packages/pg-delta/src/core/catalog.model.test.ts b/packages/pg-delta/src/core/catalog.model.test.ts deleted file mode 100644 index 20b2baeee..000000000 --- a/packages/pg-delta/src/core/catalog.model.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createEmptyCatalog } from "./catalog.model.ts"; - -describe("createEmptyCatalog", () => { - test("PG < 15 returns a minimal catalog with only a public schema", async () => { - const catalog = await createEmptyCatalog(140000, "myuser"); - - // version and currentUser forwarded - expect(catalog.version).toBe(140000); - expect(catalog.currentUser).toBe("myuser"); - - // public schema owned by the provided currentUser (pre-PG15 behavior) - const publicSchema = catalog.schemas["schema:public"]; - expect(publicSchema).toBeDefined(); - expect(publicSchema.owner).toBe("myuser"); - - // everything else is empty - expect(Object.keys(catalog.schemas)).toEqual(["schema:public"]); - const emptyRecords = [ - catalog.aggregates, - catalog.collations, - catalog.compositeTypes, - catalog.domains, - catalog.enums, - catalog.extensions, - catalog.procedures, - catalog.indexes, - catalog.materializedViews, - catalog.subscriptions, - catalog.publications, - catalog.rlsPolicies, - catalog.roles, - catalog.sequences, - catalog.tables, - catalog.triggers, - catalog.eventTriggers, - catalog.rules, - catalog.ranges, - catalog.views, - catalog.foreignDataWrappers, - catalog.servers, - catalog.userMappings, - catalog.foreignTables, - catalog.indexableObjects, - ]; - for (const record of emptyRecords) { - expect(record).toEqual({}); - } - expect(catalog.depends).toEqual([]); - }); - - test("PG 15-16 returns a full baseline catalog from snapshot", async () => { - const catalog = await createEmptyCatalog(160004, "admin_user"); - - // version and currentUser forwarded (not hardcoded from JSON) - expect(catalog.version).toBe(160004); - expect(catalog.currentUser).toBe("admin_user"); - - // public schema with pg_database_owner (PG 15+ default) and ACLs - const publicSchema = catalog.schemas["schema:public"]; - expect(publicSchema.owner).toBe("pg_database_owner"); - expect(publicSchema.privileges.length).toBeGreaterThan(0); - - // plpgsql extension pre-installed - expect(catalog.extensions["extension:plpgsql"]).toBeDefined(); - expect(catalog.extensions["extension:plpgsql"].name).toBe("plpgsql"); - - // postgres superuser role with default privileges - const role = catalog.roles["role:postgres"]; - expect(role.name).toBe("postgres"); - expect(role.is_superuser).toBe(true); - expect(role.default_privileges.length).toBeGreaterThan(0); - - // no MAINTAIN privilege (PG 17+ only) - const relPrivs = role.default_privileges.find( - (dp) => dp.objtype === "r" && dp.grantee === "postgres", - ); - expect(relPrivs).toBeDefined(); - expect(relPrivs?.privileges.map((p) => p.privilege)).not.toContain( - "MAINTAIN", - ); - - // dependency graph populated - expect(catalog.depends.length).toBeGreaterThan(0); - }); - - test("PG 17 baseline adds MAINTAIN privilege to default relation grants", async () => { - const catalog = await createEmptyCatalog(170009, "admin_user"); - - // version and currentUser forwarded - expect(catalog.version).toBe(170009); - expect(catalog.currentUser).toBe("admin_user"); - - // same baseline objects as PG 15-16 - expect(catalog.extensions["extension:plpgsql"]).toBeDefined(); - expect(catalog.roles["role:postgres"]).toBeDefined(); - expect(catalog.depends.length).toBeGreaterThan(0); - - // MAINTAIN privilege present and in alphabetical order - const relPrivs = catalog.roles["role:postgres"].default_privileges.find( - (dp) => dp.objtype === "r" && dp.grantee === "postgres", - ); - expect(relPrivs).toBeDefined(); - const privNames = relPrivs?.privileges.map((p) => p.privilege) ?? []; - expect(privNames).toContain("MAINTAIN"); - expect(privNames).toEqual([...privNames].sort()); - }); - - test("PG 17 patching does not mutate the cached PG 15-16 baseline", async () => { - // force PG 17 baseline to be built first - await createEmptyCatalog(170000, "postgres"); - - const pg16 = await createEmptyCatalog(160000, "postgres"); - const relPrivs = pg16.roles["role:postgres"].default_privileges.find( - (dp) => dp.objtype === "r" && dp.grantee === "postgres", - ); - expect(relPrivs).toBeDefined(); - expect(relPrivs?.privileges.map((p) => p.privilege)).not.toContain( - "MAINTAIN", - ); - }); -}); diff --git a/packages/pg-delta/src/core/catalog.model.ts b/packages/pg-delta/src/core/catalog.model.ts deleted file mode 100644 index d121773ba..000000000 --- a/packages/pg-delta/src/core/catalog.model.ts +++ /dev/null @@ -1,546 +0,0 @@ -import type { Pool } from "pg"; -import { extractCurrentUser, extractVersion } from "./context.ts"; -import { extractDepends, type PgDepend } from "./depend.ts"; -import { - type Aggregate, - extractAggregates, -} from "./objects/aggregate/aggregate.model.ts"; -import type { BasePgModel, TableLikeObject } from "./objects/base.model.ts"; -import { - type Collation, - extractCollations, -} from "./objects/collation/collation.model.ts"; -import type { Domain } from "./objects/domain/domain.model.ts"; -import { extractDomains } from "./objects/domain/domain.model.ts"; -import { - type EventTrigger, - extractEventTriggers, -} from "./objects/event-trigger/event-trigger.model.ts"; -import { - type Extension, - extractExtensions, -} from "./objects/extension/extension.model.ts"; -import { - extractForeignDataWrappers, - ForeignDataWrapper, -} from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts"; -import { - extractForeignTables, - ForeignTable, -} from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts"; -import { redactSensitiveOptionPairs } from "./objects/foreign-data-wrapper/sensitive-options.ts"; -import { - extractServers, - Server, -} from "./objects/foreign-data-wrapper/server/server.model.ts"; -import { - extractUserMappings, - UserMapping, -} from "./objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts"; -import { extractIndexes, type Index } from "./objects/index/index.model.ts"; -import { - extractMaterializedViews, - type MaterializedView, -} from "./objects/materialized-view/materialized-view.model.ts"; -import { - extractProcedures, - type Procedure, -} from "./objects/procedure/procedure.model.ts"; -import { - extractPublications, - type Publication, -} from "./objects/publication/publication.model.ts"; -import { - extractRlsPolicies, - type RlsPolicy, -} from "./objects/rls-policy/rls-policy.model.ts"; -import { extractRoles, type Role } from "./objects/role/role.model.ts"; -import { extractRules, type Rule } from "./objects/rule/rule.model.ts"; -import { extractSchemas, Schema } from "./objects/schema/schema.model.ts"; -import { - extractSequences, - type Sequence, -} from "./objects/sequence/sequence.model.ts"; -import { - extractSubscriptions, - Subscription, - SUBSCRIPTION_CONNINFO_PLACEHOLDER, -} from "./objects/subscription/subscription.model.ts"; -import { extractTables, type Table } from "./objects/table/table.model.ts"; -import { - extractTriggers, - type Trigger, -} from "./objects/trigger/trigger.model.ts"; -import { - type CompositeType, - extractCompositeTypes, -} from "./objects/type/composite-type/composite-type.model.ts"; -import { type Enum, extractEnums } from "./objects/type/enum/enum.model.ts"; -import { extractRanges, type Range } from "./objects/type/range/range.model.ts"; -import { extractViews, type View } from "./objects/view/view.model.ts"; - -interface CatalogProps { - aggregates: Record; - collations: Record; - compositeTypes: Record; - domains: Record; - enums: Record; - extensions: Record; - procedures: Record; - indexes: Record; - materializedViews: Record; - subscriptions: Record; - publications: Record; - rlsPolicies: Record; - roles: Record; - schemas: Record; - sequences: Record; - tables: Record; - triggers: Record; - eventTriggers: Record; - rules: Record; - ranges: Record; - views: Record; - foreignDataWrappers: Record; - servers: Record; - userMappings: Record; - foreignTables: Record; - depends: PgDepend[]; - indexableObjects: Record; - version: number; - currentUser: string; -} - -export class Catalog { - public readonly aggregates: CatalogProps["aggregates"]; - public readonly collations: CatalogProps["collations"]; - public readonly compositeTypes: CatalogProps["compositeTypes"]; - public readonly domains: CatalogProps["domains"]; - public readonly enums: CatalogProps["enums"]; - public readonly extensions: CatalogProps["extensions"]; - public readonly procedures: CatalogProps["procedures"]; - public readonly indexes: CatalogProps["indexes"]; - public readonly materializedViews: CatalogProps["materializedViews"]; - public readonly subscriptions: CatalogProps["subscriptions"]; - public readonly publications: CatalogProps["publications"]; - public readonly rlsPolicies: CatalogProps["rlsPolicies"]; - public readonly roles: CatalogProps["roles"]; - public readonly schemas: CatalogProps["schemas"]; - public readonly sequences: CatalogProps["sequences"]; - public readonly tables: CatalogProps["tables"]; - public readonly triggers: CatalogProps["triggers"]; - public readonly eventTriggers: CatalogProps["eventTriggers"]; - public readonly rules: CatalogProps["rules"]; - public readonly ranges: CatalogProps["ranges"]; - public readonly views: CatalogProps["views"]; - public readonly foreignDataWrappers: CatalogProps["foreignDataWrappers"]; - public readonly servers: CatalogProps["servers"]; - public readonly userMappings: CatalogProps["userMappings"]; - public readonly foreignTables: CatalogProps["foreignTables"]; - public readonly depends: CatalogProps["depends"]; - public readonly indexableObjects: CatalogProps["indexableObjects"]; - public readonly version: CatalogProps["version"]; - public readonly currentUser: CatalogProps["currentUser"]; - - constructor(props: CatalogProps) { - this.aggregates = props.aggregates; - this.collations = props.collations; - this.compositeTypes = props.compositeTypes; - this.domains = props.domains; - this.enums = props.enums; - this.extensions = props.extensions; - this.procedures = props.procedures; - this.indexes = props.indexes; - this.materializedViews = props.materializedViews; - this.subscriptions = props.subscriptions; - this.publications = props.publications; - this.rlsPolicies = props.rlsPolicies; - this.roles = props.roles; - this.schemas = props.schemas; - this.sequences = props.sequences; - this.tables = props.tables; - this.triggers = props.triggers; - this.eventTriggers = props.eventTriggers; - this.rules = props.rules; - this.ranges = props.ranges; - this.views = props.views; - this.foreignDataWrappers = props.foreignDataWrappers; - this.servers = props.servers; - this.userMappings = props.userMappings; - this.foreignTables = props.foreignTables; - this.depends = props.depends; - this.indexableObjects = props.indexableObjects; - this.version = props.version; - this.currentUser = props.currentUser; - } -} - -// Lazily cached deserialized baselines (shared across calls) -let _pg1516Baseline: Catalog | null = null; -let _pg17Baseline: Catalog | null = null; - -async function loadBaselineJson(): Promise> { - const mod = await import( - "./fixtures/empty-catalogs/postgres-15-16-baseline.json", - { with: { type: "json" } } - ); - return mod.default as Record; -} - -async function getPg1516Baseline(): Promise { - if (!_pg1516Baseline) { - const { deserializeCatalog } = await import("./catalog.snapshot.ts"); - const json = await loadBaselineJson(); - _pg1516Baseline = deserializeCatalog(json); - } - return _pg1516Baseline; -} - -async function getPg17Baseline(): Promise { - if (!_pg17Baseline) { - const { deserializeCatalog } = await import("./catalog.snapshot.ts"); - // PG 17 is identical to PG 15-16 except for a single addition: - // the MAINTAIN privilege on default relation (objtype "r") privileges. - // We patch the 15-16 baseline to avoid shipping a second full JSON file. - const json = await loadBaselineJson(); - const patched = structuredClone(json); - const roles = patched.roles as - | Record> - | undefined; - const pgRole = roles?.["role:postgres"]; - if (pgRole) { - const defaultPrivileges = pgRole.default_privileges as Array<{ - objtype: string; - grantee: string; - privileges: Array<{ privilege: string; grantable: boolean }>; - }>; - const relPrivs = defaultPrivileges?.find( - (dp) => dp.objtype === "r" && dp.grantee === "postgres", - ); - if (relPrivs) { - const insertIdx = relPrivs.privileges.findIndex( - (p) => p.privilege === "INSERT", - ); - if (insertIdx === -1) { - throw new Error( - "PG17 baseline patch failed: INSERT privilege not found in default relation privileges", - ); - } - relPrivs.privileges.splice(insertIdx + 1, 0, { - privilege: "MAINTAIN", - grantable: false, - }); - } - } - _pg17Baseline = deserializeCatalog(patched); - } - return _pg17Baseline; -} - -/** - * Create a baseline catalog representing a fresh PostgreSQL database. - * - * For PG 15+ this deserializes a pre-extracted snapshot of an empty `template1` - * database, including the `plpgsql` extension, `postgres` role with default - * privileges, and the `public` schema with its default ACLs and depends. - * - * For PG < 15, falls back to a minimal inline catalog with only the `public` - * schema. For exact fidelity on older versions, snapshot a real reference - * database using `serializeCatalog` and pass the deserialized result as source - * to `createPlan`. - */ -export async function createEmptyCatalog( - version: number, - currentUser: string, -): Promise { - if (version >= 170000) { - const baseline = await getPg17Baseline(); - // oxlint-disable-next-line typescript/no-misused-spread - return new Catalog({ ...baseline, version, currentUser }); - } - if (version >= 150000) { - const baseline = await getPg1516Baseline(); - // oxlint-disable-next-line typescript/no-misused-spread - return new Catalog({ ...baseline, version, currentUser }); - } - - const publicSchema = new Schema({ - name: "public", - owner: currentUser, - comment: "standard public schema", - privileges: [], - security_labels: [], - }); - - return new Catalog({ - aggregates: {}, - collations: {}, - compositeTypes: {}, - domains: {}, - enums: {}, - extensions: {}, - procedures: {}, - indexes: {}, - materializedViews: {}, - subscriptions: {}, - publications: {}, - rlsPolicies: {}, - roles: {}, - schemas: { [publicSchema.stableId]: publicSchema }, - sequences: {}, - tables: {}, - triggers: {}, - eventTriggers: {}, - rules: {}, - ranges: {}, - views: {}, - foreignDataWrappers: {}, - servers: {}, - userMappings: {}, - foreignTables: {}, - depends: [], - indexableObjects: {}, - version, - currentUser, - }); -} - -interface ExtractCatalogOptions { - /** - * Number of retry attempts for catalog extractors when `pg_get_*def()` - * returns NULL for at least one row. See `ExtractRetryOptions.retries`. - */ - extractRetries?: number; -} - -export async function extractCatalog( - pool: Pool, - options: ExtractCatalogOptions = {}, -) { - const retryOptions = { retries: options.extractRetries }; - const [ - aggregates, - collations, - compositeTypes, - domains, - enums, - extensions, - indexes, - materializedViews, - subscriptions, - publications, - procedures, - rlsPolicies, - roles, - schemas, - sequences, - tables, - triggers, - eventTriggers, - rules, - ranges, - views, - foreignDataWrappers, - servers, - userMappings, - foreignTables, - depends, - version, - currentUser, - ] = await Promise.all([ - extractAggregates(pool).then(listToRecord), - extractCollations(pool).then(listToRecord), - extractCompositeTypes(pool).then(listToRecord), - extractDomains(pool).then(listToRecord), - extractEnums(pool).then(listToRecord), - extractExtensions(pool).then(listToRecord), - extractIndexes(pool, retryOptions).then(listToRecord), - extractMaterializedViews(pool, retryOptions).then(listToRecord), - extractSubscriptions(pool).then(listToRecord), - extractPublications(pool).then(listToRecord), - extractProcedures(pool, retryOptions).then(listToRecord), - extractRlsPolicies(pool).then(listToRecord), - extractRoles(pool).then(listToRecord), - extractSchemas(pool).then(listToRecord), - extractSequences(pool).then(listToRecord), - extractTables(pool, retryOptions).then(listToRecord), - extractTriggers(pool, retryOptions).then(listToRecord), - extractEventTriggers(pool).then(listToRecord), - extractRules(pool, retryOptions).then(listToRecord), - extractRanges(pool).then(listToRecord), - extractViews(pool, retryOptions).then(listToRecord), - extractForeignDataWrappers(pool).then(listToRecord), - extractServers(pool).then(listToRecord), - extractUserMappings(pool).then(listToRecord), - extractForeignTables(pool).then(listToRecord), - extractDepends(pool), - extractVersion(pool), - extractCurrentUser(pool), - ]); - - const indexableObjects = { - ...tables, - ...materializedViews, - }; - - const catalog = new Catalog({ - aggregates, - collations, - compositeTypes, - domains, - enums, - extensions, - procedures, - indexes, - materializedViews, - subscriptions, - publications, - rlsPolicies, - roles, - schemas, - sequences, - tables, - triggers, - eventTriggers, - rules, - ranges, - views, - foreignDataWrappers, - servers, - userMappings, - foreignTables, - depends, - indexableObjects, - version, - currentUser, - }); - - return normalizeCatalog(catalog); -} - -function listToRecord(list: T[]) { - return Object.fromEntries(list.map((item) => [item.stableId, item])); -} - -function normalizeCatalog(catalog: Catalog): Catalog { - const foreignDataWrappers = mapRecord( - catalog.foreignDataWrappers, - (fdw) => - new ForeignDataWrapper({ - name: fdw.name, - owner: fdw.owner, - handler: fdw.handler, - validator: fdw.validator, - options: redactSensitiveOptionPairs(fdw.options), - comment: fdw.comment, - privileges: fdw.privileges, - }), - ); - - const servers = mapRecord(catalog.servers, (server) => { - return new Server({ - name: server.name, - owner: server.owner, - foreign_data_wrapper: server.foreign_data_wrapper, - type: server.type, - version: server.version, - options: redactSensitiveOptionPairs(server.options), - comment: server.comment, - privileges: server.privileges, - wrapper_handler: server.wrapper_handler, - wrapper_validator: server.wrapper_validator, - }); - }); - - const userMappings = mapRecord(catalog.userMappings, (mapping) => { - return new UserMapping({ - user: mapping.user, - server: mapping.server, - options: redactSensitiveOptionPairs(mapping.options), - wrapper_handler: mapping.wrapper_handler, - wrapper_validator: mapping.wrapper_validator, - }); - }); - - const foreignTables = mapRecord( - catalog.foreignTables, - (foreignTable) => - new ForeignTable({ - schema: foreignTable.schema, - name: foreignTable.name, - owner: foreignTable.owner, - server: foreignTable.server, - columns: foreignTable.columns, - options: redactSensitiveOptionPairs(foreignTable.options), - comment: foreignTable.comment, - privileges: foreignTable.privileges, - wrapper_handler: foreignTable.wrapper_handler, - wrapper_validator: foreignTable.wrapper_validator, - }), - ); - - const subscriptions = mapRecord(catalog.subscriptions, (subscription) => { - return new Subscription({ - name: subscription.name, - raw_name: subscription.raw_name, - owner: subscription.owner, - comment: subscription.comment, - enabled: subscription.enabled, - binary: subscription.binary, - streaming: subscription.streaming, - two_phase: subscription.two_phase, - disable_on_error: subscription.disable_on_error, - password_required: subscription.password_required, - run_as_owner: subscription.run_as_owner, - failover: subscription.failover, - conninfo: SUBSCRIPTION_CONNINFO_PLACEHOLDER, - slot_name: subscription.slot_name, - slot_is_none: subscription.slot_is_none, - replication_slot_created: subscription.replication_slot_created, - synchronous_commit: subscription.synchronous_commit, - publications: subscription.publications, - origin: subscription.origin, - }); - }); - - return new Catalog({ - aggregates: catalog.aggregates, - collations: catalog.collations, - compositeTypes: catalog.compositeTypes, - domains: catalog.domains, - enums: catalog.enums, - extensions: catalog.extensions, - procedures: catalog.procedures, - indexes: catalog.indexes, - materializedViews: catalog.materializedViews, - subscriptions, - publications: catalog.publications, - rlsPolicies: catalog.rlsPolicies, - roles: catalog.roles, - schemas: catalog.schemas, - sequences: catalog.sequences, - tables: catalog.tables, - triggers: catalog.triggers, - eventTriggers: catalog.eventTriggers, - rules: catalog.rules, - ranges: catalog.ranges, - views: catalog.views, - foreignDataWrappers, - servers, - userMappings, - foreignTables, - depends: catalog.depends, - indexableObjects: catalog.indexableObjects, - version: catalog.version, - currentUser: catalog.currentUser, - }); -} - -function mapRecord( - record: Record, - mapper: (value: TValue) => TResult, -): Record { - return Object.fromEntries( - Object.entries(record).map(([key, value]) => [key, mapper(value)]), - ); -} diff --git a/packages/pg-delta/src/core/catalog.snapshot.test.ts b/packages/pg-delta/src/core/catalog.snapshot.test.ts deleted file mode 100644 index c701f6a8e..000000000 --- a/packages/pg-delta/src/core/catalog.snapshot.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Catalog, createEmptyCatalog } from "./catalog.model.ts"; -import { - deserializeCatalog, - serializeCatalog, - stringifyCatalogSnapshot, -} from "./catalog.snapshot.ts"; -import { Aggregate } from "./objects/aggregate/aggregate.model.ts"; -import { Procedure } from "./objects/procedure/procedure.model.ts"; -import { RlsPolicy } from "./objects/rls-policy/rls-policy.model.ts"; -import { Role } from "./objects/role/role.model.ts"; -import { Schema } from "./objects/schema/schema.model.ts"; -import { Sequence } from "./objects/sequence/sequence.model.ts"; -import { Table } from "./objects/table/table.model.ts"; -import { flattenPlanStatements } from "./plan/render.ts"; - -function emptyCatalogProps() { - return { - aggregates: {}, - collations: {}, - compositeTypes: {}, - domains: {}, - enums: {}, - extensions: {}, - procedures: {}, - indexes: {}, - materializedViews: {}, - subscriptions: {}, - publications: {}, - rlsPolicies: {}, - roles: {}, - schemas: {}, - sequences: {}, - tables: {}, - triggers: {}, - eventTriggers: {}, - rules: {}, - ranges: {}, - views: {}, - foreignDataWrappers: {}, - servers: {}, - userMappings: {}, - foreignTables: {}, - depends: [], - indexableObjects: {}, - version: 160000, - currentUser: "postgres", - }; -} - -describe("catalog snapshot serde", () => { - test("round-trip on empty catalog", async () => { - const original = await createEmptyCatalog(160000, "postgres"); - const snapshot = serializeCatalog(original); - const json = stringifyCatalogSnapshot(snapshot); - const deserialized = deserializeCatalog(JSON.parse(json)); - - expect(deserialized.version).toBe(original.version); - expect(deserialized.currentUser).toBe(original.currentUser); - expect(Object.keys(deserialized.schemas)).toEqual( - Object.keys(original.schemas), - ); - - const origSchema = original.schemas["schema:public"]; - const deserSchema = deserialized.schemas["schema:public"]; - expect(deserSchema.name).toBe(origSchema.name); - expect(deserSchema.owner).toBe(origSchema.owner); - expect(deserSchema.comment).toBe(origSchema.comment); - expect(deserSchema.privileges).toEqual(origSchema.privileges); - }); - - test("round-trip preserves Sequence BigInt fields", () => { - const seq = new Sequence({ - schema: "public", - name: "my_seq", - data_type: "bigint", - start_value: 1, - minimum_value: BigInt("-9223372036854775808"), - maximum_value: BigInt("9223372036854775807"), - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "postgres", - }); - - const catalog = new Catalog({ - ...emptyCatalogProps(), - sequences: { [seq.stableId]: seq }, - }); - - const snapshot = serializeCatalog(catalog); - const json = stringifyCatalogSnapshot(snapshot); - const deserialized = deserializeCatalog(JSON.parse(json)); - - const deserSeq = deserialized.sequences[seq.stableId]; - expect(deserSeq).toBeDefined(); - expect(deserSeq.minimum_value).toBe(BigInt("-9223372036854775808")); - expect(deserSeq.maximum_value).toBe(BigInt("9223372036854775807")); - expect(typeof deserSeq.minimum_value).toBe("bigint"); - expect(typeof deserSeq.maximum_value).toBe("bigint"); - }); - - test("round-trip preserves Aggregate identity_arguments mapping", () => { - const agg = new Aggregate({ - schema: "public", - name: "my_agg", - identity_arguments: " integer, text ", - kind: "a", - aggkind: "n", - num_direct_args: 0, - return_type: "integer", - return_type_schema: null, - parallel_safety: "u", - is_strict: false, - transition_function: "my_sfunc", - state_data_type: "integer", - state_data_type_schema: null, - state_data_space: 0, - final_function: null, - final_function_extra_args: false, - final_function_modify: null, - combine_function: null, - serial_function: null, - deserial_function: null, - initial_condition: "0", - moving_transition_function: null, - moving_inverse_function: null, - moving_state_data_type: null, - moving_state_data_type_schema: null, - moving_state_data_space: null, - moving_final_function: null, - moving_final_function_extra_args: false, - moving_final_function_modify: null, - moving_initial_condition: null, - sort_operator: null, - argument_count: 2, - argument_default_count: 0, - argument_names: null, - argument_types: ["integer", "text"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - owner: "postgres", - comment: null, - privileges: [], - }); - - const catalog = new Catalog({ - ...emptyCatalogProps(), - aggregates: { [agg.stableId]: agg }, - }); - - const snapshot = serializeCatalog(catalog); - const json = stringifyCatalogSnapshot(snapshot); - const deserialized = deserializeCatalog(JSON.parse(json)); - - const deserAgg = deserialized.aggregates[agg.stableId]; - expect(deserAgg).toBeDefined(); - expect(deserAgg.identityArguments).toBe("integer, text"); - }); - - test("round-trip preserves depends array", () => { - const table = new Table({ - schema: "public", - name: "my_table", - owner: "postgres", - comment: null, - privileges: [], - columns: [], - constraints: [], - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - }); - - const publicSchema = new Schema({ - name: "public", - owner: "pg_database_owner", - comment: "standard public schema", - privileges: [], - }); - - const depends = [ - { - dependent_stable_id: table.stableId, - referenced_stable_id: publicSchema.stableId, - deptype: "n" as const, - }, - ]; - - const catalog = new Catalog({ - ...emptyCatalogProps(), - schemas: { [publicSchema.stableId]: publicSchema }, - tables: { [table.stableId]: table }, - indexableObjects: { [table.stableId]: table }, - depends, - }); - - const snapshot = serializeCatalog(catalog); - const json = stringifyCatalogSnapshot(snapshot); - const deserialized = deserializeCatalog(JSON.parse(json)); - - expect(deserialized.depends).toEqual(depends); - }); - - test("round-trip preserves indexableObjects from tables and materialized views", () => { - const table = new Table({ - schema: "public", - name: "tbl", - owner: "postgres", - comment: null, - privileges: [], - columns: [], - constraints: [], - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - }); - - const catalog = new Catalog({ - ...emptyCatalogProps(), - tables: { [table.stableId]: table }, - indexableObjects: { [table.stableId]: table }, - }); - - const snapshot = serializeCatalog(catalog); - const json = stringifyCatalogSnapshot(snapshot); - const deserialized = deserializeCatalog(JSON.parse(json)); - - expect(Object.keys(deserialized.indexableObjects)).toEqual([ - table.stableId, - ]); - }); - - test("deserializeCatalog rejects invalid data", () => { - expect(() => deserializeCatalog({})).toThrow(); - expect(() => deserializeCatalog("not json")).toThrow(); - expect(() => deserializeCatalog(null)).toThrow(); - expect(() => - deserializeCatalog({ version: "not-a-number", currentUser: "x" }), - ).toThrow(); - }); - - test("deserializeCatalog rejects missing required fields", () => { - expect(() => - deserializeCatalog({ - version: 1, - currentUser: "x", - }), - ).toThrow(); - }); - - test("createPlan accepts Catalog directly as source", async () => { - const { createPlan } = await import("./plan/create.ts"); - - const source = await createEmptyCatalog(160000, "postgres"); - const target = await createEmptyCatalog(160000, "postgres"); - - const result = await createPlan(source, target); - expect(result).toBeNull(); - }); - - test("createPlan accepts catalog-shaped plain object (bundle-boundary regression)", async () => { - const { createPlan } = await import("./plan/create.ts"); - - const sourceCatalog = await createEmptyCatalog(160000, "postgres"); - const targetCatalog = await createEmptyCatalog(160000, "postgres"); - // oxlint-disable-next-line typescript/no-misused-spread - const source = { ...sourceCatalog }; - - expect(source instanceof Catalog).toBe(false); - - const result = await createPlan(source as Catalog, targetCatalog); - expect(result).toBeNull(); - }); - - test("createPlan with null source produces plan when target has objects", async () => { - const { createPlan } = await import("./plan/create.ts"); - - const publicSchema = new Schema({ - name: "public", - owner: "pg_database_owner", - comment: "standard public schema", - privileges: [], - }); - - const mySchema = new Schema({ - name: "myschema", - owner: "postgres", - comment: null, - privileges: [], - }); - - const target = new Catalog({ - ...emptyCatalogProps(), - schemas: { - [publicSchema.stableId]: publicSchema, - [mySchema.stableId]: mySchema, - }, - }); - - const result = await createPlan(null, target); - expect(result).not.toBeNull(); - expect(flattenPlanStatements(result!.plan).length).toBeGreaterThan(0); - }); - - test("createPlan with filter DSL without cascade keeps dependents of excluded changes", async () => { - const { createPlan } = await import("./plan/create.ts"); - - const authSchema = new Schema({ - name: "auth", - owner: "postgres", - comment: null, - privileges: [], - }); - const publicSchema = new Schema({ - name: "public", - owner: "postgres", - comment: null, - privileges: [], - }); - const postgresRole = new Role({ - name: "postgres", - is_superuser: true, - can_inherit: true, - can_create_roles: true, - can_create_databases: true, - can_login: true, - can_replicate: true, - connection_limit: null, - can_bypass_rls: true, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const authUidProc = new Procedure({ - schema: "auth", - name: "uid", - kind: "f", - return_type: "uuid", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "s", - parallel_safety: "s", - is_strict: true, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - definition: "CREATE FUNCTION auth.uid() RETURNS uuid ...", - binary_path: null, - sql_body: null, - config: null, - owner: "postgres", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }); - const accountsTable = new Table({ - schema: "public", - name: "accounts", - owner: "postgres", - comment: null, - privileges: [], - columns: [], - constraints: [], - persistence: "p", - row_security: true, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - }); - const accountsPolicy = new RlsPolicy({ - schema: "public", - name: "accounts_select_policy", - table_name: "accounts", - command: "r", - permissive: true, - roles: ["authenticated"], - using_expression: "auth.uid() = user_id", - with_check_expression: null, - owner: "postgres", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }); - - const target = new Catalog({ - ...emptyCatalogProps(), - roles: { [postgresRole.stableId]: postgresRole }, - schemas: { - [authSchema.stableId]: authSchema, - [publicSchema.stableId]: publicSchema, - }, - procedures: { [authUidProc.stableId]: authUidProc }, - tables: { [accountsTable.stableId]: accountsTable }, - rlsPolicies: { [accountsPolicy.stableId]: accountsPolicy }, - indexableObjects: { [accountsTable.stableId]: accountsTable }, - depends: [ - { - dependent_stable_id: accountsPolicy.stableId, - referenced_stable_id: authUidProc.stableId, - deptype: "n", - }, - ], - }); - - // Filter out auth schema (no cascade): procedure is excluded but policy stays - const resultNoCascade = await createPlan(null, target, { - filter: { - not: { - or: [{ "*/schema": ["auth"] }, { "schema/name": ["auth"] }], - }, - }, - }); - expect(resultNoCascade).not.toBeNull(); - if (resultNoCascade) { - const policyChangesNoCascade = resultNoCascade.sortedChanges.filter( - (c) => c.objectType === "rls_policy", - ); - expect(policyChangesNoCascade.length).toBe(1); - } - - // Filter out auth schema with cascade: true: policy is also excluded (depends on auth.uid()) - const resultCascade = await createPlan(null, target, { - filter: { - not: { - or: [{ "*/schema": ["auth"] }, { "schema/name": ["auth"] }], - }, - cascade: true, - }, - }); - expect(resultCascade).not.toBeNull(); - if (resultCascade) { - const policyChangesCascade = resultCascade.sortedChanges.filter( - (c) => c.objectType === "rls_policy", - ); - expect(policyChangesCascade.length).toBe(0); - } - }); -}); diff --git a/packages/pg-delta/src/core/catalog.snapshot.ts b/packages/pg-delta/src/core/catalog.snapshot.ts deleted file mode 100644 index dbe60aead..000000000 --- a/packages/pg-delta/src/core/catalog.snapshot.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Catalog snapshot - full JSON-serializable representation of a Catalog. - * - * Enables catalog-export (snapshot a live DB) and catalog-import - * (use a snapshot as source/target for createPlan). - */ - -import z from "zod"; -import { Catalog } from "./catalog.model.ts"; -import type { PgDepend } from "./depend.ts"; -import { Aggregate } from "./objects/aggregate/aggregate.model.ts"; -import { Collation } from "./objects/collation/collation.model.ts"; -import { Domain } from "./objects/domain/domain.model.ts"; -import { EventTrigger } from "./objects/event-trigger/event-trigger.model.ts"; -import { Extension } from "./objects/extension/extension.model.ts"; -import { ForeignDataWrapper } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts"; -import { ForeignTable } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts"; -import { Server } from "./objects/foreign-data-wrapper/server/server.model.ts"; -import { UserMapping } from "./objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts"; -import { Index } from "./objects/index/index.model.ts"; -import { MaterializedView } from "./objects/materialized-view/materialized-view.model.ts"; -import { Procedure } from "./objects/procedure/procedure.model.ts"; -import { Publication } from "./objects/publication/publication.model.ts"; -import { RlsPolicy } from "./objects/rls-policy/rls-policy.model.ts"; -import { Role } from "./objects/role/role.model.ts"; -import { Rule } from "./objects/rule/rule.model.ts"; -import { Schema } from "./objects/schema/schema.model.ts"; -import { Sequence } from "./objects/sequence/sequence.model.ts"; -import { Subscription } from "./objects/subscription/subscription.model.ts"; -import { Table } from "./objects/table/table.model.ts"; -import { Trigger } from "./objects/trigger/trigger.model.ts"; -import { CompositeType } from "./objects/type/composite-type/composite-type.model.ts"; -import { Enum } from "./objects/type/enum/enum.model.ts"; -import { Range } from "./objects/type/range/range.model.ts"; -import { View } from "./objects/view/view.model.ts"; - -// ============================================================================ -// CatalogSnapshot type -// ============================================================================ - -/** - * Full JSON-serializable representation of a Catalog. - * - * Every object record uses plain props objects (not class instances). - * `indexableObjects` is omitted -- it is reconstructed on deserialization. - */ -export interface CatalogSnapshot { - version: number; - currentUser: string; - aggregates: Record>; - collations: Record>; - compositeTypes: Record>; - domains: Record>; - enums: Record>; - extensions: Record>; - procedures: Record>; - indexes: Record>; - materializedViews: Record>; - subscriptions: Record>; - publications: Record>; - rlsPolicies: Record>; - roles: Record>; - schemas: Record>; - sequences: Record>; - tables: Record>; - triggers: Record>; - eventTriggers: Record>; - rules: Record>; - ranges: Record>; - views: Record>; - foreignDataWrappers: Record>; - servers: Record>; - userMappings: Record>; - foreignTables: Record>; - depends: PgDepend[]; -} - -// ============================================================================ -// Zod schema for validation on deserialization -// ============================================================================ - -const objectRecord = z.record(z.string(), z.any()); - -const CatalogSnapshotSchema = z.object({ - version: z.number(), - currentUser: z.string(), - aggregates: objectRecord, - collations: objectRecord, - compositeTypes: objectRecord, - domains: objectRecord, - enums: objectRecord, - extensions: objectRecord, - procedures: objectRecord, - indexes: objectRecord, - materializedViews: objectRecord, - subscriptions: objectRecord, - publications: objectRecord, - rlsPolicies: objectRecord, - roles: objectRecord, - schemas: objectRecord, - sequences: objectRecord, - tables: objectRecord, - triggers: objectRecord, - eventTriggers: objectRecord, - rules: objectRecord, - ranges: objectRecord, - views: objectRecord, - foreignDataWrappers: objectRecord, - servers: objectRecord, - userMappings: objectRecord, - foreignTables: objectRecord, - depends: z.array( - z.object({ - dependent_stable_id: z.string(), - referenced_stable_id: z.string(), - deptype: z.enum(["n", "a", "i"]), - }), - ), -}); - -// ============================================================================ -// Serialization -// ============================================================================ - -function spreadRecord( - record: Record, -): Record> { - return Object.fromEntries( - Object.entries(record).map(([key, instance]) => [ - key, - { ...(instance as Record) }, - ]), - ); -} - -/** - * Serialize Aggregate instances back to their Props shape. - * - * Aggregate renames `identity_arguments` -> `identityArguments` and trims it - * on construction. We must map it back to `identity_arguments` so - * deserialization through the constructor works. - */ -function serializeAggregates( - record: Record, -): Record> { - return Object.fromEntries( - Object.entries(record).map(([key, agg]) => { - const { identityArguments: _, ...rest } = agg as unknown as Record< - string, - unknown - >; - return [key, { ...rest, identity_arguments: agg.identityArguments }]; - }), - ); -} - -/** - * Serialize a Catalog to a JSON-serializable CatalogSnapshot. - * - * Expects a normalized catalog (as returned by `extractCatalog`). - * BigInt values (Sequence min/max) are converted to strings by the - * custom JSON replacer -- call `stringifyCatalogSnapshot` for JSON output. - */ -export function serializeCatalog(catalog: Catalog): CatalogSnapshot { - return { - version: catalog.version, - currentUser: catalog.currentUser, - aggregates: serializeAggregates(catalog.aggregates), - collations: spreadRecord(catalog.collations), - compositeTypes: spreadRecord(catalog.compositeTypes), - domains: spreadRecord(catalog.domains), - enums: spreadRecord(catalog.enums), - extensions: spreadRecord(catalog.extensions), - procedures: spreadRecord(catalog.procedures), - indexes: spreadRecord(catalog.indexes), - materializedViews: spreadRecord(catalog.materializedViews), - subscriptions: spreadRecord(catalog.subscriptions), - publications: spreadRecord(catalog.publications), - rlsPolicies: spreadRecord(catalog.rlsPolicies), - roles: spreadRecord(catalog.roles), - schemas: spreadRecord(catalog.schemas), - sequences: spreadRecord(catalog.sequences), - tables: spreadRecord(catalog.tables), - triggers: spreadRecord(catalog.triggers), - eventTriggers: spreadRecord(catalog.eventTriggers), - rules: spreadRecord(catalog.rules), - ranges: spreadRecord(catalog.ranges), - views: spreadRecord(catalog.views), - foreignDataWrappers: spreadRecord(catalog.foreignDataWrappers), - servers: spreadRecord(catalog.servers), - userMappings: spreadRecord(catalog.userMappings), - foreignTables: spreadRecord(catalog.foreignTables), - depends: catalog.depends, - }; -} - -/** - * Serialize a CatalogSnapshot to a JSON string. - * - * Handles BigInt values (Sequence min/max) by converting them to strings. - */ -export function stringifyCatalogSnapshot(snapshot: CatalogSnapshot): string { - return JSON.stringify( - snapshot, - (_key, value) => (typeof value === "bigint" ? value.toString() : value), - 2, - ); -} - -// ============================================================================ -// Deserialization -// ============================================================================ - -function buildRecord( - record: Record>, - ctor: new (props: never) => T, -): Record { - return Object.fromEntries( - Object.entries(record).map(([key, props]) => [ - key, - new ctor(props as never), - ]), - ); -} - -/** - * Coerce BigInt fields in Sequence props from string back to BigInt. - * JSON has no BigInt type, so these are stored as strings. - */ -function coerceSequenceBigInts( - record: Record>, -): Record> { - return Object.fromEntries( - Object.entries(record).map(([key, props]) => [ - key, - { - ...props, - minimum_value: BigInt(props.minimum_value as string | bigint), - maximum_value: BigInt(props.maximum_value as string | bigint), - }, - ]), - ); -} - -/** - * Deserialize a CatalogSnapshot (plain JSON data) back into a Catalog. - * - * Validates the top-level structure with Zod, then constructs model - * class instances via their constructors. Rebuilds `indexableObjects` - * from tables + materializedViews. - */ -export function deserializeCatalog(data: unknown): Catalog { - const s = CatalogSnapshotSchema.parse(data); - - const tables = buildRecord(s.tables, Table); - const materializedViews = buildRecord(s.materializedViews, MaterializedView); - - return new Catalog({ - version: s.version, - currentUser: s.currentUser, - aggregates: buildRecord(s.aggregates, Aggregate), - collations: buildRecord(s.collations, Collation), - compositeTypes: buildRecord(s.compositeTypes, CompositeType), - domains: buildRecord(s.domains, Domain), - enums: buildRecord(s.enums, Enum), - extensions: buildRecord(s.extensions, Extension), - procedures: buildRecord(s.procedures, Procedure), - indexes: buildRecord(s.indexes, Index), - materializedViews, - subscriptions: buildRecord(s.subscriptions, Subscription), - publications: buildRecord(s.publications, Publication), - rlsPolicies: buildRecord(s.rlsPolicies, RlsPolicy), - roles: buildRecord(s.roles, Role), - schemas: buildRecord(s.schemas, Schema), - sequences: buildRecord(coerceSequenceBigInts(s.sequences), Sequence), - tables, - triggers: buildRecord(s.triggers, Trigger), - eventTriggers: buildRecord(s.eventTriggers, EventTrigger), - rules: buildRecord(s.rules, Rule), - ranges: buildRecord(s.ranges, Range), - views: buildRecord(s.views, View), - foreignDataWrappers: buildRecord(s.foreignDataWrappers, ForeignDataWrapper), - servers: buildRecord(s.servers, Server), - userMappings: buildRecord(s.userMappings, UserMapping), - foreignTables: buildRecord(s.foreignTables, ForeignTable), - depends: s.depends, - indexableObjects: { ...tables, ...materializedViews }, - }); -} diff --git a/packages/pg-delta/src/core/change-utils.test.ts b/packages/pg-delta/src/core/change-utils.test.ts deleted file mode 100644 index c5de7da5b..000000000 --- a/packages/pg-delta/src/core/change-utils.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "./change.types.ts"; -import { getSchema } from "./change-utils.ts"; - -describe("getSchema", () => { - test("returns schema for table", () => { - const change = { - objectType: "table", - table: { schema: "public" }, - } as unknown as Change; - expect(getSchema(change)).toBe("public"); - }); - - test("returns schema for view", () => { - const change = { - objectType: "view", - view: { schema: "app" }, - } as unknown as Change; - expect(getSchema(change)).toBe("app"); - }); - - test("returns schema for enum", () => { - const change = { - objectType: "enum", - enum: { schema: "types" }, - } as unknown as Change; - expect(getSchema(change)).toBe("types"); - }); - - test("returns schema.name for schema type", () => { - const change = { - objectType: "schema", - schema: { name: "auth" }, - } as unknown as Change; - expect(getSchema(change)).toBe("auth"); - }); - - test("returns null for role", () => { - const change = { - objectType: "role", - role: { name: "admin" }, - } as unknown as Change; - expect(getSchema(change)).toBeNull(); - }); - - test("returns null for publication", () => { - const change = { - objectType: "publication", - publication: { name: "pub1" }, - } as unknown as Change; - expect(getSchema(change)).toBeNull(); - }); - - test("returns null for language", () => { - const change = { - objectType: "language", - language: { name: "plpgsql" }, - } as unknown as Change; - expect(getSchema(change)).toBeNull(); - }); -}); diff --git a/packages/pg-delta/src/core/change-utils.ts b/packages/pg-delta/src/core/change-utils.ts deleted file mode 100644 index a22ebfbb2..000000000 --- a/packages/pg-delta/src/core/change-utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Change } from "./change.types.ts"; - -/** - * Extract the schema name from a Change using the model sub-object. - * - * This is a convenience function used by the filter DSL (for schema - * normalization) and the sort module. It reads the `schema` (or `name` - * for schema objectType) from the model sub-object. - */ -export function getSchema(change: Change): string | null { - if (change.scope === "default_privilege") { - return change.inSchema; - } - switch (change.objectType) { - case "aggregate": - return change.aggregate.schema; - case "collation": - return change.collation.schema; - case "composite_type": - return change.compositeType.schema; - case "domain": - return change.domain.schema; - case "enum": - return change.enum.schema; - case "event_trigger": - return change.eventTrigger.function_schema; - case "extension": - return change.extension.schema; - case "index": - return change.index.schema; - case "language": - return null; - case "materialized_view": - return change.materializedView.schema; - case "procedure": - return change.procedure.schema; - case "publication": - return null; - case "range": - return change.range.schema; - case "rls_policy": - return change.policy.schema; - case "role": - return null; - case "rule": - return change.rule.schema; - case "schema": - return change.schema.name; - case "sequence": - return change.sequence.schema; - case "subscription": - return null; - case "table": - return change.table.schema; - case "trigger": - return change.trigger.schema; - case "view": - return change.view.schema; - case "foreign_data_wrapper": - return null; - case "server": - return null; - case "user_mapping": - return null; - case "foreign_table": - return change.foreignTable.schema; - default: { - // exhaustiveness check - const _exhaustive: never = change; - return _exhaustive; - } - } -} diff --git a/packages/pg-delta/src/core/change.types.ts b/packages/pg-delta/src/core/change.types.ts deleted file mode 100644 index 43dd464ab..000000000 --- a/packages/pg-delta/src/core/change.types.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { AggregateChange } from "./objects/aggregate/changes/aggregate.types.ts"; -import type { CollationChange } from "./objects/collation/changes/collation.types.ts"; -import type { DomainChange } from "./objects/domain/changes/domain.types.ts"; -import type { EventTriggerChange } from "./objects/event-trigger/changes/event-trigger.types.ts"; -import type { ExtensionChange } from "./objects/extension/changes/extension.types.ts"; -import type { ForeignDataWrapperChange } from "./objects/foreign-data-wrapper/foreign-data-wrapper.types.ts"; -import type { IndexChange } from "./objects/index/changes/index.types.ts"; -import type { LanguageChange } from "./objects/language/changes/language.types.ts"; -import type { MaterializedViewChange } from "./objects/materialized-view/changes/materialized-view.types.ts"; -import type { ProcedureChange } from "./objects/procedure/changes/procedure.types.ts"; -import type { PublicationChange } from "./objects/publication/changes/publication.types.ts"; -import type { RlsPolicyChange } from "./objects/rls-policy/changes/rls-policy.types.ts"; -import type { RoleChange } from "./objects/role/changes/role.types.ts"; -import type { RuleChange } from "./objects/rule/changes/rule.types.ts"; -import type { SchemaChange } from "./objects/schema/changes/schema.types.ts"; -import type { SequenceChange } from "./objects/sequence/changes/sequence.types.ts"; -import type { SubscriptionChange } from "./objects/subscription/changes/subscription.types.ts"; -import type { TableChange } from "./objects/table/changes/table.types.ts"; -import type { TriggerChange } from "./objects/trigger/changes/trigger.types.ts"; -import type { TypeChange } from "./objects/type/type.types.ts"; -import type { ViewChange } from "./objects/view/changes/view.types.ts"; - -/** - * Discriminated union of all PostgreSQL object change types. - * - * Every member shares a common `objectType` discriminant (e.g. `"table"`, - * `"view"`, `"role"`) that the filter DSL pattern-matches against. Use - * {@link OBJECT_TYPE_TO_PROPERTY_KEY} to map an `objectType` value to the - * corresponding JS property key on the Change instance. - * - * @category Change Types - */ -export type Change = - | AggregateChange - | CollationChange - | DomainChange - | ExtensionChange - | IndexChange - | LanguageChange - | MaterializedViewChange - | SubscriptionChange - | PublicationChange - | ProcedureChange - | RlsPolicyChange - | RoleChange - | SchemaChange - | SequenceChange - | TableChange - | TriggerChange - | EventTriggerChange - | RuleChange - | TypeChange - | ViewChange - | ForeignDataWrapperChange; - -/** - * Exhaustive map from every `objectType` discriminant value to the JS property - * key that holds the model sub-object on the corresponding {@link Change}. - * - * Used internally by the filter DSL flattening logic to locate nested - * properties and expose them as `/` paths. - * - * @category Change Types - */ -export const OBJECT_TYPE_TO_PROPERTY_KEY: { - [K in Change["objectType"]]: string; -} = { - aggregate: "aggregate", - collation: "collation", - composite_type: "compositeType", - domain: "domain", - enum: "enum", - event_trigger: "eventTrigger", - extension: "extension", - foreign_data_wrapper: "foreignDataWrapper", - foreign_table: "foreignTable", - index: "index", - language: "language", - materialized_view: "materializedView", - procedure: "procedure", - publication: "publication", - range: "range", - rls_policy: "policy", - role: "role", - rule: "rule", - schema: "schema", - sequence: "sequence", - server: "server", - subscription: "subscription", - table: "table", - trigger: "trigger", - user_mapping: "userMapping", - view: "view", -}; diff --git a/packages/pg-delta/src/core/connection-url.test.ts b/packages/pg-delta/src/core/connection-url.test.ts deleted file mode 100644 index ca4a90b16..000000000 --- a/packages/pg-delta/src/core/connection-url.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { isIPv6, normalizeConnectionUrl } from "./connection-url.ts"; - -describe("isIPv6", () => { - describe("accepted", () => { - const accepted = [ - "::", - "::1", - "1::", - "1:2:3:4:5:6:7:8", - "2406:da18:243:740f:abda:9a5c:a92d:b3c9", - "::ffff:192.0.2.1", - "fe80::AbCd", - "fe80::1%eth0", - ]; - for (const value of accepted) { - test(`accepts "${value}"`, () => { - expect(isIPv6(value)).toBe(true); - }); - } - }); - - describe("rejected", () => { - const rejected = [ - "", - "2406:da18:243:740f", // only 4 groups - "1:2:3:4:5:6:7:8:9", // 9 groups - "1::2::3", // double compression - "gggg::1", // invalid hex - "1.2.3.4", // pure IPv4 - "[::1]", // bracketed - "localhost", - "example.com", - ":::", // malformed - ]; - for (const value of rejected) { - test(`rejects ${JSON.stringify(value)}`, () => { - expect(isIPv6(value)).toBe(false); - }); - } - }); -}); - -describe("normalizeConnectionUrl", () => { - describe("normalizes percent-encoded IPv6 hosts", () => { - test("full 8-group IPv6 becomes bracketed", () => { - const input = - "postgresql://user:pass@2406%3Ada18%3A243%3A740f%3Aabda%3A9a5c%3Aa92d%3Ab3c9:5432/db"; - expect(normalizeConnectionUrl(input)).toBe( - "postgresql://user:pass@[2406:da18:243:740f:abda:9a5c:a92d:b3c9]:5432/db", - ); - }); - - test("compressed ::1 form", () => { - const input = "postgresql://user:pass@%3A%3A1:5432/db"; - expect(normalizeConnectionUrl(input)).toBe( - "postgresql://user:pass@[::1]:5432/db", - ); - }); - - test("IPv4-mapped ::ffff:192.0.2.1", () => { - const input = "postgresql://user:pass@%3A%3Affff%3A192.0.2.1:5432/db"; - expect(normalizeConnectionUrl(input)).toBe( - "postgresql://user:pass@[::ffff:192.0.2.1]:5432/db", - ); - }); - - test("mixed-case percent triples (%3a and %3A)", () => { - const input = - "postgresql://user:pass@2406%3ada18%3A243%3a740f%3Aabda%3A9a5c%3Aa92d%3Ab3c9:5432/db"; - expect(normalizeConnectionUrl(input)).toBe( - "postgresql://user:pass@[2406:da18:243:740f:abda:9a5c:a92d:b3c9]:5432/db", - ); - }); - - test("preserves URL-encoded password and query string", () => { - const input = - "postgresql://user:p%40ss%2Fword@%3A%3A1:5432/db?sslmode=require&application_name=pgdelta"; - expect(normalizeConnectionUrl(input)).toBe( - "postgresql://user:p%40ss%2Fword@[::1]:5432/db?sslmode=require&application_name=pgdelta", - ); - }); - - test("preserves fragment", () => { - const input = "postgresql://user:pass@%3A%3A1:5432/db#frag"; - expect(normalizeConnectionUrl(input)).toBe( - "postgresql://user:pass@[::1]:5432/db#frag", - ); - }); - - test("works without a port", () => { - const input = "postgresql://user:pass@%3A%3A1/db"; - expect(normalizeConnectionUrl(input)).toBe( - "postgresql://user:pass@[::1]/db", - ); - }); - - test("works without userinfo", () => { - const input = "postgresql://%3A%3A1:5432/db"; - expect(normalizeConnectionUrl(input)).toBe("postgresql://[::1]:5432/db"); - }); - - test("works with username only (no password)", () => { - const input = "postgresql://user@%3A%3A1:5432/db"; - expect(normalizeConnectionUrl(input)).toBe( - "postgresql://user@[::1]:5432/db", - ); - }); - }); - - describe("leaves URL unchanged (guardrail)", () => { - test("already-bracketed IPv6", () => { - const input = "postgresql://user:pass@[::1]:5432/db"; - expect(normalizeConnectionUrl(input)).toBe(input); - }); - - test("IPv4 host", () => { - const input = "postgresql://user:pass@127.0.0.1:5432/db"; - expect(normalizeConnectionUrl(input)).toBe(input); - }); - - test("DNS hostname", () => { - const input = "postgresql://user:pass@db.example.com:5432/db"; - expect(normalizeConnectionUrl(input)).toBe(input); - }); - - test("percent-encoded colons that do not decode to a valid IPv6 (4 groups only)", () => { - const input = "postgresql://user:pass@2406%3Ada18%3A243%3A740f:5432/db"; - expect(normalizeConnectionUrl(input)).toBe(input); - }); - - test("non-colon percent-encoded character in hostname", () => { - const input = "postgresql://user:pass@host%2Dname:5432/db"; - expect(normalizeConnectionUrl(input)).toBe(input); - }); - - test("garbage host `%3A%3Azzz` decodes to `::zzz`, not valid IPv6", () => { - const input = "postgresql://user:pass@%3A%3Azzz:5432/db"; - expect(normalizeConnectionUrl(input)).toBe(input); - }); - }); -}); diff --git a/packages/pg-delta/src/core/connection-url.ts b/packages/pg-delta/src/core/connection-url.ts deleted file mode 100644 index 238f1b40c..000000000 --- a/packages/pg-delta/src/core/connection-url.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Connection URL normalization for pg-delta. - * - * Auto-normalizes percent-encoded IPv6 hosts in PostgreSQL connection URLs. - * A URL like `postgresql://user:pass@2406%3Ada18%3A...%3Ab3c9:5432/db` - * becomes `postgresql://user:pass@[2406:da18:...:b3c9]:5432/db` before it - * reaches `pg-connection-string` / `pg.Pool`, so DNS resolution sees the - * address in its canonical bracketed form. - * - * Non-IPv6 hosts (IPv4, DNS names, already-bracketed IPv6, partial fragments - * that just happen to contain `%3A`) are returned verbatim. - */ - -// IPv6 detection regex vendored from ip-regex (Sindre Sorhus, MIT). -// https://github.com/sindresorhus/ip-regex -const v4 = - "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}"; -const v6seg = "[a-fA-F\\d]{1,4}"; -const v6 = ` -(?: -(?:${v6seg}:){7}(?:${v6seg}|:)| -(?:${v6seg}:){6}(?:${v4}|:${v6seg}|:)| -(?:${v6seg}:){5}(?::${v4}|(?::${v6seg}){1,2}|:)| -(?:${v6seg}:){4}(?:(?::${v6seg}){0,1}:${v4}|(?::${v6seg}){1,3}|:)| -(?:${v6seg}:){3}(?:(?::${v6seg}){0,2}:${v4}|(?::${v6seg}){1,4}|:)| -(?:${v6seg}:){2}(?:(?::${v6seg}){0,3}:${v4}|(?::${v6seg}){1,5}|:)| -(?:${v6seg}:){1}(?:(?::${v6seg}){0,4}:${v4}|(?::${v6seg}){1,6}|:)| -(?::(?:(?::${v6seg}){0,5}:${v4}|(?::${v6seg}){1,7}|:)) -)(?:%[0-9a-zA-Z]{1,})? -` - .replace(/\s*\/\/.*$/gm, "") - .replace(/\n/g, "") - .trim(); - -const V6_EXACT = new RegExp(`^${v6}$`); - -/** - * Return true if `value` is a valid IPv6 literal in any canonical form: - * full 8-group, `::` compression, or IPv4-mapped (`::ffff:1.2.3.4`). - * RFC 4007 zone identifiers (`fe80::1%eth0`) are accepted. - */ -export function isIPv6(value: string): boolean { - return typeof value === "string" && V6_EXACT.test(value); -} - -/** - * Normalize a PostgreSQL connection URL so IPv6 hosts reach pg in the - * canonical bracketed form. - * - * If the URL's hostname contains a percent-encoded colon AND the decoded - * hostname is a valid IPv6 literal, the hostname is decoded and wrapped in - * `[...]`. All other fields (scheme, userinfo, port, path, query, fragment) - * are preserved byte-for-byte from the input. - * - * Any URL whose decoded hostname does not validate as IPv6 is returned - * verbatim, so a malformed input will surface its usual downstream error - * instead of being silently rewritten. - */ -export function normalizeConnectionUrl(url: string): string { - const urlObj = new URL(url); - // Cheap pre-filter: only look closer if the hostname contains a - // percent-encoded colon. Anything else is left entirely untouched. - if (!/%3[aA]/.test(urlObj.hostname)) return url; - - const decodedHost = decodeURIComponent(urlObj.hostname); - // Authoritative validation: only normalize when the decoded string is a - // real IPv6 literal. Rejects partial fragments, random hostnames that - // happen to contain `%3A`, and any malformed input. - if (!isIPv6(decodedHost)) return url; - - // Preserve username/password/port/path/search/hash exactly as they appear - // in the WHATWG URL model (these are returned already percent-encoded). - const scheme = `${urlObj.protocol}//`; - const auth = urlObj.username - ? urlObj.password - ? `${urlObj.username}:${urlObj.password}@` - : `${urlObj.username}@` - : ""; - const port = urlObj.port ? `:${urlObj.port}` : ""; - const tail = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`; - return `${scheme}${auth}[${decodedHost}]${port}${tail}`; -} diff --git a/packages/pg-delta/src/core/context.ts b/packages/pg-delta/src/core/context.ts deleted file mode 100644 index f4a54acc8..000000000 --- a/packages/pg-delta/src/core/context.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import type { Catalog } from "./catalog.model.ts"; - -/** - * Context for diff operations, containing both source and target catalogs. - */ -export interface DiffContext { - mainCatalog: Catalog; - branchCatalog: Catalog; -} - -export async function extractVersion(pool: Pool) { - const { rows } = await pool.query<{ version: number }>( - sql`select current_setting('server_version_num')::int as version`, - ); - - return rows[0].version; -} - -export async function extractCurrentUser(pool: Pool) { - const { rows } = await pool.query<{ current_user: string }>( - sql`select quote_ident(current_user) as current_user`, - ); - return rows[0].current_user; -} diff --git a/packages/pg-delta/src/core/declarative-apply/discover-sql.test.ts b/packages/pg-delta/src/core/declarative-apply/discover-sql.test.ts deleted file mode 100644 index 81e159da8..000000000 --- a/packages/pg-delta/src/core/declarative-apply/discover-sql.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { loadDeclarativeSchema } from "./discover-sql.ts"; - -describe("loadDeclarativeSchema", () => { - test("throws when path does not exist", async () => { - const nonExistent = path.join( - tmpdir(), - `pgdelta-nonexistent-${Date.now()}`, - ); - - await expect(loadDeclarativeSchema(nonExistent)).rejects.toThrow( - /Cannot access.*ENOENT/, - ); - }); - - test("throws when path is a file but not .sql", async () => { - const dir = path.join( - tmpdir(), - `pgdelta-discover-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(dir, { recursive: true }); - const txtFile = path.join(dir, "foo.txt"); - await writeFile(txtFile, "not sql"); - - try { - await expect(loadDeclarativeSchema(txtFile)).rejects.toThrow( - /Path is not a \.sql file/, - ); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("loads a single .sql file and returns one entry with relative path", async () => { - const dir = path.join( - tmpdir(), - `pgdelta-discover-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(dir, { recursive: true }); - const sqlFile = path.join(dir, "schema.sql"); - const content = "CREATE SCHEMA foo;"; - await writeFile(sqlFile, content); - - try { - const entries = await loadDeclarativeSchema(sqlFile); - expect(entries).toHaveLength(1); - expect(entries[0].filePath).toBe("schema.sql"); - expect(entries[0].sql).toBe(content); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("loads directory of .sql files in deterministic order (match pg-topo)", async () => { - const dir = path.join( - tmpdir(), - `pgdelta-discover-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(dir, { recursive: true }); - const clusterDir = path.join(dir, "cluster"); - const subDir = path.join(dir, "schemas", "public"); - await mkdir(clusterDir, { recursive: true }); - await mkdir(subDir, { recursive: true }); - await writeFile(path.join(clusterDir, "roles.sql"), "SELECT 1;"); - await writeFile(path.join(subDir, "schema.sql"), "CREATE SCHEMA public;"); - await writeFile( - path.join(subDir, "tables.sql"), - "CREATE TABLE t (id int);", - ); - - try { - const entries = await loadDeclarativeSchema(dir); - expect(entries.length).toBeGreaterThanOrEqual(3); - const paths = entries.map((e) => e.filePath); - const sorted = [...paths].sort((a, b) => a.localeCompare(b)); - expect(paths).toEqual(sorted); - expect(paths).toContain("schemas/public/schema.sql"); - expect(paths).toContain("schemas/public/tables.sql"); - expect(paths).toContain("cluster/roles.sql"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("returns empty array when directory has no .sql files", async () => { - const dir = path.join( - tmpdir(), - `pgdelta-discover-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(dir, { recursive: true }); - await writeFile(path.join(dir, "readme.txt"), "no sql here"); - - try { - const entries = await loadDeclarativeSchema(dir); - expect(entries).toHaveLength(0); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/pg-delta/src/core/declarative-apply/discover-sql.ts b/packages/pg-delta/src/core/declarative-apply/discover-sql.ts deleted file mode 100644 index 76a827af5..000000000 --- a/packages/pg-delta/src/core/declarative-apply/discover-sql.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Discover and read .sql files under a schema path (file or directory). - * Matches pg-topo's discovery order for deterministic statement ordering. - */ - -import { readdir, readFile, stat } from "node:fs/promises"; -import path from "node:path"; - -export interface SqlFileEntry { - /** Relative path from base (forward slashes, e.g. schemas/public/views/billing.sql) */ - filePath: string; - /** File content */ - sql: string; -} - -/** - * Recursively collect .sql files in a directory. Entries sorted by name, - * then full paths sorted for deterministic order (matches pg-topo discover). - */ -async function readSqlFilesInDirectory( - directoryPath: string, - outFiles: Set, -): Promise { - const entries = await readdir(directoryPath, { withFileTypes: true }); - entries.sort((left, right) => left.name.localeCompare(right.name)); - - for (const entry of entries) { - const fullPath = path.join(directoryPath, entry.name); - if (entry.isDirectory()) { - await readSqlFilesInDirectory(fullPath, outFiles); - continue; - } - - if (entry.isFile() && fullPath.toLowerCase().endsWith(".sql")) { - outFiles.add(path.resolve(fullPath)); - } - } -} - -/** - * Stable relative path: path.relative(basePath, absolutePath) with forward slashes. - */ -function toStablePath(absolutePath: string, basePath: string): string { - return path.relative(basePath, absolutePath).split(path.sep).join("/"); -} - -/** - * Load all .sql files under schemaPath (a single .sql file or a directory). - * Returns entries in the same order as pg-topo's discover (sorted by full path). - * - * @throws If schemaPath does not exist, is not a file/directory, or any file cannot be read. - * Error message includes path and code (e.g. ENOENT, EACCES) for CLI to display. - */ -export async function loadDeclarativeSchema( - schemaPath: string, -): Promise { - const resolvedRoot = path.resolve(schemaPath); - - let rootStats: Awaited>; - try { - rootStats = await stat(resolvedRoot); - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as NodeJS.ErrnoException).code) - : "UNKNOWN"; - throw new Error(`Cannot access '${schemaPath}': ${code}`); - } - - let files: string[]; - let basePath: string; - - if (rootStats.isFile()) { - if (!resolvedRoot.toLowerCase().endsWith(".sql")) { - throw new Error(`Path is not a .sql file: '${schemaPath}'`); - } - files = [resolvedRoot]; - basePath = path.dirname(resolvedRoot); - } else if (rootStats.isDirectory()) { - const fileSet = new Set(); - await readSqlFilesInDirectory(resolvedRoot, fileSet); - files = [...fileSet].sort((a, b) => a.localeCompare(b)); - basePath = resolvedRoot; - } else { - throw new Error(`Path is not a file or directory: '${schemaPath}'`); - } - - const entries: SqlFileEntry[] = []; - for (const filePath of files) { - try { - const sql = await readFile(filePath, "utf-8"); - entries.push({ - filePath: toStablePath(filePath, basePath), - sql, - }); - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as NodeJS.ErrnoException).code) - : "UNKNOWN"; - const relative = toStablePath(filePath, basePath); - throw new Error(`Cannot read file '${relative}': ${code}`); - } - } - - return entries; -} diff --git a/packages/pg-delta/src/core/declarative-apply/extract-catalog-providers.ts b/packages/pg-delta/src/core/declarative-apply/extract-catalog-providers.ts deleted file mode 100644 index 32541d55c..000000000 --- a/packages/pg-delta/src/core/declarative-apply/extract-catalog-providers.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Extract functions, types, and other catalog objects from the target database - * so pg-topo can treat them as external providers and suppress false - * UNRESOLVED_DEPENDENCY diagnostics (e.g. now(), gen_random_uuid(), nextval(), - * auth.users, extensions.uuid_generate_v4, etc.). - */ - -import type { ObjectRef } from "@supabase/pg-topo"; -import type { Pool } from "pg"; - -type FunctionRow = { - name: string; - schema: string; - kind: string; - signature: string; -}; -type TypeRow = { name: string; schema: string; typetype: string }; -type SchemaRow = { name: string }; -type RelationRow = { name: string; schema: string; relkind: string }; -type ExtensionRow = { name: string; schema: string | null }; -type RoleRow = { name: string }; -type LanguageRow = { name: string }; -type CollationRow = { name: string; schema: string }; -type FdwRow = { name: string }; -type ServerRow = { name: string }; -type EventTriggerRow = { name: string }; -type PublicationRow = { name: string }; -type SubscriptionRow = { name: string }; - -function addProvider( - providers: ObjectRef[], - ref: ObjectRef, - alsoUnderPublic = false, -): void { - providers.push(ref); - if ( - alsoUnderPublic && - (ref.schema === "pg_catalog" || ref.schema === "information_schema") - ) { - providers.push({ ...ref, schema: "public" }); - } -} - -/** - * Query the target database for all catalog objects that can be dependencies. - * Returns ObjectRefs that pg-topo can use as external providers so it does - * not flag false UNRESOLVED_DEPENDENCY diagnostics (e.g. now(), text, public). - * - * This is intentionally separate from {@link extractCatalog} in catalog.model.ts: - * - extractCatalog is for schema diffing and excludes system objects (filters - * out pg_catalog, information_schema, extension-owned). It also does not - * extract languages. We need the opposite here: all objects that might be - * referenced by SQL, including built-in functions, types, and schemas. - * - Objects in pg_catalog/information_schema are registered under both their - * real schema and "public" (via addProvider's alsoUnderPublic) so - * unqualified references in SQL resolve the same way the parser does. - */ -export async function extractCatalogProviders( - pool: Pool, -): Promise { - const providers: ObjectRef[] = []; - - const [ - functionsResult, - typesResult, - schemasResult, - relationsResult, - extensionsResult, - rolesResult, - languagesResult, - collationsResult, - fdwsResult, - serversResult, - eventTriggersResult, - publicationsResult, - subscriptionsResult, - ] = await Promise.all([ - pool.query(` - SELECT - p.proname AS name, - n.nspname AS schema, - p.prokind AS kind, - COALESCE(( - SELECT string_agg( - CASE WHEN p.proargmodes IS NOT NULL AND p.proargmodes[ord] = 'v' - THEN 'VARIADIC ' || format_type(t.oid, NULL) - ELSE format_type(t.oid, NULL) - END, ',' ORDER BY ord) - FROM unnest(p.proargtypes) WITH ORDINALITY AS t(oid, ord) - ), '') AS signature - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - `), - pool.query(` - SELECT t.typname AS name, n.nspname AS schema, t.typtype AS typetype - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname NOT LIKE 'pg_toast%' - AND t.typtype IN ('b', 'c', 'd', 'e', 'r') - `), - pool.query(` - SELECT nspname AS name FROM pg_namespace - WHERE nspname NOT LIKE 'pg_toast%' - `), - pool.query(` - SELECT c.relname AS name, n.nspname AS schema, c.relkind AS relkind - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('r', 'p', 'v', 'm', 'S', 'i') - AND n.nspname NOT LIKE 'pg_toast%' - `), - pool.query(` - SELECT e.extname AS name, n.nspname AS schema - FROM pg_extension e - LEFT JOIN pg_namespace n ON n.oid = e.extnamespace - `), - pool.query(`SELECT rolname AS name FROM pg_roles`), - pool.query(`SELECT lanname AS name FROM pg_language`), - pool.query(` - SELECT c.collname AS name, n.nspname AS schema - FROM pg_collation c - JOIN pg_namespace n ON n.oid = c.collnamespace - WHERE n.nspname NOT LIKE 'pg_toast%' - `), - pool.query(`SELECT fdwname AS name FROM pg_foreign_data_wrapper`), - pool.query(`SELECT srvname AS name FROM pg_foreign_server`), - pool.query(`SELECT evtname AS name FROM pg_event_trigger`), - pool.query(`SELECT pubname AS name FROM pg_publication`), - pool.query(`SELECT subname AS name FROM pg_subscription`), - ]); - - for (const fn of functionsResult.rows) { - const kind = - fn.kind === "a" - ? "aggregate" - : fn.kind === "p" - ? "procedure" - : "function"; - const sig = fn.signature.trim() ? `(${fn.signature})` : "()"; - const ref: ObjectRef = { - kind, - name: fn.name, - schema: fn.schema, - signature: sig, - }; - addProvider(providers, ref, true); - } - - for (const t of typesResult.rows) { - const kind = t.typetype === "d" ? "domain" : "type"; - const ref: ObjectRef = { kind, name: t.name, schema: t.schema }; - addProvider(providers, ref, true); - } - - for (const row of schemasResult.rows) { - providers.push({ kind: "schema", name: row.name }); - } - - const relkindToKind: Record = { - r: "table", - p: "table", - v: "view", - m: "materialized_view", - S: "sequence", - i: "index", - }; - for (const row of relationsResult.rows) { - const kind = relkindToKind[row.relkind]; - if (!kind) continue; - const ref: ObjectRef = { kind, name: row.name, schema: row.schema }; - addProvider(providers, ref, true); - } - - for (const row of extensionsResult.rows) { - providers.push({ - kind: "extension", - name: row.name, - schema: row.schema ?? undefined, - }); - } - - for (const row of rolesResult.rows) { - providers.push({ kind: "role", name: row.name }); - } - - for (const row of languagesResult.rows) { - providers.push({ kind: "language", name: row.name }); - } - - for (const row of collationsResult.rows) { - const ref: ObjectRef = { - kind: "collation", - name: row.name, - schema: row.schema, - }; - addProvider(providers, ref, true); - } - - for (const row of fdwsResult.rows) { - providers.push({ kind: "foreign_data_wrapper", name: row.name }); - } - - for (const row of serversResult.rows) { - providers.push({ kind: "foreign_server", name: row.name }); - } - - for (const row of eventTriggersResult.rows) { - providers.push({ kind: "event_trigger", name: row.name }); - } - - for (const row of publicationsResult.rows) { - providers.push({ kind: "publication", name: row.name }); - } - - for (const row of subscriptionsResult.rows) { - providers.push({ kind: "subscription", name: row.name }); - } - - return providers; -} diff --git a/packages/pg-delta/src/core/declarative-apply/index.test.ts b/packages/pg-delta/src/core/declarative-apply/index.test.ts deleted file mode 100644 index 915a2b808..000000000 --- a/packages/pg-delta/src/core/declarative-apply/index.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import type { Pool } from "pg"; -import { applyDeclarativeSchema } from "./index.ts"; - -// Mock extractCatalogProviders to throw, simulating an early failure -// before roundApply is ever reached. -mock.module("./extract-catalog-providers.ts", () => ({ - extractCatalogProviders: async () => { - throw new Error("simulated catalog extraction failure"); - }, -})); - -// Track the pool created internally via createManagedPool so we can verify cleanup. -let lastCreatedPool: Pool & { closeCalled: boolean }; - -mock.module("../postgres-config.ts", () => ({ - createManagedPool: async () => { - lastCreatedPool = createMockPool(); - return { - pool: lastCreatedPool, - close: async () => { - lastCreatedPool.closeCalled = true; - }, - }; - }, -})); - -function createMockPool(): Pool & { closeCalled: boolean } { - const pool = { - closeCalled: false, - connect: async () => { - throw new Error("should not connect"); - }, - end: async () => {}, - query: async () => { - throw new Error("should not query"); - }, - } as unknown as Pool & { closeCalled: boolean }; - return pool; -} - -describe("applyDeclarativeSchema", () => { - test("caller-owned pool is NOT closed on early failure", async () => { - const pool = createMockPool(); - - await expect( - applyDeclarativeSchema({ - content: [{ filePath: "test.sql", sql: "CREATE TABLE t(id int);" }], - pool, - }), - ).rejects.toThrow("simulated catalog extraction failure"); - - expect(pool.closeCalled).toBe(false); - }); - - test("internally-created pool IS closed on early failure", async () => { - await expect( - applyDeclarativeSchema({ - content: [{ filePath: "test.sql", sql: "CREATE TABLE t(id int);" }], - targetUrl: "postgresql://localhost/test", - }), - ).rejects.toThrow("simulated catalog extraction failure"); - - expect(lastCreatedPool).toBeDefined(); - expect(lastCreatedPool.closeCalled).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/declarative-apply/index.ts b/packages/pg-delta/src/core/declarative-apply/index.ts deleted file mode 100644 index 656557114..000000000 --- a/packages/pg-delta/src/core/declarative-apply/index.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Declarative schema apply – orchestrator. - * - * Accepts pre-read SQL content (file path + sql string per file), uses pg-topo - * for static dependency analysis and topological ordering, then applies - * statements to a target database using iterative rounds to handle any - * remaining dependency gaps. File discovery and reading are done by the caller - * (e.g. CLI) so I/O errors can be handled there. - */ - -import type { Diagnostic, StatementNode } from "@supabase/pg-topo"; -import { analyzeAndSort } from "@supabase/pg-topo"; -import type { Pool } from "pg"; -import { createManagedPool } from "../postgres-config.ts"; -import { extractCatalogProviders } from "./extract-catalog-providers.ts"; -import { - type ApplyResult, - type RoundResult, - roundApply, - type StatementEntry, -} from "./round-apply.ts"; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -import type { SqlFileEntry } from "./discover-sql.ts"; - -interface DeclarativeApplyOptions { - /** Pre-read SQL files: filePath (relative) and sql content. Caller does discovery and read. */ - content: SqlFileEntry[]; - /** Target database connection URL (required if pool is not provided) */ - targetUrl?: string; - /** Existing pool to use (caller owns it; not closed). If provided, targetUrl is ignored. */ - pool?: Pool; - /** Max rounds before giving up (default: 100) */ - maxRounds?: number; - /** Run final function body validation (default: true) */ - validateFunctionBodies?: boolean; - /** Disable function body checks during rounds (default: true) */ - disableCheckFunctionBodies?: boolean; - /** Progress callback fired after each round */ - onRoundComplete?: (round: RoundResult) => void; -} - -export interface DeclarativeApplyResult { - /** Result from the round-based apply engine */ - apply: ApplyResult; - /** Diagnostics from pg-topo's static analysis (warnings, not fatal) */ - diagnostics: Diagnostic[]; - /** Total number of statements discovered */ - totalStatements: number; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Convert pg-topo StatementNodes into StatementEntries for the apply engine. - */ -function toStatementEntries(nodes: StatementNode[]): StatementEntry[] { - return nodes.map((node) => ({ - id: `${node.id.filePath}:${node.id.statementIndex}`, - sql: node.sql, - statementClass: node.statementClass, - })); -} - -function remapStatementId( - statementId: { filePath: string; statementIndex: number } | undefined, - filePathMap: Map, -): typeof statementId { - if (!statementId) return undefined; - return { - ...statementId, - filePath: filePathMap.get(statementId.filePath) ?? statementId.filePath, - }; -} - -// --------------------------------------------------------------------------- -// Main entry point -// --------------------------------------------------------------------------- - -/** - * Apply a declarative SQL schema to a target database. - * - * 1. Call pg-topo analyzeAndSort on the provided SQL strings - * 2. Remap synthetic statement IDs to caller-provided file paths - * 3. Apply statements round-by-round to the target database - * 4. Optionally validate function bodies in a final pass - */ -export async function applyDeclarativeSchema( - options: DeclarativeApplyOptions, -): Promise { - const { - content, - targetUrl, - pool: providedPool, - maxRounds = 100, - validateFunctionBodies = true, - disableCheckFunctionBodies = true, - onRoundComplete, - } = options; - - if (content.length === 0) { - return { - apply: { - status: "success", - totalRounds: 0, - totalApplied: 0, - totalSkipped: 0, - rounds: [], - }, - diagnostics: [], - totalStatements: 0, - }; - } - - let pool: Pool; - let closePool: (() => Promise) | undefined; - if (providedPool != null) { - pool = providedPool; - } else if (targetUrl != null) { - const managed = await createManagedPool(targetUrl, { label: "target" }); - pool = managed.pool; - closePool = managed.close; - } else { - throw new Error("Either targetUrl or pool must be provided"); - } - - try { - const externalProviders = await extractCatalogProviders(pool); - - // Step 1: pg-topo analyze and sort (no file I/O; uses synthetic paths) - const sqlContents = content.map((entry) => entry.sql); - const analyzeResult = await analyzeAndSort(sqlContents, { - externalProviders, - }); - - const { ordered, diagnostics } = analyzeResult; - - // Step 2: Remap to real file paths - const filePathMap = new Map(); - for (let i = 0; i < content.length; i += 1) { - filePathMap.set(``, content[i].filePath); - } - - const remappedOrdered = ordered.map((node) => ({ - ...node, - id: { - ...node.id, - filePath: filePathMap.get(node.id.filePath) ?? node.id.filePath, - }, - })); - - const remappedDiagnostics = diagnostics.map((d) => ({ - ...d, - statementId: remapStatementId(d.statementId, filePathMap), - })); - - if (ordered.length === 0) { - return { - apply: { - status: "success", - totalRounds: 0, - totalApplied: 0, - totalSkipped: 0, - rounds: [], - }, - diagnostics: remappedDiagnostics, - totalStatements: 0, - }; - } - - // Step 3: Convert to statement entries and apply - const statements = toStatementEntries(remappedOrdered); - - const applyResult = await roundApply({ - pool, - statements, - maxRounds, - disableCheckFunctionBodies, - finalValidation: validateFunctionBodies, - onRoundComplete, - }); - - return { - apply: applyResult, - diagnostics: remappedDiagnostics, - totalStatements: remappedOrdered.length, - }; - } finally { - if (closePool) { - await closePool(); - } - } -} - -export type { SqlFileEntry } from "./discover-sql.ts"; - -// Re-export file discovery for programmatic callers (e.g. Supabase CLI edge-runtime templates) -export { loadDeclarativeSchema } from "./discover-sql.ts"; -// Re-export result types for callers that need them (StatementError is imported from round-apply directly where needed) -export type { ApplyResult, RoundResult } from "./round-apply.ts"; diff --git a/packages/pg-delta/src/core/declarative-apply/round-apply.test.ts b/packages/pg-delta/src/core/declarative-apply/round-apply.test.ts deleted file mode 100644 index 4dcbde9f1..000000000 --- a/packages/pg-delta/src/core/declarative-apply/round-apply.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import debug from "debug"; -import type { Pool, PoolClient, QueryResult } from "pg"; -import { - type RoundResult, - rewriteAsOrReplace, - roundApply, - type StatementEntry, -} from "./round-apply.ts"; - -// --------------------------------------------------------------------------- -// Mock helpers -// --------------------------------------------------------------------------- - -/** - * Create a mock PoolClient that executes queries against a provided handler. - * The handler receives the SQL and can either resolve or throw a pg-style error. - */ -function createMockClient(queryHandler: (sql: string) => void): PoolClient { - return { - query: async (sql: string) => { - queryHandler(sql); - return { rows: [], rowCount: 0 } as unknown as QueryResult; - }, - release: () => {}, - } as unknown as PoolClient; -} - -/** - * Create a mock Pool that returns the provided mock client. - */ -function createMockPool(client: PoolClient): Pool { - return { - connect: async () => client, - end: () => {}, - } as unknown as Pool; -} - -/** - * Create a postgres-style error with a SQLSTATE code. - */ -function pgError(code: string, message: string): Error & { code: string } { - const err = new Error(message) as Error & { code: string }; - err.code = code; - return err; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("roundApply", () => { - test("should apply all statements in a single round when no errors", async () => { - const statements: StatementEntry[] = [ - { id: "1", sql: "CREATE SCHEMA test;" }, - { id: "2", sql: "CREATE TABLE test.users (id int);" }, - ]; - - const client = createMockClient(() => { - // All queries succeed - }); - const pool = createMockPool(client); - - const result = await roundApply({ pool, statements }); - - expect(result.status).toBe("success"); - expect(result.totalRounds).toBe(1); - expect(result.totalApplied).toBe(2); - expect(result.rounds).toHaveLength(1); - expect(result.rounds[0].applied).toBe(2); - expect(result.rounds[0].deferred).toBe(0); - }); - - test("should defer dependency errors and retry in next round", async () => { - const statements: StatementEntry[] = [ - { id: "table", sql: "CREATE TABLE test.users (id int);" }, - { id: "schema", sql: "CREATE SCHEMA test;" }, - ]; - - // Track which round we're on - const appliedSet = new Set(); - - const client = createMockClient((sql: string) => { - if (sql.startsWith("SET ")) return; // Allow SET statements - - if (sql.includes("CREATE TABLE") && !appliedSet.has("schema")) { - // Table creation fails because schema doesn't exist yet - throw pgError("3F000", 'schema "test" does not exist'); - } - // Track successful applies - if (sql.includes("CREATE SCHEMA")) appliedSet.add("schema"); - }); - const pool = createMockPool(client); - - const result = await roundApply({ pool, statements }); - - expect(result.status).toBe("success"); - expect(result.totalRounds).toBe(2); - expect(result.totalApplied).toBe(2); - // Round 1: schema succeeds, table deferred - expect(result.rounds[0].applied).toBe(1); - expect(result.rounds[0].deferred).toBe(1); - // Round 2: table succeeds - expect(result.rounds[1].applied).toBe(1); - expect(result.rounds[1].deferred).toBe(0); - }); - - test("should report stuck when no progress can be made", async () => { - const statements: StatementEntry[] = [ - { id: "1", sql: "CREATE TABLE a (id int REFERENCES b(id));" }, - { id: "2", sql: "CREATE TABLE b (id int REFERENCES a(id));" }, - ]; - - const client = createMockClient((sql: string) => { - if (sql.startsWith("SET ")) return; - // Both always fail with dependency errors (circular) - throw pgError("42P01", "relation does not exist"); - }); - const pool = createMockPool(client); - - const result = await roundApply({ pool, statements, maxRounds: 5 }); - - expect(result.status).toBe("stuck"); - expect(result.stuckStatements).toHaveLength(2); - expect(result.totalApplied).toBe(0); - }); - - test("should skip environment capability errors", async () => { - const statements: StatementEntry[] = [ - { - id: "ext", - sql: "CREATE EXTENSION pgaudit;", - statementClass: "CREATE_EXTENSION", - }, - { id: "schema", sql: "CREATE SCHEMA test;" }, - ]; - - const client = createMockClient((sql: string) => { - if (sql.startsWith("SET ")) return; - if (sql.includes("CREATE EXTENSION")) { - throw pgError("58P01", "extension pgaudit control file not found"); - } - }); - const pool = createMockPool(client); - - const result = await roundApply({ pool, statements }); - - expect(result.status).toBe("success"); - expect(result.totalApplied).toBe(1); - expect(result.totalSkipped).toBe(1); - }); - - test("should report hard failures for non-dependency errors", async () => { - const statements: StatementEntry[] = [ - { id: "1", sql: "CREATE TABLE test (id int);" }, - { id: "2", sql: "INVALID SQL;" }, - ]; - - const client = createMockClient((sql: string) => { - if (sql.startsWith("SET ")) return; - if (sql.includes("INVALID")) { - throw pgError("42601", "syntax error"); - } - }); - const pool = createMockPool(client); - - const result = await roundApply({ pool, statements }); - - expect(result.status).toBe("error"); - expect(result.totalApplied).toBe(1); - expect(result.errors).toHaveLength(1); - expect(result.errors?.[0].code).toBe("42601"); - }); - - test("should call onRoundComplete callback", async () => { - const statements: StatementEntry[] = [ - { id: "1", sql: "CREATE SCHEMA test;" }, - ]; - - const client = createMockClient((sql: string) => { - if (sql.startsWith("SET ")) return; - }); - const pool = createMockPool(client); - - const rounds: RoundResult[] = []; - const result = await roundApply({ - pool, - statements, - onRoundComplete: (round) => rounds.push(round), - }); - - expect(result.status).toBe("success"); - expect(rounds).toHaveLength(1); - expect(rounds[0].round).toBe(1); - expect(rounds[0].applied).toBe(1); - }); - - test("should set check_function_bodies = off by default", async () => { - const statements: StatementEntry[] = [ - { id: "1", sql: "CREATE SCHEMA test;" }, - ]; - - const queryCalls: string[] = []; - const client = createMockClient((sql: string) => { - queryCalls.push(sql); - }); - const pool = createMockPool(client); - - await roundApply({ pool, statements }); - - expect(queryCalls[0]).toBe("SET check_function_bodies = off"); - }); - - test("should run final validation for functions when enabled", async () => { - const statements: StatementEntry[] = [ - { - id: "fn", - sql: "CREATE FUNCTION test_fn() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;", - statementClass: "CREATE_FUNCTION", - }, - ]; - - const queryCalls: string[] = []; - const client = createMockClient((sql: string) => { - queryCalls.push(sql); - }); - const pool = createMockPool(client); - - const result = await roundApply({ - pool, - statements, - finalValidation: true, - }); - - expect(result.status).toBe("success"); - // Should have: SET off, CREATE FUNCTION, SET on, CREATE OR REPLACE FUNCTION - const validationCall = queryCalls.find((sql) => - sql.includes("CREATE OR REPLACE FUNCTION"), - ); - expect(validationCall).toBeDefined(); - expect(queryCalls).toContain("SET check_function_bodies = on"); - }); - - test("should handle annotated functions in final validation", async () => { - const statements: StatementEntry[] = [ - { - id: "fn", - sql: "-- pg-topo:requires function:app.other(int)\n-- pg-topo:requires function:app.multiline(int)\nCREATE FUNCTION test_fn() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;", - statementClass: "CREATE_FUNCTION", - }, - ]; - - const queryCalls: string[] = []; - const client = createMockClient((sql: string) => { - queryCalls.push(sql); - }); - const pool = createMockPool(client); - - const result = await roundApply({ - pool, - statements, - finalValidation: true, - }); - - expect(result.status).toBe("success"); - const validationCall = queryCalls.find((sql) => - sql.includes("OR REPLACE FUNCTION"), - ); - expect(validationCall).toBeDefined(); - expect(validationCall).toContain("-- pg-topo:requires"); - }); - - test("should respect maxRounds limit", async () => { - // Simulate a scenario where each round makes some progress but - // never finishes: statements 1..5 succeed one per round based on - // a counter, but the last one always defers. - const statements: StatementEntry[] = [ - { id: "a1", sql: "CREATE TABLE a1 (id int);" }, - { id: "a2", sql: "CREATE TABLE a2 (id int);" }, - { id: "a3", sql: "CREATE TABLE a3 (id int);" }, - { id: "stuck", sql: "CREATE TABLE stuck (id int);" }, - ]; - - let _appliedCount = 0; - - const client = createMockClient((sql: string) => { - if (sql.startsWith("SET ")) return; - if (sql.includes("stuck")) { - // Always fails with dependency error - throw pgError("42P01", "relation does not exist"); - } - // Each non-stuck statement succeeds once - _appliedCount++; - }); - const pool = createMockPool(client); - - // With maxRounds=2, we apply a1,a2,a3 in rounds 1-2 but "stuck" never resolves - // Actually with 4 statements: round 1 applies 3, defers 1. Round 2: stuck (0 applied, 1 deferred) - // So stuck detection kicks in at round 2, not maxRounds - // To test maxRounds limit, we need a scenario where we can't detect stuck early. - // Instead, test that stuck detection happens correctly with a single deferred statement. - const result = await roundApply({ pool, statements, maxRounds: 2 }); - - expect(result.status).toBe("stuck"); - // Round 1: 3 applied, 1 deferred. Round 2: 0 applied, 1 deferred -> stuck - expect(result.totalRounds).toBe(2); - expect(result.totalApplied).toBe(3); - expect(result.stuckStatements).toHaveLength(1); - expect(result.stuckStatements?.[0].statement.id).toBe("stuck"); - }); - - test("should handle multi-round resolution with many statements", async () => { - // Simulate: schema -> table -> index dependency chain, presented in reverse - const statements: StatementEntry[] = [ - { id: "idx", sql: "CREATE INDEX idx ON test.users (name);" }, - { id: "table", sql: "CREATE TABLE test.users (id int, name text);" }, - { id: "schema", sql: "CREATE SCHEMA test;" }, - ]; - - const appliedSet = new Set(); - - const client = createMockClient((sql: string) => { - if (sql.startsWith("SET ")) return; - - if (sql.includes("CREATE INDEX") && !appliedSet.has("table")) { - throw pgError("42P01", 'relation "test.users" does not exist'); - } - if (sql.includes("CREATE TABLE") && !appliedSet.has("schema")) { - throw pgError("3F000", 'schema "test" does not exist'); - } - - if (sql.includes("CREATE SCHEMA")) appliedSet.add("schema"); - if (sql.includes("CREATE TABLE")) appliedSet.add("table"); - if (sql.includes("CREATE INDEX")) appliedSet.add("idx"); - }); - const pool = createMockPool(client); - - const result = await roundApply({ pool, statements }); - - expect(result.status).toBe("success"); - expect(result.totalRounds).toBe(3); - expect(result.totalApplied).toBe(3); - }); - - test("should restore check_function_bodies when finalValidation is false", async () => { - const statements: StatementEntry[] = [ - { id: "1", sql: "CREATE SCHEMA test;" }, - ]; - const queryCalls: string[] = []; - const client = createMockClient((sql: string) => { - queryCalls.push(sql); - }); - const pool = createMockPool(client); - - await roundApply({ pool, statements, finalValidation: false }); - - // Should see: SET off, CREATE SCHEMA, SET on (restore) - expect(queryCalls).toContain("SET check_function_bodies = off"); - expect(queryCalls).toContain("SET check_function_bodies = on"); - // Restore must come after the last statement - const offIdx = queryCalls.indexOf("SET check_function_bodies = off"); - const onIdx = queryCalls.lastIndexOf("SET check_function_bodies = on"); - expect(onIdx).toBeGreaterThan(offIdx); - }); - - test("should restore check_function_bodies when stuck", async () => { - const statements: StatementEntry[] = [ - { id: "1", sql: "CREATE TABLE a (id int REFERENCES b(id));" }, - ]; - const queryCalls: string[] = []; - const client = createMockClient((sql: string) => { - queryCalls.push(sql); - if (!sql.startsWith("SET ")) { - throw pgError("42P01", "relation does not exist"); - } - }); - const pool = createMockPool(client); - - const result = await roundApply({ pool, statements, maxRounds: 2 }); - expect(result.status).toBe("stuck"); - expect( - queryCalls.filter((s) => s === "SET check_function_bodies = on"), - ).toHaveLength(1); - }); - - test("should log deferred statement id and reason when DEBUG=pg-delta:declarative-apply", async () => { - const statements: StatementEntry[] = [ - { id: "table", sql: "CREATE TABLE test.users (id int);" }, - { id: "schema", sql: "CREATE SCHEMA test;" }, - ]; - - const appliedSet = new Set(); - const client = createMockClient((sql: string) => { - if (sql.startsWith("SET ")) return; - if (sql.includes("CREATE TABLE") && !appliedSet.has("schema")) { - throw pgError("3F000", 'schema "test" does not exist'); - } - if (sql.includes("CREATE SCHEMA")) appliedSet.add("schema"); - }); - const pool = createMockPool(client); - - const logs: string[] = []; - const originalLog = debug.log; - debug.log = (...args: unknown[]) => { - logs.push(args.map(String).join(" ")); - }; - debug.enable("pg-delta:declarative-apply"); - try { - const result = await roundApply({ pool, statements }); - expect(result.status).toBe("success"); - expect(result.rounds[0].deferred).toBe(1); - - const logText = logs.join("\n"); - expect(logText).toContain("deferred"); - expect(logText).toContain("table"); - expect(logText).toContain("3F000"); - expect(logText).toMatch(/schema.*does not exist/i); - } finally { - debug.log = originalLog; - debug.disable(); - } - }); -}); - -describe("rewriteAsOrReplace", () => { - test("adds OR REPLACE to CREATE FUNCTION", () => { - expect( - rewriteAsOrReplace( - "CREATE FUNCTION foo() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;", - ), - ).toBe( - "CREATE OR REPLACE FUNCTION foo() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;", - ); - }); - - test("does not double-add OR REPLACE", () => { - const sql = - "CREATE OR REPLACE FUNCTION foo() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;"; - expect(rewriteAsOrReplace(sql)).toBe(sql); - }); - - test("handles CREATE PROCEDURE", () => { - expect( - rewriteAsOrReplace( - "CREATE PROCEDURE bar() AS $$ BEGIN END; $$ LANGUAGE plpgsql;", - ), - ).toBe( - "CREATE OR REPLACE PROCEDURE bar() AS $$ BEGIN END; $$ LANGUAGE plpgsql;", - ); - }); - - test("does not double-add OR REPLACE on procedure", () => { - const sql = - "CREATE OR REPLACE PROCEDURE bar() AS $$ BEGIN END; $$ LANGUAGE plpgsql;"; - expect(rewriteAsOrReplace(sql)).toBe(sql); - }); - - test("preserves leading line comments", () => { - const sql = - "-- pg-topo:requires function:app.other(int)\nCREATE FUNCTION foo() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;"; - const result = rewriteAsOrReplace(sql); - expect(result).toContain("-- pg-topo:requires"); - expect(result).toContain("OR REPLACE FUNCTION"); - }); - - test("is case-insensitive", () => { - expect( - rewriteAsOrReplace( - "create function foo() returns void as $$ begin end; $$ language plpgsql;", - ), - ).toContain("OR REPLACE function"); - }); - - test("preserves leading block comments and adds OR REPLACE", () => { - const sql = - "/* some block comment */\nCREATE FUNCTION foo() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;"; - const result = rewriteAsOrReplace(sql); - expect(result).toContain("/* some block comment */"); - expect(result).toContain("OR REPLACE FUNCTION"); - }); - - test("handles mixed line and block comments before CREATE", () => { - const sql = - "-- line comment\n/* block */\nCREATE FUNCTION foo() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;"; - const result = rewriteAsOrReplace(sql); - expect(result).toContain("-- line comment"); - expect(result).toContain("/* block */"); - expect(result).toContain("OR REPLACE FUNCTION"); - }); - - test("handles block comment before CREATE PROCEDURE", () => { - const sql = - "/* annotation */\nCREATE PROCEDURE bar() AS $$ BEGIN END; $$ LANGUAGE plpgsql;"; - const result = rewriteAsOrReplace(sql); - expect(result).toContain("/* annotation */"); - expect(result).toContain("OR REPLACE PROCEDURE"); - }); - - test("does not double-add OR REPLACE after block comment", () => { - const sql = - "/* comment */\nCREATE OR REPLACE FUNCTION foo() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;"; - expect(rewriteAsOrReplace(sql)).toBe(sql); - }); -}); diff --git a/packages/pg-delta/src/core/declarative-apply/round-apply.ts b/packages/pg-delta/src/core/declarative-apply/round-apply.ts deleted file mode 100644 index 7b53b3a26..000000000 --- a/packages/pg-delta/src/core/declarative-apply/round-apply.ts +++ /dev/null @@ -1,562 +0,0 @@ -/** - * Round-based declarative schema apply engine. - * - * Applies SQL statements to a database using iterative rounds: - * 1. Try each pending statement - * 2. On dependency errors, defer to next round - * 3. Repeat until all applied or no progress (stuck) - * 4. Optional final validation pass for function bodies - */ - -import debug from "debug"; -import type { Pool, PoolClient } from "pg"; - -const debugApply = debug("pg-delta:declarative-apply"); - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface StatementEntry { - /** Unique identifier for the statement (e.g. "file:index") */ - id: string; - /** The SQL to execute */ - sql: string; - /** Optional statement classification (e.g. CREATE_FUNCTION) */ - statementClass?: string; -} - -export interface StatementError { - /** Statement that failed */ - statement: StatementEntry; - /** PostgreSQL error code (SQLSTATE) */ - code: string; - /** Human-readable error message */ - message: string; - /** Whether this was classified as a dependency error */ - isDependencyError: boolean; - /** 1-based character offset in the statement SQL where the error occurred */ - position?: number; - /** PostgreSQL error detail (e.g. token invalid) */ - detail?: string; - /** PostgreSQL hint */ - hint?: string; -} - -export interface RoundResult { - /** Round number (1-based) */ - round: number; - /** Number of statements successfully applied this round */ - applied: number; - /** Number of statements deferred to next round */ - deferred: number; - /** Number of statements that failed with non-dependency errors */ - failed: number; - /** Errors encountered this round */ - errors: StatementError[]; -} - -export interface ApplyResult { - /** Overall status */ - status: "success" | "stuck" | "error"; - /** Total number of rounds executed */ - totalRounds: number; - /** Total number of statements successfully applied */ - totalApplied: number; - /** Total number of statements skipped (environment/capability errors) */ - totalSkipped: number; - /** Statements that could not be applied (stuck) */ - stuckStatements?: StatementError[]; - /** Non-dependency errors that caused hard failures */ - errors?: StatementError[]; - /** Errors from the final function body validation pass */ - validationErrors?: StatementError[]; - /** Per-round results */ - rounds: RoundResult[]; -} - -interface RoundApplyOptions { - /** Target database pool */ - pool: Pool; - /** Ordered SQL statements to apply */ - statements: StatementEntry[]; - /** Max rounds before giving up (default: 100) */ - maxRounds?: number; - /** Disable function body checks during application (default: true) */ - disableCheckFunctionBodies?: boolean; - /** Run final validation with check_function_bodies=on (default: true) */ - finalValidation?: boolean; - /** Progress callback fired after each round */ - onRoundComplete?: (round: RoundResult) => void; -} - -// --------------------------------------------------------------------------- -// Dependency error classification -// --------------------------------------------------------------------------- - -/** - * SQLSTATE codes that indicate a missing dependency (object not yet created). - * Mirrors pg-topo's isDependencyErrorCode. - */ -const DEPENDENCY_ERROR_CODES = new Set([ - "42P01", // undefined_table - "42703", // undefined_column - "42704", // undefined_object - "42883", // undefined_function - "3F000", // invalid_schema_name -]); - -/** - * Detect errors caused by environment/capability limitations rather than - * schema bugs. These statements are skipped permanently (not retried). - * - * Strategy: SQLSTATE codes are the primary gate (fast, stable). For codes - * reused across unrelated error conditions (e.g. 42710 = "duplicate object" - * covers both roles and extensions), the error message is used as a secondary - * disambiguator. Messages are lowercased before matching to handle - * case-sensitivity differences across PG versions. - */ -function isEnvironmentCapabilityError( - code: string | undefined, - message: string, - statementClass: string | undefined, -): boolean { - // Feature not supported - if (code === "0A000") return true; - - // Extension not available - if ( - code === "58P01" && - message.includes("extension") && - (message.includes("control file") || message.includes("is not available")) - ) { - return true; - } - - // Subscription / logical replication not available - if ( - statementClass === "CREATE_SUBSCRIPTION" && - (code === "58P01" || - message.includes("walreceiver") || - message.includes("logical replication")) - ) { - return true; - } - - // Event trigger requires superuser - if ( - statementClass === "CREATE_EVENT_TRIGGER" && - (code === "42501" || message.includes("must be superuser")) - ) { - return true; - } - - // Language does not exist (e.g. plv8) - if ( - (statementClass === "CREATE_FUNCTION" || - statementClass === "CREATE_PROCEDURE") && - message.includes("language") && - message.includes("does not exist") - ) { - return true; - } - - // Role already exists - if ( - statementClass === "CREATE_ROLE" && - (code === "42710" || code === "23505") && - message.includes("role") && - (message.includes("already exists") || - message.includes("duplicate key") || - message.includes("pg_authid_rolname_index")) - ) { - return true; - } - - // Extension already exists - if ( - statementClass === "CREATE_EXTENSION" && - code === "42710" && - message.includes("extension") && - message.includes("already exists") - ) { - return true; - } - - // Sequence ownership constraint - if ( - code === "55000" && - message.includes("sequence must have same owner as table it is linked to") - ) { - return true; - } - - // Publication replica identity - if ( - code === "55000" && - message.includes("does not have a replica identity") && - message.includes("publishes updates") - ) { - return true; - } - - return false; -} - -function isDependencyError(code: string | undefined): boolean { - return code !== undefined && DEPENDENCY_ERROR_CODES.has(code); -} - -interface PgError extends Error { - code?: string; - /** 1-based character position in query (pg may send as string) */ - position?: string | number; - detail?: string; - hint?: string; -} - -function parsePgPosition(pos: string | number | undefined): number | undefined { - if (pos === undefined) return undefined; - if (typeof pos === "number" && Number.isInteger(pos) && pos > 0) return pos; - if (typeof pos === "string") { - const n = Number.parseInt(pos, 10); - if (Number.isInteger(n) && n > 0) return n; - } - return undefined; -} - -// --------------------------------------------------------------------------- -// Core round-based apply -// --------------------------------------------------------------------------- - -/** - * Apply SQL statements to a database using iterative rounds. - * - * Algorithm: - * 1. Optionally set check_function_bodies = off - * 2. For each round, iterate over pending statements: - * - On success: mark as applied - * - On dependency error: defer to next round - * - On environment error: skip permanently with warning - * - On other error: mark as failed - * 3. If a round makes no progress (0 applied), stop (stuck) - * 4. If finalValidation is true, re-run CREATE FUNCTION/PROCEDURE - * with check_function_bodies = on - */ -export async function roundApply( - options: RoundApplyOptions, -): Promise { - const { - pool, - statements, - maxRounds = 100, - disableCheckFunctionBodies = true, - finalValidation = true, - onRoundComplete, - } = options; - - const rounds: RoundResult[] = []; - const allErrors: StatementError[] = []; - let totalApplied = 0; - let totalSkipped = 0; - - // Track which statements still need to be applied - let pending: StatementEntry[] = [...statements]; - // Track statements that failed with non-dependency errors - const hardFailed: StatementError[] = []; - // Track skipped (environment) statements - const skipped: StatementEntry[] = []; - // Track applied function/procedure statements for final validation - const appliedFunctions: StatementEntry[] = []; - - const client: PoolClient = await pool.connect(); - - try { - // Disable function body checks to avoid false failures from - // functions referencing not-yet-created objects - if (disableCheckFunctionBodies) { - await client.query("SET check_function_bodies = off"); - } - - for (let round = 1; round <= maxRounds && pending.length > 0; round++) { - debugApply("round %d: %d pending", round, pending.length); - const roundErrors: StatementError[] = []; - const deferred: StatementEntry[] = []; - let appliedThisRound = 0; - let failedThisRound = 0; - - for (const stmt of pending) { - try { - await client.query(stmt.sql); - appliedThisRound++; - totalApplied++; - - // Track functions for final validation - if ( - stmt.statementClass === "CREATE_FUNCTION" || - stmt.statementClass === "CREATE_PROCEDURE" - ) { - appliedFunctions.push(stmt); - } - } catch (err) { - const pgErr = err as PgError; - const code = pgErr.code ?? ""; - const message = (pgErr.message ?? "").toLowerCase(); - - // Check if this is an environment/capability limitation - if ( - isEnvironmentCapabilityError(code, message, stmt.statementClass) - ) { - debugApply( - "skipped %s: %s", - stmt.id, - pgErr.message ?? code ?? "environment/capability", - ); - skipped.push(stmt); - totalSkipped++; - continue; - } - - // Check if this is a dependency error (retryable) - if (isDependencyError(code)) { - debugApply( - "deferred %s: %s - %s", - stmt.id, - code, - pgErr.message ?? "Unknown error", - ); - if (pgErr.detail) debugApply(" detail: %s", pgErr.detail); - if (pgErr.hint) debugApply(" hint: %s", pgErr.hint); - deferred.push(stmt); - roundErrors.push({ - statement: stmt, - code, - message: pgErr.message ?? "Unknown error", - isDependencyError: true, - position: parsePgPosition(pgErr.position), - detail: pgErr.detail, - hint: pgErr.hint, - }); - continue; - } - - // Hard failure - non-dependency, non-environment error - failedThisRound++; - debugApply( - "failed %s: %s - %s", - stmt.id, - code, - pgErr.message ?? "Unknown error", - ); - if (pgErr.detail) debugApply(" detail: %s", pgErr.detail); - if (pgErr.hint) debugApply(" hint: %s", pgErr.hint); - const stmtError: StatementError = { - statement: stmt, - code, - message: pgErr.message ?? "Unknown error", - isDependencyError: false, - position: parsePgPosition(pgErr.position), - detail: pgErr.detail, - hint: pgErr.hint, - }; - roundErrors.push(stmtError); - hardFailed.push(stmtError); - allErrors.push(stmtError); - } - } - - if (debugApply.enabled && deferred.length > 0) { - debugApply( - "Round %d complete: %d applied, %d deferred, %d failed", - round, - appliedThisRound, - deferred.length, - failedThisRound, - ); - for (const e of roundErrors.filter((er) => er.isDependencyError)) { - debugApply( - " deferred %s: %s - %s", - e.statement.id, - e.code, - e.message, - ); - if (e.detail) debugApply(" detail: %s", e.detail); - if (e.hint) debugApply(" hint: %s", e.hint); - } - } - - const roundResult: RoundResult = { - round, - applied: appliedThisRound, - deferred: deferred.length, - failed: failedThisRound, - errors: roundErrors, - }; - rounds.push(roundResult); - onRoundComplete?.(roundResult); - - // No progress this round - we're stuck - if (appliedThisRound === 0 && deferred.length > 0) { - // Collect the latest error for each stuck statement - const stuckStatements = deferred.map((stmt) => { - const lastError = roundErrors.find((e) => e.statement.id === stmt.id); - return ( - lastError ?? { - statement: stmt, - code: "UNKNOWN", - message: "Deferred without a recorded error", - isDependencyError: true, - } - ); - }); - - return { - status: "stuck", - totalRounds: round, - totalApplied, - totalSkipped, - stuckStatements, - errors: hardFailed.length > 0 ? hardFailed : undefined, - rounds, - }; - } - - pending = deferred; - } - - // If we exhausted maxRounds but still have pending, report stuck - if (pending.length > 0) { - return { - status: "stuck", - totalRounds: maxRounds, - totalApplied, - totalSkipped, - stuckStatements: pending.map((stmt) => ({ - statement: stmt, - code: "MAX_ROUNDS", - message: `Exceeded maximum rounds (${maxRounds})`, - isDependencyError: true, - })), - errors: hardFailed.length > 0 ? hardFailed : undefined, - rounds, - }; - } - - // Final validation pass: re-run functions with check_function_bodies = on - let validationErrors: StatementError[] | undefined; - if (finalValidation && appliedFunctions.length > 0) { - validationErrors = await validateFunctionBodies(client, appliedFunctions); - } - - return { - status: - hardFailed.length > 0 - ? "error" - : validationErrors && validationErrors.length > 0 - ? "error" - : "success", - totalRounds: rounds.length, - totalApplied, - totalSkipped, - errors: hardFailed.length > 0 ? hardFailed : undefined, - validationErrors: - validationErrors && validationErrors.length > 0 - ? validationErrors - : undefined, - rounds, - }; - } finally { - if (disableCheckFunctionBodies) { - try { - // Restore check_function_bodies for the connection being returned to the pool. - // validateFunctionBodies uses SET LOCAL inside a rolled-back transaction so it - // never changes the session-level value, but the SET at the start of rounds still - // needs to be undone here. - await client.query("SET check_function_bodies = on"); - } catch { - // Best-effort restore; connection is being released anyway - } - } - client.release(); - } -} - -/** - * Rewrite a CREATE FUNCTION/PROCEDURE statement to use OR REPLACE for - * idempotent re-execution during validation. Handles leading line-comments, - * block comments (pg-topo annotations), and avoids double-adding OR REPLACE. - */ -export function rewriteAsOrReplace(sql: string): string { - return sql.replace( - /^((?:(?:\s*--[^\n]*\n)|(?:\s*\/\*[\s\S]*?\*\/\s*))*\s*CREATE\s+)(?!OR\s+REPLACE\b)(FUNCTION|PROCEDURE)/i, - "$1OR REPLACE $2", - ); -} - -/** - * Re-run CREATE FUNCTION/PROCEDURE statements with check_function_bodies = on - * using CREATE OR REPLACE to validate function bodies after all objects exist. - * - * Runs entirely inside a transaction that is always rolled back, so: - * - SET LOCAL search_path and check_function_bodies are transaction-scoped and - * never leak to the caller's session. - * - The CREATE OR REPLACE changes are undone, leaving the DB exactly as it was - * after the main apply rounds. - * - SAVEPOINTs around each statement prevent an aborted-transaction error from - * blocking validation of the remaining functions. - */ -async function validateFunctionBodies( - client: PoolClient, - functions: StatementEntry[], -): Promise { - const errors: StatementError[] = []; - - await client.query("BEGIN"); - try { - // Auto-detect all user schemas so unqualified names in function bodies - // resolve correctly, regardless of the session's default search_path. - const { rows } = await client.query<{ schemas: string | null }>(` - SELECT string_agg(quote_ident(nspname), ', ' ORDER BY nspname) AS schemas - FROM pg_namespace - WHERE nspname NOT LIKE 'pg_%' - AND nspname <> 'information_schema' - `); - const detectedSchemas = rows[0]?.schemas; - if (detectedSchemas) { - debugApply("validation search_path: %s, pg_catalog", detectedSchemas); - await client.query( - `SET LOCAL search_path = ${detectedSchemas}, pg_catalog`, - ); - } - - await client.query("SET LOCAL check_function_bodies = on"); - - for (const stmt of functions) { - const replaceSql = rewriteAsOrReplace(stmt.sql); - - await client.query("SAVEPOINT validate_fn"); - try { - await client.query(replaceSql); - await client.query("RELEASE SAVEPOINT validate_fn"); - } catch (err) { - await client.query("ROLLBACK TO SAVEPOINT validate_fn"); - const pgErr = err as PgError; - errors.push({ - statement: stmt, - code: pgErr.code ?? "", - message: pgErr.message ?? "Unknown validation error", - isDependencyError: false, - position: parsePgPosition(pgErr.position), - detail: pgErr.detail, - hint: pgErr.hint, - }); - } - } - } finally { - // Always roll back: undoes all CREATE OR REPLACE changes and reverts the - // SET LOCAL search_path / check_function_bodies so nothing leaks. - await client.query("ROLLBACK"); - } - - return errors; -} diff --git a/packages/pg-delta/src/core/depend.ts b/packages/pg-delta/src/core/depend.ts deleted file mode 100644 index 8083851a4..000000000 --- a/packages/pg-delta/src/core/depend.ts +++ /dev/null @@ -1,1895 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; - -/** - * Dependency type as defined in PostgreSQL's pg_depend.deptype. - * n: normal - * a: auto - * i: internal - */ -type PgDependType = "n" | "a" | "i"; - -export interface PgDepend { - dependent_stable_id: string; - referenced_stable_id: string; - /** - * Dependency type as defined in PostgreSQL's pg_depend.deptype. - * - * - "n" (normal): Ordinary dependency — if the referenced object is dropped, the dependent object is also dropped automatically. - * Example: a table column depends on its table. - * - "a" (auto): Automatically created dependency — the dependent object was created as a result of creating the referenced object, - * and should be dropped automatically when the referenced object is dropped, but not otherwise treated as a strong link. - * - "i" (internal): Internal dependency — the dependent object is a low-level part of the referenced object and cannot be dropped - * without dropping the whole referenced object. Example: a table's toast table or an index that's part of a unique constraint. - */ - deptype: PgDependType; -} - -/** - * Extract dependencies for privileges and memberships so that GRANT/REVOKE - * operations are properly ordered with respect to their target objects/roles. - * - * Encodes edges like: - * - acl:::grantee: -> - * - acl:::grantee: -> role: - * - aclcol:table:.::grantee: -> table:. - * - aclcol:... -> role: - * - defacl::::grantee: -> role: - * - defacl:... -> role: - * - defacl:... -> schema: (when scoped to a schema) - * - membership:-> -> role: - * - membership:-> -> role: - */ -async function extractPrivilegeAndMembershipDepends( - pool: Pool, -): Promise { - const { rows } = await pool.query(sql` -with - -- OBJECT PRIVILEGES (relations) - extension_rel_oids as ( - select objid from pg_depend d - where d.refclassid = 'pg_extension'::regclass and d.classid = 'pg_class'::regclass - ), - rel_acls as ( - select - c.relkind, - c.relnamespace::regnamespace::text as schema_name, - c.relname as relname, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee - from pg_catalog.pg_class c - join lateral aclexplode(c.relacl) as x(grantor, grantee, privilege_type, is_grantable) on true - left join extension_rel_oids e on e.objid = c.oid - where c.relkind in ('r','p','v','m','S','f') - and not c.relnamespace::regnamespace::text like any(array['pg\\_%','information\\_schema']) - and e.objid is null - ), - rel_targets as ( - select - case - when relkind in ('r','p') then format('table:%I.%I', schema_name, relname) - when relkind = 'v' then format('view:%I.%I', schema_name, relname) - when relkind = 'm' then format('materializedView:%I.%I', schema_name, relname) - when relkind = 'S' then format('sequence:%I.%I', schema_name, relname) - when relkind = 'f' then format('foreignTable:%I.%I', schema_name, relname) - else null - end as target_stable_id, - schema_name as target_schema, - grantee - from rel_acls - ), - - -- OBJECT PRIVILEGES (schemas) - extension_ns_oids as ( - select objid from pg_depend d - where d.refclassid = 'pg_extension'::regclass and d.classid = 'pg_namespace'::regclass - ), - ns_acls as ( - select - format('schema:%I', n.nspname) as schema_stable_id, - n.nspname as schema_name, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee - from pg_catalog.pg_namespace n - join lateral aclexplode(n.nspacl) as x(grantor, grantee, privilege_type, is_grantable) on true - left join extension_ns_oids e on e.objid = n.oid - where not n.nspname like any(array['pg\\_%','information\\_schema']) - and e.objid is null - ), - - -- OBJECT PRIVILEGES (languages) - extension_lang_oids as ( - select objid from pg_depend d - where d.refclassid = 'pg_extension'::regclass and d.classid = 'pg_language'::regclass - ), - lang_acls as ( - select - format('language:%I', l.lanname) as language_stable_id, - NULL::text as language_schema, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee - from pg_catalog.pg_language l - join lateral aclexplode(l.lanacl) as x(grantor, grantee, privilege_type, is_grantable) on true - left join extension_lang_oids e on e.objid = l.oid - where l.lanname not in ('internal','c') - ), - - -- OBJECT PRIVILEGES (routines) - extension_proc_oids as ( - select objid from pg_depend d - where d.refclassid = 'pg_extension'::regclass and d.classid = 'pg_proc'::regclass - ), - proc_acls as ( - select - p.pronamespace::regnamespace::text as schema_name, - p.proname as procname, - p.prokind, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee, - (select coalesce(string_agg(format_type(oid, null), ',' order by ord), '') from unnest(p.proargtypes) with ordinality as t(oid, ord)) as arg_types, - trim(pg_catalog.pg_get_function_identity_arguments(p.oid)) as identity_arguments - from pg_catalog.pg_proc p - join lateral aclexplode(p.proacl) as x(grantor, grantee, privilege_type, is_grantable) on true - left join extension_proc_oids e on e.objid = p.oid - join pg_language l on l.oid = p.prolang - where not p.pronamespace::regnamespace::text like any(array['pg\\_%','information\\_schema']) - and e.objid is null - and l.lanname not in ('c','internal') - ), - proc_targets as ( - select - case - when prokind = 'a' then format('aggregate:%I.%I(%s)', schema_name, procname, identity_arguments) - else format('procedure:%I.%I(%s)', schema_name, procname, arg_types) - end as target_stable_id, - schema_name, - grantee - from proc_acls - ), - - -- OBJECT PRIVILEGES (types/domains) - extension_type_oids as ( - select objid from pg_depend d - where d.refclassid = 'pg_extension'::regclass and d.classid = 'pg_type'::regclass - ), - type_acls as ( - select - t.typtype, - t.typnamespace::regnamespace::text as schema_name, - t.typname as type_name, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee - from pg_catalog.pg_type t - join lateral aclexplode(t.typacl) as x(grantor, grantee, privilege_type, is_grantable) on true - left join extension_type_oids e on e.objid = t.oid - where not t.typnamespace::regnamespace::text like any(array['pg\\_%','information\\_schema']) - and e.objid is null - and t.typtype in ('d','e','r','c') - ), - type_targets as ( - select - (case - when typtype = 'd' then format('domain:%I.%I', schema_name, type_name) - when typtype = 'e' then format('type:%I.%I', schema_name, type_name) - when typtype = 'r' then format('type:%I.%I', schema_name, type_name) - when typtype = 'c' then format('type:%I.%I', schema_name, type_name) - else null - end) as target_stable_id, - schema_name, - grantee - from type_acls - ), - - -- COLUMN PRIVILEGES - rels as ( - select c.oid, - c.relkind, - c.relnamespace::regnamespace::text as schema_name, - c.relname as relname - from pg_catalog.pg_class c - left join pg_depend de on de.classid='pg_class'::regclass and de.objid=c.oid and de.refclassid='pg_extension'::regclass - where c.relkind in ('r','p','v','m','f') - and not c.relnamespace::regnamespace::text like any(array['pg\\_%','information\\_schema']) - and de.objid is null - ), - col_acls as ( - select - format('table:%I.%I', r.schema_name, r.relname) as table_stable_id, - r.schema_name as table_schema, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee - from rels r - join pg_attribute a on a.attrelid = r.oid and a.attnum > 0 and not a.attisdropped - join lateral aclexplode(a.attacl) as x(grantor, grantee, privilege_type, is_grantable) on true - ), - - -- DEFAULT PRIVILEGES - defacls as ( - select - format( - 'defacl:%s:%s:%s:grantee:%s', - d.defaclrole::regrole::text, - d.defaclobjtype::text, - coalesce(format('schema:%s', d.defaclnamespace::regnamespace::text), 'global'), - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end - ) as defacl_stable_id, - format('role:%s', d.defaclrole::regrole::text) as grantor_role_stable_id, - format('role:%s', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end) as grantee_role_stable_id, - case - when d.defaclnamespace = 0 then null - else format('schema:%s', d.defaclnamespace::regnamespace::text) - end as schema_stable_id, - case - when d.defaclnamespace = 0 then null - else d.defaclnamespace::regnamespace::text - end as schema_name - from pg_default_acl d - cross join lateral aclexplode(coalesce(d.defaclacl, ARRAY[]::aclitem[])) as x(grantor, grantee, privilege_type, is_grantable) - ), - - -- OBJECT PRIVILEGES (Foreign Data Wrappers) - fdw_acls as ( - select - format('foreignDataWrapper:%I', fdw.fdwname) as fdw_stable_id, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee - from pg_catalog.pg_foreign_data_wrapper fdw - join lateral aclexplode(fdw.fdwacl) as x(grantor, grantee, privilege_type, is_grantable) on true - where not fdw.fdwname like any(array['pg\\_%']) - ), - - -- OBJECT PRIVILEGES (Servers) - server_acls as ( - select - format('server:%I', srv.srvname) as server_stable_id, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee - from pg_catalog.pg_foreign_server srv - join pg_catalog.pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw - join lateral aclexplode(srv.srvacl) as x(grantor, grantee, privilege_type, is_grantable) on true - where not fdw.fdwname like any(array['pg\\_%']) - ), - - -- OBJECT PRIVILEGES (Foreign Tables) - foreign_table_acls as ( - select - c.relnamespace::regnamespace::text as schema_name, - c.relname as relname, - case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end as grantee - from pg_catalog.pg_class c - join pg_foreign_table ft on ft.ftrelid = c.oid - join pg_foreign_server srv on srv.oid = ft.ftserver - join pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw - join lateral aclexplode(c.relacl) as x(grantor, grantee, privilege_type, is_grantable) on true - left join extension_rel_oids e on e.objid = c.oid - where c.relkind = 'f' - and not c.relnamespace::regnamespace::text like any(array['pg\\_%','information\\_schema']) - and e.objid is null - and not fdw.fdwname like any(array['pg\\_%']) - ), - foreign_table_targets as ( - select - format('foreignTable:%I.%I', schema_name, relname) as target_stable_id, - schema_name as target_schema, - grantee - from foreign_table_acls - ), - - -- ROLE MEMBERSHIPS - memberships as ( - select quote_ident(r.rolname) as role_name, m.rolname as member_name - from pg_auth_members am - join pg_roles r on r.oid = am.roleid - join pg_roles m on m.oid = am.member - ) - -select distinct - dependent_stable_id, - referenced_stable_id, - deptype -from ( - select distinct - format('acl:%s::grantee:%s', target_stable_id, grantee) as dependent_stable_id, - target_stable_id as referenced_stable_id, - 'n'::char as deptype, - target_schema as dep_schema, - target_schema as ref_schema - from rel_targets - where target_stable_id is not null - - union all - select distinct - format('acl:%s::grantee:%s', target_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - target_schema as dep_schema, - NULL::text as ref_schema - from rel_targets - where target_stable_id is not null - - union all - select distinct - format('acl:%s::grantee:%s', schema_stable_id, grantee) as dependent_stable_id, - schema_stable_id as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - schema_name as ref_schema - from ns_acls - - union all - select distinct - format('acl:%s::grantee:%s', schema_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - NULL::text as ref_schema - from ns_acls - - union all - select distinct - format('acl:%s::grantee:%s', language_stable_id, grantee) as dependent_stable_id, - language_stable_id as referenced_stable_id, - 'n'::char as deptype, - NULL::text as dep_schema, - NULL::text as ref_schema - from lang_acls - - union all - select distinct - format('acl:%s::grantee:%s', language_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - NULL::text as dep_schema, - NULL::text as ref_schema - from lang_acls - - union all - select distinct - format('acl:%s::grantee:%s', target_stable_id, grantee) as dependent_stable_id, - target_stable_id as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - schema_name as ref_schema - from proc_targets - - union all - select distinct - format('acl:%s::grantee:%s', target_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - NULL::text as ref_schema - from proc_targets - - union all - select distinct - format('acl:%s::grantee:%s', target_stable_id, grantee) as dependent_stable_id, - target_stable_id as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - schema_name as ref_schema - from type_targets - where target_stable_id is not null - - union all - select distinct - format('acl:%s::grantee:%s', target_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - NULL::text as ref_schema - from type_targets - where target_stable_id is not null - - union all - select distinct - format('aclcol:%s::grantee:%s', table_stable_id, grantee) as dependent_stable_id, - table_stable_id as referenced_stable_id, - 'n'::char as deptype, - table_schema as dep_schema, - table_schema as ref_schema - from col_acls - - union all - select distinct - format('aclcol:%s::grantee:%s', table_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - table_schema as dep_schema, - NULL::text as ref_schema - from col_acls - - union all - select distinct - defacl_stable_id as dependent_stable_id, - grantor_role_stable_id as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - NULL::text as ref_schema - from defacls - - union all - select distinct - defacl_stable_id as dependent_stable_id, - grantee_role_stable_id as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - NULL::text as ref_schema - from defacls - - union all - select distinct - defacl_stable_id as dependent_stable_id, - schema_stable_id as referenced_stable_id, - 'n'::char as deptype, - schema_name as dep_schema, - schema_name as ref_schema - from defacls - where schema_stable_id is not null - - union all - select distinct - format('membership:%s->%s', role_name, member_name) as dependent_stable_id, - format('role:%s', role_name) as referenced_stable_id, - 'n'::char as deptype, - NULL::text as dep_schema, - NULL::text as ref_schema - from memberships - - union all - select distinct - format('membership:%s->%s', role_name, member_name) as dependent_stable_id, - format('role:%s', member_name) as referenced_stable_id, - 'n'::char as deptype, - NULL::text as dep_schema, - NULL::text as ref_schema - from memberships - - union all - select distinct - format('acl:%s::grantee:%s', fdw_stable_id, grantee) as dependent_stable_id, - fdw_stable_id as referenced_stable_id, - 'n'::char as deptype, - NULL::text as dep_schema, - NULL::text as ref_schema - from fdw_acls - - union all - select distinct - format('acl:%s::grantee:%s', fdw_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - NULL::text as dep_schema, - NULL::text as ref_schema - from fdw_acls - - union all - select distinct - format('acl:%s::grantee:%s', server_stable_id, grantee) as dependent_stable_id, - server_stable_id as referenced_stable_id, - 'n'::char as deptype, - NULL::text as dep_schema, - NULL::text as ref_schema - from server_acls - - union all - select distinct - format('acl:%s::grantee:%s', server_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - NULL::text as dep_schema, - NULL::text as ref_schema - from server_acls - - union all - select distinct - format('acl:%s::grantee:%s', target_stable_id, grantee) as dependent_stable_id, - target_stable_id as referenced_stable_id, - 'n'::char as deptype, - target_schema as dep_schema, - target_schema as ref_schema - from foreign_table_targets - where target_stable_id is not null - - union all - select distinct - format('acl:%s::grantee:%s', target_stable_id, grantee) as dependent_stable_id, - format('role:%s', grantee) as referenced_stable_id, - 'n'::char as deptype, - target_schema as dep_schema, - NULL::text as ref_schema - from foreign_table_targets - where target_stable_id is not null -) all_rows -where dependent_stable_id <> referenced_stable_id - and NOT ( - COALESCE(dep_schema, '') LIKE ANY (ARRAY['pg\\_%','information\\_schema']) - ) - `); - - return rows; -} - -/** - * Extract all dependencies from pg_depend, joining with pg_class for class names and applying user object filters. - * @param sql - The SQL client. - * @param params - Object containing arrays of OIDs for filtering (user_oids, user_namespace_oids, etc.) - * @returns Array of dependency objects with class names. - */ -export async function extractDepends(pool: Pool): Promise { - const { rows: dependsRows } = await pool.query(sql` - WITH ids AS ( - -- only the objects that actually show up in dependencies (both sides) - SELECT DISTINCT classid, objid, objsubid FROM pg_depend WHERE deptype IN ('n','a') - UNION - SELECT DISTINCT refclassid, refobjid, refobjsubid FROM pg_depend WHERE deptype IN ('n','a') - ), - objects AS ( - /* Schemas */ - SELECT 'pg_namespace'::regclass AS classid, n.oid AS objid, 0::int2 AS objsubid, - n.nspname AS schema_name, - format('schema:%I', n.nspname) AS stable_id - FROM pg_namespace n - JOIN ids i ON i.classid = 'pg_namespace'::regclass AND i.objid = n.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Tables / Views / MViews / Sequences / Indexes / Composite types (pg_class) */ - SELECT 'pg_class'::regclass, c.oid, 0::int2, - ns.nspname, - CASE - WHEN ns.nspname IN ('information_schema','pg_catalog','pg_toast') THEN - CASE c.relkind - WHEN 'r' THEN format('systemTable:%I.%I', ns.nspname, c.relname) - WHEN 'v' THEN format('systemView:%I.%I', ns.nspname, c.relname) - WHEN 'S' THEN format('systemSequence:%I.%I', ns.nspname, c.relname) - WHEN 'i' THEN format('systemIndex:%I.%I.%I', ns.nspname, tbl.relname, c.relname) - ELSE format('systemObject:%I.%I:%s', ns.nspname, c.relname, c.relkind::text) - END - ELSE - CASE c.relkind - WHEN 'r' THEN format('table:%I.%I', ns.nspname, c.relname) - WHEN 'p' THEN format('table:%I.%I', ns.nspname, c.relname) - WHEN 'v' THEN format('view:%I.%I', ns.nspname, c.relname) - WHEN 'm' THEN format('materializedView:%I.%I', ns.nspname, c.relname) - WHEN 'S' THEN format('sequence:%I.%I', ns.nspname, c.relname) - WHEN 'f' THEN format('foreignTable:%I.%I', ns.nspname, c.relname) - WHEN 'i' THEN format('index:%I.%I.%I', ns.nspname, tbl.relname, c.relname) - WHEN 'c' THEN format('type:%I.%I', ns.nspname, c.relname) - ELSE format('unknown:%s.%s', 'pg_class', c.oid::text) - END - END AS stable_id - FROM pg_class c - JOIN pg_namespace ns ON ns.oid = c.relnamespace - LEFT JOIN pg_index idx ON idx.indexrelid = c.oid - LEFT JOIN pg_class tbl ON tbl.oid = idx.indrelid - JOIN ids i ON i.classid = 'pg_class'::regclass AND i.objid = c.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Columns (so refobjsubid > 0 resolves to a column stable id) */ - SELECT 'pg_class'::regclass, a.attrelid, a.attnum, - ns.nspname, - format('column:%I.%I.%I', ns.nspname, c.relname, a.attname) - FROM pg_attribute a - JOIN pg_class c ON c.oid = a.attrelid - JOIN pg_namespace ns ON ns.oid = c.relnamespace - JOIN ids i ON i.classid = 'pg_class'::regclass AND i.objid = a.attrelid AND i.objsubid = a.attnum - WHERE a.attnum > 0 AND NOT a.attisdropped - - UNION ALL - /* Types (map row types back to their owning relation when applicable) */ - SELECT 'pg_type'::regclass, t.oid, 0::int2, - COALESCE(rns.nspname, ns.nspname) AS schema_name, -- prefer owning rel's schema if present - CASE t.typtype - WHEN 'd' THEN format('domain:%I.%I', ns.nspname, t.typname) - WHEN 'e' THEN format('type:%I.%I', ns.nspname, t.typname) - WHEN 'r' THEN format('type:%I.%I', ns.nspname, t.typname) - WHEN 'm' THEN format('multirange:%I.%I', ns.nspname, t.typname) - - WHEN 'c' THEN - CASE - /* Row type owned by a table / partitioned table / foreign table */ - WHEN r.oid IS NOT NULL AND r.relkind IN ('r','p','f') THEN - CASE - WHEN rns.nspname IN ('information_schema','pg_catalog','pg_toast') - THEN format('systemTable:%I.%I', rns.nspname, r.relname) - ELSE format('table:%I.%I', rns.nspname, r.relname) - END - - /* Row type owned by a view */ - WHEN r.oid IS NOT NULL AND r.relkind = 'v' THEN - CASE - WHEN rns.nspname IN ('information_schema','pg_catalog','pg_toast') - THEN format('systemView:%I.%I', rns.nspname, r.relname) - ELSE format('view:%I.%I', rns.nspname, r.relname) - END - - /* Row type owned by a materialized view */ - WHEN r.oid IS NOT NULL AND r.relkind = 'm' THEN - CASE - /* your pg_class system-branch uses systemObject for relkind m */ - WHEN rns.nspname IN ('information_schema','pg_catalog','pg_toast') - THEN format('systemObject:%I.%I:%s', rns.nspname, r.relname, 'm') - ELSE format('materializedView:%I.%I', rns.nspname, r.relname) - END - - /* Standalone composite type */ - ELSE format('type:%I.%I', ns.nspname, t.typname) - END - - WHEN 'p' THEN format('pseudoType:%I.%I', ns.nspname, t.typname) - ELSE format('type:%I.%I', ns.nspname, t.typname) - END AS stable_id - FROM pg_type t - JOIN pg_namespace ns ON ns.oid = t.typnamespace - LEFT JOIN pg_class r ON r.oid = t.typrelid - LEFT JOIN pg_namespace rns ON rns.oid = r.relnamespace - JOIN ids i ON i.classid = 'pg_type'::regclass AND i.objid = t.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Constraints on domain */ - SELECT 'pg_constraint'::regclass, c.oid, 0::int2, - ns.nspname, - format('constraint:%I.%I.%I', ns.nspname, ty.typname, c.conname) - FROM pg_constraint c - JOIN pg_type ty ON ty.oid = c.contypid - JOIN pg_namespace ns ON ns.oid = ty.typnamespace - JOIN ids i ON i.classid = 'pg_constraint'::regclass AND i.objid = c.oid AND COALESCE(i.objsubid,0) = 0 - WHERE c.contypid <> 0 - - UNION ALL - /* Constraints on table */ - SELECT 'pg_constraint'::regclass, c.oid, 0::int2, - ns.nspname, - format('constraint:%I.%I.%I', ns.nspname, tbl.relname, c.conname) - FROM pg_constraint c - JOIN pg_class tbl ON tbl.oid = c.conrelid - JOIN pg_namespace ns ON ns.oid = tbl.relnamespace - JOIN ids i ON i.classid = 'pg_constraint'::regclass AND i.objid = c.oid AND COALESCE(i.objsubid,0) = 0 - WHERE c.conrelid <> 0 - - UNION ALL - /* RLS policies */ - SELECT 'pg_policy'::regclass, p.oid, 0::int2, - ns.nspname, - format('rlsPolicy:%I.%I.%I', ns.nspname, tbl.relname, p.polname) - FROM pg_policy p - JOIN pg_class tbl ON tbl.oid = p.polrelid - JOIN pg_namespace ns ON ns.oid = tbl.relnamespace - JOIN ids i ON i.classid = 'pg_policy'::regclass AND i.objid = p.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Functions/Procedures/Aggregates: types-only signature */ - SELECT 'pg_proc'::regclass, p.oid, 0::int2, - ns.nspname, - CASE - WHEN p.prokind = 'a' THEN format( - 'aggregate:%I.%I(%s)', - ns.nspname, - p.proname, - trim(pg_catalog.pg_get_function_identity_arguments(p.oid)) - ) - ELSE format( - 'procedure:%I.%I(%s)', - ns.nspname, - p.proname, - COALESCE(( - SELECT string_agg(format_type(t.oid, NULL), ',' ORDER BY ord) - FROM unnest(p.proargtypes) WITH ORDINALITY AS t(oid, ord) - ), '') - ) - END - FROM pg_proc p - JOIN pg_namespace ns ON ns.oid = p.pronamespace - JOIN ids i ON i.classid = 'pg_proc'::regclass AND i.objid = p.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Triggers */ - SELECT 'pg_trigger'::regclass, tg.oid, 0::int2, - ns.nspname, - format('trigger:%I.%I.%I', ns.nspname, tbl.relname, tg.tgname) - FROM pg_trigger tg - JOIN pg_class tbl ON tbl.oid = tg.tgrelid - JOIN pg_namespace ns ON ns.oid = tbl.relnamespace - JOIN ids i ON i.classid = 'pg_trigger'::regclass AND i.objid = tg.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Rewrite rules */ - SELECT 'pg_rewrite'::regclass, r.oid, 0::int2, - ns.nspname, - format('rule:%I.%I.%I', ns.nspname, tbl.relname, r.rulename) - FROM pg_rewrite r - JOIN pg_class tbl ON tbl.oid = r.ev_class - JOIN pg_namespace ns ON ns.oid = tbl.relnamespace - JOIN ids i ON i.classid = 'pg_rewrite'::regclass AND i.objid = r.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Full-text search objects */ - SELECT 'pg_ts_config'::regclass, c.oid, 0::int2, ns.nspname, format('tsConfig:%I.%I', ns.nspname, c.cfgname) - FROM pg_ts_config c - JOIN pg_namespace ns ON ns.oid = c.cfgnamespace - JOIN ids i ON i.classid = 'pg_ts_config'::regclass AND i.objid = c.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - SELECT 'pg_ts_dict'::regclass, d.oid, 0::int2, ns.nspname, format('tsDict:%I.%I', ns.nspname, d.dictname) - FROM pg_ts_dict d - JOIN pg_namespace ns ON ns.oid = d.dictnamespace - JOIN ids i ON i.classid = 'pg_ts_dict'::regclass AND i.objid = d.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - SELECT 'pg_ts_template'::regclass, t.oid, 0::int2, ns.nspname, format('tsTemplate:%I.%I', ns.nspname, t.tmplname) - FROM pg_ts_template t - JOIN pg_namespace ns ON ns.oid = t.tmplnamespace - JOIN ids i ON i.classid = 'pg_ts_template'::regclass AND i.objid = t.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Column defaults (attrdef) → column stable id */ - SELECT 'pg_attrdef'::regclass, ad.oid, 0::int2, - ns.nspname, - format('column:%I.%I.%I', ns.nspname, tbl.relname, col.attname) - FROM pg_attrdef ad - JOIN pg_class tbl ON tbl.oid = ad.adrelid - JOIN pg_namespace ns ON ns.oid = tbl.relnamespace - JOIN pg_attribute col - ON col.attrelid = ad.adrelid AND col.attnum = ad.adnum AND col.attnum > 0 AND NOT col.attisdropped - JOIN ids i ON i.classid = 'pg_attrdef'::regclass AND i.objid = ad.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Default ACLs */ - SELECT 'pg_default_acl'::regclass, da.oid, 0::int2, - ns.nspname, - format('defaultAcl:%I.%s', ns.nspname, da.defaclobjtype::text) - FROM pg_default_acl da - JOIN pg_namespace ns ON ns.oid = da.defaclnamespace - JOIN ids i ON i.classid = 'pg_default_acl'::regclass AND i.objid = da.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Publications */ - SELECT 'pg_publication'::regclass, p.oid, 0::int2, - NULL::text, - format('publication:%I', p.pubname) - FROM pg_publication p - JOIN ids i ON i.classid = 'pg_publication'::regclass AND i.objid = p.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Publication–table membership rows (collapse to publication stable id) */ - SELECT 'pg_publication_rel'::regclass, pr.oid, 0::int2, - NULL::text AS schema_name, -- publication isn’t really “in” a schema - format('publication:%I', pub.pubname) AS stable_id - FROM pg_publication_rel pr - JOIN pg_publication pub ON pub.oid = pr.prpubid - JOIN ids i ON i.classid = 'pg_publication_rel'::regclass - AND i.objid = pr.oid - AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Language (no schema), Event trigger, Extension */ - SELECT 'pg_language'::regclass, l.oid, 0::int2, NULL::text, format('language:%I', l.lanname) - FROM pg_language l - JOIN ids i ON i.classid = 'pg_language'::regclass AND i.objid = l.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - SELECT 'pg_event_trigger'::regclass, et.oid, 0::int2, NULL::text, format('eventTrigger:%I', et.evtname) - FROM pg_event_trigger et - JOIN ids i ON i.classid = 'pg_event_trigger'::regclass AND i.objid = et.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - SELECT 'pg_extension'::regclass, e.oid, 0::int2, NULL::text, format('extension:%I', e.extname) - FROM pg_extension e - JOIN ids i ON i.classid = 'pg_extension'::regclass AND i.objid = e.oid AND COALESCE(i.objsubid,0) = 0 - - UNION ALL - /* Subscriptions (cluster-wide; scope to current database) */ - SELECT 'pg_subscription'::regclass, s.oid, 0::int2, - NULL::text, - format('subscription:%I', s.subname) - FROM pg_subscription s - JOIN ids i - ON i.classid = 'pg_subscription'::regclass - AND i.objid = s.oid - AND COALESCE(i.objsubid,0) = 0 - WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) - - UNION ALL - /* Foreign Data Wrappers */ - SELECT 'pg_foreign_data_wrapper'::regclass, fdw.oid, 0::int2, - NULL::text, - format('foreignDataWrapper:%I', fdw.fdwname) - FROM pg_foreign_data_wrapper fdw - JOIN ids i ON i.classid = 'pg_foreign_data_wrapper'::regclass AND i.objid = fdw.oid AND COALESCE(i.objsubid,0) = 0 - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - /* Foreign Servers */ - SELECT 'pg_foreign_server'::regclass, srv.oid, 0::int2, - NULL::text, - format('server:%I', srv.srvname) - FROM pg_foreign_server srv - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - JOIN ids i ON i.classid = 'pg_foreign_server'::regclass AND i.objid = srv.oid AND COALESCE(i.objsubid,0) = 0 - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - /* User Mappings */ - SELECT 'pg_user_mapping'::regclass, um.umid, 0::int2, - NULL::text, - format('userMapping:%I:%s', srv.srvname, CASE WHEN um.umuser = 0 THEN 'PUBLIC' ELSE um.umuser::regrole::text END) - FROM pg_user_mappings um - JOIN pg_foreign_server srv ON srv.oid = um.srvid - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - JOIN ids i ON i.classid = 'pg_user_mapping'::regclass AND i.objid = um.umid AND COALESCE(i.objsubid,0) = 0 - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - ), - base AS ( - SELECT DISTINCT - COALESCE(dep.stable_id, format('unknown:%s.%s', (d.classid::regclass)::text, d.objid::text)) AS dependent_stable_id, - COALESCE(ref.stable_id, format('unknown:%s.%s', (d.refclassid::regclass)::text, d.refobjid::text)) AS referenced_stable_id, - d.deptype, - dep.schema_name AS dep_schema, - ref.schema_name AS ref_schema - FROM pg_depend d - LEFT JOIN objects dep - ON dep.classid = d.classid AND dep.objid = d.objid AND dep.objsubid = COALESCE(NULLIF(d.objsubid,0),0) - LEFT JOIN objects ref - ON ref.classid = d.refclassid AND ref.objid = d.refobjid AND ref.objsubid = COALESCE(NULLIF(d.refobjsubid,0),0) - WHERE d.deptype IN ('n','a') - ), - comment_deps AS ( - -- Table comments - SELECT DISTINCT - format('comment:%s', format('table:%I.%I', n.nspname, c.relname)) AS dependent_stable_id, - format('table:%I.%I', n.nspname, c.relname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_class c ON d.classoid = 'pg_class'::regclass AND d.objoid = c.oid AND d.objsubid = 0 - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind IN ('r','p') - - UNION ALL - - -- Foreign table comments - SELECT DISTINCT - format('comment:%s', format('foreignTable:%I.%I', n.nspname, c.relname)) AS dependent_stable_id, - format('foreignTable:%I.%I', n.nspname, c.relname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_class c ON d.classoid = 'pg_class'::regclass AND d.objoid = c.oid AND d.objsubid = 0 - JOIN pg_namespace n ON c.relnamespace = n.oid - JOIN pg_foreign_table ft ON ft.ftrelid = c.oid - JOIN pg_foreign_server srv ON srv.oid = ft.ftserver - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - WHERE c.relkind = 'f' - AND NOT n.nspname LIKE ANY (ARRAY['pg\\_%','information\\_schema']) - AND NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - - -- Materialized view comments - SELECT DISTINCT - format( - 'comment:%s', - format('materializedView:%I.%I', n.nspname, c.relname) - ) AS dependent_stable_id, - format('materializedView:%I.%I', n.nspname, c.relname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_class c ON d.classoid = 'pg_class'::regclass AND d.objoid = c.oid AND d.objsubid = 0 - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind = 'm' - - UNION ALL - - -- Composite type comments - SELECT DISTINCT - format( - 'comment:%s', - format('type:%I.%I', n.nspname, t.relname) - ) AS dependent_stable_id, - format('type:%I.%I', n.nspname, t.relname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_type ty - ON d.classoid = 'pg_type'::regclass - AND d.objoid = ty.oid - AND d.objsubid = 0 - JOIN pg_class t - ON t.reltype = ty.oid - JOIN pg_namespace n - ON n.oid = t.relnamespace - WHERE t.relkind = 'c' - - UNION ALL - - -- Domain comments - SELECT DISTINCT - format( - 'comment:%s', - format('domain:%I.%I', t.typnamespace::regnamespace::text, t.typname) - ) AS dependent_stable_id, - format('domain:%I.%I', t.typnamespace::regnamespace::text, t.typname) AS referenced_stable_id, - 'a'::"char" AS deptype, - t.typnamespace::regnamespace::text AS dep_schema, - t.typnamespace::regnamespace::text AS ref_schema - FROM pg_description d - JOIN pg_type t ON d.classoid = 'pg_type'::regclass AND d.objoid = t.oid AND t.typtype = 'd' AND d.objsubid = 0 - - - UNION ALL - - -- Collation comments - SELECT DISTINCT - format( - 'comment:%s', - format('collation:%I.%I', n.nspname, c.collname) - ) AS dependent_stable_id, - format('collation:%I.%I', n.nspname, c.collname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_collation c ON d.classoid = 'pg_collation'::regclass AND d.objoid = c.oid AND d.objsubid = 0 - JOIN pg_namespace n ON c.collnamespace = n.oid - - - UNION ALL - - -- Enum type comments - SELECT DISTINCT - format( - 'comment:%s', - format('type:%I.%I', t.typnamespace::regnamespace::text, t.typname) - ) AS dependent_stable_id, - format('type:%I.%I', t.typnamespace::regnamespace::text, t.typname) AS referenced_stable_id, - 'a'::"char" AS deptype, - t.typnamespace::regnamespace::text AS dep_schema, - t.typnamespace::regnamespace::text AS ref_schema - FROM pg_description d - JOIN pg_type t ON d.classoid = 'pg_type'::regclass AND d.objoid = t.oid AND t.typtype = 'e' AND d.objsubid = 0 - - - UNION ALL - - -- Range type comments - SELECT DISTINCT - format( - 'comment:%s', - format('type:%I.%I', t.typnamespace::regnamespace::text, t.typname) - ) AS dependent_stable_id, - format('type:%I.%I', t.typnamespace::regnamespace::text, t.typname) AS referenced_stable_id, - 'a'::"char" AS deptype, - t.typnamespace::regnamespace::text AS dep_schema, - t.typnamespace::regnamespace::text AS ref_schema - FROM pg_description d - JOIN pg_type t ON d.classoid = 'pg_type'::regclass AND d.objoid = t.oid AND t.typtype = 'r' AND d.objsubid = 0 - - - UNION ALL - - -- Column comments (reference table as the owning object) - SELECT DISTINCT - format( - 'comment:%s', - format('column:%I.%I.%I', n.nspname, c.relname, a.attname) - ) AS dependent_stable_id, - format('column:%I.%I.%I', n.nspname, c.relname, a.attname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_class c ON d.classoid = 'pg_class'::regclass AND d.objoid = c.oid AND d.objsubid > 0 - JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid AND a.attnum > 0 AND NOT a.attisdropped - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind IN ('r','p') - - UNION ALL - - -- Index comments - SELECT DISTINCT - format( - 'comment:%s', - format('index:%I.%I.%I', n.nspname, tbl.relname, c.relname) - ) AS dependent_stable_id, - format('index:%I.%I.%I', n.nspname, tbl.relname, c.relname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_class c ON d.classoid = 'pg_class'::regclass AND d.objoid = c.oid AND d.objsubid = 0 - JOIN pg_namespace n ON c.relnamespace = n.oid - LEFT JOIN pg_index idx ON idx.indexrelid = c.oid - LEFT JOIN pg_class tbl ON tbl.oid = idx.indrelid - WHERE c.relkind = 'i' - - UNION ALL - - -- Materialized view column comments (reference materialized view as the owning object) - SELECT DISTINCT - format( - 'comment:%s', - format('column:%I.%I.%I', n.nspname, c.relname, a.attname) - ) AS dependent_stable_id, - format('column:%I.%I.%I', n.nspname, c.relname, a.attname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_class c ON d.classoid = 'pg_class'::regclass AND d.objoid = c.oid AND d.objsubid > 0 - JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid AND a.attnum > 0 AND NOT a.attisdropped - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind = 'm' - - UNION ALL - - -- Composite type attribute comments - SELECT DISTINCT - format( - 'comment:%s', - format('%s:%s', format('type:%I.%I', n.nspname, t.relname), a.attname) - ) AS dependent_stable_id, - format('%s:%s', format('type:%I.%I', n.nspname, t.relname), a.attname) AS referenced_stable_id, - 'a'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_description d - JOIN pg_class t ON d.classoid = 'pg_class'::regclass AND d.objoid = t.oid AND t.relkind = 'c' - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.objsubid AND a.attnum > 0 AND NOT a.attisdropped - JOIN pg_namespace n ON t.relnamespace = n.oid - - - UNION ALL - - -- Language comments - SELECT DISTINCT - format('comment:%s', format('language:%I', l.lanname)) AS dependent_stable_id, - format('language:%I', l.lanname) AS referenced_stable_id, - 'a'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_description d - JOIN pg_language l ON d.classoid = 'pg_language'::regclass AND d.objoid = l.oid AND d.objsubid = 0 - WHERE l.lanname NOT IN ('internal', 'c') - UNION ALL - - -- Event trigger comments - SELECT DISTINCT - format('comment:%s', format('eventTrigger:%I', et.evtname)) AS dependent_stable_id, - format('eventTrigger:%I', et.evtname) AS referenced_stable_id, - 'a'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_description d - JOIN pg_event_trigger et ON d.classoid = 'pg_event_trigger'::regclass AND d.objoid = et.oid AND d.objsubid = 0 - - UNION ALL - - -- Publication comments - SELECT DISTINCT - format('comment:%s', format('publication:%I', p.pubname)) AS dependent_stable_id, - format('publication:%I', p.pubname) AS referenced_stable_id, - 'a'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_description d - JOIN pg_publication p - ON d.classoid = 'pg_publication'::regclass - AND d.objoid = p.oid - AND d.objsubid = 0 - - UNION ALL - - -- Subscription comments - SELECT DISTINCT - format('comment:%s', format('subscription:%I', s.subname)) AS dependent_stable_id, - format('subscription:%I', s.subname) AS referenced_stable_id, - 'a'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_description d - JOIN pg_subscription s - ON d.classoid = 'pg_subscription'::regclass - AND d.objoid = s.oid - AND d.objsubid = 0 - WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) - - UNION ALL - - -- Extension comments - SELECT DISTINCT - format('comment:%s', format('extension:%I', e.extname)) AS dependent_stable_id, - format('extension:%I', e.extname) AS referenced_stable_id, - 'a'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_description d - JOIN pg_extension e ON d.classoid = 'pg_extension'::regclass AND d.objoid = e.oid AND d.objsubid = 0 - - UNION ALL - - -- Procedure/function/aggregate comments - SELECT DISTINCT - CASE - WHEN p.prokind = 'a' THEN format( - 'comment:%s', - format( - 'aggregate:%I.%I(%s)', - p.pronamespace::regnamespace::text, - p.proname, - trim(pg_catalog.pg_get_function_identity_arguments(p.oid)) - ) - ) - ELSE format( - 'comment:%s', - format( - 'procedure:%I.%I(%s)', - p.pronamespace::regnamespace::text, - p.proname, - coalesce( - (select string_agg(format_type(oid, null), ',' order by ord) from unnest(p.proargtypes) with ordinality as t(oid, ord)), - '' - ) - ) - ) - END AS dependent_stable_id, - CASE - WHEN p.prokind = 'a' THEN format( - 'aggregate:%I.%I(%s)', - p.pronamespace::regnamespace::text, - p.proname, - trim(pg_catalog.pg_get_function_identity_arguments(p.oid)) - ) - ELSE format( - 'procedure:%I.%I(%s)', - p.pronamespace::regnamespace::text, - p.proname, - coalesce( - (select string_agg(format_type(oid, null), ',' order by ord) from unnest(p.proargtypes) with ordinality as t(oid, ord)), - '' - ) - ) - END AS referenced_stable_id, - 'a'::"char" AS deptype, - p.pronamespace::regnamespace::text AS dep_schema, - p.pronamespace::regnamespace::text AS ref_schema - FROM pg_description d - JOIN pg_proc p ON d.classoid = 'pg_proc'::regclass AND d.objoid = p.oid AND d.objsubid = 0 - - - UNION ALL - - -- RLS policy comments - SELECT DISTINCT - format( - 'comment:%s', - format('rlsPolicy:%I.%I.%I', ns.nspname, tc.relname, pol.polname) - ) AS dependent_stable_id, - format('rlsPolicy:%I.%I.%I', ns.nspname, tc.relname, pol.polname) AS referenced_stable_id, - 'a'::"char" AS deptype, - ns.nspname AS dep_schema, - ns.nspname AS ref_schema - FROM pg_description d - JOIN pg_policy pol ON d.classoid = 'pg_policy'::regclass AND d.objoid = pol.oid AND d.objsubid = 0 - JOIN pg_class tc ON pol.polrelid = tc.oid - JOIN pg_namespace ns ON tc.relnamespace = ns.oid - - - UNION ALL - - -- Role comments - SELECT DISTINCT - format('comment:%s', format('role:%I', r.rolname)) AS dependent_stable_id, - format('role:%I', r.rolname) AS referenced_stable_id, - 'a'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_description d - JOIN pg_roles r ON d.classoid = 'pg_authid'::regclass AND d.objoid = r.oid AND d.objsubid = 0 - - UNION ALL - - -- Constraint comments - SELECT DISTINCT - format( - 'comment:%s', - format('constraint:%I.%I.%I', ns.nspname, tbl.relname, con.conname) - ) AS dependent_stable_id, - format('constraint:%I.%I.%I', ns.nspname, tbl.relname, con.conname) AS referenced_stable_id, - 'a'::"char" AS deptype, - ns.nspname AS dep_schema, - ns.nspname AS ref_schema - FROM pg_description d - JOIN pg_constraint con ON d.classoid = 'pg_constraint'::regclass AND d.objoid = con.oid - JOIN pg_class tbl ON con.conrelid = tbl.oid - JOIN pg_namespace ns ON tbl.relnamespace = ns.oid - WHERE con.conrelid <> 0 - - UNION ALL - - -- Foreign Data Wrapper comments - SELECT DISTINCT - format('comment:%s', format('foreignDataWrapper:%I', fdw.fdwname)) AS dependent_stable_id, - format('foreignDataWrapper:%I', fdw.fdwname) AS referenced_stable_id, - 'a'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_description d - JOIN pg_foreign_data_wrapper fdw - ON d.classoid = 'pg_foreign_data_wrapper'::regclass - AND d.objoid = fdw.oid - AND d.objsubid = 0 - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - - -- Server comments - SELECT DISTINCT - format('comment:%s', format('server:%I', srv.srvname)) AS dependent_stable_id, - format('server:%I', srv.srvname) AS referenced_stable_id, - 'a'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_description d - JOIN pg_foreign_server srv - ON d.classoid = 'pg_foreign_server'::regclass - AND d.objoid = srv.oid - AND d.objsubid = 0 - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - ), - type_usage_deps AS ( - -- Composite type attribute dependencies on user-defined types (domain/enum/range/multirange/composite) - SELECT DISTINCT - format('type:%I.%I', ns.nspname, comp.relname) AS dependent_stable_id, - CASE ref_t.typtype - WHEN 'd' THEN format('domain:%I.%I', refns.nspname, ref_t.typname) - WHEN 'e' THEN format('type:%I.%I', refns.nspname, ref_t.typname) - WHEN 'r' THEN format('type:%I.%I', refns.nspname, ref_t.typname) - WHEN 'm' THEN format('multirange:%I.%I', refns.nspname, ref_t.typname) - WHEN 'c' THEN format('type:%I.%I', refns.nspname, ref_comp.relname) - ELSE NULL - END AS referenced_stable_id, - 'n'::"char" AS deptype, - ns.nspname AS dep_schema, - refns.nspname AS ref_schema - FROM pg_class comp - JOIN pg_namespace ns ON ns.oid = comp.relnamespace - JOIN pg_attribute a ON a.attrelid = comp.oid AND a.attnum > 0 AND NOT a.attisdropped - JOIN pg_type ref_t ON ref_t.oid = a.atttypid - JOIN pg_namespace refns ON refns.oid = ref_t.typnamespace - LEFT JOIN pg_class ref_comp ON ref_comp.oid = ref_t.typrelid - WHERE comp.relkind = 'c' - AND NOT refns.nspname LIKE ANY (ARRAY['pg\\_%','information\\_schema']) - AND ( - ref_t.typtype IN ('d','e','r','m') - OR (ref_t.typtype = 'c' AND ref_comp.relkind = 'c') - ) - AND CASE ref_t.typtype - WHEN 'c' THEN ref_comp.relname IS NOT NULL - ELSE true - END - ), - view_rewrite_rel_deps AS ( - SELECT DISTINCT - COALESCE( - dep_view.stable_id, - CASE v.relkind - WHEN 'v' THEN format('view:%I.%I', v_ns.nspname, v.relname) - WHEN 'm' THEN format('materializedView:%I.%I', v_ns.nspname, v.relname) - ELSE format('unknown:%s.%s', 'pg_class', v.oid::text) - END - ) AS dependent_stable_id, - COALESCE( - ref_obj.stable_id, - CASE - WHEN ref_attr.attnum IS NOT NULL THEN format('column:%I.%I.%I', ref_ns.nspname, ref_rel.relname, ref_attr.attname) - WHEN ref_rel.relkind IN ('r','p','f') THEN format('table:%I.%I', ref_ns.nspname, ref_rel.relname) - WHEN ref_rel.relkind = 'v' THEN format('view:%I.%I', ref_ns.nspname, ref_rel.relname) - WHEN ref_rel.relkind = 'm' THEN format('materializedView:%I.%I', ref_ns.nspname, ref_rel.relname) - ELSE format('unknown:%s.%s', 'pg_class', COALESCE(ref_rel.oid::text, d.refobjid::text)) - END - ) AS referenced_stable_id, - d.deptype, - COALESCE(dep_view.schema_name, v_ns.nspname) AS dep_schema, - COALESCE(ref_obj.schema_name, ref_ns.nspname) AS ref_schema - FROM pg_depend d - JOIN pg_rewrite r ON r.oid = d.objid - JOIN pg_class v ON r.ev_class = v.oid - JOIN pg_namespace v_ns ON v.relnamespace = v_ns.oid - LEFT JOIN objects dep_view - ON dep_view.classid = 'pg_class'::regclass - AND dep_view.objid = v.oid - AND dep_view.objsubid = 0 - LEFT JOIN pg_class ref_rel ON ref_rel.oid = d.refobjid - LEFT JOIN pg_namespace ref_ns ON ref_rel.relnamespace = ref_ns.oid - LEFT JOIN pg_attribute ref_attr - ON ref_attr.attrelid = ref_rel.oid - AND ref_attr.attnum = d.refobjsubid - AND d.refobjsubid <> 0 - LEFT JOIN objects ref_obj - ON ref_obj.classid = d.refclassid - AND ref_obj.objid = d.refobjid - AND ref_obj.objsubid = COALESCE(NULLIF(d.refobjsubid,0),0) - WHERE d.classid = 'pg_rewrite'::regclass - AND d.refclassid = 'pg_class'::regclass - AND v.relkind IN ('v','m') - AND d.deptype = 'n' - AND (d.refobjsubid = 0 OR (ref_attr.attnum > 0 AND NOT ref_attr.attisdropped)) - AND ref_rel.oid IS NOT NULL - AND ( - ref_attr.attnum IS NOT NULL - OR ref_rel.relkind IN ('r','p','f','v','m') - ) - ), - view_rewrite_proc_deps AS ( - SELECT DISTINCT - COALESCE( - dep_view.stable_id, - CASE v.relkind - WHEN 'v' THEN format('view:%I.%I', v_ns.nspname, v.relname) - WHEN 'm' THEN format('materializedView:%I.%I', v_ns.nspname, v.relname) - ELSE format('unknown:%s.%s', 'pg_class', v.oid::text) - END - ) AS dependent_stable_id, - COALESCE( - ref_proc_obj.stable_id, - CASE - WHEN ref_proc.prokind = 'a' THEN format( - 'aggregate:%I.%I(%s)', - ref_proc_ns.nspname, - ref_proc.proname, - trim(pg_catalog.pg_get_function_identity_arguments(ref_proc.oid)) - ) - ELSE format( - 'procedure:%I.%I(%s)', - ref_proc_ns.nspname, - ref_proc.proname, - COALESCE( - ( - SELECT string_agg(format_type(oid, NULL), ',' ORDER BY ord) - FROM unnest(ref_proc.proargtypes) WITH ORDINALITY AS t(oid, ord) - ), - '' - ) - ) - END - ) AS referenced_stable_id, - d.deptype, - COALESCE(dep_view.schema_name, v_ns.nspname) AS dep_schema, - COALESCE(ref_proc_obj.schema_name, ref_proc_ns.nspname) AS ref_schema - FROM pg_depend d - JOIN pg_rewrite r ON r.oid = d.objid - JOIN pg_class v ON r.ev_class = v.oid - JOIN pg_namespace v_ns ON v.relnamespace = v_ns.oid - LEFT JOIN objects dep_view - ON dep_view.classid = 'pg_class'::regclass - AND dep_view.objid = v.oid - AND dep_view.objsubid = 0 - JOIN pg_proc ref_proc ON ref_proc.oid = d.refobjid - JOIN pg_namespace ref_proc_ns ON ref_proc_ns.oid = ref_proc.pronamespace - LEFT JOIN objects ref_proc_obj - ON ref_proc_obj.classid = 'pg_proc'::regclass - AND ref_proc_obj.objid = ref_proc.oid - AND ref_proc_obj.objsubid = 0 - WHERE d.classid = 'pg_rewrite'::regclass - AND d.refclassid = 'pg_proc'::regclass - AND v.relkind IN ('v','m') - AND d.deptype = 'n' - ), - constraint_deps AS ( - SELECT DISTINCT - format('constraint:%I.%I.%I', fk_ns.nspname, fk_table.relname, fk_con.conname) AS dependent_stable_id, - format('constraint:%I.%I.%I', ref_ns.nspname, ref_table.relname, ref_con.conname) AS referenced_stable_id, - 'n'::"char" AS deptype, - fk_ns.nspname AS dep_schema, - ref_ns.nspname AS ref_schema - FROM pg_constraint fk_con - JOIN pg_class fk_table ON fk_con.conrelid = fk_table.oid - JOIN pg_namespace fk_ns ON fk_table.relnamespace = fk_ns.oid - JOIN pg_class ref_table ON fk_con.confrelid = ref_table.oid - JOIN pg_namespace ref_ns ON ref_table.relnamespace = ref_ns.oid - JOIN pg_constraint ref_con ON ( - ref_con.conrelid = fk_con.confrelid - AND ref_con.contype IN ('p', 'u') - AND ref_con.conkey = fk_con.confkey - ) - WHERE fk_con.contype = 'f' - ), - index_schema_deps AS ( - -- Indexes depend on their schema (ensure schema exists before indexes) - SELECT DISTINCT - format('index:%I.%I.%I', ns.nspname, tbl.relname, idx_rel.relname) AS dependent_stable_id, - format('schema:%I', ns.nspname) AS referenced_stable_id, - 'n'::"char" AS deptype, - ns.nspname AS dep_schema, - ns.nspname AS ref_schema - FROM pg_class idx_rel - JOIN pg_index idx ON idx.indexrelid = idx_rel.oid - JOIN pg_class tbl ON tbl.oid = idx.indrelid - JOIN pg_namespace ns ON ns.oid = idx_rel.relnamespace - WHERE idx_rel.relkind = 'i' - ), - index_table_deps AS ( - -- Indexes depend on their owning table - SELECT DISTINCT - format('index:%I.%I.%I', ns.nspname, tbl.relname, idx_rel.relname) AS dependent_stable_id, - format('table:%I.%I', ns.nspname, tbl.relname) AS referenced_stable_id, - 'n'::"char" AS deptype, - ns.nspname AS dep_schema, - ns.nspname AS ref_schema - FROM pg_class idx_rel - JOIN pg_index idx ON idx.indexrelid = idx_rel.oid - JOIN pg_class tbl ON tbl.oid = idx.indrelid - JOIN pg_namespace ns ON ns.oid = idx_rel.relnamespace - WHERE idx_rel.relkind = 'i' - ), - index_extension_deps AS ( - -- Indexes depend on extensions that provide their operator classes - -- (e.g. gin_trgm_ops from pg_trgm). Without this, CREATE INDEX can be - -- ordered before CREATE EXTENSION when schemas sort alphabetically. - SELECT DISTINCT - format('index:%I.%I.%I', ns.nspname, tbl.relname, idx_rel.relname) AS dependent_stable_id, - format('extension:%I', ext.extname) AS referenced_stable_id, - 'n'::"char" AS deptype, - ns.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_class idx_rel - JOIN pg_index idx ON idx.indexrelid = idx_rel.oid - JOIN pg_class tbl ON tbl.oid = idx.indrelid - JOIN pg_namespace ns ON ns.oid = idx_rel.relnamespace - JOIN LATERAL unnest(idx.indclass) WITH ORDINALITY AS oc(oid, ord) ON true - JOIN pg_opclass opcl ON opcl.oid = oc.oid - JOIN pg_depend d ON d.classid = 'pg_opclass'::regclass - AND d.objid = opcl.oid - AND d.refclassid = 'pg_extension'::regclass - AND d.deptype = 'e' - JOIN pg_extension ext ON ext.oid = d.refobjid - WHERE idx_rel.relkind IN ('i', 'I') - ), - ownership_deps AS ( - -- Schema ownership dependencies - SELECT DISTINCT - format('schema:%I', n.nspname) AS dependent_stable_id, - format('role:%s', n.nspowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_namespace n - - UNION ALL - - -- Table ownership dependencies - SELECT DISTINCT - format('table:%I.%I', n.nspname, c.relname) AS dependent_stable_id, - format('role:%s', c.relowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind IN ('r','p') - - UNION ALL - - -- View ownership dependencies - SELECT DISTINCT - format('view:%I.%I', n.nspname, c.relname) AS dependent_stable_id, - format('role:%s', c.relowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind = 'v' - - UNION ALL - - -- Materialized view ownership dependencies - SELECT DISTINCT - format('materializedView:%I.%I', n.nspname, c.relname) AS dependent_stable_id, - format('role:%s', c.relowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind = 'm' - - UNION ALL - - -- Sequence ownership dependencies - SELECT DISTINCT - format('sequence:%I.%I', n.nspname, c.relname) AS dependent_stable_id, - format('role:%s', c.relowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind = 'S' - - UNION ALL - - -- Composite type ownership dependencies - SELECT DISTINCT - format('type:%I.%I', n.nspname, c.relname) AS dependent_stable_id, - format('role:%s', c.relowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind = 'c' - - UNION ALL - - -- Function/procedure/aggregate ownership dependencies - SELECT DISTINCT - CASE - WHEN p.prokind = 'a' THEN format( - 'aggregate:%I.%I(%s)', - n.nspname, - p.proname, - trim(pg_catalog.pg_get_function_identity_arguments(p.oid)) - ) - ELSE format( - 'procedure:%I.%I(%s)', - n.nspname, - p.proname, - COALESCE( - ( - SELECT string_agg(format_type(oid, NULL), ',' ORDER BY ord) - FROM unnest(p.proargtypes) WITH ORDINALITY AS t(oid, ord) - ), - '' - ) - ) - END AS dependent_stable_id, - format('role:%s', p.proowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - - UNION ALL - - -- Domain ownership dependencies - SELECT DISTINCT - format('domain:%I.%I', n.nspname, t.typname) AS dependent_stable_id, - format('role:%s', t.typowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_type t - JOIN pg_namespace n ON t.typnamespace = n.oid - WHERE t.typtype = 'd' - - UNION ALL - - -- Enum ownership dependencies - SELECT DISTINCT - format('type:%I.%I', n.nspname, t.typname) AS dependent_stable_id, - format('role:%s', t.typowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_type t - JOIN pg_namespace n ON t.typnamespace = n.oid - WHERE t.typtype = 'e' - - UNION ALL - - -- Range type ownership dependencies - SELECT DISTINCT - format('type:%I.%I', n.nspname, t.typname) AS dependent_stable_id, - format('role:%s', t.typowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_type t - JOIN pg_namespace n ON t.typnamespace = n.oid - WHERE t.typtype = 'r' - - UNION ALL - - -- Multirange type ownership dependencies - SELECT DISTINCT - format('multirange:%I.%I', n.nspname, t.typname) AS dependent_stable_id, - format('role:%s', t.typowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_type t - JOIN pg_namespace n ON t.typnamespace = n.oid - WHERE t.typtype = 'm' - - UNION ALL - - -- Base type ownership dependencies - SELECT DISTINCT - format('type:%I.%I', n.nspname, t.typname) AS dependent_stable_id, - format('role:%s', t.typowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_type t - JOIN pg_namespace n ON t.typnamespace = n.oid - WHERE t.typtype = 'b' - - UNION ALL - - -- Trigger ownership dependencies (triggers inherit owner from their table) - SELECT DISTINCT - format('trigger:%I.%I.%I', tn.nspname, tc.relname, tg.tgname) AS dependent_stable_id, - format('role:%s', tc.relowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - tn.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_trigger tg - JOIN pg_class tc ON tg.tgrelid = tc.oid - JOIN pg_namespace tn ON tc.relnamespace = tn.oid - WHERE NOT tg.tgisinternal - - UNION ALL - - -- RLS Policy ownership dependencies (policies inherit owner from their table) - SELECT DISTINCT - format('rlsPolicy:%I.%I.%I', tn.nspname, tc.relname, pol.polname) AS dependent_stable_id, - format('role:%s', tc.relowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - tn.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_policy pol - JOIN pg_class tc ON pol.polrelid = tc.oid - JOIN pg_namespace tn ON tc.relnamespace = tn.oid - - - UNION ALL - - -- Language ownership dependencies - SELECT DISTINCT - format('language:%I', l.lanname) AS dependent_stable_id, - format('role:%s', l.lanowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_language l - WHERE l.lanname NOT IN ('internal', 'c', 'sql') - - UNION ALL - - -- Event trigger ownership dependencies - SELECT DISTINCT - format('eventTrigger:%I', et.evtname) AS dependent_stable_id, - format('role:%s', et.evtowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_event_trigger et - - UNION ALL - - -- Extension ownership dependencies - SELECT DISTINCT - format('extension:%I', e.extname) AS dependent_stable_id, - format('role:%s', e.extowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_extension e - WHERE e.extname <> 'plpgsql' - - UNION ALL - - -- Subscription ownership dependencies - SELECT DISTINCT - format('subscription:%I', s.subname) AS dependent_stable_id, - format('role:%s', s.subowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_subscription s - WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) - - UNION ALL - - -- Publication ownership dependencies - SELECT DISTINCT - format('publication:%I', p.pubname) AS dependent_stable_id, - format('role:%s', p.pubowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_publication p - - UNION ALL - - -- Collation ownership dependencies - SELECT DISTINCT - format('collation:%I.%I', n.nspname, c.collname) AS dependent_stable_id, - format('role:%s', c.collowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_collation c - JOIN pg_namespace n ON c.collnamespace = n.oid - - UNION ALL - - -- Foreign Data Wrapper ownership dependencies - SELECT DISTINCT - format('foreignDataWrapper:%I', fdw.fdwname) AS dependent_stable_id, - format('role:%s', fdw.fdwowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_foreign_data_wrapper fdw - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - - -- Server ownership dependencies - SELECT DISTINCT - format('server:%I', srv.srvname) AS dependent_stable_id, - format('role:%s', srv.srvowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_foreign_server srv - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - - -- Foreign Table ownership dependencies - SELECT DISTINCT - format('foreignTable:%I.%I', n.nspname, c.relname) AS dependent_stable_id, - format('role:%s', c.relowner::regrole::text) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - JOIN pg_foreign_table ft ON ft.ftrelid = c.oid - JOIN pg_foreign_server srv ON srv.oid = ft.ftserver - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - WHERE c.relkind = 'f' - AND NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - ), - publication_deps AS ( - SELECT DISTINCT - format('publication:%I', pub.pubname) AS dependent_stable_id, - format('table:%I.%I', ns.nspname, cls.relname) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - ns.nspname AS ref_schema - FROM pg_publication pub - JOIN pg_publication_rel pr ON pr.prpubid = pub.oid - JOIN pg_class cls ON cls.oid = pr.prrelid - JOIN pg_namespace ns ON ns.oid = cls.relnamespace - WHERE NOT ns.nspname LIKE ANY (ARRAY['pg\\_%','information\\_schema']) - ), - publication_schema_deps AS ( - SELECT DISTINCT - format('publication:%I', pub.pubname) AS dependent_stable_id, - format('schema:%I', ns.nspname) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - ns.nspname AS ref_schema - FROM pg_publication pub - JOIN pg_publication_namespace pn ON pn.pnpubid = pub.oid - JOIN pg_namespace ns ON ns.oid = pn.pnnspid - WHERE NOT ns.nspname LIKE ANY (ARRAY['pg\\_%','information\\_schema']) - ), - fdw_deps AS ( - -- Servers depend on their Foreign Data Wrapper - SELECT DISTINCT - format('server:%I', srv.srvname) AS dependent_stable_id, - format('foreignDataWrapper:%I', fdw.fdwname) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_foreign_server srv - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - - -- User Mappings depend on their Server - SELECT DISTINCT - format('userMapping:%I:%s', srv.srvname, CASE WHEN um.umuser = 0 THEN 'PUBLIC' ELSE um.umuser::regrole::text END) AS dependent_stable_id, - format('server:%I', srv.srvname) AS referenced_stable_id, - 'n'::"char" AS deptype, - NULL::text AS dep_schema, - NULL::text AS ref_schema - FROM pg_user_mappings um - JOIN pg_foreign_server srv ON srv.oid = um.srvid - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - WHERE NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - - -- Foreign Tables depend on their Server - SELECT DISTINCT - format('foreignTable:%I.%I', n.nspname, c.relname) AS dependent_stable_id, - format('server:%I', srv.srvname) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - NULL::text AS ref_schema - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - JOIN pg_foreign_table ft ON ft.ftrelid = c.oid - JOIN pg_foreign_server srv ON srv.oid = ft.ftserver - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - WHERE c.relkind = 'f' - AND NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - - UNION ALL - - -- Foreign Tables depend on their Schema - SELECT DISTINCT - format('foreignTable:%I.%I', n.nspname, c.relname) AS dependent_stable_id, - format('schema:%I', n.nspname) AS referenced_stable_id, - 'n'::"char" AS deptype, - n.nspname AS dep_schema, - n.nspname AS ref_schema - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - JOIN pg_foreign_table ft ON ft.ftrelid = c.oid - JOIN pg_foreign_server srv ON srv.oid = ft.ftserver - JOIN pg_foreign_data_wrapper fdw ON fdw.oid = srv.srvfdw - WHERE c.relkind = 'f' - AND NOT fdw.fdwname LIKE ANY (ARRAY['pg\\_%']) - ), - all_rows AS ( - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM base - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM comment_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM type_usage_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM view_rewrite_rel_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM view_rewrite_proc_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM constraint_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM index_schema_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM index_table_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM index_extension_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM ownership_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM publication_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM publication_schema_deps - UNION ALL - SELECT dependent_stable_id, referenced_stable_id, deptype, dep_schema, ref_schema FROM fdw_deps - ) - SELECT DISTINCT - dependent_stable_id, - referenced_stable_id, - deptype - FROM all_rows - -- In some corner case (composite type) we can have the same stable ids in the case where an internal object depends on it's parent type - -- eg: compositeType contains internal columns but we don't distinct them from the parent type itself in our stable ids - WHERE dependent_stable_id <> referenced_stable_id - -- filter rows where dependent object is part of Postgres internals - AND NOT ( - COALESCE(dep_schema, '') LIKE ANY (ARRAY['pg\\_%','information\\_schema']) - ) - ORDER BY dependent_stable_id, referenced_stable_id; - `); - - // Extract privilege and membership dependencies - const privilegeDepends = await extractPrivilegeAndMembershipDepends(pool); - - // Combine all dependency sources and remove duplicates - const allDepends = new Set([...dependsRows, ...privilegeDepends]); - - return Array.from(allDepends).sort( - (a, b) => - a.dependent_stable_id.localeCompare(b.dependent_stable_id) || - a.referenced_stable_id.localeCompare(b.referenced_stable_id), - ); -} diff --git a/packages/pg-delta/src/core/diagnostic.ts b/packages/pg-delta/src/core/diagnostic.ts new file mode 100644 index 000000000..384f7fe34 --- /dev/null +++ b/packages/pg-delta/src/core/diagnostic.ts @@ -0,0 +1,44 @@ +/** + * The one shared diagnostic shape used by every layer (stage-1 deliverable 7): + * extraction's unresolved references, loader rejections, planner failures, + * apply reports. One shape → one CLI renderer. + */ +import type { StableId } from "./stable-id.ts"; + +export interface Diagnostic { + code: string; + severity: "error" | "warning" | "info"; + subject?: StableId; + message: string; + context?: Record; +} + +/** Diagnostic code for an extension-intent object that cannot be given a stable + * key (e.g. an unnamed pg_cron job). A handler emits it during capture; on the + * SOURCE side it is a warning (the object is simply unmanaged), but on the + * DESIRED side `plan()` treats it as fatal — declared intent the engine cannot + * key can never converge. Shared here so the emitter (a handler) and the gate + * (`plan()`) agree on the string without a cross-layer import. */ +export const INTENT_UNKEYED = "intent-unkeyed"; + +/** Diagnostic code for a `pg_user_mapping` row whose options a non-superuser + * extraction could not read (the `pg_user_mappings` fallback view NULLs + * `umoptions` for a row the caller isn't authorized on — see + * `src/extract/foreign.ts`). The mapping fact is SKIPPED rather than + * recorded with fabricated empty options, so its true state is UNKNOWN on + * that side. A warning at extraction time is not enough on its own: if the + * OTHER side of a diff can see the mapping, the missing fact reads as an + * intentional add/remove and `plan()` would emit a wrong CREATE/DROP USER + * MAPPING. `plan()` escalates to fatal exactly when a delta actually touches + * one of these subjects (mirrors `INTENT_UNKEYED` above). Shared here so the + * emitter (`extractForeign`) and the gate (`plan()`) agree on the string + * without a cross-layer import. */ +export const USER_MAPPING_UNREADABLE = "user-mapping-unreadable"; + +/** Thrown by public API stubs for not-yet-implemented stages (stage 0). */ +export class NotImplementedError extends Error { + constructor(feature: string) { + super(`Not implemented: ${feature}`); + this.name = "NotImplementedError"; + } +} diff --git a/packages/pg-delta/src/core/diff.guard.test.ts b/packages/pg-delta/src/core/diff.guard.test.ts new file mode 100644 index 000000000..32e0d89ca --- /dev/null +++ b/packages/pg-delta/src/core/diff.guard.test.ts @@ -0,0 +1,56 @@ +/** + * Guard (stage-4 gate): the differ must contain ZERO per-kind knowledge — + * all per-kind significance is the rule table's job (§3.5). Crude but + * effective: grep the differ source for any object-kind name. If the differ + * ever branches on a kind, this fails. + */ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +const FACT_KINDS = [ + "schema", + "role", + "extension", + "table", + "view", + "materializedView", + "foreignTable", + "sequence", + "index", + "collation", + "domain", + "type", + "column", + "constraint", + "trigger", + "rule", + "policy", + "default", + "membership", + "userMapping", + "typeAttribute", + "publicationRel", + "publicationSchema", + "securityLabel", + "defaultPrivilege", + "function", + "procedure", + "aggregate", + "publication", + "subscription", + "fdw", + "server", + "eventTrigger", +]; + +describe("differ is kind-free (guardrail: granularity is one)", () => { + test("diff.ts source references no object-kind name", () => { + const src = readFileSync(new URL("./diff.ts", import.meta.url), "utf8"); + // strip comments so prose ("what a table is") doesn't trip the grep + const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); + const leaked = FACT_KINDS.filter((kind) => + new RegExp(`["'\\.]${kind}\\b`).test(code), + ); + expect(leaked).toEqual([]); + }); +}); diff --git a/packages/pg-delta/src/core/diff.test.ts b/packages/pg-delta/src/core/diff.test.ts new file mode 100644 index 000000000..73ed4015a --- /dev/null +++ b/packages/pg-delta/src/core/diff.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { diff } from "./diff.ts"; +import { buildFactBase, type DependencyEdge, type Fact } from "./fact.ts"; +import type { StableId } from "./stable-id.ts"; + +const schema: StableId = { kind: "schema", name: "public" }; +const table: StableId = { kind: "table", schema: "public", name: "users" }; +const colA: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "a", +}; +const colB: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "b", +}; +const role: StableId = { kind: "role", name: "r" }; + +function facts(overrides?: { colAType?: string; withColB?: boolean }): Fact[] { + const out: Fact[] = [ + { id: schema, payload: {} }, + { id: role, payload: { login: true } }, + { id: table, parent: schema, payload: { persistence: "p" } }, + { + id: colA, + parent: table, + payload: { type: overrides?.colAType ?? "integer", notNull: false }, + }, + ]; + if (overrides?.withColB !== false) { + out.push({ + id: colB, + parent: table, + payload: { type: "text", notNull: true }, + }); + } + return out; +} + +describe("diff", () => { + test("diff(A, A) is empty", () => { + expect( + diff(buildFactBase(facts(), []), buildFactBase(facts(), [])), + ).toEqual([]); + }); + + test("changed attribute yields a set delta with from/to", () => { + const a = buildFactBase(facts(), []); + const b = buildFactBase(facts({ colAType: "bigint" }), []); + const deltas = diff(a, b); + expect(deltas).toEqual([ + { verb: "set", id: colA, attr: "type", from: "integer", to: "bigint" }, + ]); + }); + + test("a payload key starting with `_` is non-semantic metadata — no delta", () => { + // version-dependent / extraction-only metadata (e.g. an ACL's owner default + // privilege set) lives under a `_` key so it never produces a spurious delta + // (which would otherwise throw `no rule for attribute …`) across snapshots + // or PG versions. + const a = buildFactBase( + [ + { + id: table, + parent: schema, + payload: { persistence: "p", _meta: [1] }, + }, + { id: schema, payload: {} }, + ], + [], + ); + const b = buildFactBase( + [ + { + id: table, + parent: schema, + payload: { persistence: "p", _meta: [2, 3] }, + }, + { id: schema, payload: {} }, + ], + [], + ); + expect(diff(a, b)).toEqual([]); + }); + + test("removed fact yields remove with the full fact", () => { + const a = buildFactBase(facts(), []); + const b = buildFactBase(facts({ withColB: false }), []); + const deltas = diff(a, b); + expect(deltas).toEqual([ + { + verb: "remove", + fact: { + id: colB, + parent: table, + payload: { type: "text", notNull: true }, + }, + }, + ]); + }); + + test("a removed container emits removes for every fact in the subtree", () => { + const a = buildFactBase(facts(), []); + const b = buildFactBase( + [ + { id: schema, payload: {} }, + { id: role, payload: { login: true } }, + ], + [], + ); + const deltas = diff(a, b); + expect(deltas.map((d) => d.verb)).toEqual(["remove", "remove", "remove"]); + expect(deltas.every((d) => d.verb === "remove")).toBe(true); + }); + + test("edge differences yield link/unlink", () => { + const ownerEdge: DependencyEdge = { from: table, to: role, kind: "owner" }; + const a = buildFactBase(facts(), []); + const b = buildFactBase(facts(), [ownerEdge]); + expect(diff(a, b)).toEqual([{ verb: "link", edge: ownerEdge }]); + expect(diff(b, a)).toEqual([{ verb: "unlink", edge: ownerEdge }]); + }); + + test("attribute added/dropped from a payload diffs as set with undefined side", () => { + const a = buildFactBase([{ id: role, payload: { login: true } }], []); + const b = buildFactBase( + [{ id: role, payload: { login: true, replication: true } }], + [], + ); + expect(diff(a, b)).toEqual([ + { verb: "set", id: role, attr: "replication", from: undefined, to: true }, + ]); + }); + + test("output is deterministic and sorted", () => { + const a = buildFactBase(facts(), []); + const b = buildFactBase( + [ + { id: schema, payload: {} }, + { id: role, payload: { login: false } }, + { id: table, parent: schema, payload: { persistence: "u" } }, + { + id: colA, + parent: table, + payload: { type: "bigint", notNull: false }, + }, + ], + [], + ); + const d1 = diff(a, b); + const d2 = diff(a, b); + expect(d1).toEqual(d2); + // sorted by encoded id: column:... < role:... < table:... + expect(d1.map((d) => d.verb)).toEqual(["set", "remove", "set", "set"]); + }); + + test("mirror property: diff(B, A) reverses verbs", () => { + const a = buildFactBase(facts(), []); + const b = buildFactBase(facts({ withColB: false, colAType: "bigint" }), []); + const forward = diff(a, b); + const backward = diff(b, a); + const flip = (v: "add" | "remove" | "set" | "link" | "unlink") => + v === "add" + ? "remove" + : v === "remove" + ? "add" + : v === "link" + ? "unlink" + : v === "unlink" + ? "link" + : v; + expect(backward.map((d) => d.verb).sort()).toEqual( + forward.map((d) => flip(d.verb)).sort(), + ); + }); +}); diff --git a/packages/pg-delta/src/core/diff.ts b/packages/pg-delta/src/core/diff.ts new file mode 100644 index 000000000..ea8a69f29 --- /dev/null +++ b/packages/pg-delta/src/core/diff.ts @@ -0,0 +1,151 @@ +/** + * Generic diff: rollup-guided descent to fact-level deltas + * (target-architecture §3.3). + * + * ZERO per-kind code lives here — by design and by test. The differ never + * knows what a table is; per-kind significance is the rule table's job. + */ +import type { DependencyEdge, Fact, FactBase } from "./fact.ts"; +import { canonicalize, type PayloadValue } from "./hash.ts"; +import { encodeId, type StableId } from "./stable-id.ts"; + +export type Delta = + | { verb: "add"; fact: Fact } + | { verb: "remove"; fact: Fact } + | { + verb: "set"; + id: StableId; + attr: string; + from: PayloadValue; + to: PayloadValue; + } + | { verb: "link"; edge: DependencyEdge } + | { verb: "unlink"; edge: DependencyEdge }; + +function edgeKey(e: DependencyEdge): string { + return `${encodeId(e.from)}|${e.kind}|${encodeId(e.to)}`; +} + +export function diff(a: FactBase, b: FactBase): Delta[] { + const deltas: Delta[] = []; + + // Facts present for reference only (managed-view projection of a policy's + // assumedSchemas): kept so dependents resolve, but never diffed. Skip a fact's + // OWN deltas (and edges) when it is reference-only on EITHER side, while still + // descending into its children — a managed object (a user trigger) can be a + // child of a reference-only parent (the platform `auth.users`). + const isReferenceOnly = (id: StableId): boolean => { + const key = encodeId(id); + return a.referenceOnly.has(key) || b.referenceOnly.has(key); + }; + + const emitSubtree = ( + fb: FactBase, + fact: Fact, + verb: "add" | "remove", + ): void => { + if (!isReferenceOnly(fact.id)) { + deltas.push(verb === "add" ? { verb, fact } : { verb, fact }); + // Emit edge deltas for the (dis)appearing fact too, mirroring compareFact + // for changed facts: an added subtree's outgoing edges are new `link`s, a + // removed subtree's are `unlink`s. Edge-driven actions (e.g. owner → + // ALTER … OWNER TO, move 2) must fire on create/drop, not only on change. + for (const edge of fb.outgoingEdges(fact.id)) { + deltas.push( + verb === "add" ? { verb: "link", edge } : { verb: "unlink", edge }, + ); + } + } + for (const child of fb.childrenOf(fact.id)) emitSubtree(fb, child, verb); + }; + + const compareFact = (id: StableId): void => { + if (isReferenceOnly(id)) return; // present for reference, never diffed + const factA = a.get(id); + const factB = b.get(id); + if (factA && factB) { + // payload attributes + if (a.hashOf(id) !== b.hashOf(id)) { + const keys = new Set([ + ...Object.keys(factA.payload), + ...Object.keys(factB.payload), + ]); + for (const attr of [...keys].sort()) { + // keys starting with `_` are non-semantic metadata (see hash.ts): + // extraction-only / version-dependent values carried for the planner, + // never diffed — they have no attribute rule and would otherwise throw. + if (attr.startsWith("_")) continue; + const from = factA.payload[attr]; + const to = factB.payload[attr]; + const fromC = from === undefined ? " absent" : canonicalize(from); + const toC = to === undefined ? " absent" : canonicalize(to); + if (fromC !== toC) deltas.push({ verb: "set", id, attr, from, to }); + } + } + // outgoing edges + const edgesA = new Map(a.outgoingEdges(id).map((e) => [edgeKey(e), e])); + const edgesB = new Map(b.outgoingEdges(id).map((e) => [edgeKey(e), e])); + for (const [key, edge] of edgesA) { + if (!edgesB.has(key)) deltas.push({ verb: "unlink", edge }); + } + for (const [key, edge] of edgesB) { + if (!edgesA.has(key)) deltas.push({ verb: "link", edge }); + } + } + }; + + const compareSubtree = (id: StableId): void => { + const inA = a.has(id); + const inB = b.has(id); + if (inA && inB && a.rollupOf(id) === b.rollupOf(id)) return; // skip subtree + if (inA && !inB) { + emitSubtree(a, a.get(id) as Fact, "remove"); + return; + } + if (!inA && inB) { + emitSubtree(b, b.get(id) as Fact, "add"); + return; + } + compareFact(id); + const childIds = new Map(); + if (inA) + for (const c of a.childrenOf(id)) childIds.set(encodeId(c.id), c.id); + if (inB) + for (const c of b.childrenOf(id)) childIds.set(encodeId(c.id), c.id); + for (const childId of childIds.values()) compareSubtree(childId); + }; + + const rootIds = new Map(); + for (const r of a.roots()) rootIds.set(encodeId(r.id), r.id); + for (const r of b.roots()) rootIds.set(encodeId(r.id), r.id); + for (const id of rootIds.values()) compareSubtree(id); + + return deltas.sort((x, y) => (sortKey(x) < sortKey(y) ? -1 : 1)); +} + +/** The StableId a delta is "about" — the fact for add/remove, the id carried + * by a set, or the edge's `from` for link/unlink. Exported so a consumer + * outside the differ (e.g. `plan()`'s unreadable-user-mapping gate) can match + * deltas against a subject without re-deriving this per verb. */ +export function subjectOf(d: Delta): StableId { + switch (d.verb) { + case "add": + case "remove": + return d.fact.id; + case "set": + return d.id; + case "link": + case "unlink": + return d.edge.from; + } +} + +function sortKey(d: Delta): string { + const attr = + d.verb === "set" + ? d.attr + : d.verb === "link" || d.verb === "unlink" + ? edgeKey(d.edge) + : ""; + return `${encodeId(subjectOf(d))}|${d.verb}|${attr}`; +} diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts deleted file mode 100644 index 6c1a09393..000000000 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ /dev/null @@ -1,980 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Catalog, createEmptyCatalog } from "./catalog.model.ts"; -import type { Change } from "./change.types.ts"; -import { expandReplaceDependencies } from "./expand-replace-dependencies.ts"; -import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts"; -import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts"; -import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts"; -import { Procedure } from "./objects/procedure/procedure.model.ts"; -import { - AlterRlsPolicySetUsingExpression, - AlterRlsPolicySetWithCheckExpression, -} from "./objects/rls-policy/changes/rls-policy.alter.ts"; -import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-policy.comment.ts"; -import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.ts"; -import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.ts"; -import { RlsPolicy } from "./objects/rls-policy/rls-policy.model.ts"; -import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts"; -import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; -import { diffSequences } from "./objects/sequence/sequence.diff.ts"; -import { Sequence } from "./objects/sequence/sequence.model.ts"; -import { - AlterTableAlterColumnSetDefault, - AlterTableChangeOwner, - AlterTableDropColumn, - AlterTableDropConstraint, - AlterTableEnableRowLevelSecurity, - AlterTableSetReplicaIdentity, -} from "./objects/table/changes/table.alter.ts"; -import { CreateTable } from "./objects/table/changes/table.create.ts"; -import { DropTable } from "./objects/table/changes/table.drop.ts"; -import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts"; -import { Table } from "./objects/table/table.model.ts"; -import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts"; -import { DropEnum } from "./objects/type/enum/changes/enum.drop.ts"; -import { Enum } from "./objects/type/enum/enum.model.ts"; -import { CreateView } from "./objects/view/changes/view.create.ts"; -import { DropView } from "./objects/view/changes/view.drop.ts"; -import { View } from "./objects/view/view.model.ts"; - -function mockChange(overrides: { - creates?: string[]; - drops?: string[]; -}): Change { - const { creates = [], drops = [] } = overrides; - return { - objectType: "table", - operation: "create", - scope: "object", - creates, - drops, - invalidates: [], - requires: [], - table: { schema: "public", name: "t" }, - serialize: () => [], - get requiresForDrop(): string[] { - return []; - }, - } as unknown as Change; -} - -function mockInvalidatingChange(invalidates: string[]): Change { - return { - objectType: "table", - operation: "alter", - scope: "object", - creates: [], - drops: [], - invalidates, - requires: [], - table: { schema: "public", name: "t" }, - serialize: () => "", - } as unknown as Change; -} - -describe("expandReplaceDependencies", () => { - test("returns changes unchanged when there are no replace roots", async () => { - const catalog = await createEmptyCatalog(160004, "u"); - const changes: Change[] = [ - mockChange({ creates: ["table:public.t"], drops: [] }), - ]; - const result = expandReplaceDependencies({ - changes, - mainCatalog: catalog, - branchCatalog: catalog, - }); - expect(result.changes).toHaveLength(1); - expect(result.changes).toBe(changes); - expect(result.replacedTableIds.size).toBe(0); - }); - - test("returns changes unchanged when replace roots have no dependents in catalog", async () => { - const catalog = await createEmptyCatalog(160004, "u"); - const changes: Change[] = [ - mockChange({ - creates: ["type:public.e"], - drops: ["type:public.e"], - }), - ]; - const result = expandReplaceDependencies({ - changes, - mainCatalog: catalog, - branchCatalog: catalog, - }); - expect(result.changes).toHaveLength(1); - expect(result.changes[0]).toBe(changes[0]); - expect(result.replacedTableIds.size).toBe(0); - }); - - test("returns same array reference when replaceRoots.size is 0", async () => { - const catalog = await createEmptyCatalog(160004, "u"); - const changes: Change[] = [ - mockChange({ creates: ["table:public.a"], drops: ["table:public.b"] }), - ]; - const result = expandReplaceDependencies({ - changes, - mainCatalog: catalog, - branchCatalog: catalog, - }); - expect(result.changes).toBe(changes); - expect(result.replacedTableIds.size).toBe(0); - }); - - test("promotes surviving dependent view when its referenced table is dropped without a same-name create", async () => { - // Reproduces issue #228 case 3: ALTER TABLE users RENAME TO members. - // pg-delta sees `users` as drop-only and `members` as create-only — the - // stableIds differ, so neither is in the createdIds∩droppedIds replace - // root set. The dependent view `user_count` exists in both catalogs - // (its definition was rewritten to FROM members in branch). Without - // expansion, DROP TABLE users would fail because user_count still - // references it. The expander must seed the drop-only table as a root - // so the surviving dependent gets promoted to DROP+CREATE. - const baseline = await createEmptyCatalog(170000, "postgres"); - const usersTable = new Table({ - schema: "public", - name: "users", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const mainView = new View({ - schema: "public", - name: "user_count", - owner: "postgres", - definition: " SELECT count(*) AS n FROM public.users;", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - partition_bound: null, - comment: null, - columns: [ - { - name: "n", - position: 1, - data_type: "bigint", - data_type_str: "bigint", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - options: null, - privileges: [], - }); - const branchView = new View({ - // oxlint-disable-next-line typescript/no-misused-spread - ...mainView, - definition: " SELECT count(*) AS n FROM public.members;", - }); - - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - tables: { [usersTable.stableId]: usersTable }, - views: { [mainView.stableId]: mainView }, - depends: [ - { - dependent_stable_id: mainView.stableId, - referenced_stable_id: usersTable.stableId, - deptype: "n", - }, - ], - }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - views: { [branchView.stableId]: branchView }, - }); - - // Simulated planner output: DropTable(users) + CreateView orReplace(user_count). - // The surviving view appears only as a "create" (CREATE OR REPLACE VIEW), - // never as a drop, so DROP TABLE users would fail without expansion. - const changes: Change[] = [ - new DropTable({ table: usersTable }), - new CreateView({ view: branchView, orReplace: true }), - ]; - const result = expandReplaceDependencies({ - changes, - mainCatalog, - branchCatalog, - }); - - // The view's surviving CREATE OR REPLACE remains, AND a DropView is - // injected so the drop phase removes the view before the table. - expect(result.changes.some((c) => c instanceof DropView)).toBe(true); - }); - - test("does not replace the owning table for an owned sequence recreation", async () => { - const baseline = await createEmptyCatalog(170000, "postgres"); - // Use `persistence` (UNLOGGED → LOGGED) to trigger the - // non-alterable replace path: it's the only field still in - // NON_ALTERABLE_FIELDS. `data_type` was previously in that list - // but is now alterable in place via ALTER SEQUENCE ... AS . - const mainSequence = new Sequence({ - schema: "public", - name: "user_id_seq", - data_type: "bigint", - start_value: 1, - minimum_value: 1n, - maximum_value: 9223372036854775807n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "u", - owned_by_schema: "public", - owned_by_table: "users", - owned_by_column: "id", - comment: null, - privileges: [], - owner: "postgres", - }); - const branchSequence = new Sequence({ - // oxlint-disable-next-line typescript/no-misused-spread - ...mainSequence, - persistence: "p", - }); - const usersTable = new Table({ - schema: "public", - name: "users", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - columns: [ - { - name: "id", - position: 1, - data_type: "bigint", - data_type_str: "bigint", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "nextval('public.user_id_seq'::regclass)", - comment: null, - }, - ], - privileges: [], - }); - const changes = diffSequences( - { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - }, - { [mainSequence.stableId]: mainSequence }, - { [branchSequence.stableId]: branchSequence }, - { [usersTable.stableId]: usersTable }, - ); - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - sequences: { [mainSequence.stableId]: mainSequence }, - tables: { [usersTable.stableId]: usersTable }, - depends: [ - { - dependent_stable_id: mainSequence.stableId, - referenced_stable_id: "column:public.users.id", - deptype: "a", - }, - { - dependent_stable_id: "column:public.users.id", - referenced_stable_id: mainSequence.stableId, - deptype: "n", - }, - ], - }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - sequences: { [branchSequence.stableId]: branchSequence }, - tables: { [usersTable.stableId]: usersTable }, - depends: [], - }); - - const expanded = expandReplaceDependencies({ - changes, - mainCatalog, - branchCatalog, - }); - - expect(changes[0]).toBeInstanceOf(DropSequence); - expect(changes[1]).toBeInstanceOf(CreateSequence); - expect(changes[3]).toBeInstanceOf(AlterTableAlterColumnSetDefault); - expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( - false, - ); - expect( - expanded.changes.some((change) => change instanceof CreateTable), - ).toBe(false); - expect(expanded.replacedTableIds.size).toBe(0); - }); - - test("reports replaced tables for downstream post-diff normalization", async () => { - // Reproduction guard for the enum-replacement expansion case: the expander - // must report which dependent tables it promoted to DropTable+CreateTable, - // but the pruning of same-table AlterTableDropColumn/DropConstraint belongs - // to the later post-diff normalization pass, not this expansion step. - const baseline = await createEmptyCatalog(170000, "postgres"); - const mainEnum = new Enum({ - schema: "public", - name: "item_status", - owner: "postgres", - labels: [ - { sort_order: 1, label: "draft" }, - { sort_order: 2, label: "published" }, - { sort_order: 3, label: "archived" }, - ], - comment: null, - privileges: [], - }); - const branchEnum = new Enum({ - // oxlint-disable-next-line typescript/no-misused-spread - ...mainEnum, - labels: [ - { sort_order: 1, label: "draft" }, - { sort_order: 2, label: "published" }, - ], - }); - const columnTemplate = { - data_type: "integer" as const, - data_type_str: "integer", - is_custom_type: false as const, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }; - const mainChildren = new Table({ - schema: "public", - name: "children", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - columns: [ - { ...columnTemplate, name: "id", position: 1, not_null: true }, - { ...columnTemplate, name: "parent_ref", position: 2 }, - { - ...columnTemplate, - name: "status", - position: 3, - data_type: "item_status", - data_type_str: "public.item_status", - is_custom_type: true, - custom_type_type: "e", - custom_type_category: "E", - custom_type_schema: "public", - custom_type_name: "item_status", - }, - ], - privileges: [], - }); - const branchChildren = new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...mainChildren, - columns: [ - { ...columnTemplate, name: "id", position: 1, not_null: true }, - { - ...columnTemplate, - name: "status", - position: 2, - data_type: "item_status", - data_type_str: "public.item_status", - is_custom_type: true, - custom_type_type: "e", - custom_type_category: "E", - custom_type_schema: "public", - custom_type_name: "item_status", - }, - ], - }); - - // Pre-existing planner output: the enum replacement from diffEnums plus - // targeted ALTER TABLE statements from diffTables. The two cycle-forming - // ALTERs (drop-column, drop-constraint) must be elided. The privilege - // ALTER and the owner / RLS / replica-identity ALTERs must all survive. - const droppedColumn = mainChildren.columns.find( - (c) => c.name === "parent_ref", - ); - if (!droppedColumn) throw new Error("test setup: parent_ref missing"); - const preExistingDropColumn = new AlterTableDropColumn({ - table: mainChildren, - column: droppedColumn, - }); - const preExistingDropConstraint = new AlterTableDropConstraint({ - table: mainChildren, - constraint: { - name: "children_parent_ref_fkey", - constraint_type: "f", - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["parent_ref"], - foreign_key_columns: ["id"], - foreign_key_table: "parents", - foreign_key_schema: "public", - foreign_key_table_is_partition: false, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: "public", - foreign_key_effective_table: "parents", - on_update: "a", - on_delete: "a", - match_type: "s", - check_expression: null, - owner: "postgres", - definition: "FOREIGN KEY (parent_ref) REFERENCES public.parents(id)", - comment: null, - }, - }); - const preExistingChangeOwner = new AlterTableChangeOwner({ - table: branchChildren, - owner: "new_owner", - }); - const preExistingEnableRls = new AlterTableEnableRowLevelSecurity({ - table: branchChildren, - }); - const preExistingReplicaIdentity = new AlterTableSetReplicaIdentity({ - table: branchChildren, - mode: "f", - }); - const preExistingGrant = new GrantTablePrivileges({ - table: branchChildren, - grantee: "reader", - privileges: [{ privilege: "SELECT", grantable: false }], - }); - const changes: Change[] = [ - new DropEnum({ enum: mainEnum }), - new CreateEnum({ enum: branchEnum }), - preExistingDropColumn, - preExistingDropConstraint, - preExistingChangeOwner, - preExistingEnableRls, - preExistingReplicaIdentity, - preExistingGrant, - ]; - - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - enums: { [mainEnum.stableId]: mainEnum }, - tables: { [mainChildren.stableId]: mainChildren }, - // pg_depend: column children.status depends on type item_status. - depends: [ - { - dependent_stable_id: "column:public.children.status", - referenced_stable_id: mainEnum.stableId, - deptype: "n", - }, - ], - }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - enums: { [branchEnum.stableId]: branchEnum }, - tables: { [branchChildren.stableId]: branchChildren }, - depends: [], - }); - - const expanded = expandReplaceDependencies({ - changes, - mainCatalog, - branchCatalog, - }); - - // The replace-table pair was added. - expect(expanded.changes.some((c) => c instanceof DropTable)).toBe(true); - expect(expanded.changes.some((c) => c instanceof CreateTable)).toBe(true); - expect(expanded.replacedTableIds.has(mainChildren.stableId)).toBe(true); - // Expansion itself keeps the pre-existing ALTERs; the post-diff cycle pass - // decides which of them are superseded by the replacement. - expect(expanded.changes).toContain(preExistingDropColumn); - expect(expanded.changes).toContain(preExistingDropConstraint); - expect( - expanded.changes.some((c) => c instanceof AlterTableDropColumn), - ).toBe(true); - expect( - expanded.changes.some((c) => c instanceof AlterTableDropConstraint), - ).toBe(true); - // The enum replace roots are still present. - expect(expanded.changes.some((c) => c instanceof DropEnum)).toBe(true); - expect(expanded.changes.some((c) => c instanceof CreateEnum)).toBe(true); - // Non-cycle object-scope ALTERs are carried through untouched. - expect(expanded.changes).toContain(preExistingChangeOwner); - expect(expanded.changes).toContain(preExistingEnableRls); - expect(expanded.changes).toContain(preExistingReplicaIdentity); - // Privilege-scope ALTER on the recreated table survives. - expect(expanded.changes).toContain(preExistingGrant); - expect(expanded.replacedTableIds.has("table:public.parents")).toBe(false); - }); - - test("promotes dependent view when a procedure's parameter types change", async () => { - // Procedure stableIds are signature-qualified, so a parameter-type change - // produces different stableIds in `createdIds` and `droppedIds`. The - // expander must still treat the (schema, name)-matched pair as a replace - // root so a dependent view is promoted from `CREATE OR REPLACE VIEW` to - // `DROP VIEW` + `CREATE VIEW` (otherwise `DROP FUNCTION` fails with - // "cannot drop function because other objects depend on it"). - const baseline = await createEmptyCatalog(170000, "postgres"); - const procedureBase = { - schema: "public", - name: "format_id", - kind: "f" as const, - return_type: "text", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "i" as const, - parallel_safety: "u" as const, - execution_cost: 100, - result_rows: 0, - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 1, - argument_default_count: 0, - argument_names: ["id"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "SELECT 'id:' || id::text", - binary_path: null, - sql_body: null, - config: null, - owner: "postgres", - comment: null, - privileges: [], - }; - const mainProcedure = new Procedure({ - ...procedureBase, - argument_types: ["int4"], - definition: "CREATE FUNCTION public.format_id(id integer) ...", - }); - const branchProcedure = new Procedure({ - ...procedureBase, - argument_types: ["int8"], - definition: "CREATE FUNCTION public.format_id(id bigint) ...", - }); - const viewBase = { - schema: "public", - name: "items_formatted", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - owner: "postgres", - comment: null, - columns: [], - privileges: [], - }; - const mainView = new View({ - ...viewBase, - definition: "SELECT public.format_id(id) FROM public.items", - }); - const branchView = new View({ - ...viewBase, - definition: "SELECT public.format_id(id::bigint) FROM public.items", - }); - - const changes: Change[] = [ - new DropProcedure({ procedure: mainProcedure }), - new CreateProcedure({ procedure: branchProcedure }), - // view.diff emits this because pg_get_viewdef text differs after the - // underlying function signature changes. - new CreateView({ view: branchView, orReplace: true }), - ]; - - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - procedures: { [mainProcedure.stableId]: mainProcedure }, - views: { [mainView.stableId]: mainView }, - depends: [ - { - dependent_stable_id: mainView.stableId, - referenced_stable_id: mainProcedure.stableId, - deptype: "n", - }, - ], - }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - procedures: { [branchProcedure.stableId]: branchProcedure }, - views: { [branchView.stableId]: branchView }, - depends: [], - }); - - const expanded = expandReplaceDependencies({ - changes, - mainCatalog, - branchCatalog, - }); - - expect(expanded.changes.some((c) => c instanceof DropView)).toBe(true); - }); - - test("promotes dependent RLS policy when a procedure's signature changes", async () => { - const baseline = await createEmptyCatalog(170000, "postgres"); - const procedureBase = { - schema: "public", - name: "check_role", - kind: "f" as const, - return_type: "boolean", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v" as const, - parallel_safety: "u" as const, - execution_cost: 100, - result_rows: 0, - is_strict: false, - leakproof: false, - returns_set: false, - argument_names: ["id", "role"], - all_argument_types: null, - argument_modes: null, - source_code: "BEGIN RETURN true; END;", - binary_path: null, - sql_body: null, - config: null, - owner: "postgres", - comment: null, - privileges: [], - }; - const mainProcedure = new Procedure({ - ...procedureBase, - argument_count: 2, - argument_default_count: 0, - argument_types: ["uuid", "text"], - argument_defaults: null, - definition: - "CREATE FUNCTION public.check_role(id uuid, role text) RETURNS boolean ...", - }); - const branchProcedure = new Procedure({ - ...procedureBase, - argument_count: 3, - argument_default_count: 1, - argument_names: ["id", "role", "extra"], - argument_types: ["uuid", "text", "text"], - argument_defaults: "'default'::text", - definition: - "CREATE FUNCTION public.check_role(id uuid, role text, extra text DEFAULT 'default'::text) RETURNS boolean ...", - }); - const policyBase = { - schema: "public", - table_name: "profiles", - name: "check_role_policy", - command: "r" as const, - permissive: true, - roles: ["public"], - using_expression: "public.check_role(id, role)", - with_check_expression: null, - owner: "postgres", - comment: "policy comment", - referenced_relations: [], - }; - const mainPolicy = new RlsPolicy({ - ...policyBase, - referenced_procedures: [ - { - schema: "public", - name: "check_role", - argument_types: ["uuid", "text"], - }, - ], - }); - const branchPolicy = new RlsPolicy({ - ...policyBase, - referenced_procedures: [ - { - schema: "public", - name: "check_role", - argument_types: ["uuid", "text", "text"], - }, - ], - }); - - const changes: Change[] = [ - new DropProcedure({ procedure: mainProcedure }), - new CreateProcedure({ procedure: branchProcedure }), - ]; - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - procedures: { [mainProcedure.stableId]: mainProcedure }, - rlsPolicies: { [mainPolicy.stableId]: mainPolicy }, - depends: [ - { - dependent_stable_id: mainPolicy.stableId, - referenced_stable_id: mainProcedure.stableId, - deptype: "n", - }, - ], - }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - procedures: { [branchProcedure.stableId]: branchProcedure }, - rlsPolicies: { [branchPolicy.stableId]: branchPolicy }, - depends: [], - }); - - const expanded = expandReplaceDependencies({ - changes, - mainCatalog, - branchCatalog, - }); - - expect(expanded.changes.some((c) => c instanceof DropRlsPolicy)).toBe(true); - expect(expanded.changes.some((c) => c instanceof CreateRlsPolicy)).toBe( - true, - ); - expect( - expanded.changes.some((c) => c instanceof CreateCommentOnRlsPolicy), - ).toBe(true); - }); - - test("promotes dependent RLS policy when a referenced column is invalidated", async () => { - const baseline = await createEmptyCatalog(170000, "postgres"); - const columnTemplate = { - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }; - const tableBase = { - schema: "public", - name: "solution_categories_with_policy", - persistence: "p" as const, - row_security: true, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - constraints: [], - privileges: [], - }; - const mainRoleColumn = { - ...columnTemplate, - name: "role", - }; - const branchRoleColumn = { - ...columnTemplate, - name: "role", - data_type: "user_role_enum", - data_type_str: "public.user_role_enum", - is_custom_type: true, - custom_type_type: "e", - custom_type_category: "E", - custom_type_schema: "public", - custom_type_name: "user_role_enum", - }; - const mainTable = new Table({ - ...tableBase, - columns: [mainRoleColumn], - }); - const branchTable = new Table({ - ...tableBase, - columns: [branchRoleColumn], - }); - const policyBase = { - schema: "public", - table_name: "solution_categories_with_policy", - name: "categories_admin_manage", - command: "*" as const, - permissive: true, - roles: ["public"], - owner: "postgres", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const mainPolicy = new RlsPolicy({ - ...policyBase, - using_expression: "role = 'admin'", - with_check_expression: "role = 'admin'", - }); - const branchPolicy = new RlsPolicy({ - ...policyBase, - using_expression: "role = 'admin'::public.user_role_enum", - with_check_expression: "role = 'admin'::public.user_role_enum", - }); - const alterUsing = new AlterRlsPolicySetUsingExpression({ - policy: mainPolicy, - usingExpression: branchPolicy.using_expression, - }); - const alterWithCheck = new AlterRlsPolicySetWithCheckExpression({ - policy: mainPolicy, - withCheckExpression: branchPolicy.with_check_expression, - }); - const changes: Change[] = [ - mockInvalidatingChange([ - "column:public.solution_categories_with_policy.role", - ]), - alterUsing, - alterWithCheck, - ]; - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - tables: { [mainTable.stableId]: mainTable }, - rlsPolicies: { [mainPolicy.stableId]: mainPolicy }, - depends: [ - { - dependent_stable_id: mainPolicy.stableId, - referenced_stable_id: - "column:public.solution_categories_with_policy.role", - deptype: "n", - }, - ], - }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - tables: { [branchTable.stableId]: branchTable }, - rlsPolicies: { [branchPolicy.stableId]: branchPolicy }, - depends: [], - }); - - const expanded = expandReplaceDependencies({ - changes, - mainCatalog, - branchCatalog, - }); - - expect(expanded.changes.some((c) => c instanceof DropRlsPolicy)).toBe(true); - expect(expanded.changes.some((c) => c instanceof CreateRlsPolicy)).toBe( - true, - ); - expect(expanded.changes).not.toContain(alterUsing); - expect(expanded.changes).not.toContain(alterWithCheck); - }); -}); diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts deleted file mode 100644 index c1a464418..000000000 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ /dev/null @@ -1,707 +0,0 @@ -import type { Catalog } from "./catalog.model.ts"; -import type { Change } from "./change.types.ts"; -import type { ObjectDiffContext } from "./objects/diff-context.ts"; -import { CreateDomain } from "./objects/domain/changes/domain.create.ts"; -import { DropDomain } from "./objects/domain/changes/domain.drop.ts"; -import { CreateIndex } from "./objects/index/changes/index.create.ts"; -import { DropIndex } from "./objects/index/changes/index.drop.ts"; -import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts"; -import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts"; -import { buildCreateMaterializedViewChanges } from "./objects/materialized-view/materialized-view.diff.ts"; -import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts"; -import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts"; -import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-policy.comment.ts"; -import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.ts"; -import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.ts"; -import { AlterTableAddConstraint } from "./objects/table/changes/table.alter.ts"; -import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts"; -import { CreateTable } from "./objects/table/changes/table.create.ts"; -import { DropTable } from "./objects/table/changes/table.drop.ts"; -import { CreateCompositeType } from "./objects/type/composite-type/changes/composite-type.create.ts"; -import { DropCompositeType } from "./objects/type/composite-type/changes/composite-type.drop.ts"; -import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts"; -import { DropEnum } from "./objects/type/enum/changes/enum.drop.ts"; -import { CreateRange } from "./objects/type/range/changes/range.create.ts"; -import { DropRange } from "./objects/type/range/changes/range.drop.ts"; -import { stableId } from "./objects/utils.ts"; -import { CreateView } from "./objects/view/changes/view.create.ts"; -import { DropView } from "./objects/view/changes/view.drop.ts"; -import { buildCreateViewChanges } from "./objects/view/view.diff.ts"; - -type ResolvedObject = - | { - kind: "table"; - main: Catalog["tables"][string]; - branch: Catalog["tables"][string]; - } - | { - kind: "view"; - main: Catalog["views"][string]; - branch: Catalog["views"][string]; - } - | { - kind: "index"; - main: Catalog["indexes"][string]; - branch: Catalog["indexes"][string]; - branchIndexableObject: Catalog["indexableObjects"][string] | undefined; - } - | { - kind: "materialized_view"; - main: Catalog["materializedViews"][string]; - branch: Catalog["materializedViews"][string]; - } - | { - kind: "procedure"; - main: Catalog["procedures"][string]; - branch: Catalog["procedures"][string]; - } - | { - kind: "rls_policy"; - main: Catalog["rlsPolicies"][string]; - branch: Catalog["rlsPolicies"][string]; - } - | { - kind: "enum"; - main: Catalog["enums"][string]; - branch: Catalog["enums"][string]; - } - | { - kind: "range"; - main: Catalog["ranges"][string]; - branch: Catalog["ranges"][string]; - } - | { - kind: "composite_type"; - main: Catalog["compositeTypes"][string]; - branch: Catalog["compositeTypes"][string]; - } - | { - kind: "domain"; - main: Catalog["domains"][string]; - branch: Catalog["domains"][string]; - }; - -/** - * For objects we are replacing (drop + create), ensure that any dependents are also - * replaced so that destructive drops succeed. Uses dependency edges from pg_depend - * (already captured in Catalog.depends) plus change metadata (creates/drops/requires). - * - * New changes are appended; ordering and any multi-statement cycle normalization - * are handled later by post-diff helpers and the sorter. - */ -interface ExpandReplaceDependenciesResult { - changes: Change[]; - replacedTableIds: ReadonlySet; -} - -export function expandReplaceDependencies({ - changes, - mainCatalog, - branchCatalog, - diffContext, -}: { - changes: Change[]; - mainCatalog: Catalog; - branchCatalog: Catalog; - diffContext?: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >; -}): ExpandReplaceDependenciesResult { - const createdIds = new Set(); - const droppedIds = new Set(); - - for (const change of changes) { - for (const id of change.creates ?? []) createdIds.add(id); - for (const id of change.drops ?? []) droppedIds.add(id); - } - - const replaceRoots = new Set(); - for (const id of createdIds) { - if (droppedIds.has(id)) { - replaceRoots.add(id); - } - } - - const promotedRlsPolicyIds = new Set(); - const additions: Change[] = collectInvalidatedRlsPolicyReplacements({ - changes, - mainCatalog, - branchCatalog, - createdIds, - droppedIds, - promotedRlsPolicyIds, - }); - - // Procedure stableIds are signature-qualified - // (`procedure:schema.name(argtypes)`), so a function whose parameter types - // change has different ids in `createdIds` and `droppedIds` and would not - // appear in the intersection above. Treat any dropped procedure whose - // `(schema, name)` matches a created procedure as a replace root so - // dependents referencing the old signature via pg_depend get promoted to - // DROP+CREATE. - const createdProcedureNames = new Set(); - for (const id of createdIds) { - const key = parseProcedureSchemaName(id); - if (key) createdProcedureNames.add(key); - } - for (const id of droppedIds) { - const key = parseProcedureSchemaName(id); - if (key && createdProcedureNames.has(key)) { - replaceRoots.add(id); - } - } - - // Drop-only objects (no matching create — typically a renamed-away table or - // type) are also expansion roots: anything in main that depends on them via - // pg_depend must drop before the parent does. Without this seed, a renamed - // table whose dependent view stays in the branch catalog (with an updated - // definition that no longer references the old name) would still try to - // run DROP TABLE old_name while old_name is referenced by the view, which - // PostgreSQL refuses without CASCADE. The walk below promotes the surviving - // dependent to DROP+CREATE so its drop is sequenced before the parent drop. - for (const id of droppedIds) { - if (createdIds.has(id)) continue; - if (replaceRoots.has(id)) continue; - // Only seed for object kinds that can have catalog dependents we know - // how to recreate via buildReplaceChanges. - if ( - id.startsWith("table:") || - id.startsWith("view:") || - id.startsWith("materializedView:") || - id.startsWith("type:") || - id.startsWith("domain:") - ) { - replaceRoots.add(id); - } - } - - if (replaceRoots.size === 0 && additions.length === 0) { - return { - changes, - replacedTableIds: new Set(), - }; - } - - // Build referenced -> dependents adjacency from main catalog dependencies. - const dependentsByReferenced = new Map>(); - for (const dep of mainCatalog.depends) { - let list = dependentsByReferenced.get(dep.referenced_stable_id); - if (!list) { - list = new Set(); - dependentsByReferenced.set(dep.referenced_stable_id, list); - } - list.add(dep.dependent_stable_id); - } - - const visitedTargets = new Set(); - const visitedRefs = new Set(replaceRoots); - const queue: string[] = [...replaceRoots]; - // Tables being replaced by an expansion-added DropTable+CreateTable pair. - // Any pre-existing targeted AlterTable*(T) object-scope change is superseded - // by the replacement and must be removed to avoid contradictions (e.g. an - // AlterTableDropColumn on a table that is about to be dropped) and the - // associated drop-phase cycle with the catalog constraint→column edge. - const tablesReplacedByExpansion = new Set(); - - while (queue.length > 0) { - const refId = queue.shift() as string; - const dependents = dependentsByReferenced.get(refId); - if (!dependents) continue; - - for (const dependentRaw of dependents) { - if ( - isOwnedSequenceColumnDependency( - refId, - dependentRaw, - mainCatalog, - branchCatalog, - ) - ) { - continue; - } - - // Continue traversing the dependency graph from the raw dependent id. - if (!visitedRefs.has(dependentRaw)) { - visitedRefs.add(dependentRaw); - queue.push(dependentRaw); - } - - const targetId = normalizeDependentId(dependentRaw); - if (!targetId) continue; - - // Also traverse using the normalized owning object id (e.g., table for a column). - if (!visitedRefs.has(targetId)) { - visitedRefs.add(targetId); - queue.push(targetId); - } - - if (visitedTargets.has(targetId)) continue; - visitedTargets.add(targetId); - - // Already handled (either original replace or previously added). - if (createdIds.has(targetId) && droppedIds.has(targetId)) continue; - if (replaceRoots.has(targetId)) continue; - - const resolved = resolveObjectForStableId( - targetId, - mainCatalog, - branchCatalog, - ); - if (!resolved) continue; - - const hasCreate = createdIds.has(targetId); - const hasDrop = droppedIds.has(targetId); - - const addDrop = !hasDrop; - const addCreate = !hasCreate; - - if (!addDrop && !addCreate) continue; - - const replacementChanges = buildReplaceChanges(resolved, { - addDrop, - addCreate, - diffContext, - }); - if (!replacementChanges) continue; - - additions.push(...replacementChanges); - if (resolved.kind === "rls_policy") { - promotedRlsPolicyIds.add(targetId); - } - - // If we added a DropTable(T) for an existing table, mark T so any - // pre-existing object-scope AlterTable*(T) changes get dropped below — - // the DropTable+CreateTable pair supersedes all structural alterations. - if (resolved.kind === "table" && addDrop) { - tablesReplacedByExpansion.add(targetId); - } - - // Track new creates/drops so we don't duplicate work for downstream dependents. - for (const change of replacementChanges) { - for (const id of change.creates ?? []) createdIds.add(id); - for (const id of change.drops ?? []) droppedIds.add(id); - } - } - } - - if (additions.length === 0) { - return { - changes, - replacedTableIds: tablesReplacedByExpansion, - }; - } - - return { - changes: [ - ...removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), - ...additions, - ], - replacedTableIds: tablesReplacedByExpansion, - }; -} - -function collectInvalidatedRlsPolicyReplacements({ - changes, - mainCatalog, - branchCatalog, - createdIds, - droppedIds, - promotedRlsPolicyIds, -}: { - changes: Change[]; - mainCatalog: Catalog; - branchCatalog: Catalog; - createdIds: Set; - droppedIds: Set; - promotedRlsPolicyIds: Set; -}): Change[] { - // In-place rewrites report stable ids through `invalidates`: the referenced - // object keeps its identity, but dependents bound to the old definition must - // be torn down first. RLS policy expressions are tracked in pg_depend, so use - // those catalog edges to promote only policies that depend on an invalidated - // id, without coupling this expansion pass to a concrete table-change class. - const invalidatedIds = new Set(); - for (const change of changes) { - for (const invalidatedId of change.invalidates) { - invalidatedIds.add(invalidatedId); - } - } - if (invalidatedIds.size === 0) return []; - - const replacements: Change[] = []; - for (const dep of mainCatalog.depends) { - if (!invalidatedIds.has(dep.referenced_stable_id)) continue; - - const targetId = normalizeDependentId(dep.dependent_stable_id); - if (!targetId?.startsWith("rlsPolicy:")) continue; - if (promotedRlsPolicyIds.has(targetId)) continue; - if (createdIds.has(targetId) && droppedIds.has(targetId)) continue; - - const resolved = resolveObjectForStableId( - targetId, - mainCatalog, - branchCatalog, - ); - if (!resolved || resolved.kind !== "rls_policy") continue; - - const addDrop = !droppedIds.has(targetId); - const addCreate = !createdIds.has(targetId); - const replacementChanges = buildReplaceChanges(resolved, { - addDrop, - addCreate, - }); - if (!replacementChanges) continue; - - replacements.push(...replacementChanges); - promotedRlsPolicyIds.add(targetId); - for (const change of replacementChanges) { - for (const id of change.creates ?? []) createdIds.add(id); - for (const id of change.drops ?? []) droppedIds.add(id); - } - } - - return replacements; -} - -function removeSupersededRlsPolicyAlters( - changes: Change[], - promotedRlsPolicyIds: ReadonlySet, -): Change[] { - if (promotedRlsPolicyIds.size === 0) return changes; - return changes.filter((change) => { - if (change.objectType !== "rls_policy" || change.operation !== "alter") { - return true; - } - return !promotedRlsPolicyIds.has(change.policy.stableId); - }); -} - -function isOwnedSequenceColumnDependency( - referencedId: string, - dependentId: string, - mainCatalog: Catalog, - branchCatalog: Catalog, -): boolean { - // When a sequence replace root is still OWNED BY the same column, the - // sequence->column pg_depend edge is bookkeeping for ownership, not a signal - // that the whole owning table needs to be replaced. Skipping that edge keeps - // expandReplaceDependencies focused on recreating the sequence itself. - if ( - !referencedId.startsWith("sequence:") || - !dependentId.startsWith("column:") - ) { - return false; - } - - const sequence = - branchCatalog.sequences[referencedId] ?? - mainCatalog.sequences[referencedId]; - if ( - !sequence?.owned_by_schema || - !sequence.owned_by_table || - !sequence.owned_by_column - ) { - return false; - } - - return ( - dependentId === - stableId.column( - sequence.owned_by_schema, - sequence.owned_by_table, - sequence.owned_by_column, - ) - ); -} - -function parseProcedureSchemaName(stableId: string): string | null { - if (!stableId.startsWith("procedure:")) return null; - const paren = stableId.indexOf("("); - if (paren === -1) return null; - return stableId.slice("procedure:".length, paren); -} - -function normalizeDependentId(dependentId: string): string | null { - let id = dependentId; - - while (id.startsWith("comment:")) { - id = id.slice("comment:".length); - } - - if ( - id.startsWith("acl:") || - id.startsWith("defacl:") || - id.startsWith("membership:") || - id.startsWith("role:") || - id.startsWith("schema:") - ) { - return null; - } - - if (id.startsWith("column:")) { - const parts = id.slice("column:".length).split("."); - if (parts.length >= 2) { - const [schema, table] = parts; - return `table:${schema}.${table}`; - } - return null; - } - - if (id.startsWith("constraint:")) { - const parts = id.slice("constraint:".length).split("."); - if (parts.length >= 2) { - const [schema, table] = parts; - return `table:${schema}.${table}`; - } - return null; - } - - return id; -} - -function resolveObjectForStableId( - stableId: string, - mainCatalog: Catalog, - branchCatalog: Catalog, -): ResolvedObject | null { - if (stableId.startsWith("table:")) { - const main = mainCatalog.tables[stableId]; - const branch = branchCatalog.tables[stableId]; - return main && branch ? { kind: "table", main, branch } : null; - } - - if (stableId.startsWith("view:")) { - const main = mainCatalog.views[stableId]; - const branch = branchCatalog.views[stableId]; - return main && branch ? { kind: "view", main, branch } : null; - } - - if (stableId.startsWith("materializedView:")) { - const main = mainCatalog.materializedViews[stableId]; - const branch = branchCatalog.materializedViews[stableId]; - return main && branch ? { kind: "materialized_view", main, branch } : null; - } - - if (stableId.startsWith("index:")) { - const main = mainCatalog.indexes[stableId]; - const branch = branchCatalog.indexes[stableId]; - return main && branch - ? { - kind: "index", - main, - branch, - branchIndexableObject: - branchCatalog.indexableObjects[branch.tableStableId], - } - : null; - } - - if (stableId.startsWith("procedure:")) { - const main = mainCatalog.procedures[stableId]; - const branch = branchCatalog.procedures[stableId]; - return main && branch ? { kind: "procedure", main, branch } : null; - } - - if (stableId.startsWith("rlsPolicy:")) { - const main = mainCatalog.rlsPolicies[stableId]; - const branch = branchCatalog.rlsPolicies[stableId]; - return main && branch ? { kind: "rls_policy", main, branch } : null; - } - - if (stableId.startsWith("domain:")) { - const main = mainCatalog.domains[stableId]; - const branch = branchCatalog.domains[stableId]; - return main && branch ? { kind: "domain", main, branch } : null; - } - - if (stableId.startsWith("type:")) { - const enumMain = mainCatalog.enums[stableId]; - const enumBranch = branchCatalog.enums[stableId]; - if (enumMain && enumBranch) { - return { kind: "enum", main: enumMain, branch: enumBranch }; - } - - const rangeMain = mainCatalog.ranges[stableId]; - const rangeBranch = branchCatalog.ranges[stableId]; - if (rangeMain && rangeBranch) { - return { kind: "range", main: rangeMain, branch: rangeBranch }; - } - - const compositeMain = mainCatalog.compositeTypes[stableId]; - const compositeBranch = branchCatalog.compositeTypes[stableId]; - if (compositeMain && compositeBranch) { - return { - kind: "composite_type", - main: compositeMain, - branch: compositeBranch, - }; - } - } - - return null; -} - -function buildReplaceChanges( - resolved: ResolvedObject, - options: { - addDrop: boolean; - addCreate: boolean; - diffContext?: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >; - }, -): Change[] | null { - const { addDrop, addCreate, diffContext } = options; - - if (!addDrop && !addCreate) return null; - - switch (resolved.kind) { - case "table": - return [ - ...(addDrop ? [new DropTable({ table: resolved.main })] : []), - ...(addCreate - ? [ - new CreateTable({ table: resolved.branch }), - ...((resolved.branch.constraints ?? []) - .filter((c) => !c.is_partition_clone) - .flatMap((constraint) => { - const items: Change[] = [ - new AlterTableAddConstraint({ - table: resolved.branch, - constraint, - }), - ]; - if ( - constraint.comment !== null && - constraint.comment !== undefined - ) { - items.push( - new CreateCommentOnConstraint({ - table: resolved.branch, - constraint, - }), - ); - } - return items; - }) as Change[]), - ] - : []), - ]; - case "view": - return [ - ...(addDrop ? [new DropView({ view: resolved.main })] : []), - ...(addCreate - ? buildCreateViewReplacementChanges(resolved.branch, diffContext) - : []), - ]; - case "materialized_view": - return [ - ...(addDrop - ? [new DropMaterializedView({ materializedView: resolved.main })] - : []), - ...(addCreate - ? diffContext - ? buildCreateMaterializedViewChanges(diffContext, resolved.branch) - : [ - new CreateMaterializedView({ - materializedView: resolved.branch, - }), - ] - : []), - ]; - case "index": - // Constraint-owned, primary, and partition-attached indexes are managed - // by the owning constraint or parent-index DDL, not standalone - // CREATE INDEX / DROP INDEX. The `case "table":` branch above already - // recreates constraints via AlterTableAddConstraint; emitting a - // standalone drop/create here would fail in PostgreSQL - // ("cannot drop index ... because constraint ... requires it") or - // duplicate the index the constraint recreates. Skip matches - // diffIndexes (packages/pg-delta/src/core/objects/index/index.diff.ts). - if ( - resolved.main.is_owned_by_constraint || - resolved.main.is_primary || - resolved.main.is_index_partition || - resolved.branch.is_owned_by_constraint || - resolved.branch.is_primary || - resolved.branch.is_index_partition - ) { - return null; - } - return [ - ...(addDrop ? [new DropIndex({ index: resolved.main })] : []), - ...(addCreate - ? [ - new CreateIndex({ - index: resolved.branch, - indexableObject: resolved.branchIndexableObject, - }), - ] - : []), - ]; - case "procedure": - return [ - ...(addDrop ? [new DropProcedure({ procedure: resolved.main })] : []), - ...(addCreate - ? [new CreateProcedure({ procedure: resolved.branch })] - : []), - ]; - case "rls_policy": - return [ - ...(addDrop ? [new DropRlsPolicy({ policy: resolved.main })] : []), - ...(addCreate - ? [ - new CreateRlsPolicy({ policy: resolved.branch }), - ...(resolved.branch.comment !== null - ? [new CreateCommentOnRlsPolicy({ policy: resolved.branch })] - : []), - ] - : []), - ]; - case "enum": - return [ - ...(addDrop ? [new DropEnum({ enum: resolved.main })] : []), - ...(addCreate ? [new CreateEnum({ enum: resolved.branch })] : []), - ]; - case "range": - return [ - ...(addDrop ? [new DropRange({ range: resolved.main })] : []), - ...(addCreate ? [new CreateRange({ range: resolved.branch })] : []), - ]; - case "composite_type": - return [ - ...(addDrop - ? [new DropCompositeType({ compositeType: resolved.main })] - : []), - ...(addCreate - ? [new CreateCompositeType({ compositeType: resolved.branch })] - : []), - ]; - case "domain": - return [ - ...(addDrop ? [new DropDomain({ domain: resolved.main })] : []), - ...(addCreate ? [new CreateDomain({ domain: resolved.branch })] : []), - ]; - default: - return null; - } -} - -function buildCreateViewReplacementChanges( - view: Catalog["views"][string], - diffContext: - | Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - > - | undefined, -): Change[] { - // Dependency-closure replacements synthesize a create without going through - // `diffViews`, so replay the same owner/comment/security-label/ACL metadata - // that a normal non-alterable view replacement would emit. - return diffContext - ? buildCreateViewChanges(diffContext, view) - : [new CreateView({ view })]; -} diff --git a/packages/pg-delta/src/core/export/file-mapper.test.ts b/packages/pg-delta/src/core/export/file-mapper.test.ts deleted file mode 100644 index a264ca587..000000000 --- a/packages/pg-delta/src/core/export/file-mapper.test.ts +++ /dev/null @@ -1,816 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../change.types.ts"; -import { - applyGrouping, - type CompiledPattern, - compilePatterns, - createFileMapper, - flattenSchema, - resolveGroupName, -} from "./file-mapper.ts"; -import type { FilePath } from "./types.ts"; - -// ============================================================================ -// Helpers – minimal Change stubs -// ============================================================================ - -/** Minimal table change stub with partition info. */ -function tableChange(opts: { - schema: string; - name: string; - isPartition?: boolean; - parentName?: string | null; - parentSchema?: string | null; -}): Change { - return { - objectType: "table", - operation: "create", - scope: "object", - table: { - schema: opts.schema, - name: opts.name, - is_partition: opts.isPartition ?? false, - parent_name: opts.parentName ?? null, - parent_schema: opts.parentSchema ?? null, - }, - serialize: () => `CREATE TABLE ${opts.schema}.${opts.name} ()`, - } as unknown as Change; -} - -/** Build a FilePath for a schema-scoped table. */ -function tableFP(objectName: string, schema = "public"): FilePath { - return { - path: `schemas/${schema}/tables/${objectName}.sql`, - category: "tables", - metadata: { objectType: "table", schemaName: schema, objectName }, - }; -} - -function triggerChange(opts: { - schema: string; - name: string; - tableName: string; - tableRelkind: "r" | "p" | "f" | "v" | "m"; -}): Change { - return { - objectType: "trigger", - operation: "create", - scope: "object", - trigger: { - schema: opts.schema, - name: opts.name, - table_name: opts.tableName, - table_relkind: opts.tableRelkind, - }, - serialize: () => - `CREATE TRIGGER ${opts.name} ON ${opts.schema}.${opts.tableName}`, - } as unknown as Change; -} - -function ruleChange(opts: { - schema: string; - name: string; - tableName: string; - relationKind: "r" | "p" | "f" | "v" | "m"; -}): Change { - return { - objectType: "rule", - operation: "create", - scope: "object", - rule: { - schema: opts.schema, - name: opts.name, - table_name: opts.tableName, - relation_kind: opts.relationKind, - }, - serialize: () => - `CREATE RULE ${opts.name} AS ON SELECT TO ${opts.tableName}`, - } as unknown as Change; -} - -// ============================================================================ -// compilePatterns -// ============================================================================ - -describe("compilePatterns", () => { - test("compiles string patterns to RegExp", () => { - const { compiled } = compilePatterns([ - { pattern: "^project", name: "project" }, - ]); - expect(compiled).toHaveLength(1); - expect(compiled[0].regex).toBeInstanceOf(RegExp); - expect(compiled[0].regex.test("project_claim")).toBe(true); - expect(compiled[0].name).toBe("project"); - }); - - test("passes RegExp patterns through unchanged", () => { - const re = /^kubernetes/; - const { compiled } = compilePatterns([{ pattern: re, name: "kubernetes" }]); - expect(compiled[0].regex).toBe(re); - }); - - test("strips /g flag to prevent stateful .test()", () => { - const patterns = [{ pattern: /^auth/g, name: "auth-group" }]; - const { compiled } = compilePatterns(patterns); - const filePath: FilePath = { - path: "schemas/public/tables/auth_users.sql", - category: "tables", - metadata: { - objectType: "table", - schemaName: "public", - objectName: "auth_users", - }, - }; - const change = tableChange({ schema: "public", name: "auth_users" }); - - // Call multiple times — should always return the same result - const r1 = resolveGroupName(change, filePath, compiled, false); - const r2 = resolveGroupName(change, filePath, compiled, false); - const r3 = resolveGroupName(change, filePath, compiled, false); - expect(r1).toBe("auth-group"); - expect(r2).toBe("auth-group"); - expect(r3).toBe("auth-group"); - }); - - test("reports warnings for invalid regex patterns", () => { - const { compiled, warnings } = compilePatterns([ - { pattern: "[invalid(", name: "bad" }, - { pattern: "^valid", name: "good" }, - ]); - expect(compiled).toHaveLength(1); - expect(compiled[0].name).toBe("good"); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("[invalid("); - }); -}); - -// ============================================================================ -// resolveGroupName -// ============================================================================ - -describe("resolveGroupName", () => { - const noPatterns: CompiledPattern[] = []; - - test("returns null for cluster-level objects (no schemaName)", () => { - const filePath: FilePath = { - path: "cluster/roles.sql", - category: "cluster", - metadata: { objectType: "role" }, - }; - const change = { objectType: "role" } as unknown as Change; - const { compiled: patterns } = compilePatterns([ - { pattern: /role/, name: "role" }, - ]); - expect(resolveGroupName(change, filePath, patterns, true)).toBeNull(); - }); - - test("returns null when no patterns match", () => { - const change = tableChange({ schema: "public", name: "users" }); - expect( - resolveGroupName(change, tableFP("users"), noPatterns, false), - ).toBeNull(); - }); - - // ---------- Auto-detect partitions ---------- - - describe("auto-detect partitions", () => { - test("detects partitioned table and returns parent name", () => { - const change = tableChange({ - schema: "public", - name: "events_p20260107", - isPartition: true, - parentName: "events", - parentSchema: "public", - }); - expect( - resolveGroupName(change, tableFP("events_p20260107"), noPatterns, true), - ).toBe("events"); - }); - - test("skips auto-detection when autoPartitions is false", () => { - const change = tableChange({ - schema: "public", - name: "events_p20260107", - isPartition: true, - parentName: "events", - parentSchema: "public", - }); - expect( - resolveGroupName( - change, - tableFP("events_p20260107"), - noPatterns, - false, - ), - ).toBeNull(); - }); - - test("chains auto-detect parent name through regex patterns", () => { - // Partition parent "kubernetes_resource_events" matches /^kubernetes/ - const change = tableChange({ - schema: "public", - name: "kubernetes_resource_events_p20260107", - isPartition: true, - parentName: "kubernetes_resource_events", - parentSchema: "public", - }); - const { compiled: patterns } = compilePatterns([ - { pattern: /^kubernetes/, name: "kubernetes" }, - ]); - expect( - resolveGroupName( - change, - tableFP("kubernetes_resource_events_p20260107"), - patterns, - true, - ), - ).toBe("kubernetes"); - }); - - test("falls back to parent name when no pattern matches", () => { - const change = tableChange({ - schema: "public", - name: "events_p20260107", - isPartition: true, - parentName: "events", - parentSchema: "public", - }); - const { compiled: patterns } = compilePatterns([ - { pattern: /^unrelated/, name: "unrelated" }, - ]); - expect( - resolveGroupName(change, tableFP("events_p20260107"), patterns, true), - ).toBe("events"); - }); - }); - - // ---------- Regex matching ---------- - - describe("regex patterns", () => { - test("matches prefix-style regex", () => { - const change = tableChange({ schema: "public", name: "project_claim" }); - const { compiled: patterns } = compilePatterns([ - { pattern: /^project/, name: "project" }, - ]); - expect( - resolveGroupName(change, tableFP("project_claim"), patterns, false), - ).toBe("project"); - }); - - test("matches contains-style regex", () => { - const change = tableChange({ - schema: "public", - name: "get_organization_role", - }); - const { compiled: patterns } = compilePatterns([ - { pattern: /organization/, name: "organization" }, - ]); - expect( - resolveGroupName( - change, - tableFP("get_organization_role"), - patterns, - false, - ), - ).toBe("organization"); - }); - - test("matches suffix-style regex", () => { - const change = tableChange({ - schema: "public", - name: "access_tokens", - }); - const { compiled: patterns } = compilePatterns([ - { pattern: /tokens$/, name: "tokens" }, - ]); - expect( - resolveGroupName(change, tableFP("access_tokens"), patterns, false), - ).toBe("tokens"); - }); - - test("matches plurals with prefix regex (users matches /^user/)", () => { - const change = tableChange({ schema: "public", name: "users" }); - const { compiled: patterns } = compilePatterns([ - { pattern: /^user/, name: "user" }, - ]); - expect(resolveGroupName(change, tableFP("users"), patterns, false)).toBe( - "user", - ); - }); - - test("first matching pattern wins (ordering controls priority)", () => { - const change = tableChange({ - schema: "public", - name: "organization_members", - }); - // Both patterns match; first one wins. - const { compiled: patterns } = compilePatterns([ - { pattern: /^organization/, name: "org-prefix" }, - { pattern: /organization/, name: "org-contains" }, - ]); - expect( - resolveGroupName( - change, - tableFP("organization_members"), - patterns, - false, - ), - ).toBe("org-prefix"); - }); - - test("skips non-matching patterns and picks the first that matches", () => { - const change = tableChange({ - schema: "public", - name: "access_tokens", - }); - const { compiled: patterns } = compilePatterns([ - { pattern: /^project/, name: "project" }, - { pattern: /tokens$/, name: "tokens" }, - ]); - expect( - resolveGroupName(change, tableFP("access_tokens"), patterns, false), - ).toBe("tokens"); - }); - - test("string patterns are compiled as regex", () => { - const change = tableChange({ - schema: "public", - name: "billing_invoices", - }); - const { compiled: patterns } = compilePatterns([ - { pattern: "^billing", name: "billing" }, - ]); - expect( - resolveGroupName(change, tableFP("billing_invoices"), patterns, false), - ).toBe("billing"); - }); - }); -}); - -// ============================================================================ -// applyGrouping -// ============================================================================ - -describe("applyGrouping", () => { - const baseFilePath: FilePath = { - path: "schemas/public/tables/wal_verification_results_p20260107.sql", - category: "tables", - metadata: { - objectType: "table", - schemaName: "public", - objectName: "wal_verification_results_p20260107", - }, - }; - - describe("single-file mode", () => { - test("replaces filename with group name in same category directory", () => { - const result = applyGrouping( - baseFilePath, - "wal_verification_results", - "single-file", - ); - expect(result.path).toBe( - "schemas/public/tables/wal_verification_results.sql", - ); - expect(result.category).toBe("tables"); - expect(result.metadata.objectName).toBe("wal_verification_results"); - expect(result.metadata.schemaName).toBe("public"); - }); - }); - - describe("subdirectory mode", () => { - test("moves to group-named directory with category as filename", () => { - const result = applyGrouping( - baseFilePath, - "wal_verification_results", - "subdirectory", - ); - expect(result.path).toBe( - "schemas/public/wal_verification_results/tables.sql", - ); - expect(result.category).toBe("tables"); - expect(result.metadata.objectName).toBe("wal_verification_results"); - }); - - test("works for types category", () => { - const typePath: FilePath = { - path: "schemas/public/types/kubernetes_resource_event_type.sql", - category: "types", - metadata: { - objectType: "enum", - schemaName: "public", - objectName: "kubernetes_resource_event_type", - }, - }; - const result = applyGrouping(typePath, "kubernetes", "subdirectory"); - expect(result.path).toBe("schemas/public/kubernetes/types.sql"); - }); - - test("works for functions category", () => { - const funcPath: FilePath = { - path: "schemas/public/functions/get_project_owner.sql", - category: "functions", - metadata: { - objectType: "function", - schemaName: "public", - objectName: "get_project_owner", - }, - }; - const result = applyGrouping(funcPath, "project", "subdirectory"); - expect(result.path).toBe("schemas/public/project/functions.sql"); - }); - }); -}); - -// ============================================================================ -// createFileMapper (end-to-end) -// ============================================================================ - -describe("createFileMapper", () => { - test("returns getFilePath unchanged when no grouping is provided", () => { - const mapper = createFileMapper(undefined); - const change = tableChange({ schema: "public", name: "users" }); - expect(mapper(change).path).toBe("schemas/public/tables/users.sql"); - }); - - test("maps partition tables to parent file when no grouping is provided", () => { - const mapper = createFileMapper(undefined); - const partition = tableChange({ - schema: "public", - name: "events_p20260107", - isPartition: true, - parentName: "events", - parentSchema: "public", - }); - expect(mapper(partition).path).toBe("schemas/public/tables/events.sql"); - }); - - test("groups partition tables into parent file (single-file mode)", () => { - const mapper = createFileMapper({ - mode: "single-file", - autoGroupPartitions: true, - }); - - const parent = tableChange({ schema: "public", name: "events" }); - expect(mapper(parent).path).toBe("schemas/public/tables/events.sql"); - - const partition = tableChange({ - schema: "public", - name: "events_p20260107", - isPartition: true, - parentName: "events", - parentSchema: "public", - }); - expect(mapper(partition).path).toBe("schemas/public/tables/events.sql"); - }); - - test("maps trigger on view into views folder", () => { - const mapper = createFileMapper(undefined); - const change = triggerChange({ - schema: "public", - name: "public_projects_on_insert", - tableName: "projects", - tableRelkind: "v", - }); - expect(mapper(change).path).toBe("schemas/public/views/projects.sql"); - }); - - test("maps trigger on table into tables folder", () => { - const mapper = createFileMapper(undefined); - const change = triggerChange({ - schema: "public", - name: "users_on_insert", - tableName: "users", - tableRelkind: "r", - }); - expect(mapper(change).path).toBe("schemas/public/tables/users.sql"); - }); - - test("maps rule on view into views folder", () => { - const mapper = createFileMapper(undefined); - const change = ruleChange({ - schema: "public", - name: "projects_read_rule", - tableName: "projects", - relationKind: "v", - }); - expect(mapper(change).path).toBe("schemas/public/views/projects.sql"); - }); - - test("maps rule on table into tables folder", () => { - const mapper = createFileMapper(undefined); - const change = ruleChange({ - schema: "public", - name: "users_read_rule", - tableName: "users", - relationKind: "r", - }); - expect(mapper(change).path).toBe("schemas/public/tables/users.sql"); - }); - - test("groups partition tables into subdirectory (subdirectory mode)", () => { - const mapper = createFileMapper({ - mode: "subdirectory", - autoGroupPartitions: true, - }); - - const partition = tableChange({ - schema: "public", - name: "events_p20260107", - isPartition: true, - parentName: "events", - parentSchema: "public", - }); - expect(mapper(partition).path).toBe("schemas/public/events/tables.sql"); - }); - - test("groups by prefix regex (single-file mode)", () => { - const mapper = createFileMapper({ - mode: "single-file", - autoGroupPartitions: false, - groupPatterns: [{ pattern: /^project/, name: "project" }], - }); - - const change = tableChange({ - schema: "public", - name: "project_claim_tokens", - }); - expect(mapper(change).path).toBe("schemas/public/tables/project.sql"); - }); - - test("groups by contains regex (subdirectory mode)", () => { - const mapper = createFileMapper({ - mode: "subdirectory", - autoGroupPartitions: false, - groupPatterns: [{ pattern: /organization/, name: "organization" }], - }); - - const change = tableChange({ - schema: "public", - name: "get_organization_role", - }); - expect(mapper(change).path).toBe("schemas/public/organization/tables.sql"); - }); - - test("groups by suffix regex (single-file mode)", () => { - const mapper = createFileMapper({ - mode: "single-file", - autoGroupPartitions: false, - groupPatterns: [{ pattern: /tokens$/, name: "tokens" }], - }); - - const change = tableChange({ schema: "public", name: "access_tokens" }); - expect(mapper(change).path).toBe("schemas/public/tables/tokens.sql"); - }); - - test("does not group unrelated tables", () => { - const mapper = createFileMapper({ - mode: "single-file", - autoGroupPartitions: true, - groupPatterns: [{ pattern: /^project/, name: "project" }], - }); - - const change = tableChange({ schema: "public", name: "users" }); - expect(mapper(change).path).toBe("schemas/public/tables/users.sql"); - }); - - test("first pattern wins when multiple match", () => { - const mapper = createFileMapper({ - mode: "single-file", - autoGroupPartitions: false, - groupPatterns: [ - { pattern: /^organization/, name: "org-prefix" }, - { pattern: /organization/, name: "org-contains" }, - ], - }); - - const change = tableChange({ - schema: "public", - name: "organization_members", - }); - expect(mapper(change).path).toBe("schemas/public/tables/org-prefix.sql"); - }); - - test("chains partition auto-detect through regex (kubernetes scenario)", () => { - const mapper = createFileMapper({ - mode: "single-file", - autoGroupPartitions: true, - groupPatterns: [{ pattern: /^kubernetes/, name: "kubernetes" }], - }); - - // Parent table - const parent = tableChange({ - schema: "public", - name: "kubernetes_resource_events", - }); - expect(mapper(parent).path).toBe("schemas/public/tables/kubernetes.sql"); - - // Partition: auto-detect → parent "kubernetes_resource_events" → /^kubernetes/ matches - const partition = tableChange({ - schema: "public", - name: "kubernetes_resource_events_p20260107", - isPartition: true, - parentName: "kubernetes_resource_events", - parentSchema: "public", - }); - expect(mapper(partition).path).toBe("schemas/public/tables/kubernetes.sql"); - - // Another kubernetes table - const other = tableChange({ - schema: "public", - name: "kubernetes_clusters", - }); - expect(mapper(other).path).toBe("schemas/public/tables/kubernetes.sql"); - }); - - test("all pattern types combined end-to-end", () => { - const mapper = createFileMapper({ - mode: "subdirectory", - autoGroupPartitions: false, - groupPatterns: [ - { pattern: /^project/, name: "project" }, - { pattern: /organization/, name: "organization" }, - { pattern: /keys$/, name: "keys" }, - ], - }); - - // Prefix match - const t1 = tableChange({ schema: "public", name: "project_claim_tokens" }); - expect(mapper(t1).path).toBe("schemas/public/project/tables.sql"); - - // Contains match - const t2 = tableChange({ - schema: "public", - name: "get_organization_role", - }); - expect(mapper(t2).path).toBe("schemas/public/organization/tables.sql"); - - // Suffix match - const t3 = tableChange({ schema: "public", name: "api_keys" }); - expect(mapper(t3).path).toBe("schemas/public/keys/tables.sql"); - - // No match - const t4 = tableChange({ schema: "public", name: "users" }); - expect(mapper(t4).path).toBe("schemas/public/tables/users.sql"); - }); - - test("accepts string patterns (compiled as regex)", () => { - const mapper = createFileMapper({ - mode: "single-file", - autoGroupPartitions: false, - groupPatterns: [{ pattern: "^billing", name: "billing" }], - }); - - const change = tableChange({ - schema: "public", - name: "billing_invoices", - }); - expect(mapper(change).path).toBe("schemas/public/tables/billing.sql"); - }); -}); - -// ============================================================================ -// flattenSchema -// ============================================================================ - -describe("flattenSchema", () => { - test("collapses tables path to schemas/{schema}/tables.sql", () => { - const fp: FilePath = { - path: "schemas/partman/tables/template_public_events.sql", - category: "tables", - metadata: { - objectType: "table", - schemaName: "partman", - objectName: "template_public_events", - }, - }; - const result = flattenSchema(fp); - expect(result.path).toBe("schemas/partman/tables.sql"); - expect(result.category).toBe("tables"); - expect(result.metadata.objectName).toBe("tables"); - expect(result.metadata.schemaName).toBe("partman"); - }); - - test("collapses functions path to schemas/{schema}/functions.sql", () => { - const fp: FilePath = { - path: "schemas/pgboss/functions/some_function.sql", - category: "functions", - metadata: { - objectType: "function", - schemaName: "pgboss", - objectName: "some_function", - }, - }; - const result = flattenSchema(fp); - expect(result.path).toBe("schemas/pgboss/functions.sql"); - expect(result.metadata.objectName).toBe("functions"); - }); - - test("leaves schema.sql unchanged", () => { - const fp: FilePath = { - path: "schemas/partman/schema.sql", - category: "schema", - metadata: { - objectType: "schema", - schemaName: "partman", - objectName: "partman", - }, - }; - const result = flattenSchema(fp); - expect(result).toBe(fp); // same reference -- untouched - }); -}); - -// ============================================================================ -// createFileMapper -- flatSchemas -// ============================================================================ - -describe("createFileMapper with flatSchemas", () => { - test("flattens tables in a flat schema to one file", () => { - const mapper = createFileMapper({ - mode: "single-file", - flatSchemas: ["partman"], - }); - - const t1 = tableChange({ - schema: "partman", - name: "template_public_events", - }); - const t2 = tableChange({ - schema: "partman", - name: "template_public_wal_verification_results", - }); - - expect(mapper(t1).path).toBe("schemas/partman/tables.sql"); - expect(mapper(t2).path).toBe("schemas/partman/tables.sql"); - }); - - test("does not flatten schemas that are not in the flatSchemas list", () => { - const mapper = createFileMapper({ - mode: "single-file", - flatSchemas: ["partman"], - }); - - const change = tableChange({ schema: "public", name: "users" }); - expect(mapper(change).path).toBe("schemas/public/tables/users.sql"); - }); - - test("flat schema takes priority over regex patterns", () => { - const mapper = createFileMapper({ - mode: "single-file", - flatSchemas: ["partman"], - groupPatterns: [{ pattern: /^template/, name: "template" }], - }); - - // The table name matches the regex, but since the schema is flat, we - // should get the flat path instead. - const change = tableChange({ - schema: "partman", - name: "template_public_events", - }); - expect(mapper(change).path).toBe("schemas/partman/tables.sql"); - }); - - test("leaves schema.sql unchanged for flat schemas", () => { - const mapper = createFileMapper({ - mode: "single-file", - flatSchemas: ["partman"], - }); - - // Simulate a schema-level change (not a table) - const change = { - objectType: "schema", - operation: "create", - scope: "object", - schema: { name: "partman" }, - serialize: () => "CREATE SCHEMA partman", - } as unknown as Change; - - const result = mapper(change); - // schema.sql is not affected by flattening - expect(result.path).toBe("schemas/partman/schema.sql"); - }); - - test("regex patterns still apply to non-flat schemas", () => { - const mapper = createFileMapper({ - mode: "subdirectory", - flatSchemas: ["partman"], - groupPatterns: [{ pattern: /^project/, name: "project" }], - }); - - // Flat schema → flattened - const t1 = tableChange({ - schema: "partman", - name: "template_public_events", - }); - expect(mapper(t1).path).toBe("schemas/partman/tables.sql"); - - // Non-flat schema → regex patterns apply - const t2 = tableChange({ schema: "public", name: "project_claim_tokens" }); - expect(mapper(t2).path).toBe("schemas/public/project/tables.sql"); - - // Non-flat, non-matching → default - const t3 = tableChange({ schema: "public", name: "users" }); - expect(mapper(t3).path).toBe("schemas/public/tables/users.sql"); - }); -}); diff --git a/packages/pg-delta/src/core/export/file-mapper.ts b/packages/pg-delta/src/core/export/file-mapper.ts deleted file mode 100644 index 1e84785c5..000000000 --- a/packages/pg-delta/src/core/export/file-mapper.ts +++ /dev/null @@ -1,579 +0,0 @@ -/** - * Map changes to declarative schema file paths. - */ - -import createDebug from "debug"; -import type { Change } from "../change.types.ts"; -import { - getObjectName, - getObjectSchema, - getParentInfo, -} from "../plan/serialize.ts"; -import type { - FileCategory, - FilePath, - Grouping, - GroupingPattern, -} from "./types.ts"; - -const debugExport = createDebug("pg-delta:export"); - -// ============================================================================ -// Helpers -// ============================================================================ - -type RoleDefaultPrivilegeChange = Change & { - objectType: "role"; - scope: "default_privilege"; - inSchema: string | null; -}; - -function isRoleDefaultPrivilegeChange( - change: Change, -): change is RoleDefaultPrivilegeChange { - return ( - change.objectType === "role" && - change.scope === "default_privilege" && - "inSchema" in change - ); -} - -function requireSchema(change: Change): string { - const schema = getObjectSchema(change); - if (!schema) { - throw new Error( - `Expected schema for ${change.objectType} change '${getObjectName(change)}' (operation: ${change.operation})`, - ); - } - return schema; -} - -function schemaPath(schema: string, ...parts: string[]): string { - return `schemas/${schema}/${parts.join("/")}`; -} - -// ============================================================================ -// File Path Mapping -// ============================================================================ - -export function getFilePath(change: Change): FilePath { - switch (change.objectType) { - case "role": - if (isRoleDefaultPrivilegeChange(change) && change.inSchema) { - const schemaName = change.inSchema; - return { - path: schemaPath(schemaName, "schema.sql"), - category: "schema", - metadata: { - objectType: "default_privilege", - schemaName, - objectName: schemaName, - }, - }; - } - return { - path: "cluster/roles.sql", - category: "cluster", - metadata: { objectType: "role" }, - }; - case "extension": { - const extensionName = getObjectName(change); - return { - path: `cluster/extensions/${extensionName}.sql`, - category: "extensions", - metadata: { objectType: "extension", objectName: extensionName }, - }; - } - case "foreign_data_wrapper": - case "server": - case "user_mapping": - return { - path: "cluster/foreign_data_wrappers.sql", - category: "cluster", - metadata: { objectType: change.objectType }, - }; - case "publication": - return { - path: "cluster/publications.sql", - category: "cluster", - metadata: { objectType: "publication" }, - }; - case "subscription": - return { - path: "cluster/subscriptions.sql", - category: "cluster", - metadata: { objectType: "subscription" }, - }; - case "event_trigger": - return { - path: "cluster/event_triggers.sql", - category: "cluster", - metadata: { objectType: "event_trigger" }, - }; - case "language": - return { - path: "cluster/languages.sql", - category: "cluster", - metadata: { objectType: "language" }, - }; - case "schema": { - const schemaName = change.schema.name; - return { - path: schemaPath(schemaName, "schema.sql"), - category: "schema", - metadata: { - objectType: "schema", - schemaName, - objectName: schemaName, - }, - }; - } - case "enum": - case "composite_type": - case "range": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - return { - path: schemaPath(schema, "types", `${objectName}.sql`), - category: "types", - metadata: { - objectType: change.objectType, - schemaName: schema, - objectName, - }, - }; - } - case "domain": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - return { - path: schemaPath(schema, "domains", `${objectName}.sql`), - category: "domains", - metadata: { - objectType: "domain", - schemaName: schema, - objectName, - }, - }; - } - case "collation": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - return { - path: schemaPath(schema, "collations", `${objectName}.sql`), - category: "collations", - metadata: { - objectType: "collation", - schemaName: schema, - objectName, - }, - }; - } - case "sequence": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - - // ALTER SEQUENCE ... OWNED BY must be grouped with the owning table, - // not the sequence file, to avoid ordering issues: the table must exist - // before the OWNED BY clause can reference its column. - if ( - change.operation === "alter" && - "ownedBy" in change && - change.ownedBy - ) { - const ownedBy = change.ownedBy as { - schema: string; - table: string; - column: string; - }; - return { - path: schemaPath(ownedBy.schema, "tables", `${ownedBy.table}.sql`), - category: "tables", - metadata: { - objectType: "table", - schemaName: ownedBy.schema, - objectName: ownedBy.table, - }, - }; - } - - return { - path: schemaPath(schema, "sequences", `${objectName}.sql`), - category: "sequences", - metadata: { - objectType: "sequence", - schemaName: schema, - objectName, - }, - }; - } - case "table": { - const schema = change.table.schema; - const tableName = change.table.name; - // Partitions always go in the same file as their parent table. - if (change.table.is_partition && change.table.parent_name) { - const parentSchema = change.table.parent_schema ?? change.table.schema; - return { - path: schemaPath( - parentSchema, - "tables", - `${change.table.parent_name}.sql`, - ), - category: "tables", - metadata: { - objectType: "table", - schemaName: parentSchema, - objectName: change.table.parent_name, - }, - }; - } - return { - path: schemaPath(schema, "tables", `${tableName}.sql`), - category: "tables", - metadata: { - objectType: "table", - schemaName: schema, - objectName: tableName, - }, - }; - } - case "foreign_table": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - return { - path: schemaPath(schema, "foreign_tables", `${objectName}.sql`), - category: "foreign_tables", - metadata: { - objectType: "foreign_table", - schemaName: schema, - objectName, - }, - }; - } - case "view": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - return { - path: schemaPath(schema, "views", `${objectName}.sql`), - category: "views", - metadata: { - objectType: "view", - schemaName: schema, - objectName, - }, - }; - } - case "materialized_view": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - return { - path: schemaPath(schema, "matviews", `${objectName}.sql`), - category: "matviews", - metadata: { - objectType: "materialized_view", - schemaName: schema, - objectName, - }, - }; - } - case "procedure": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - const isProcedure = change.procedure.kind === "p"; - return { - path: schemaPath( - schema, - isProcedure ? "procedures" : "functions", - `${objectName}.sql`, - ), - category: isProcedure ? "procedures" : "functions", - metadata: { - objectType: isProcedure ? "procedure" : "function", - schemaName: schema, - objectName, - }, - }; - } - case "aggregate": { - const schema = requireSchema(change); - const objectName = getObjectName(change); - return { - path: schemaPath(schema, "aggregates", `${objectName}.sql`), - category: "aggregates", - metadata: { - objectType: "aggregate", - schemaName: schema, - objectName, - }, - }; - } - case "index": { - const schema = requireSchema(change); - const parent = getParentInfo(change); - if (!parent) { - throw new Error( - `Expected parent for index '${getObjectName(change)}' in schema '${schema}'`, - ); - } - const parentName = parent.name; - const category = - parent.type === "materialized_view" ? "matviews" : "tables"; - return { - path: schemaPath(schema, category, `${parentName}.sql`), - category, - metadata: { - objectType: parent.type, - schemaName: schema, - objectName: parentName, - }, - }; - } - case "trigger": - case "rls_policy": - case "rule": { - const schema = requireSchema(change); - const parent = getParentInfo(change); - if (!parent) { - throw new Error( - `Expected parent for ${change.objectType} '${getObjectName(change)}' in schema '${schema}'`, - ); - } - const parentName = parent.name; - const category = - parent.type === "view" - ? "views" - : parent.type === "materialized_view" - ? "matviews" - : "tables"; - return { - path: schemaPath(schema, category, `${parentName}.sql`), - category, - metadata: { - objectType: parent.type, - schemaName: schema, - objectName: parentName, - }, - }; - } - default: { - const _exhaustive: never = change; - return _exhaustive; - } - } -} - -// ============================================================================ -// Entity Grouping -// ============================================================================ - -/** A compiled grouping pattern: pre-built RegExp + group name. */ -export interface CompiledPattern { - regex: RegExp; - name: string; -} - -/** Result of compilePatterns: valid compiled patterns plus warnings for skipped/invalid regexes. */ -interface CompilePatternsResult { - compiled: CompiledPattern[]; - warnings: string[]; -} - -/** - * Compile user-facing `GroupingPattern[]` into `CompiledPattern[]`. - * Strings are turned into `new RegExp(str)`. Invalid regex strings are skipped - * (no throw), so the returned `compiled` array may be shorter than the input. - * Any skipped patterns are reported in `warnings`. - */ -export function compilePatterns( - patterns: GroupingPattern[], -): CompilePatternsResult { - const compiled: CompiledPattern[] = []; - const warnings: string[] = []; - for (const p of patterns) { - let regex: RegExp; - if (typeof p.pattern === "string") { - try { - regex = new RegExp(p.pattern); - } catch (e) { - const msg = `Skipping invalid grouping regex '${p.pattern}': ${e instanceof Error ? e.message : String(e)}`; - debugExport(msg); - warnings.push(msg); - continue; - } - } else { - // Strip /g and /y flags — .test() mutates lastIndex with these flags, - // causing non-deterministic matching across repeated calls. - const flags = p.pattern.flags.replace(/[gy]/g, ""); - regex = - flags !== p.pattern.flags - ? new RegExp(p.pattern.source, flags) - : p.pattern; - } - compiled.push({ regex, name: p.name }); - } - return { compiled, warnings }; -} - -/** - * Create a file mapper that applies regex-based grouping on top of the - * default `getFilePath` mapping. - * - * When no grouping config is provided (or it is undefined), the plain - * `getFilePath` function is returned unchanged. - */ -export function createFileMapper( - grouping?: Grouping, - onWarning?: (message: string) => void, -): (change: Change) => FilePath { - if (!grouping) return getFilePath; - - const { compiled, warnings } = compilePatterns(grouping.groupPatterns ?? []); - for (const w of warnings) { - onWarning?.(w); - } - const autoPartitions = grouping.autoGroupPartitions !== false; // default true - const flatSet = new Set(grouping.flatSchemas ?? []); - - return (change: Change): FilePath => { - const basePath = getFilePath(change); - - // Flat schemas: collapse everything into one file per category. - // Applied first -- skips pattern matching for these schemas. - if ( - flatSet.size > 0 && - basePath.metadata.schemaName && - flatSet.has(basePath.metadata.schemaName) - ) { - return flattenSchema(basePath); - } - - const groupName = resolveGroupName( - change, - basePath, - compiled, - autoPartitions, - ); - if (!groupName) return basePath; - return applyGrouping(basePath, groupName, grouping.mode); - }; -} - -/** - * Flatten a schema-scoped file path into one file per category. - * - * e.g. `schemas/partman/tables/template_public_events.sql` - * → `schemas/partman/tables.sql` - * - * `schema.sql` is left unchanged (it is already flat). - */ -export function flattenSchema(filePath: FilePath): FilePath { - const schema = filePath.metadata.schemaName ?? ""; - const category = filePath.category; - - // schema.sql stays as-is - if (category === "schema") return filePath; - - return { - path: schemaPath(schema, `${category}.sql`), - category, - metadata: { - ...filePath.metadata, - objectName: category, - }, - }; -} - -/** - * Determine the group name for a change, or `null` if it should not be - * grouped. - * - * Resolution order: - * 1. Automatic partition detection -- resolve the parent table name. - * 2. Regex patterns -- first match wins (user controls priority by ordering). - * - * The resolved name from step 1 is fed through step 2 so that a partition - * parent name can itself be matched by a broader pattern (e.g. parent - * "kubernetes_resource_events" matches `/^kubernetes/`). - * - * If auto-detect resolved a parent but no pattern matched, the parent name - * is used as-is. - */ -export function resolveGroupName( - change: Change, - filePath: FilePath, - patterns: CompiledPattern[], - autoPartitions: boolean, -): string | null { - // Only schema-scoped objects can be grouped (skip cluster-level). - if (!filePath.metadata.schemaName) return null; - - // 1. Auto-detect partitions: table changes where the table is a partition - // of another table. - let resolvedName: string | null = null; - if ( - autoPartitions && - change.objectType === "table" && - change.table.is_partition && - change.table.parent_name - ) { - resolvedName = change.table.parent_name; - } - - // 2. Regex patterns -- first match wins. - const nameToMatch = resolvedName ?? filePath.metadata.objectName; - if (nameToMatch) { - for (const p of patterns) { - if (p.regex.test(nameToMatch)) { - return p.name; - } - } - } - - // 3. If auto-detect found a parent but no pattern matched, use the parent - // name directly. - return resolvedName; -} - -/** - * Rewrite a `FilePath` according to the chosen grouping mode. - * - * - **single-file**: the filename becomes `{prefix}.sql` inside the original - * category directory. - * e.g. `schemas/public/tables/wal_verification_results_p20260107.sql` - * → `schemas/public/tables/wal_verification_results.sql` - * - * - **subdirectory**: the file is moved to a prefix-named directory under the - * schema root, with the category as the filename. - * e.g. `schemas/public/tables/wal_verification_results_p20260107.sql` - * → `schemas/public/wal_verification_results/tables.sql` - */ -export function applyGrouping( - filePath: FilePath, - prefix: string, - mode: Grouping["mode"], -): FilePath { - const schema = filePath.metadata.schemaName ?? ""; - const category = filePath.category as FileCategory; - - if (mode === "single-file") { - // Replace the filename, keep the category directory. - return { - path: schemaPath(schema, category, `${prefix}.sql`), - category, - metadata: { - ...filePath.metadata, - objectName: prefix, - }, - }; - } - - // subdirectory mode: schemas/{schema}/{prefix}/{category}.sql - return { - path: schemaPath(schema, prefix, `${category}.sql`), - category, - metadata: { - ...filePath.metadata, - objectName: prefix, - }, - }; -} diff --git a/packages/pg-delta/src/core/export/grouper.ts b/packages/pg-delta/src/core/export/grouper.ts deleted file mode 100644 index aeb033114..000000000 --- a/packages/pg-delta/src/core/export/grouper.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Group changes into declarative schema files and order them for readability. - */ - -import type { Change } from "../change.types.ts"; -import { getFilePath } from "./file-mapper.ts"; -import type { FileCategory, FileMetadata, FilePath } from "./types.ts"; -import { CATEGORY_PRIORITY } from "./types.ts"; - -// ============================================================================ -// Types -// ============================================================================ - -interface FileGroup { - path: string; - category: FileCategory; - metadata: FileMetadata; - changes: Change[]; -} - -// ============================================================================ -// Within-file ordering -// ============================================================================ - -const OPERATION_PRIORITY: Record = { - create: 0, - alter: 1, -}; - -const SCOPE_PRIORITY: Record = { - object: 0, - comment: 1, - privilege: 2, - default_privilege: 3, - membership: 4, -}; - -/** - * Sort changes within a file for readability: - * 1. By operation: create → alter - * 2. By scope: object → comment → privilege → default_privilege → membership - * 3. Stable tie-break by original position - */ -function sortChangesWithinFile(changes: Change[]): Change[] { - // Tag each change with its original index for stable tie-breaking. - const tagged = changes.map((change, index) => ({ change, index })); - tagged.sort((a, b) => { - const opA = OPERATION_PRIORITY[a.change.operation] ?? 99; - const opB = OPERATION_PRIORITY[b.change.operation] ?? 99; - if (opA !== opB) return opA - opB; - - const scopeA = - SCOPE_PRIORITY[(a.change as { scope?: string }).scope ?? "object"] ?? 99; - const scopeB = - SCOPE_PRIORITY[(b.change as { scope?: string }).scope ?? "object"] ?? 99; - if (scopeA !== scopeB) return scopeA - scopeB; - - return a.index - b.index; - }); - return tagged.map((t) => t.change); -} - -// ============================================================================ -// Grouping & Ordering -// ============================================================================ - -export function groupChangesByFile( - changes: Change[], - mapper: (change: Change) => FilePath = getFilePath, -): FileGroup[] { - const groups = new Map(); - - for (const change of changes) { - const file = mapper(change); - - const existing = groups.get(file.path); - if (!existing) { - groups.set(file.path, { - path: file.path, - category: file.category, - metadata: file.metadata, - changes: [change], - }); - continue; - } - - existing.changes.push(change); - } - - // Sort within each file for readability. - for (const group of groups.values()) { - group.changes = sortChangesWithinFile(group.changes); - } - - // Sort files by category priority, then alphabetically by path. - return Array.from(groups.values()).sort(sortByCategory); -} - -/** - * Sort by category priority, then path for determinism. - */ -function sortByCategory(a: FileGroup, b: FileGroup): number { - const categoryDiff = - CATEGORY_PRIORITY[a.category] - CATEGORY_PRIORITY[b.category]; - if (categoryDiff !== 0) return categoryDiff; - - return a.path.localeCompare(b.path); -} diff --git a/packages/pg-delta/src/core/export/index.ts b/packages/pg-delta/src/core/export/index.ts deleted file mode 100644 index 2d740e469..000000000 --- a/packages/pg-delta/src/core/export/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Declarative schema export. - */ - -import type { Change } from "../change.types.ts"; -import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts"; -import { - type Integration, - type ResolvedIntegration, - resolveIntegration, -} from "../integrations/integration.types.ts"; -import type { createPlan } from "../plan/create.ts"; -import { DEFAULT_OPTIONS } from "../plan/sql-format/constants.ts"; -import type { SqlFormatOptions } from "../plan/sql-format/types.ts"; -import { formatSqlScript } from "../plan/statements.ts"; -import { createFileMapper } from "./file-mapper.ts"; -import { groupChangesByFile } from "./grouper.ts"; -import type { DeclarativeSchemaOutput, FileEntry, Grouping } from "./types.ts"; - -// ============================================================================ -// Types -// ============================================================================ - -/** - * The result of createPlan, containing the plan, sorted changes, and context. - * Use this type when you have already confirmed createPlan returned non-null. - */ -type PlanResult = NonNullable>>; - -// ============================================================================ -// Public API -// ============================================================================ - -export interface ExportOptions { - /** Integration for custom serialization */ - integration?: Pick; - /** - * SQL formatter options to control the output style. - * Merged on top of the default export options (maxWidth: 180, keywordCase: "upper"). - * See `SqlFormatOptions` for available keys. - */ - formatOptions?: SqlFormatOptions | null; - /** - * Group entities by name prefix into consolidated files or subdirectories. - * Supports automatic partition detection and/or explicit prefix lists. - */ - grouping?: Grouping; - /** Callback for non-fatal warnings (e.g. invalid grouping regex patterns). */ - onWarning?: (message: string) => void; -} - -/** - * Export a declarative schema from a plan result. - * - * Takes the output of `createPlan()` and generates a declarative schema output - * with files grouped by object type. Drop operations are excluded since - * declarative mode targets the final desired state. - * - * Dependency-based filtering (cascading exclusions) is handled by `createPlan`, - * so this function only needs to filter out drop operations. - * - * @param planResult - The result from createPlan() containing plan, sortedChanges, and ctx - * @param options - Optional integration for custom serialization - * @returns Declarative schema output with grouped files - */ -export function exportDeclarativeSchema( - planResult: PlanResult, - options?: ExportOptions, -): DeclarativeSchemaOutput { - const { ctx, sortedChanges } = planResult; - const integration = options?.integration - ? resolveIntegration(options?.integration) - : {}; - const formatOptions: SqlFormatOptions | undefined = - options?.formatOptions === null - ? undefined - : { - ...DEFAULT_OPTIONS, - maxWidth: 180, - keywordCase: "upper", - ...options?.formatOptions, - }; - - // Drop filtering and dependency cascading are handled upstream by createPlan. - - const { hash: sourceFingerprint, stableIds } = buildPlanScopeFingerprint( - ctx.mainCatalog, - sortedChanges, - ); - const targetFingerprint = hashStableIds(ctx.branchCatalog, stableIds); - - const mapper = createFileMapper(options?.grouping, options?.onWarning); - const groups = groupChangesByFile(sortedChanges, mapper); - const files = groups.map((group, index) => { - const statements = group.changes.map((change) => - serializeChange(change, integration), - ); - return buildFileEntry( - group.path, - group.metadata, - statements, - index, - formatOptions, - ); - }); - - return { - version: 1, - mode: "declarative", - generatedAt: new Date().toISOString(), - source: { fingerprint: sourceFingerprint }, - target: { fingerprint: targetFingerprint }, - files, - }; -} - -function serializeChange( - change: Change, - integration?: ResolvedIntegration, -): string { - return integration?.serialize?.(change) ?? change.serialize(); -} - -function buildFileEntry( - path: string, - metadata: FileEntry["metadata"], - statements: string[], - order: number, - formatOptions?: SqlFormatOptions, -): FileEntry { - return { - path, - order, - statements: statements.length, - sql: formatSqlScript(statements, formatOptions), - metadata, - }; -} diff --git a/packages/pg-delta/src/core/export/types.ts b/packages/pg-delta/src/core/export/types.ts deleted file mode 100644 index 85ed3af80..000000000 --- a/packages/pg-delta/src/core/export/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Type definitions for declarative schema export. - */ - -// ============================================================================ -// File Categories -// ============================================================================ - -export const CATEGORY_PRIORITY = { - cluster: 0, - schema: 1, - extensions: 2, - types: 3, - sequences: 4, - tables: 5, - foreign_tables: 6, - views: 7, - matviews: 8, - functions: 9, - procedures: 10, - aggregates: 11, - domains: 12, - collations: 13, - publications: 14, - subscriptions: 15, - event_triggers: 16, -} as const; - -export type FileCategory = keyof typeof CATEGORY_PRIORITY; - -// ============================================================================ -// Output Types -// ============================================================================ - -export interface FileMetadata { - objectType: string; - schemaName?: string; - objectName?: string; -} - -export interface FileEntry { - path: string; - order: number; - statements: number; - sql: string; - metadata: FileMetadata; -} - -export interface DeclarativeSchemaOutput { - version: 1; - mode: "declarative"; - generatedAt: string; - source: { fingerprint: string }; - target: { fingerprint: string }; - files: FileEntry[]; -} - -// ============================================================================ -// Entity Grouping -// ============================================================================ - -/** A regex pattern with a group name used as the directory or file name. */ -export interface GroupingPattern { - /** Regex to test against the object name. Strings are compiled to RegExp. */ - pattern: string | RegExp; - /** Group name used as the directory or file name. */ - name: string; -} - -export interface Grouping { - /** How grouped entities are organized on disk. */ - mode: "single-file" | "subdirectory"; - /** - * Regex-based patterns to match object names. - * First matching pattern wins -- ordering controls priority. - * - * Examples: - * - `{ pattern: /^project/, name: "project" }` – prefix - * - `{ pattern: /organization/, name: "organization" }` – contains - * - `{ pattern: /tokens$/, name: "tokens" }` – suffix - */ - groupPatterns?: GroupingPattern[]; - /** - * Automatically detect partitioned tables and group partitions with - * their parent table. Defaults to `true`. - */ - autoGroupPartitions?: boolean; - /** - * Schemas to flatten: all objects are merged into one file per category. - * e.g. `schemas/partman/tables.sql` instead of `schemas/partman/tables/foo.sql`. - * Useful for small or extension schemas that don't need per-object files. - */ - flatSchemas?: string[]; -} - -// ============================================================================ -// Internal Types -// ============================================================================ - -export interface FilePath { - path: string; - category: FileCategory; - metadata: FileMetadata; -} diff --git a/packages/pg-delta/src/core/fact.test.ts b/packages/pg-delta/src/core/fact.test.ts new file mode 100644 index 000000000..7401afe0b --- /dev/null +++ b/packages/pg-delta/src/core/fact.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact, type DependencyEdge } from "./fact.ts"; +import type { StableId } from "./stable-id.ts"; + +const schema: StableId = { kind: "schema", name: "public" }; +const table: StableId = { kind: "table", schema: "public", name: "users" }; +const colA: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "a", +}; +const colB: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "b", +}; +const role: StableId = { kind: "role", name: "owner1" }; + +function baseFacts(): Fact[] { + return [ + { id: schema, payload: {} }, + { id: role, payload: { login: false } }, + { id: table, parent: schema, payload: { persistence: "p" } }, + { id: colA, parent: table, payload: { type: "integer", notNull: false } }, + { id: colB, parent: table, payload: { type: "text", notNull: true } }, + ]; +} + +describe("buildFactBase", () => { + test("insertion order does not affect any hash", () => { + const facts = baseFacts(); + const fb1 = buildFactBase(facts, []); + const fb2 = buildFactBase([...facts].reverse(), []); + expect(fb1.rootHash).toBe(fb2.rootHash); + expect(fb1.rollupOf(table)).toBe(fb2.rollupOf(table)); + }); + + test("a leaf payload change propagates to ancestors but not siblings", () => { + const fb1 = buildFactBase(baseFacts(), []); + const changed = baseFacts().map((f) => + f.id === colA ? { ...f, payload: { type: "bigint", notNull: false } } : f, + ); + const fb2 = buildFactBase(changed, []); + expect(fb2.hashOf(colA)).not.toBe(fb1.hashOf(colA)); + expect(fb2.rollupOf(table)).not.toBe(fb1.rollupOf(table)); + expect(fb2.rollupOf(schema)).not.toBe(fb1.rollupOf(schema)); + expect(fb2.rootHash).not.toBe(fb1.rootHash); + // sibling untouched + expect(fb2.hashOf(colB)).toBe(fb1.hashOf(colB)); + expect(fb2.rollupOf(colB)).toBe(fb1.rollupOf(colB)); + // unrelated root untouched + expect(fb2.rollupOf(role)).toBe(fb1.rollupOf(role)); + }); + + test("renaming a child changes parent rollup but not parent structural rollup", () => { + const fb1 = buildFactBase(baseFacts(), []); + const renamed: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "a2", + }; + const facts = baseFacts().map((f) => + f.id === colA ? { ...f, id: renamed } : f, + ); + const fb2 = buildFactBase(facts, []); + expect(fb2.rollupOf(table)).not.toBe(fb1.rollupOf(table)); + expect(fb2.structuralRollupOf(table)).toBe(fb1.structuralRollupOf(table)); + }); + + test("an edge change is visible in the rollup of its source fact", () => { + const e1: DependencyEdge[] = [{ from: table, to: role, kind: "owner" }]; + const fb1 = buildFactBase(baseFacts(), e1); + const fb0 = buildFactBase(baseFacts(), []); + expect(fb1.rollupOf(table)).not.toBe(fb0.rollupOf(table)); + expect(fb1.rootHash).not.toBe(fb0.rootHash); + // the edge target's own rollup is unaffected (edges are outgoing-folded) + expect(fb1.rollupOf(role)).toBe(fb0.rollupOf(role)); + }); + + test("renaming a root changes the root hash", () => { + const fb1 = buildFactBase(baseFacts(), []); + const renamedRole: StableId = { kind: "role", name: "owner2" }; + const facts = baseFacts().map((f) => + f.id === role ? { ...f, id: renamedRole } : f, + ); + const fb2 = buildFactBase(facts, []); + expect(fb2.rootHash).not.toBe(fb1.rootHash); + }); + + test("duplicate ids throw", () => { + expect(() => + buildFactBase( + [...baseFacts(), { id: colA, parent: table, payload: {} }], + [], + ), + ).toThrow(/duplicate/i); + }); + + test("a parent reference to a missing fact throws", () => { + const orphan: Fact = { + id: { kind: "column", schema: "x", table: "missing", name: "c" }, + parent: { kind: "table", schema: "x", name: "missing" }, + payload: {}, + }; + expect(() => buildFactBase([...baseFacts(), orphan], [])).toThrow( + /parent/i, + ); + }); + + test("a self-parent fact is rejected (its cycle has no root)", () => { + // Every parent exists (it is itself), so the missing-parent check passes, + // but the fact never reaches a parentless root: roots() omits it and + // rootHash silently fingerprints like a base that does not contain it. + const self: StableId = { kind: "schema", name: "loop" }; + expect(() => + buildFactBase([{ id: self, parent: self, payload: {} }], []), + ).toThrow(/cycle/i); + }); + + test("a parent cycle among facts is rejected and names the members", () => { + const a: StableId = { kind: "schema", name: "a" }; + const b: StableId = { kind: "schema", name: "b" }; + expect(() => + buildFactBase( + [ + { id: a, parent: b, payload: {} }, + { id: b, parent: a, payload: {} }, + ], + [], + ), + ).toThrow(/cycle/i); + }); + + test("dangling edges are dropped with a diagnostic, not thrown", () => { + const dangling: DependencyEdge[] = [ + { + from: table, + to: { kind: "table", schema: "x", name: "ghost" }, + kind: "depends", + }, + ]; + const fb = buildFactBase(baseFacts(), dangling); + expect(fb.diagnostics).toHaveLength(1); + expect(fb.diagnostics[0]?.code).toBe("dangling_edge"); + expect([...fb.edges]).toHaveLength(0); + }); + + test("children are listed and facts retrievable", () => { + const fb = buildFactBase(baseFacts(), []); + expect(fb.childrenOf(table).map((f) => f.id)).toEqual([colA, colB]); + expect(fb.get(colA)?.payload).toEqual({ type: "integer", notNull: false }); + expect(fb.get({ kind: "table", schema: "no", name: "pe" })).toBeUndefined(); + }); +}); diff --git a/packages/pg-delta/src/core/fact.ts b/packages/pg-delta/src/core/fact.ts new file mode 100644 index 000000000..184d2b69b --- /dev/null +++ b/packages/pg-delta/src/core/fact.ts @@ -0,0 +1,332 @@ +/** + * The fact base: a normalized, content-addressed relation + * (target-architecture §3.1). + * + * Every addressable thing is its own fact with a parent *relation*; + * hierarchy is a view. Payloads are identity-free (enforced upstream at + * payload construction): a fact's own name lives in its id, never in what + * is hashed. + */ +import type { Diagnostic } from "./diagnostic.ts"; +import { + contentHash, + hashString, + type ContentHash, + type Payload, +} from "./hash.ts"; +import { encodeId, type StableId } from "./stable-id.ts"; + +export interface Fact { + id: StableId; + parent?: StableId; + payload: Payload; +} + +/** Where a fact base came from. Provenance is metadata, NOT state: it never + * enters the rollup hash, so two bases with identical facts compare equal + * regardless of source. Policy (§3.9) and frontends use it for routing. */ +export type FactSource = "liveDb" | "sqlFiles" | "snapshot"; + +export type EdgeKind = "depends" | "owner" | "memberOfExtension" | "managedBy"; + +export interface DependencyEdge { + /** The dependent object (must be torn down before / built after `to`). */ + from: StableId; + /** The referenced object. */ + to: StableId; + kind: EdgeKind; +} + +/** + * The single `allowDangling` predicate for the ownership-serialization carve-out. + * + * `projectManagementScope("database")` removes cluster-global role facts but + * DELIBERATELY retains each surviving object's `owner` edge to a removed role as a + * dangling ASSUMED reference, so ownership still serializes as `ALTER … OWNER TO`. + * EVERY reconstruction that can carry such a view forward (`resolveView`'s + * reference-only rebuild, `excludeFactsAndDescendants`, `projectTarget`, the scope + * projection itself) must use THIS predicate, or the rebuild silently re-prunes + * the retained edge and the export emits zero `OWNER TO` (regression: extensions / + * assumed schemas force the rebuild that a public-only view never triggered). + * Centralized here so the rule cannot drift between call sites. + */ +export const retainOwnerRoleDangling = (edge: DependencyEdge): boolean => + edge.kind === "owner" && edge.to.kind === "role"; + +interface Entry { + fact: Fact; + encoded: string; + hash: ContentHash; +} + +export class FactBase { + readonly diagnostics: Diagnostic[] = []; + /** provenance; metadata only, never folded into rollups */ + readonly source: FactSource; + /** + * Encoded ids of facts that are present for REFERENCE ONLY — kept in the view + * so managed dependents can resolve them (e.g. a platform `auth.users` so a + * user trigger on it has a parent), but never diffed (no add/remove/set/edge + * delta). Set by the managed-view projection (resolveView) for objects in a + * policy's `assumedSchemas`. Empty for raw extraction and the corpus. + */ + readonly referenceOnly: ReadonlySet; + readonly #byId = new Map(); + readonly #children = new Map(); + readonly #outgoing = new Map(); + readonly #incoming = new Map(); + #edges: DependencyEdge[] = []; + readonly #rollups = new Map(); + readonly #structural = new Map(); + #rootHash: ContentHash | undefined; + + constructor( + facts: Fact[], + edges: DependencyEdge[], + source: FactSource = "liveDb", + referenceOnly: ReadonlySet = new Set(), + /** Opt-in allowance for a projection that DELIBERATELY retains an edge whose + * endpoint it just removed — e.g. `projectManagementScope("database")` keeps + * an `owner` edge to a scope-projected role as an ASSUMED reference so + * ownership still serializes as `ALTER … OWNER TO`. When `allowDangling(edge)` + * is true the edge is retained WITHOUT a `dangling_edge` diagnostic and + * indexed only on whichever endpoint is present. Default: every dangling edge + * is silently pruned (extraction relies on this for non-extracted system + * owners), so this narrowly-scoped hook is the single exception. */ + opts: { allowDangling?: (edge: DependencyEdge) => boolean } = {}, + ) { + this.source = source; + this.referenceOnly = referenceOnly; + for (const fact of facts) { + const encoded = encodeId(fact.id); + if (this.#byId.has(encoded)) { + throw new Error(`FactBase: duplicate fact id ${encoded}`); + } + this.#byId.set(encoded, { + fact, + encoded, + hash: contentHash(fact.payload), + }); + } + for (const entry of this.#byId.values()) { + const parent = entry.fact.parent; + if (parent === undefined) continue; + const parentKey = encodeId(parent); + if (!this.#byId.has(parentKey)) { + throw new Error( + `FactBase: fact ${entry.encoded} references missing parent ${parentKey}`, + ); + } + const siblings = this.#children.get(parentKey) ?? []; + siblings.push(entry); + this.#children.set(parentKey, siblings); + } + for (const children of this.#children.values()) { + children.sort((a, b) => (a.encoded < b.encoded ? -1 : 1)); + } + // Acyclicity / root-reachability. The missing-parent check above only + // proves every parent EXISTS; a self-parent or parent cycle passes it (each + // member's parent is present) yet reaches no parentless root, so roots() + // omits the whole component and rootHash fingerprints like a base that does + // not contain it — a poisoned base that diff() silently never descends into. + // Reject it here. Single pass, memoized reachability (O(n), no recursion). + const reachesRoot = new Set(); // encoded ids known to reach a root + for (const start of this.#byId.values()) { + if (reachesRoot.has(start.encoded)) continue; + const path: string[] = []; + const onPath = new Set(); + let cursor: Entry | undefined = start; + while (cursor !== undefined) { + if (reachesRoot.has(cursor.encoded)) break; // joins a known-good chain + if (onPath.has(cursor.encoded)) { + const from = path.indexOf(cursor.encoded); + const members = [...path.slice(from), cursor.encoded]; + throw new Error( + `FactBase: parent cycle (no root) among facts ${members.join(" -> ")}`, + ); + } + onPath.add(cursor.encoded); + path.push(cursor.encoded); + const parent = cursor.fact.parent; + if (parent === undefined) break; // reached a parentless root + // parent presence was validated above, so this is always defined + cursor = this.#byId.get(encodeId(parent)); + } + for (const key of path) reachesRoot.add(key); + } + for (const edge of edges) { + const fromKey = encodeId(edge.from); + const toKey = encodeId(edge.to); + const fromPresent = this.#byId.has(fromKey); + const toPresent = this.#byId.has(toKey); + if (!fromPresent || !toPresent) { + if (opts.allowDangling?.(edge)) { + // deliberately retained dangling edge (assumed reference): keep it and + // index only the present endpoint(s); no diagnostic. + this.#edges.push(edge); + if (fromPresent) { + const outList = this.#outgoing.get(fromKey) ?? []; + outList.push(edge); + this.#outgoing.set(fromKey, outList); + } + if (toPresent) { + const inList = this.#incoming.get(toKey) ?? []; + inList.push(edge); + this.#incoming.set(toKey, inList); + } + continue; + } + this.diagnostics.push({ + code: "dangling_edge", + severity: "warning", + subject: fromPresent ? edge.to : edge.from, + message: `edge ${fromKey} -[${edge.kind}]-> ${toKey} references a fact not in the base`, + }); + continue; + } + this.#edges.push(edge); + const outList = this.#outgoing.get(fromKey) ?? []; + outList.push(edge); + this.#outgoing.set(fromKey, outList); + const inList = this.#incoming.get(toKey) ?? []; + inList.push(edge); + this.#incoming.set(toKey, inList); + } + } + + get edges(): readonly DependencyEdge[] { + return this.#edges; + } + + /** O(1) fact lookup by its already-encoded id (avoids decode + facts().find). + * Consumers holding an encoded key use this instead of scanning facts(). */ + getByEncoded(encoded: string): Fact | undefined { + return this.#byId.get(encoded)?.fact; + } + + facts(): Fact[] { + return [...this.#byId.values()].map((e) => e.fact); + } + + get(id: StableId): Fact | undefined { + return this.#byId.get(encodeId(id))?.fact; + } + + has(id: StableId): boolean { + return this.#byId.has(encodeId(id)); + } + + /** Whether `id` is present for REFERENCE ONLY (see `referenceOnly`). Satisfies + * the `FactView` member so a rule can distinguish "on the target" from + * "produced by this plan". */ + isReferenceOnly(id: StableId): boolean { + return this.referenceOnly.has(encodeId(id)); + } + + hashOf(id: StableId): ContentHash { + const entry = this.#byId.get(encodeId(id)); + if (!entry) throw new Error(`FactBase: unknown fact ${encodeId(id)}`); + return entry.hash; + } + + childrenOf(id: StableId): Fact[] { + return (this.#children.get(encodeId(id)) ?? []).map((e) => e.fact); + } + + outgoingEdges(id: StableId): readonly DependencyEdge[] { + return this.#outgoing.get(encodeId(id)) ?? []; + } + + /** Edges pointing AT `id` (reverse index): the facts that depend on it. Used + * by the planner's forced-rebuild reachability walk (O(reachable) instead of + * rescanning every edge each round). */ + incomingEdges(id: StableId): readonly DependencyEdge[] { + return this.#incoming.get(encodeId(id)) ?? []; + } + + /** Reverse edges by already-encoded id (avoids re-encoding in hot walks). */ + incomingEdgesByEncoded(encoded: string): readonly DependencyEdge[] { + return this.#incoming.get(encoded) ?? []; + } + + /** Roots: facts with no parent, sorted by encoded id. */ + roots(): Fact[] { + return [...this.#byId.values()] + .filter((e) => e.fact.parent === undefined) + .sort((a, b) => (a.encoded < b.encoded ? -1 : 1)) + .map((e) => e.fact); + } + + /** + * Named Merkle rollup: payload hash + (childId=childRollup) pairs sorted + * by child id + outgoing edge hashes sorted. Identity changes in the + * subtree propagate (a renamed child changes the parent's rollup). + */ + rollupOf(id: StableId): ContentHash { + return this.#rollup(encodeId(id)); + } + + #rollup(key: string): ContentHash { + const cached = this.#rollups.get(key); + if (cached !== undefined) return cached; + const entry = this.#byId.get(key); + if (!entry) throw new Error(`FactBase: unknown fact ${key}`); + const childParts = (this.#children.get(key) ?? []).map( + (c) => `${c.encoded}=${this.#rollup(c.encoded)}`, + ); + const edgeParts = (this.#outgoing.get(key) ?? []) + .map((e) => `${encodeId(e.from)}-[${e.kind}]->${encodeId(e.to)}`) + .sort(); + const rollup = hashString( + `F|${entry.hash}|C|${childParts.join(",")}|E|${edgeParts.join(",")}`, + ); + this.#rollups.set(key, rollup); + return rollup; + } + + /** + * Structural rollup: identity-free fold (payload hashes + child structural + * rollups sorted by value; edges excluded — they embed identities). Used + * for container rename matching (§4.1). + */ + structuralRollupOf(id: StableId): ContentHash { + return this.#structuralRollup(encodeId(id)); + } + + #structuralRollup(key: string): ContentHash { + const cached = this.#structural.get(key); + if (cached !== undefined) return cached; + const entry = this.#byId.get(key); + if (!entry) throw new Error(`FactBase: unknown fact ${key}`); + const childParts = (this.#children.get(key) ?? []) + .map((c) => this.#structuralRollup(c.encoded)) + .sort(); + const rollup = hashString(`S|${entry.hash}|C|${childParts.join(",")}`); + this.#structural.set(key, rollup); + return rollup; + } + + /** The fingerprint of the whole state: (rootId=rollup) pairs, sorted. + * NOTE: this folds EVERY fact, including `referenceOnly` assumed-schema facts. + * The apply fingerprint gate relies on that (a plan is only applicable against + * the same baseline) — see the "KNOWN PITFALL" note in apply.ts. */ + get rootHash(): ContentHash { + if (this.#rootHash === undefined) { + const parts = this.roots().map( + (f) => `${encodeId(f.id)}=${this.rollupOf(f.id)}`, + ); + this.#rootHash = hashString(`ROOT|${parts.join(",")}`); + } + return this.#rootHash; + } +} + +export function buildFactBase( + facts: Fact[], + edges: DependencyEdge[], + source: FactSource = "liveDb", + referenceOnly: ReadonlySet = new Set(), + opts: { allowDangling?: (edge: DependencyEdge) => boolean } = {}, +): FactBase { + return new FactBase(facts, edges, source, referenceOnly, opts); +} diff --git a/packages/pg-delta/src/core/fingerprint.ts b/packages/pg-delta/src/core/fingerprint.ts deleted file mode 100644 index bf17500ab..000000000 --- a/packages/pg-delta/src/core/fingerprint.ts +++ /dev/null @@ -1,204 +0,0 @@ -import crypto from "node:crypto"; -import type { Catalog } from "./catalog.model.ts"; -import type { Change } from "./change.types.ts"; -import type { BasePgModel } from "./objects/base.model.ts"; - -/** - * Build a deterministic fingerprint for the objects actually touched by a plan. - * Uses the stableIds declared by the changes (creates/requires/drops) and snapshots - * only the catalog entities that exist for those stableIds (parent objects, no virtuals). - */ -export function buildPlanScopeFingerprint( - catalog: Catalog, - changes: Change[], -): { hash: string; stableIds: string[] } { - const stableIds = collectStableIds(changes); - const hash = hashStableIds(catalog, stableIds); - return { hash, stableIds }; -} - -/** - * Compute a fingerprint from a catalog and a set of stableIds. - */ -export function hashStableIds(catalog: Catalog, stableIds: string[]): string { - const catalogLookup = buildCatalogLookup(catalog); - - const projection: Array<{ - stableId: string; - snapshot: { identity: unknown; data: unknown }; - }> = []; - - for (const stableId of stableIds) { - const record = catalogLookup[stableId]; - if (!record) { - continue; - } - projection.push({ - stableId, - snapshot: record.stableSnapshot(), - }); - } - - const canonical = stableStringify(projection); - return sha256(canonical); -} - -/** - * Hash a string to hex SHA256. - */ -function sha256(input: string): string { - return crypto.createHash("sha256").update(input).digest("hex"); -} - -/** - * Collect the union of stableIds referenced by all changes. - */ -function collectStableIds(changes: Change[]): string[] { - const ids = new Set(); - - for (const change of changes) { - for (const id of getChangeStableIds(change)) { - ids.add(id); - } - } - - return Array.from(ids).sort((a, b) => a.localeCompare(b)); -} - -/** - * Gather the stableIds a change touches (creates/requires/drops) and, when the - * change has a primary entity with a stableId, include it as well. - */ -function getChangeStableIds(change: Change): string[] { - const ids: string[] = []; - - // Dependencies declared on the change. - ids.push(...change.creates, ...change.requires, ...change.drops); - - // Best-effort primary entity stableId, when available. - const primary = getPrimaryStableId(change); - if (primary) ids.push(primary); - - return ids; -} - -/** - * Extract the primary entity stableId for a change, when it exists. - */ -function getPrimaryStableId(change: Change): string | null { - switch (change.objectType) { - case "aggregate": - return change.aggregate.stableId; - case "collation": - return change.collation.stableId; - case "composite_type": - return change.compositeType.stableId; - case "domain": - return change.domain.stableId; - case "enum": - return change.enum.stableId; - case "event_trigger": - return change.eventTrigger.stableId; - case "extension": - return change.extension.stableId; - case "foreign_data_wrapper": - return change.foreignDataWrapper.stableId; - case "foreign_table": - return change.foreignTable.stableId; - case "index": - return change.index.stableId; - case "language": - return change.language.stableId; - case "materialized_view": - return change.materializedView.stableId; - case "procedure": - return change.procedure.stableId; - case "publication": - return change.publication.stableId; - case "range": - return change.range.stableId; - case "role": - return change.role.stableId; - case "schema": - return change.schema.stableId; - case "sequence": - return change.sequence.stableId; - case "server": - return change.server.stableId; - case "subscription": - return change.subscription.stableId; - case "table": - return change.table.stableId; - case "trigger": - return change.trigger.stableId; - case "rls_policy": - return change.policy.stableId; - case "rule": - return change.rule.stableId; - case "view": - return change.view.stableId; - case "user_mapping": - return change.userMapping.stableId; - default: - return null; - } -} - -/** - * Build a flat lookup of catalog objects keyed by stableId. - */ -function buildCatalogLookup(catalog: Catalog): Record { - return { - ...catalog.aggregates, - ...catalog.collations, - ...catalog.compositeTypes, - ...catalog.domains, - ...catalog.enums, - ...catalog.extensions, - ...catalog.procedures, - ...catalog.indexes, - ...catalog.materializedViews, - ...catalog.subscriptions, - ...catalog.publications, - ...catalog.rlsPolicies, - ...catalog.roles, - ...catalog.schemas, - ...catalog.sequences, - ...catalog.tables, - ...catalog.triggers, - ...catalog.eventTriggers, - ...catalog.rules, - ...catalog.ranges, - ...catalog.views, - ...catalog.foreignDataWrappers, - ...catalog.servers, - ...catalog.userMappings, - ...catalog.foreignTables, - }; -} - -/** - * Deterministic stringify with sorted object keys. - */ -function stableStringify(value: unknown): string { - if (value === null || typeof value !== "object") { - if (typeof value === "bigint") { - return JSON.stringify(value.toString()); - } - return JSON.stringify(value); - } - - if (Array.isArray(value)) { - return `[${value.map(stableStringify).join(",")}]`; - } - - const entries = Object.entries(value as Record).sort( - ([a], [b]) => a.localeCompare(b), - ); - - const inner = entries - .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`) - .join(","); - - return `{${inner}}`; -} diff --git a/packages/pg-delta/src/core/fixtures/empty-catalogs/postgres-15-16-baseline.json b/packages/pg-delta/src/core/fixtures/empty-catalogs/postgres-15-16-baseline.json deleted file mode 100644 index 0bd3f1d03..000000000 --- a/packages/pg-delta/src/core/fixtures/empty-catalogs/postgres-15-16-baseline.json +++ /dev/null @@ -1,287 +0,0 @@ -{ - "version": 150014, - "currentUser": "postgres", - "aggregates": {}, - "collations": {}, - "compositeTypes": {}, - "domains": {}, - "enums": {}, - "extensions": { - "extension:plpgsql": { - "name": "plpgsql", - "schema": "pg_catalog", - "relocatable": false, - "version": "1.0", - "owner": "postgres", - "comment": "PL/pgSQL procedural language", - "members": [ - "procedure:pg_catalog.plpgsql_call_handler()", - "procedure:pg_catalog.plpgsql_inline_handler(internal)", - "procedure:pg_catalog.plpgsql_validator(oid)" - ] - } - }, - "procedures": {}, - "indexes": {}, - "materializedViews": {}, - "subscriptions": {}, - "publications": {}, - "rlsPolicies": {}, - "roles": { - "role:postgres": { - "name": "postgres", - "is_superuser": true, - "can_inherit": true, - "can_create_roles": true, - "can_create_databases": true, - "can_login": true, - "can_replicate": true, - "connection_limit": -1, - "can_bypass_rls": true, - "config": null, - "comment": null, - "members": [], - "default_privileges": [ - { - "in_schema": null, - "objtype": "S", - "grantee": "postgres", - "privileges": [ - { - "privilege": "USAGE", - "grantable": false - } - ], - "is_implicit": true - }, - { - "in_schema": null, - "objtype": "T", - "grantee": "PUBLIC", - "privileges": [ - { - "privilege": "USAGE", - "grantable": false - } - ], - "is_implicit": true - }, - { - "in_schema": null, - "objtype": "T", - "grantee": "postgres", - "privileges": [ - { - "privilege": "USAGE", - "grantable": false - } - ], - "is_implicit": true - }, - { - "in_schema": null, - "objtype": "f", - "grantee": "PUBLIC", - "privileges": [ - { - "privilege": "EXECUTE", - "grantable": false - } - ], - "is_implicit": true - }, - { - "in_schema": null, - "objtype": "f", - "grantee": "postgres", - "privileges": [ - { - "privilege": "EXECUTE", - "grantable": false - } - ], - "is_implicit": true - }, - { - "in_schema": null, - "objtype": "n", - "grantee": "postgres", - "privileges": [ - { - "privilege": "CREATE", - "grantable": false - }, - { - "privilege": "USAGE", - "grantable": false - } - ], - "is_implicit": true - }, - { - "in_schema": null, - "objtype": "r", - "grantee": "postgres", - "privileges": [ - { - "privilege": "DELETE", - "grantable": false - }, - { - "privilege": "INSERT", - "grantable": false - }, - { - "privilege": "REFERENCES", - "grantable": false - }, - { - "privilege": "SELECT", - "grantable": false - }, - { - "privilege": "TRIGGER", - "grantable": false - }, - { - "privilege": "TRUNCATE", - "grantable": false - }, - { - "privilege": "UPDATE", - "grantable": false - } - ], - "is_implicit": true - } - ] - } - }, - "schemas": { - "schema:public": { - "name": "public", - "owner": "pg_database_owner", - "comment": "standard public schema", - "privileges": [ - { - "grantee": "PUBLIC", - "privilege": "USAGE", - "grantable": false - }, - { - "grantee": "pg_database_owner", - "privilege": "CREATE", - "grantable": false - }, - { - "grantee": "pg_database_owner", - "privilege": "USAGE", - "grantable": false - } - ] - } - }, - "sequences": {}, - "tables": {}, - "triggers": {}, - "eventTriggers": {}, - "rules": {}, - "ranges": {}, - "views": {}, - "foreignDataWrappers": {}, - "servers": {}, - "userMappings": {}, - "foreignTables": {}, - "depends": [ - { - "dependent_stable_id": "acl:schema:public::grantee:pg_database_owner", - "referenced_stable_id": "role:pg_database_owner", - "deptype": "n" - }, - { - "dependent_stable_id": "acl:schema:public::grantee:pg_database_owner", - "referenced_stable_id": "schema:public", - "deptype": "n" - }, - { - "dependent_stable_id": "acl:schema:public::grantee:PUBLIC", - "referenced_stable_id": "role:PUBLIC", - "deptype": "n" - }, - { - "dependent_stable_id": "acl:schema:public::grantee:PUBLIC", - "referenced_stable_id": "schema:public", - "deptype": "n" - }, - { - "dependent_stable_id": "comment:extension:plpgsql", - "referenced_stable_id": "extension:plpgsql", - "deptype": "a" - }, - { - "dependent_stable_id": "comment:language:plpgsql", - "referenced_stable_id": "language:plpgsql", - "deptype": "a" - }, - { - "dependent_stable_id": "comment:language:sql", - "referenced_stable_id": "language:sql", - "deptype": "a" - }, - { - "dependent_stable_id": "language:plpgsql", - "referenced_stable_id": "procedure:pg_catalog.plpgsql_call_handler()", - "deptype": "n" - }, - { - "dependent_stable_id": "language:plpgsql", - "referenced_stable_id": "procedure:pg_catalog.plpgsql_inline_handler(internal)", - "deptype": "n" - }, - { - "dependent_stable_id": "language:plpgsql", - "referenced_stable_id": "procedure:pg_catalog.plpgsql_validator(oid)", - "deptype": "n" - }, - { - "dependent_stable_id": "language:plpgsql", - "referenced_stable_id": "role:postgres", - "deptype": "n" - }, - { - "dependent_stable_id": "membership:pg_read_all_settings->pg_monitor", - "referenced_stable_id": "role:pg_monitor", - "deptype": "n" - }, - { - "dependent_stable_id": "membership:pg_read_all_settings->pg_monitor", - "referenced_stable_id": "role:pg_read_all_settings", - "deptype": "n" - }, - { - "dependent_stable_id": "membership:pg_read_all_stats->pg_monitor", - "referenced_stable_id": "role:pg_monitor", - "deptype": "n" - }, - { - "dependent_stable_id": "membership:pg_read_all_stats->pg_monitor", - "referenced_stable_id": "role:pg_read_all_stats", - "deptype": "n" - }, - { - "dependent_stable_id": "membership:pg_stat_scan_tables->pg_monitor", - "referenced_stable_id": "role:pg_monitor", - "deptype": "n" - }, - { - "dependent_stable_id": "membership:pg_stat_scan_tables->pg_monitor", - "referenced_stable_id": "role:pg_stat_scan_tables", - "deptype": "n" - }, - { - "dependent_stable_id": "schema:public", - "referenced_stable_id": "role:pg_database_owner", - "deptype": "n" - } - ] -} \ No newline at end of file diff --git a/packages/pg-delta/src/core/hash.test.ts b/packages/pg-delta/src/core/hash.test.ts new file mode 100644 index 000000000..ff1618c40 --- /dev/null +++ b/packages/pg-delta/src/core/hash.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { canonicalize, contentHash } from "./hash.ts"; + +describe("canonicalize", () => { + test("object key order does not matter", () => { + expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 })); + }); + + test("keys starting with `_` are non-semantic metadata, excluded from the encoding", () => { + // extraction-only / version-dependent metadata (e.g. an ACL's owner default + // privilege set) must not join the equality surface, or it would produce + // spurious diff deltas / fingerprint drift across PG versions and snapshots. + expect(canonicalize({ a: 1, _meta: [1, 2] })).toBe(canonicalize({ a: 1 })); + expect(contentHash({ a: 1, _meta: ["x"] })).toBe( + contentHash({ a: 1, _meta: ["y", "z"] }), + ); + expect(canonicalize({ x: { a: 1, _m: 9 } })).toBe( + canonicalize({ x: { a: 1 } }), + ); + }); + + test("nested key order does not matter", () => { + expect(canonicalize({ x: { b: [1, { z: 1, y: 2 }], a: null } })).toBe( + canonicalize({ x: { a: null, b: [1, { y: 2, z: 1 }] } }), + ); + }); + + test("array order DOES matter", () => { + expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1])); + }); + + test("scalar types are distinguished", () => { + expect(canonicalize("1")).not.toBe(canonicalize(1)); + expect(canonicalize(true)).not.toBe(canonicalize("true")); + expect(canonicalize(null)).not.toBe(canonicalize("null")); + expect(canonicalize(1)).not.toBe(canonicalize(1n)); + }); + + test("bigint is supported and stable", () => { + expect(canonicalize({ v: 9007199254740993n })).toBe( + canonicalize({ v: 9007199254740993n }), + ); + }); + + test("undefined object values are dropped (absent == undefined)", () => { + expect(canonicalize({ a: 1, b: undefined })).toBe(canonicalize({ a: 1 })); + }); + + test("empty containers are distinct from null/absent", () => { + const variants = [canonicalize({}), canonicalize([]), canonicalize(null)]; + expect(new Set(variants).size).toBe(3); + }); +}); + +describe("contentHash", () => { + test("is a 64-char hex sha-256", () => { + expect(contentHash({ a: 1 })).toMatch(/^[0-9a-f]{64}$/); + }); + + test("equal payloads hash equal, different payloads differ", () => { + expect(contentHash({ a: [1, "x"], b: null })).toBe( + contentHash({ b: null, a: [1, "x"] }), + ); + expect(contentHash({ a: 1 })).not.toBe(contentHash({ a: 2 })); + }); + + // Golden hashes pin the canonical encoding itself: if these break, the + // encoding changed and every persisted snapshot/fingerprint would be + // invalidated. Change them only deliberately, with a format-version bump. + test("golden hashes", () => { + expect(contentHash(null)).toBe( + "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b", + ); + expect(contentHash({ name: "users", cols: [1, 2n, "3", true, null] })).toBe( + contentHash({ cols: [1, 2n, "3", true, null], name: "users" }), + ); + }); +}); diff --git a/packages/pg-delta/src/core/hash.ts b/packages/pg-delta/src/core/hash.ts new file mode 100644 index 000000000..bc39691c8 --- /dev/null +++ b/packages/pg-delta/src/core/hash.ts @@ -0,0 +1,88 @@ +/** + * Canonical payload encoding + content hashing (target-architecture §3.1). + * + * The canonical encoding is the equality surface of the whole system: fact + * hashes, rollups, fingerprints, and proof verdicts all reduce to it. Its + * exact byte output is pinned by golden tests — changing it is a + * format-version bump, never a refactor. + * + * Rules: + * - object keys sorted by code point; `undefined` values dropped (absent) + * - object keys starting with `_` are NON-SEMANTIC METADATA and are dropped: + * extraction-only / environment- or version-dependent values (e.g. an ACL's + * create-time owner default privilege set) ride along on the payload for the + * planner but must NOT join the equality surface, or they would cause spurious + * diff deltas and fingerprint drift across PG versions and snapshots + * - arrays preserve order (set-valued attributes must be sorted upstream, + * at payload construction) + * - scalars are type-distinguished: `"1"` ≠ `1` ≠ `1n` + * - non-finite numbers are rejected (no NaN/Infinity in payloads) + */ +import { createHash } from "node:crypto"; + +export type Payload = { [key: string]: PayloadValue }; +export type PayloadValue = + | string + | number + | bigint + | boolean + | null + | undefined + | PayloadValue[] + | { [key: string]: PayloadValue }; + +export function canonicalize(value: PayloadValue): string { + if (value === null) return "null"; + switch (typeof value) { + case "string": + return JSON.stringify(value); + case "boolean": + return value ? "true" : "false"; + case "bigint": + return `${value}n`; + case "number": + if (!Number.isFinite(value)) { + throw new Error(`canonicalize: non-finite number ${value}`); + } + // normalize -0 so it cannot produce a distinct encoding + return JSON.stringify(value === 0 ? 0 : value); + case "undefined": + throw new Error( + "canonicalize: undefined is only allowed as an (omitted) object value", + ); + case "object": { + if (Array.isArray(value)) { + return `[${value + .map((item) => { + if (item === undefined) { + throw new Error( + "canonicalize: arrays must not contain undefined", + ); + } + return canonicalize(item); + }) + .join(",")}]`; + } + const keys = Object.keys(value) + .filter((k) => value[k] !== undefined && !k.startsWith("_")) + .sort(); + return `{${keys + .map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`) + .join(",")}}`; + } + default: + throw new Error(`canonicalize: unsupported type ${typeof value}`); + } +} + +export type ContentHash = string; + +/** SHA-256 (hex) over the canonical encoding. ≥128-bit per §3.1. */ +export function contentHash(value: PayloadValue): ContentHash { + return createHash("sha256").update(canonicalize(value)).digest("hex"); +} + +/** Hash an already-canonical string (used by rollups to fold hashes). */ +export function hashString(s: string): ContentHash { + return createHash("sha256").update(s).digest("hex"); +} diff --git a/packages/pg-delta/src/core/index.ts b/packages/pg-delta/src/core/index.ts new file mode 100644 index 000000000..c913a7ffc --- /dev/null +++ b/packages/pg-delta/src/core/index.ts @@ -0,0 +1,28 @@ +/** + * Core barrel: stable-id codec, hash/payload primitives, fact-base + * construction, snapshot round-trip, diff engine, and diagnostics. + * These are the stable building blocks; the planning, apply, and proof + * layers sit on top of them. + */ +export { NotImplementedError, type Diagnostic } from "./diagnostic.ts"; +export { + encodeId, + parseId, + type StableId, + type FactKind, +} from "./stable-id.ts"; +export { + canonicalize, + contentHash, + type Payload, + type ContentHash, +} from "./hash.ts"; +export { + buildFactBase, + FactBase, + type Fact, + type DependencyEdge, + type EdgeKind, +} from "./fact.ts"; +export { serializeSnapshot, deserializeSnapshot } from "./snapshot.ts"; +export { diff, type Delta } from "./diff.ts"; diff --git a/packages/pg-delta/src/core/integrations/filter/dsl.test.ts b/packages/pg-delta/src/core/integrations/filter/dsl.test.ts deleted file mode 100644 index 88869081a..000000000 --- a/packages/pg-delta/src/core/integrations/filter/dsl.test.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../../change.types.ts"; -import { compileFilterDSL, evaluatePattern } from "./dsl.ts"; - -const tableCreate = { - objectType: "table", - operation: "create", - scope: "object", - table: { - schema: "public", - name: "t", - owner: "postgres", - is_partition: false, - }, - requires: ["schema:public"], - creates: ["table:public.t"], -} as unknown as Change; - -const viewAlter = { - objectType: "view", - operation: "alter", - scope: "comment", - view: { schema: "private", name: "v", owner: "admin" }, - requires: ["schema:private", "type:auth.users"], -} as unknown as Change; - -const roleDrop = { - objectType: "role", - operation: "drop", - scope: "object", - role: { name: "admin" }, - requires: [], -} as unknown as Change; - -const membershipChange = { - objectType: "role", - operation: "create", - scope: "membership", - member: "app_user", - role: { name: "admin_group" }, - requires: [], -} as unknown as Change; - -const securityLabelChange = { - objectType: "schema", - operation: "create", - scope: "security_label", - schema: { name: "labeled" }, - securityLabel: { provider: "dummy", label: "classified" }, - requires: ["schema:labeled"], - creates: ["security_label:schema:labeled:dummy"], -} as unknown as Change; - -describe("evaluatePattern", () => { - describe("bare key matching (top-level properties)", () => { - test("objectType match", () => { - expect(evaluatePattern({ objectType: "table" }, tableCreate)).toBe(true); - }); - - test("objectType mismatch", () => { - expect(evaluatePattern({ objectType: "view" }, tableCreate)).toBe(false); - }); - - test("operation match", () => { - expect(evaluatePattern({ operation: "create" }, tableCreate)).toBe(true); - }); - - test("operation mismatch", () => { - expect(evaluatePattern({ operation: "drop" }, tableCreate)).toBe(false); - }); - - test("scope match", () => { - expect(evaluatePattern({ scope: "object" }, tableCreate)).toBe(true); - }); - - test("scope mismatch", () => { - expect(evaluatePattern({ scope: "comment" }, tableCreate)).toBe(false); - }); - - test("multiple bare keys AND together", () => { - expect( - evaluatePattern( - { objectType: "table", operation: "create" }, - tableCreate, - ), - ).toBe(true); - expect( - evaluatePattern( - { objectType: "table", operation: "drop" }, - tableCreate, - ), - ).toBe(false); - }); - - test("empty pattern matches everything", () => { - expect(evaluatePattern({}, tableCreate)).toBe(true); - expect(evaluatePattern({}, roleDrop)).toBe(true); - }); - }); - - describe("path key matching (model sub-object properties)", () => { - test("exact path match", () => { - expect(evaluatePattern({ "table/schema": "public" }, tableCreate)).toBe( - true, - ); - expect(evaluatePattern({ "table/schema": "private" }, tableCreate)).toBe( - false, - ); - }); - - test("path with array value checks inclusion", () => { - expect( - evaluatePattern({ "table/schema": ["public", "private"] }, tableCreate), - ).toBe(true); - expect( - evaluatePattern({ "table/schema": ["private", "auth"] }, tableCreate), - ).toBe(false); - }); - - test("path not found returns false", () => { - expect(evaluatePattern({ "table/schema": "public" }, roleDrop)).toBe( - false, - ); - }); - }); - - describe("wildcard pattern matching", () => { - test("*/schema matches any objectType's schema", () => { - expect(evaluatePattern({ "*/schema": "public" }, tableCreate)).toBe(true); - expect(evaluatePattern({ "*/schema": "private" }, viewAlter)).toBe(true); - expect(evaluatePattern({ "*/schema": "public" }, viewAlter)).toBe(false); - }); - - test("*/owner matches across object types", () => { - expect(evaluatePattern({ "*/owner": "postgres" }, tableCreate)).toBe( - true, - ); - expect(evaluatePattern({ "*/owner": "admin" }, viewAlter)).toBe(true); - }); - - test("*/schema does not match objectTypes without schema", () => { - expect(evaluatePattern({ "*/schema": "anything" }, roleDrop)).toBe(false); - }); - - test("*/name matches role/name", () => { - expect(evaluatePattern({ "*/name": "admin" }, roleDrop)).toBe(true); - }); - - test("wildcard with array value", () => { - expect( - evaluatePattern({ "*/schema": ["public", "private"] }, tableCreate), - ).toBe(true); - }); - }); - - describe("boolean matching", () => { - test("matches boolean value", () => { - expect( - evaluatePattern({ "table/is_partition": false }, tableCreate), - ).toBe(true); - expect(evaluatePattern({ "table/is_partition": true }, tableCreate)).toBe( - false, - ); - }); - }); - - describe("regex matching", () => { - test("regex on string value", () => { - expect( - evaluatePattern( - { "table/name": { op: "regex", value: "^t" } }, - tableCreate, - ), - ).toBe(true); - expect( - evaluatePattern( - { "table/name": { op: "regex", value: "^z" } }, - tableCreate, - ), - ).toBe(false); - }); - - test("regex with array of patterns", () => { - expect( - evaluatePattern( - { "table/name": { op: "regex", value: ["^z", "^t"] } }, - tableCreate, - ), - ).toBe(true); - }); - - test("regex on array value (requires)", () => { - expect( - evaluatePattern( - { requires: { op: "regex", value: "^schema:" } }, - tableCreate, - ), - ).toBe(true); - expect( - evaluatePattern( - { requires: { op: "regex", value: "^type:auth\\." } }, - viewAlter, - ), - ).toBe(true); - expect( - evaluatePattern( - { requires: { op: "regex", value: "^type:auth\\." } }, - tableCreate, - ), - ).toBe(false); - }); - }); - - describe("array value matching (requires/creates)", () => { - test("string match against array checks any element", () => { - expect(evaluatePattern({ requires: "schema:public" }, tableCreate)).toBe( - true, - ); - expect(evaluatePattern({ requires: "schema:private" }, tableCreate)).toBe( - false, - ); - }); - - test("array match against array checks intersection", () => { - expect( - evaluatePattern( - { requires: ["schema:public", "schema:other"] }, - tableCreate, - ), - ).toBe(true); - }); - - test("no match on empty requires", () => { - expect(evaluatePattern({ requires: "schema:public" }, roleDrop)).toBe( - false, - ); - }); - }); - - describe("member/grantee matching", () => { - test("member match", () => { - expect(evaluatePattern({ member: "app_user" }, membershipChange)).toBe( - true, - ); - expect(evaluatePattern({ member: "other_user" }, membershipChange)).toBe( - false, - ); - }); - - test("member not present returns false", () => { - expect(evaluatePattern({ member: "app_user" }, tableCreate)).toBe(false); - }); - }); - - describe("security label matching", () => { - test("provider matches security label changes", () => { - expect( - evaluatePattern( - { scope: "security_label", provider: "dummy" }, - securityLabelChange, - ), - ).toBe(true); - expect( - evaluatePattern( - { scope: "security_label", provider: "other" }, - securityLabelChange, - ), - ).toBe(false); - }); - }); - - describe("composition patterns", () => { - test("not negates a pattern", () => { - expect( - evaluatePattern({ not: { objectType: "table" } }, tableCreate), - ).toBe(false); - expect( - evaluatePattern({ not: { objectType: "view" } }, tableCreate), - ).toBe(true); - }); - - test("and requires all to match", () => { - expect( - evaluatePattern( - { and: [{ objectType: "table" }, { operation: "create" }] }, - tableCreate, - ), - ).toBe(true); - expect( - evaluatePattern( - { and: [{ objectType: "table" }, { operation: "drop" }] }, - tableCreate, - ), - ).toBe(false); - }); - - test("or requires any to match", () => { - expect( - evaluatePattern( - { or: [{ objectType: "table" }, { objectType: "view" }] }, - tableCreate, - ), - ).toBe(true); - expect( - evaluatePattern( - { or: [{ objectType: "role" }, { objectType: "view" }] }, - tableCreate, - ), - ).toBe(false); - }); - - test("nested composition", () => { - expect( - evaluatePattern( - { not: { or: [{ objectType: "role" }, { objectType: "view" }] } }, - tableCreate, - ), - ).toBe(true); - }); - - test("composition with wildcard patterns", () => { - expect( - evaluatePattern( - { not: { "*/schema": ["auth", "extensions"] } }, - tableCreate, - ), - ).toBe(true); - }); - }); - - describe("combined path + bare keys", () => { - test("objectType and path key AND together", () => { - expect( - evaluatePattern( - { objectType: "table", "table/is_partition": false }, - tableCreate, - ), - ).toBe(true); - expect( - evaluatePattern( - { objectType: "table", "table/is_partition": true }, - tableCreate, - ), - ).toBe(false); - }); - }); - - describe("cascade property", () => { - test("cascade is ignored and does not affect match", () => { - expect( - evaluatePattern( - { objectType: "table", cascade: true } as Parameters< - typeof evaluatePattern - >[0], - tableCreate, - ), - ).toBe(true); - expect( - evaluatePattern( - { objectType: "table", cascade: false } as Parameters< - typeof evaluatePattern - >[0], - tableCreate, - ), - ).toBe(true); - expect( - evaluatePattern( - { not: { "*/schema": "auth" }, cascade: true }, - tableCreate, - ), - ).toBe(true); - }); - }); -}); - -describe("compileFilterDSL", () => { - test("returns a function that evaluates the pattern", () => { - const filter = compileFilterDSL({ objectType: "table" }); - expect(typeof filter).toBe("function"); - expect(filter(tableCreate)).toBe(true); - expect(filter(roleDrop)).toBe(false); - }); - - test("works with composition patterns", () => { - const filter = compileFilterDSL({ - or: [{ objectType: "table" }, { objectType: "role" }], - }); - expect(filter(tableCreate)).toBe(true); - expect(filter(roleDrop)).toBe(true); - expect(filter(viewAlter)).toBe(false); - }); - - test("works with wildcard-based patterns", () => { - const filter = compileFilterDSL({ - "*/schema": "public", - }); - expect(filter(tableCreate)).toBe(true); - expect(filter(viewAlter)).toBe(false); - }); - - test("throws on invalid regex pattern", () => { - expect(() => - compileFilterDSL({ - "table/name": { op: "regex", value: "[invalid" }, - }), - ).toThrow(/Invalid regex pattern "\[invalid" in filter DSL/); - }); - - test("throws on invalid regex in array of patterns", () => { - expect(() => - compileFilterDSL({ - "table/name": { op: "regex", value: ["^valid$", "(unclosed"] }, - }), - ).toThrow(/Invalid regex pattern "\(unclosed" in filter DSL/); - }); - - test("throws on invalid regex nested in composition", () => { - expect(() => - compileFilterDSL({ - or: [ - { objectType: "table" }, - { "table/name": { op: "regex", value: "**bad" } }, - ], - }), - ).toThrow(/Invalid regex pattern "\*\*bad" in filter DSL/); - }); -}); - -describe("glob pattern features", () => { - const tableCreate = { - objectType: "table", - operation: "create", - scope: "object", - table: { - schema: "public", - name: "t", - owner: "postgres", - is_partition: false, - }, - requires: ["schema:public"], - creates: ["table:public.t"], - } as unknown as Change; - - const viewAlter = { - objectType: "view", - operation: "alter", - scope: "comment", - view: { schema: "private", name: "v", owner: "admin" }, - requires: ["schema:private", "type:auth.users"], - } as unknown as Change; - - const roleDrop = { - objectType: "role", - operation: "drop", - scope: "object", - role: { name: "admin" }, - requires: [], - } as unknown as Change; - - test("brace expansion in path pattern keys", () => { - const filter = compileFilterDSL({ "{table,view}/schema": "public" }); - expect(filter(tableCreate)).toBe(true); - expect(filter(viewAlter)).toBe(false); // private, not public - expect(filter(roleDrop)).toBe(false); // no matching key - }); - - test("partial wildcard in field names", () => { - const filter = compileFilterDSL({ "table/is_*": false }); - expect(filter(tableCreate)).toBe(true); // is_partition = false - }); - - test("extglob negation in pattern keys", () => { - const filter = compileFilterDSL({ "!(role)/schema": "public" }); - expect(filter(tableCreate)).toBe(true); // table/schema = public - expect(filter(roleDrop)).toBe(false); // role excluded by negation - }); -}); diff --git a/packages/pg-delta/src/core/integrations/filter/dsl.ts b/packages/pg-delta/src/core/integrations/filter/dsl.ts deleted file mode 100644 index 8407f6700..000000000 --- a/packages/pg-delta/src/core/integrations/filter/dsl.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Filter DSL - A serializable domain-specific language for change filtering. - * - * Uses wildcard-based path matching on flattened change properties. - * Path patterns as keys, values as matchers. Multiple keys in one object = AND. - * - * Path convention: - * - Top-level change properties are bare keys: `objectType`, `operation`, `scope`, `member`, `grantee` - * - Model sub-object properties use `/`: `table/schema`, `role/name` - * - Wildcard `*` matches any single path segment: `* /schema` → `table/schema`, `view/schema`, etc. - * - Separator is `/` - * - * Value matching: - * - string → exact equality - * - string[] → value must be in array (inclusion) - * - boolean → exact equality - * - number → exact equality - * - { op: "regex", value: string | string[] } → regex test - * - * When the flat value is an array (e.g. `requires`), match succeeds if any element satisfies. - */ - -import type { Change } from "../../change.types.ts"; -import type { ChangeFilter } from "./filter.types.ts"; -import { compileWildcard, type FlatValue, flattenChange } from "./flatten.ts"; - -/** - * Regex operator for advanced value matching. - */ -type RegexOperator = { - op: "regex"; - value: string | string[]; -}; - -/** - * A value matcher for a path pattern key. - */ -type ValueMatcher = string | string[] | boolean | number | RegexOperator; - -/** - * Path pattern — matches against flattened change properties. - * Keys are path patterns (with optional wildcards), values are matchers. - * Multiple keys are combined with AND (all must match). - * - * Reserved keys: `and`, `or`, `not`, `cascade`. - * - * @example - * ```json - * { "objectType": "table", "operation": "create" } - * ``` - * - * @example Wildcard path matching any object's schema - * ```json - * { "* /schema": "public" } - * ``` - * - * @category Filter DSL - */ -export type PathPattern = { - [path: string]: ValueMatcher; -} & { - cascade?: boolean; - and?: never; - or?: never; - not?: never; -}; - -/** - * Composition pattern - combines other patterns using logical operators. - * Composition operators are exclusive - cannot be mixed with path keys. - */ -type CompositionPattern = - | { - and: FilterPattern[]; - cascade?: boolean; - or?: never; - not?: never; - } - | { - or: FilterPattern[]; - cascade?: boolean; - and?: never; - not?: never; - } - | { - not: FilterPattern; - cascade?: boolean; - and?: never; - or?: never; - }; - -/** - * A single filter expression: either a {@link PathPattern} that matches against - * flattened change properties, or a composition pattern that combines other - * patterns using `and` / `or` / `not` logical operators. - * - * @example Exclude all changes in pg_catalog - * ```json - * { "not": { "* /schema": "pg_catalog" } } - * ``` - * - * @category Filter DSL - */ -export type FilterPattern = PathPattern | CompositionPattern; - -/** - * Top-level Filter DSL type — a single {@link FilterPattern} expression that - * determines which changes an integration includes or excludes. - * - * @example Include only table and view creates in public - * ```json - * { - * "and": [ - * { "objectType": ["table", "view"] }, - * { "operation": "create" }, - * { "* /schema": "public" } - * ] - * } - * ``` - * - * @category Filter DSL - */ -export type FilterDSL = FilterPattern; - -// Reserved keys that are not path patterns -const RESERVED_KEYS = new Set(["and", "or", "not", "cascade"]); - -/** - * Match a flat value against a value matcher. - * - * When the flat value is an array, the match succeeds if any element satisfies. - */ -function matchValue(actual: FlatValue, expected: ValueMatcher): boolean { - if (actual === null || actual === undefined) { - return false; - } - - // String matcher → exact equality - if (typeof expected === "string") { - if (Array.isArray(actual)) { - return actual.some((v) => v === expected); - } - return actual === expected; - } - - // Boolean matcher → exact equality - if (typeof expected === "boolean") { - return actual === expected; - } - - // Number matcher → exact equality - if (typeof expected === "number") { - return actual === expected; - } - - // Array matcher → inclusion (value must be in array) - if (Array.isArray(expected)) { - if (Array.isArray(actual)) { - return actual.some((v) => - (expected as ReadonlyArray).includes(v), - ); - } - return typeof actual === "string" && expected.includes(actual); - } - - // Regex operator - if ( - typeof expected === "object" && - expected !== null && - "op" in expected && - expected.op === "regex" - ) { - const patterns = Array.isArray(expected.value) - ? expected.value - : [expected.value]; - if (Array.isArray(actual)) { - return actual.some((a) => - patterns.some((p) => new RegExp(p).test(String(a))), - ); - } - return patterns.some((p) => new RegExp(p).test(String(actual))); - } - - return false; -} - -/** - * Evaluate a pattern against a change. - * - * @param pattern - The pattern to evaluate - * @param change - The change to match against - * @returns true if the pattern matches, false otherwise - */ -export function evaluatePattern( - pattern: FilterPattern, - change: Change, -): boolean { - // Handle composition operators first (they take precedence) - - // NOT operator - negate the result - if ("not" in pattern && pattern.not) { - return !evaluatePattern(pattern.not, change); - } - - // AND operator - all patterns must match - if ("and" in pattern && pattern.and) { - return pattern.and.every((p) => evaluatePattern(p, change)); - } - - // OR operator - any pattern must match - if ("or" in pattern && pattern.or) { - return pattern.or.some((p) => evaluatePattern(p, change)); - } - - // Path pattern matching: flatten the change, then for each key in the pattern, - // wildcard-match against flat map paths and compare values. - const flat = flattenChange(change); - - for (const [patternKey, matcher] of Object.entries(pattern)) { - if (RESERVED_KEYS.has(patternKey)) continue; - - const wildcardMatcher = compileWildcard(patternKey); - - // Find all flat keys that match this wildcard pattern - const matchingKeys = Object.keys(flat).filter((k) => wildcardMatcher(k)); - - if (matchingKeys.length === 0) { - // No flat keys match this wildcard → pattern key not satisfied - return false; - } - - // At least one matching key must satisfy the value matcher - const anyMatch = matchingKeys.some((k) => - matchValue(flat[k], matcher as ValueMatcher), - ); - if (!anyMatch) return false; - } - - // All pattern keys satisfied - return true; -} - -/** - * Compile a Filter DSL to a ChangeFilter function. - * - * @param dsl - The filter DSL pattern - * @returns A ChangeFilter function that evaluates the pattern - * - * @example - * ``` - * const filter = compileFilterDSL({ - * or: [ - * { objectType: "schema", operation: "create" }, - * { "table/schema": "public" } - * ] - * }); - * ``` - */ -export function compileFilterDSL(dsl: FilterDSL): ChangeFilter { - validateRegexPatterns(dsl); - return (change: Change): boolean => { - return evaluatePattern(dsl, change); - }; -} - -/** - * Walk the pattern tree and validate all regex patterns at compile time. - * Throws a descriptive error if any regex pattern is invalid. - */ -function validateRegexPatterns(pattern: FilterPattern): void { - if ("not" in pattern && pattern.not) { - validateRegexPatterns(pattern.not); - return; - } - if ("and" in pattern && pattern.and) { - for (const p of pattern.and) validateRegexPatterns(p); - return; - } - if ("or" in pattern && pattern.or) { - for (const p of pattern.or) validateRegexPatterns(p); - return; - } - - for (const [key, value] of Object.entries(pattern)) { - if (RESERVED_KEYS.has(key)) continue; - if ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - "op" in value && - value.op === "regex" - ) { - const patterns = Array.isArray(value.value) ? value.value : [value.value]; - for (const p of patterns) { - try { - new RegExp(p); - } catch (e) { - throw new Error( - `Invalid regex pattern "${p}" in filter DSL: ${(e as Error).message}`, - ); - } - } - } - } -} diff --git a/packages/pg-delta/src/core/integrations/filter/filter.types.ts b/packages/pg-delta/src/core/integrations/filter/filter.types.ts deleted file mode 100644 index 2db7ecd29..000000000 --- a/packages/pg-delta/src/core/integrations/filter/filter.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Change } from "../../change.types.ts"; - -export type ChangeFilter = (change: Change) => boolean; diff --git a/packages/pg-delta/src/core/integrations/filter/flatten.test.ts b/packages/pg-delta/src/core/integrations/filter/flatten.test.ts deleted file mode 100644 index 7f21c3b28..000000000 --- a/packages/pg-delta/src/core/integrations/filter/flatten.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../../change.types.ts"; -import { compileWildcard, flattenChange } from "./flatten.ts"; - -describe("flattenChange", () => { - test("flattens top-level scalar properties", () => { - const change = { - objectType: "table", - operation: "create", - scope: "object", - table: { schema: "public", name: "users" }, - requires: ["schema:public"], - creates: ["table:public.users"], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat.objectType).toBe("table"); - expect(flat.operation).toBe("create"); - expect(flat.scope).toBe("object"); - }); - - test("flattens array properties", () => { - const change = { - objectType: "table", - operation: "create", - scope: "object", - table: { schema: "public", name: "users" }, - requires: ["schema:public", "role:postgres"], - creates: ["table:public.users"], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat.requires).toEqual(["schema:public", "role:postgres"]); - expect(flat.creates).toEqual(["table:public.users"]); - }); - - test("flattens model sub-object properties with objectType prefix", () => { - const change = { - objectType: "table", - operation: "create", - scope: "object", - table: { - schema: "public", - name: "users", - owner: "postgres", - is_partition: false, - persistence: "p", - }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat["table/schema"]).toBe("public"); - expect(flat["table/name"]).toBe("users"); - expect(flat["table/owner"]).toBe("postgres"); - expect(flat["table/is_partition"]).toBe(false); - expect(flat["table/persistence"]).toBe("p"); - }); - - test("includes member when present", () => { - const change = { - objectType: "role", - operation: "create", - scope: "membership", - member: "app_user", - role: { name: "admin" }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat.member).toBe("app_user"); - }); - - test("includes grantee when present", () => { - const change = { - objectType: "table", - operation: "create", - scope: "privilege", - grantee: "reader", - table: { schema: "public", name: "users" }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat.grantee).toBe("reader"); - }); - - test("skips nested objects", () => { - const change = { - objectType: "table", - operation: "create", - scope: "object", - table: { - schema: "public", - name: "users", - columns: [{ name: "id", type: "integer" }], - constraints: { pk: { type: "primary_key" } }, - }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat["table/columns"]).toBeUndefined(); - expect(flat["table/constraints"]).toBeUndefined(); - }); - - test("handles null model property values", () => { - const change = { - objectType: "procedure", - operation: "create", - scope: "object", - procedure: { - schema: "public", - name: "my_func", - binary_path: null, - language: "plpgsql", - }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat["procedure/binary_path"]).toBeNull(); - expect(flat["procedure/language"]).toBe("plpgsql"); - }); - - test("defaults requires/creates/drops to empty arrays", () => { - const change = { - objectType: "role", - operation: "create", - scope: "object", - role: { name: "admin" }, - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat.requires).toEqual([]); - expect(flat.creates).toEqual([]); - expect(flat.drops).toEqual([]); - }); - - test("normalizes schema/schema for schema changes", () => { - const change = { - objectType: "schema", - operation: "create", - scope: "object", - schema: { name: "auth" }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat["schema/name"]).toBe("auth"); - expect(flat["schema/schema"]).toBe("auth"); - }); - - test("normalizes event_trigger/schema from function_schema", () => { - const change = { - objectType: "event_trigger", - operation: "create", - scope: "object", - eventTrigger: { - name: "my_trigger", - function_schema: "public", - function_name: "my_func", - }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat["event_trigger/function_schema"]).toBe("public"); - expect(flat["event_trigger/schema"]).toBe("public"); - }); - - test("flattens rls_policy model properties (camelCase mapping)", () => { - const change = { - objectType: "rls_policy", - operation: "create", - scope: "object", - policy: { schema: "public", table: "users", name: "select_policy" }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat["rls_policy/schema"]).toBe("public"); - expect(flat["rls_policy/table"]).toBe("users"); - expect(flat["rls_policy/name"]).toBe("select_policy"); - }); - - test("flattens all top-level scalar properties systematically", () => { - const change = { - objectType: "role", - operation: "create", - scope: "membership", - member: "app_user", - role: { name: "admin" }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat.member).toBe("app_user"); - }); - - test("flattens inSchema for default_privilege changes", () => { - const change = { - objectType: "role", - operation: "create", - scope: "default_privilege", - inSchema: "public", - objtype: "table", - role: { name: "admin" }, - requires: [], - } as unknown as Change; - - const flat = flattenChange(change); - expect(flat.inSchema).toBe("public"); - expect(flat.objtype).toBe("table"); - expect(flat["role/schema"]).toBe("public"); - }); - - test("caches results (returns same reference)", () => { - const change = { - objectType: "table", - operation: "create", - scope: "object", - table: { schema: "public", name: "t" }, - requires: [], - } as unknown as Change; - - const flat1 = flattenChange(change); - const flat2 = flattenChange(change); - expect(flat1).toBe(flat2); - }); -}); - -describe("compileWildcard", () => { - test("exact match on bare key", () => { - const matcher = compileWildcard("objectType"); - expect(matcher("objectType")).toBe(true); - expect(matcher("operation")).toBe(false); - expect(matcher("table/objectType")).toBe(false); - }); - - test("exact match on path key", () => { - const matcher = compileWildcard("table/schema"); - expect(matcher("table/schema")).toBe(true); - expect(matcher("view/schema")).toBe(false); - expect(matcher("table/name")).toBe(false); - }); - - test("wildcard matches any single segment", () => { - const matcher = compileWildcard("*/schema"); - expect(matcher("table/schema")).toBe(true); - expect(matcher("view/schema")).toBe(true); - expect(matcher("aggregate/schema")).toBe(true); - expect(matcher("schema")).toBe(false); - expect(matcher("a/b/schema")).toBe(false); - }); - - test("does not match different segment count", () => { - const matcher = compileWildcard("*/schema"); - expect(matcher("objectType")).toBe(false); - }); - - test("brace expansion matches multiple alternatives", () => { - const matcher = compileWildcard("{table,view}/schema"); - expect(matcher("table/schema")).toBe(true); - expect(matcher("view/schema")).toBe(true); - expect(matcher("role/schema")).toBe(false); - }); - - test("partial wildcard matches prefix", () => { - const matcher = compileWildcard("table/is_*"); - expect(matcher("table/is_partition")).toBe(true); - expect(matcher("table/is_typed")).toBe(true); - expect(matcher("table/name")).toBe(false); - }); - - test("extglob negation excludes specific segment", () => { - const matcher = compileWildcard("!(role)/schema"); - expect(matcher("table/schema")).toBe(true); - expect(matcher("view/schema")).toBe(true); - expect(matcher("role/schema")).toBe(false); - }); -}); diff --git a/packages/pg-delta/src/core/integrations/filter/flatten.ts b/packages/pg-delta/src/core/integrations/filter/flatten.ts deleted file mode 100644 index a2c6046f3..000000000 --- a/packages/pg-delta/src/core/integrations/filter/flatten.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Change flattening and wildcard path matching for the filter DSL. - * - * Each Change is flattened into a Record where top-level - * scalar properties become bare keys and model sub-object properties become - * `/` paths. Wildcard patterns (e.g. `* /schema`) match - * against these flat paths. - */ - -import picomatch from "picomatch"; -import type { Change } from "../../change.types.ts"; -import { OBJECT_TYPE_TO_PROPERTY_KEY } from "../../change.types.ts"; -import { getSchema } from "../../change-utils.ts"; - -/** - * A flat value extracted from a Change: scalar types or arrays of scalars. - * - * The filter DSL flattens every {@link Change} into a - * `Record` before pattern matching. Only these primitive - * types survive the flattening step; nested objects are expanded into - * `/` paths. - * - * @category Filter DSL - */ -export type FlatValue = - | string - | number - | boolean - | null - | Array; - -/** - * WeakMap cache to avoid re-flattening the same Change instance. - */ -const flattenCache = new WeakMap>(); - -/** - * Convert an unknown value to a FlatValue if it's a supported type. - * - * Supported types (kept in the flat record): - * - null / undefined → null (missing or explicitly null) - * - string, number, boolean → as-is - * - Array where every element is string or number → as-is - * - * Anything else (nested objects, arrays of objects, functions, …) is NOT - * representable as a flat value, so we return `undefined` to signal - * "skip this entry". - */ -function toFlatValue(value: unknown): FlatValue | undefined { - if (value === null || value === undefined) return null; - if ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) - return value; - if ( - Array.isArray(value) && - value.every((v: unknown) => typeof v === "string" || typeof v === "number") - ) { - return value as Array; - } - return undefined; -} - -/** - * Flatten a Change into a Record. - * - * A Change object has two kinds of properties: - * - * 1. **Top-level properties** — scalars and arrays directly on the object. - * These become bare keys in the flat record. - * - * 2. **Model sub-object** — a single nested object whose JS property name is - * given by OBJECT_TYPE_TO_PROPERTY_KEY. Its scalar fields are flattened - * with an `/` prefix. - * - * After the main loop, a schema normalization step ensures that - * `/schema` exists for every change that logically belongs to - * a schema — even when the model stores the schema under a different name. - * - * Results are cached per Change instance (WeakMap) so repeated calls are free. - */ -export function flattenChange(change: Change): Record { - const cached = flattenCache.get(change); - if (cached) return cached; - - const flat: Record = {}; - - const modelKey = OBJECT_TYPE_TO_PROPERTY_KEY[change.objectType]; - const prefix = change.objectType; - - for (const [key, value] of Object.entries(change)) { - if ( - key === modelKey && - value && - typeof value === "object" && - !Array.isArray(value) - ) { - for (const [subKey, subValue] of Object.entries( - value as Record, - )) { - const flatVal = toFlatValue(subValue); - if (flatVal !== undefined) { - flat[`${prefix}/${subKey}`] = flatVal; - } - } - } else if ( - key === "securityLabel" && - value && - typeof value === "object" && - !Array.isArray(value) - ) { - // Security labels are change-level metadata, so expose provider/label as - // bare keys for filters like { scope: "security_label", provider: "..." }. - for (const [subKey, subValue] of Object.entries( - value as Record, - )) { - const flatVal = toFlatValue(subValue); - if (flatVal !== undefined) { - flat[subKey] = flatVal; - } - } - } else { - const flatVal = toFlatValue(value); - if (flatVal !== undefined) { - flat[key] = flatVal; - } - } - } - - // requires/creates/drops are prototype getters (not own properties), - // so Object.entries() above won't see them. Access them explicitly. - flat.requires = change.requires ?? []; - flat.creates = change.creates ?? []; - flat.drops = change.drops ?? []; - - // Schema normalization: ensure /schema exists for all changes - // that have a schema. Handles: schema objects (name→schema), event triggers - // (function_schema→schema), default_privilege scope (inSchema→schema). - const schemaKey = `${prefix}/schema`; - if (!(schemaKey in flat)) { - const schemaValue = getSchema(change); - if (schemaValue !== null) { - flat[schemaKey] = schemaValue; - } - } - - flattenCache.set(change, flat); - return flat; -} - -/** - * Compile a glob pattern string into a matcher function. - * - * Uses picomatch for full glob support: - * - `objectType` matches only `objectType` - * - `table/schema` matches only `table/schema` - * - `* /schema` matches `table/schema`, `view/schema`, etc. - * - `{table,view}/schema` matches `table/schema` and `view/schema` - * - `table/is_*` matches `table/is_partition`, `table/is_typed`, etc. - * - `!(role)/schema` matches any objectType's schema except `role` - */ -export function compileWildcard(pattern: string): (path: string) => boolean { - return picomatch(pattern, { dot: true }); -} diff --git a/packages/pg-delta/src/core/integrations/integration-dsl.ts b/packages/pg-delta/src/core/integrations/integration-dsl.ts deleted file mode 100644 index ba73c5702..000000000 --- a/packages/pg-delta/src/core/integrations/integration-dsl.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Integration DSL - A serializable domain-specific language for integrations. - * - * Combines filter and serialization DSLs into a single serializable structure. - */ - -import type { CatalogSnapshot } from "../catalog.snapshot.ts"; -import type { FilterDSL } from "./filter/dsl.ts"; -import type { SerializeDSL } from "./serialize/dsl.ts"; - -/** - * Serializable representation of a pg-delta integration. - * - * An integration combines a {@link FilterDSL} (which changes to include) with a - * {@link SerializeDSL} (how to render them as SQL) and an optional baseline - * catalog snapshot. - * - * @category Integration - */ -export type IntegrationDSL = { - /** - * Base integration(s) to extend. Filters are AND-combined, serialize rules - * are concatenated (base rules first, higher priority in first-match-wins), - * and the most specific emptyCatalog wins. - * - * Only core integration names are accepted (e.g., "supabase"). - * Can be a single name or an array of names. - * Circular extends are detected and rejected. - */ - extends?: string | string[]; - /** - * Filter DSL - determines which changes to include/exclude. - * If not provided, all changes are included. - */ - filter?: FilterDSL; - /** - * Serialization DSL - customizes how changes are serialized. - * If not provided, changes are serialized with default options. - */ - serialize?: SerializeDSL; - /** - * Baseline catalog snapshot for this integration. - * - * When `--source` is omitted, this snapshot is deserialized and used as the - * source catalog instead of `createEmptyCatalog`. This lets integrations - * define what "empty" means for their platform (e.g. Supabase ships with - * pre-existing schemas, extensions, and roles). - */ - emptyCatalog?: CatalogSnapshot; -}; diff --git a/packages/pg-delta/src/core/integrations/integration.types.ts b/packages/pg-delta/src/core/integrations/integration.types.ts deleted file mode 100644 index 407fa449d..000000000 --- a/packages/pg-delta/src/core/integrations/integration.types.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { compileFilterDSL, type FilterDSL } from "./filter/dsl.ts"; -import type { ChangeFilter } from "./filter/filter.types.ts"; -import { compileSerializeDSL, type SerializeDSL } from "./serialize/dsl.ts"; -import type { ChangeSerializer } from "./serialize/serialize.types.ts"; - -/** - * A resolved integration is an integration that has been compiled to a function. - */ -export type ResolvedIntegration = { - filter?: ChangeFilter; - serialize?: ChangeSerializer; -}; - -/** - * A raw integration is an integration that has not been compiled to a function. - */ -export type IntegrationDSL = { - filter?: FilterDSL; - serialize?: SerializeDSL; -}; - -/** - * An integration is a raw integration that has not been compiled to a function. - */ -export type Integration = { - filter?: ResolvedIntegration["filter"] | IntegrationDSL["filter"]; - serialize?: ResolvedIntegration["serialize"] | IntegrationDSL["serialize"]; -}; - -/** - * Resolve an integration either DSL or already resovled into a ResolvedIntegration. - * @param integration - The integration to resolve. - * @returns The resolved integration. - */ -export function resolveIntegration( - integration: Integration, -): ResolvedIntegration | undefined { - // Determine if filter/serialize are DSL or functions, and extract DSL for storage - const isFilterDSL = - integration.filter && typeof integration.filter !== "function"; - const isSerializeDSL = - integration.serialize && typeof integration.serialize !== "function"; - const filterDSL = isFilterDSL ? (integration.filter as FilterDSL) : undefined; - const serializeDSL = isSerializeDSL - ? (integration.serialize as SerializeDSL) - : undefined; - - // Build final integration: compile DSL if needed, use functions directly otherwise - if (integration.filter || integration.serialize) { - return { - filter: - typeof integration.filter === "function" - ? integration.filter - : filterDSL - ? compileFilterDSL(filterDSL) - : undefined, - serialize: - typeof integration.serialize === "function" - ? integration.serialize - : serializeDSL - ? compileSerializeDSL(serializeDSL) - : undefined, - }; - } -} diff --git a/packages/pg-delta/src/core/integrations/merge.test.ts b/packages/pg-delta/src/core/integrations/merge.test.ts deleted file mode 100644 index cc10b5ea7..000000000 --- a/packages/pg-delta/src/core/integrations/merge.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { IntegrationDSL } from "./integration-dsl.ts"; -import { mergeIntegrations } from "./merge.ts"; - -describe("mergeIntegrations", () => { - test("empty list returns empty object", () => { - expect(mergeIntegrations([])).toEqual({}); - }); - - test("single integration is returned as-is", () => { - const integration: IntegrationDSL = { - filter: { objectType: "table" }, - serialize: [ - { - when: { objectType: "schema" }, - options: { skipAuthorization: true }, - }, - ], - }; - expect(mergeIntegrations([integration])).toBe(integration); - }); - - test("filters are AND-combined", () => { - const base: IntegrationDSL = { - filter: { "*/schema": "public" }, - }; - const ext: IntegrationDSL = { - filter: { objectType: "table" }, - }; - - const merged = mergeIntegrations([base, ext]); - expect(merged.filter).toEqual({ - and: [{ "*/schema": "public" }, { objectType: "table" }], - }); - }); - - test("single filter is not wrapped in and", () => { - const base: IntegrationDSL = {}; - const ext: IntegrationDSL = { - filter: { objectType: "table" }, - }; - - const merged = mergeIntegrations([base, ext]); - expect(merged.filter).toEqual({ objectType: "table" }); - }); - - test("serialize rules are concatenated (base first)", () => { - const base: IntegrationDSL = { - serialize: [ - { - when: { objectType: "schema" }, - options: { skipAuthorization: true }, - }, - ], - }; - const ext: IntegrationDSL = { - serialize: [ - { - when: { objectType: "table" }, - options: { skipAuthorization: false }, - }, - ], - }; - - const merged = mergeIntegrations([base, ext]); - expect(merged.serialize).toEqual([ - { when: { objectType: "schema" }, options: { skipAuthorization: true } }, - { when: { objectType: "table" }, options: { skipAuthorization: false } }, - ]); - }); - - test("emptyCatalog: most-specific (last) wins", () => { - const baseCatalog = { - version: 15, - schemas: {}, - } as IntegrationDSL["emptyCatalog"]; - const extCatalog = { - version: 16, - schemas: {}, - } as IntegrationDSL["emptyCatalog"]; - - const base: IntegrationDSL = { emptyCatalog: baseCatalog }; - const ext: IntegrationDSL = { emptyCatalog: extCatalog }; - - const merged = mergeIntegrations([base, ext]); - expect(merged.emptyCatalog).toBe(extCatalog); - }); - - test("emptyCatalog: falls back to base if most-specific is undefined", () => { - const baseCatalog = { - version: 15, - schemas: {}, - } as IntegrationDSL["emptyCatalog"]; - - const base: IntegrationDSL = { emptyCatalog: baseCatalog }; - const ext: IntegrationDSL = {}; - - const merged = mergeIntegrations([base, ext]); - expect(merged.emptyCatalog).toBe(baseCatalog); - }); - - test("full merge combines all fields", () => { - const base: IntegrationDSL = { - filter: { "*/schema": "public" }, - serialize: [ - { - when: { objectType: "schema" }, - options: { skipAuthorization: true }, - }, - ], - }; - const ext: IntegrationDSL = { - filter: { not: { objectType: "role" } }, - serialize: [ - { - when: { objectType: "table" }, - options: { skipAuthorization: false }, - }, - ], - }; - - const merged = mergeIntegrations([base, ext]); - expect(merged.filter).toEqual({ - and: [{ "*/schema": "public" }, { not: { objectType: "role" } }], - }); - expect(merged.serialize).toHaveLength(2); - }); -}); diff --git a/packages/pg-delta/src/core/integrations/merge.ts b/packages/pg-delta/src/core/integrations/merge.ts deleted file mode 100644 index 5cbee1042..000000000 --- a/packages/pg-delta/src/core/integrations/merge.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Integration merging — combines multiple IntegrationDSL objects. - * - * - Filters are AND-combined - * - Serialize rules are concatenated (earlier integrations first = higher priority) - * - emptyCatalog: most-specific (last) integration's value wins - */ - -import type { FilterDSL } from "./filter/dsl.ts"; -import type { IntegrationDSL } from "./integration-dsl.ts"; -import type { SerializeDSL } from "./serialize/dsl.ts"; - -/** - * Merge an ordered list of integrations into a single IntegrationDSL. - * - * Integrations are ordered from base (first) to most-specific (last). - * - Filters: AND-combined (all must pass) - * - Serialize: concatenated (base rules first → higher priority, first-match-wins) - * - emptyCatalog: most-specific non-undefined value wins - * - * @param integrations - Ordered list of integrations (base first, most-specific last) - * @returns A single merged IntegrationDSL - */ -export function mergeIntegrations( - integrations: IntegrationDSL[], -): IntegrationDSL { - if (integrations.length === 0) return {}; - if (integrations.length === 1) return integrations[0]; - - // Collect all filters - const filters: FilterDSL[] = []; - for (const integration of integrations) { - if (integration.filter) { - filters.push(integration.filter); - } - } - - // Collect all serialize rules (base first = higher priority) - const serializeRules: SerializeDSL = []; - for (const integration of integrations) { - if (integration.serialize) { - serializeRules.push(...integration.serialize); - } - } - - // emptyCatalog: most-specific (last) non-undefined wins - let emptyCatalog: IntegrationDSL["emptyCatalog"]; - for (let i = integrations.length - 1; i >= 0; i--) { - if (integrations[i].emptyCatalog !== undefined) { - emptyCatalog = integrations[i].emptyCatalog; - break; - } - } - - const merged: IntegrationDSL = {}; - - if (filters.length === 1) { - merged.filter = filters[0]; - } else if (filters.length > 1) { - merged.filter = { and: filters }; - } - - if (serializeRules.length > 0) { - merged.serialize = serializeRules; - } - - if (emptyCatalog !== undefined) { - merged.emptyCatalog = emptyCatalog; - } - - return merged; -} diff --git a/packages/pg-delta/src/core/integrations/serialize/dsl.test.ts b/packages/pg-delta/src/core/integrations/serialize/dsl.test.ts deleted file mode 100644 index be9cd683f..000000000 --- a/packages/pg-delta/src/core/integrations/serialize/dsl.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../../change.types.ts"; -import { compileSerializeDSL } from "./dsl.ts"; -import type { SerializeOptions } from "./serialize.types.ts"; - -function makeChange( - type: string, - operation: string, - serializeFn: (opts?: SerializeOptions) => string, -): Change { - return { - objectType: type, - operation, - scope: "object", - schema: { name: "test" }, - extension: { schema: "pgmq" }, - serialize: serializeFn, - } as unknown as Change; -} - -describe("compileSerializeDSL", () => { - test("matching extension rule can skip schema serialization", () => { - const serializer = compileSerializeDSL([ - { - when: { objectType: "extension", operation: "create" }, - options: { skipSchema: true }, - }, - ]); - - const change = makeChange("extension", "create", (opts) => - opts?.skipSchema - ? "CREATE EXTENSION pgmq" - : "CREATE EXTENSION pgmq WITH SCHEMA pgmq", - ); - - expect(serializer(change)).toBe("CREATE EXTENSION pgmq"); - }); - - test("matching rule applies its options", () => { - const serializer = compileSerializeDSL([ - { - when: { objectType: "schema", operation: "create" }, - options: { skipAuthorization: true }, - }, - ]); - - const change = makeChange("schema", "create", (opts) => - opts?.skipAuthorization - ? "CREATE SCHEMA test" - : "CREATE SCHEMA test AUTHORIZATION owner", - ); - - expect(serializer(change)).toBe("CREATE SCHEMA test"); - }); - - test("no matching rule uses default serialization", () => { - const serializer = compileSerializeDSL([ - { - when: { objectType: "table" }, - options: { skipAuthorization: true }, - }, - ]); - - const change = makeChange("schema", "create", (opts) => - opts?.skipAuthorization - ? "CREATE SCHEMA test" - : "CREATE SCHEMA test AUTHORIZATION owner", - ); - - expect(serializer(change)).toBe("CREATE SCHEMA test AUTHORIZATION owner"); - }); - - test("first matching rule wins", () => { - const serializer = compileSerializeDSL([ - { - when: { objectType: "schema" }, - options: { skipAuthorization: true }, - }, - { - when: { objectType: "schema" }, - options: { skipAuthorization: false }, - }, - ]); - - const change = makeChange("schema", "create", (opts) => - opts?.skipAuthorization ? "WITHOUT AUTH" : "WITH AUTH", - ); - - expect(serializer(change)).toBe("WITHOUT AUTH"); - }); - - test("skips non-matching first rule and applies second", () => { - const serializer = compileSerializeDSL([ - { - when: { objectType: "table" }, - options: { skipAuthorization: true }, - }, - { - when: { objectType: "schema" }, - options: { skipAuthorization: false }, - }, - ]); - - const change = makeChange("schema", "create", (opts) => - opts?.skipAuthorization ? "WITHOUT AUTH" : "WITH AUTH", - ); - - expect(serializer(change)).toBe("WITH AUTH"); - }); -}); diff --git a/packages/pg-delta/src/core/integrations/serialize/dsl.ts b/packages/pg-delta/src/core/integrations/serialize/dsl.ts deleted file mode 100644 index f40e3168c..000000000 --- a/packages/pg-delta/src/core/integrations/serialize/dsl.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Serialization DSL - A serializable domain-specific language for customizing change serialization. - * - * Reuses the filter pattern matching logic to determine when to apply serialization options. - */ - -import type { Change } from "../../change.types.ts"; -import { evaluatePattern, type FilterPattern } from "../filter/dsl.ts"; -import type { ChangeSerializer, SerializeOptions } from "./serialize.types.ts"; - -/** - * A serialization rule that applies options when a pattern matches. - */ -type SerializeRule = { - /** - * Pattern to match against changes. - * Uses the same pattern matching logic as filters. - */ - when: FilterPattern; - /** - * Serialization options to apply when the pattern matches. - */ - options: SerializeOptions; -}; - -/** - * Array of serialization rules evaluated in order. The first matching rule's - * options are passed to `change.serialize()`. If no rule matches, default - * serialization is used. - * - * @category Integration - */ -export type SerializeDSL = SerializeRule[]; - -/** - * Compile a Serialization DSL to a ChangeSerializer function. - * - * Rules are evaluated in order, and the first matching rule's options are applied. - * If no rule matches, the change is serialized with default options. - * - * @param dsl - The serialization DSL - * @returns A ChangeSerializer function that applies the rules - * - * @example - * ```ts - * const serializer = compileSerializeDSL([ - * { - * when: { - * objectType: "schema", - * operation: "create", - * "schema/owner": ["service_role"] - * }, - * options: { skipAuthorization: true } - * } - * ]); - * ``` - */ -export function compileSerializeDSL(dsl: SerializeDSL): ChangeSerializer { - return (change: Change): string | undefined => { - // Find first matching rule - for (const rule of dsl) { - if (evaluatePattern(rule.when, change)) { - // Apply this rule's options - return change.serialize(rule.options); - } - } - - // No rule matched - use default serialization - return change.serialize(); - }; -} diff --git a/packages/pg-delta/src/core/integrations/serialize/serialize.types.ts b/packages/pg-delta/src/core/integrations/serialize/serialize.types.ts deleted file mode 100644 index fbb1f3a64..000000000 --- a/packages/pg-delta/src/core/integrations/serialize/serialize.types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Change } from "../../change.types.ts"; - -/** - * Shared serialization options passed to `change.serialize(options)`. - * - * This is the global source of truth for serialize-option flags used by the - * integration serialization DSL and concrete change serializers. - * - * @category Integration - */ -export type SerializeOptions = { - /** Skip `AUTHORIZATION` when serializing schema creation. */ - skipAuthorization?: boolean; - /** Skip `WITH SCHEMA ...` when serializing extension creation. */ - skipSchema?: boolean; -}; - -/** - * Schema-specific view of {@link SerializeOptions}. - * - * @category Integration - */ -export type SchemaSerializeOptions = Pick< - SerializeOptions, - "skipAuthorization" ->; - -/** - * Extension-specific view of {@link SerializeOptions}. - * - * @category Integration - */ -export type ExtensionSerializeOptions = Pick; - -/** - * Compiled serializer function used during plan/declarative export rendering. - * - * @category Integration - */ -export type ChangeSerializer = (change: Change) => string | undefined; diff --git a/packages/pg-delta/src/core/integrations/supabase.test.ts b/packages/pg-delta/src/core/integrations/supabase.test.ts deleted file mode 100644 index e4919bc4e..000000000 --- a/packages/pg-delta/src/core/integrations/supabase.test.ts +++ /dev/null @@ -1,533 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../change.types.ts"; -import { evaluatePattern } from "./filter/dsl.ts"; -import { supabase } from "./supabase.ts"; - -if (!supabase.filter) { - throw new Error("supabase integration is missing a filter"); -} -const filter = supabase.filter; - -/** - * Build a synthetic FDW change shaped like what `flattenChange` consumes. - * The change carries a `foreignDataWrapper` model whose `handler`/`validator` - * are schema-qualified function references (the form - * `extractForeignDataWrappers` produces). - */ -function fdwChange( - operation: "create" | "alter" | "drop", - fdw: { - name: string; - owner: string; - handler: string | null; - validator: string | null; - }, -): Change { - return { - objectType: "foreign_data_wrapper", - operation, - scope: "object", - foreignDataWrapper: fdw, - requires: [], - creates: [], - drops: [], - } as unknown as Change; -} - -/** - * Synthetic FDW privilege change. The three concrete privilege classes - * (`GrantForeignDataWrapperPrivileges`, `RevokeForeignDataWrapperPrivileges`, - * `RevokeGrantOptionForeignDataWrapperPrivileges`) all extend - * `AlterForeignDataWrapperChange`, so their `operation` is `"alter"` in - * production. The filter rule we exercise here keys off `scope` only, - * but pinning `operation: "alter"` keeps the synthetic shape honest. - */ -function fdwPrivilegeChange(fdw: { name: string; owner: string }): Change { - return { - objectType: "foreign_data_wrapper", - operation: "alter", - scope: "privilege", - foreignDataWrapper: { ...fdw, handler: null, validator: null }, - grantee: "postgres", - requires: [], - creates: [], - drops: [], - } as unknown as Change; -} - -function serverChange( - operation: "create" | "alter" | "drop", - server: { - name: string; - owner: string; - foreign_data_wrapper: string; - wrapper_handler: string | null; - wrapper_validator: string | null; - }, -): Change { - return { - objectType: "server", - operation, - scope: "object", - server: { - type: null, - version: null, - options: null, - comment: null, - privileges: [], - ...server, - }, - requires: [], - creates: [], - drops: [], - } as unknown as Change; -} - -function foreignTableChange( - operation: "create" | "alter" | "drop", - foreignTable: { - schema: string; - name: string; - owner: string; - server: string; - wrapper_handler: string | null; - wrapper_validator: string | null; - }, -): Change { - return { - objectType: "foreign_table", - operation, - scope: "object", - foreignTable: { - options: null, - comment: null, - columns: [], - privileges: [], - security_labels: [], - ...foreignTable, - }, - requires: [], - creates: [], - drops: [], - } as unknown as Change; -} - -function userMappingChange( - operation: "create" | "alter" | "drop", - userMapping: { - user: string; - server: string; - wrapper_handler: string | null; - wrapper_validator: string | null; - }, -): Change { - return { - objectType: "user_mapping", - operation, - scope: "object", - userMapping: { - options: null, - ...userMapping, - }, - requires: [], - creates: [], - drops: [], - } as unknown as Change; -} - -function serverPrivilegeChange(server: { - name: string; - owner: string; -}): Change { - return { - objectType: "server", - operation: "alter", - scope: "privilege", - server, - grantee: "postgres", - requires: [], - creates: [], - drops: [], - } as unknown as Change; -} - -/** - * Build a synthetic trigger change shaped like what `flattenChange` consumes. - * The flattener emits keys `trigger/schema`, `trigger/table_name`, - * `trigger/function_schema`, etc. by walking the nested `trigger` model. - */ -function triggerChange( - operation: "create" | "alter" | "drop", - trigger: { - schema: string; - name: string; - table_name: string; - function_schema: string; - function_name: string; - owner: string; - }, -): Change { - return { - objectType: "trigger", - operation, - scope: "object", - trigger, - requires: [], - creates: [], - drops: [], - } as unknown as Change; -} - -describe("supabase integration filter — foreign data wrappers", () => { - // Regression for CLI-1470. Wasm-based foreign data wrappers on Supabase - // (e.g. `clerk`, `clerk_oauth`) are provisioned at project creation by - // `supabase_admin` and their handler/validator live in `extensions.*`. - // pg-delta must not emit `CREATE/DROP/ALTER FOREIGN DATA WRAPPER` for - // them, even when the FDW owner has been rewritten away from - // `supabase_admin` (e.g. after a dump/restore). - test("suppresses CREATE for FDW with handler in extensions schema", () => { - const change = fdwChange("create", { - name: "clerk", - owner: "postgres", - handler: "extensions.wasm_fdw_handler", - validator: "extensions.wasm_fdw_validator", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses DROP for FDW with handler in extensions schema", () => { - const change = fdwChange("drop", { - name: "clerk_oauth", - owner: "postgres", - handler: "extensions.wasm_fdw_handler", - validator: "extensions.wasm_fdw_validator", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses ALTER for FDW with handler in extensions schema", () => { - const change = fdwChange("alter", { - name: "clerk", - owner: "postgres", - handler: "extensions.wasm_fdw_handler", - validator: "extensions.wasm_fdw_validator", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses FDW when only the validator lives in extensions", () => { - const change = fdwChange("create", { - name: "partial_wasm", - owner: "postgres", - handler: null, - validator: "extensions.wasm_fdw_validator", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("preserves user FDW whose handler lives outside extensions", () => { - const change = fdwChange("create", { - name: "user_fdw", - owner: "postgres", - handler: "public.my_fdw_handler", - validator: "public.my_fdw_validator", - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - // `postgres_fdw` (and other contrib FDWs) install their handler/validator - // into `extensions` on Supabase, but they ARE available in the local image, - // so a user-created `postgres_fdw` wrapper must roundtrip. Only the Wasm - // `wasm_fdw_handler` / `wasm_fdw_validator` functions identify the - // platform-managed wrappers that local Docker cannot provision. - test("preserves user FDW whose handler is extensions.postgres_fdw_handler", () => { - const change = fdwChange("create", { - name: "postgres_fdw", - owner: "postgres", - handler: "extensions.postgres_fdw_handler", - validator: "extensions.postgres_fdw_validator", - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - // The Wasm discriminator must be an exact function-name match, not a - // prefix: a user function whose name merely starts with `wasm_fdw_handler` - // (e.g. `wasm_fdw_handler_custom`) is not the platform `wrappers` handler - // and must roundtrip. - test("preserves user FDW whose handler extends the wasm_fdw_handler prefix", () => { - const change = fdwChange("create", { - name: "custom_wasm", - owner: "postgres", - handler: "extensions.wasm_fdw_handler_custom", - validator: "extensions.wasm_fdw_validator_custom", - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - test("preserves user FDW with no handler/validator", () => { - const change = fdwChange("create", { - name: "user_fdw_bare", - owner: "postgres", - handler: null, - validator: null, - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); -}); - -describe("supabase integration filter — foreign data wrapper / server ACLs", () => { - // Regression for CLI-1469. `GRANT`/`REVOKE ... ON FOREIGN DATA WRAPPER` - // require superuser. On Supabase Cloud `postgres` has the elevated - // rights to make them work; the local Docker image does not, so - // `supabase db reset` aborts with `permission denied for foreign-data - // wrapper`. FDW ACL is platform-managed, not user-declarative state — - // suppress regardless of owner because `pg_dump` rewrites OWNER TO - // away from `supabase_admin`. - test("suppresses FDW ACL when owner=supabase_admin (existing */owner rule)", () => { - const change = fdwPrivilegeChange({ - name: "dblink_fdw", - owner: "supabase_admin", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses FDW ACL when owner=postgres (post-restore)", () => { - const change = fdwPrivilegeChange({ - name: "dblink_fdw", - owner: "postgres", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - // FOREIGN SERVER ACL is owner-scoped, not blanket-suppressed: - // server GRANT/REVOKE does not require superuser, so a user-owned - // server's ACL must roundtrip. The pre-existing `*/owner` rule - // already drops platform-managed servers (owner ∈ system roles). - test("suppresses server ACL when owner=supabase_admin (existing */owner rule)", () => { - const change = serverPrivilegeChange({ - name: "platform_server", - owner: "supabase_admin", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("preserves server ACL when owner=postgres", () => { - const change = serverPrivilegeChange({ - name: "user_dblink_server", - owner: "postgres", - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - // Non-privilege FDW changes whose handler/validator aren't in - // `extensions.*` should still pass through (a user FDW is plain DDL, - // not the platform-managed flavor). - test("preserves non-privilege FDW changes for user wrappers", () => { - const change = fdwChange("create", { - name: "user_fdw", - owner: "postgres", - handler: "public.my_fdw_handler", - validator: null, - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); -}); - -describe("supabase integration filter — Wasm FDW dependents", () => { - const wasmWrapper = { - wrapper_handler: "extensions.wasm_fdw_handler", - wrapper_validator: "extensions.wasm_fdw_validator", - } as const; - - const userWrapper = { - wrapper_handler: "public.postgres_fdw_handler", - wrapper_validator: "public.postgres_fdw_validator", - } as const; - - // `postgres_fdw` installs its handler/validator into `extensions` on - // Supabase, but the contrib FDW IS available locally, so user-owned - // servers / foreign tables / user mappings built on it must roundtrip. - // Keying suppression on the bare `extensions.*` namespace would wrongly - // drop them; only the Wasm `wasm_fdw_*` functions mark platform wrappers. - const extensionsPgFdwWrapper = { - wrapper_handler: "extensions.postgres_fdw_handler", - wrapper_validator: "extensions.postgres_fdw_validator", - } as const; - - test("suppresses CREATE SERVER bound to extensions.* Wasm FDW", () => { - const change = serverChange("create", { - name: "clerk_oauth_server", - owner: "postgres", - foreign_data_wrapper: "clerk_oauth", - ...wasmWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses DROP FOREIGN TABLE bound to extensions.* Wasm FDW", () => { - const change = foreignTableChange("drop", { - schema: "public", - name: "clerk_oauth", - owner: "postgres", - server: "clerk_oauth_server", - ...wasmWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses ALTER FOREIGN TABLE bound to extensions.* Wasm FDW", () => { - const change = foreignTableChange("alter", { - schema: "public", - name: "clerk_oauth", - owner: "postgres", - server: "clerk_oauth_server", - ...wasmWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses DROP USER MAPPING bound to extensions.* Wasm FDW", () => { - const change = userMappingChange("drop", { - user: "postgres", - server: "clerk_server", - ...wasmWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses CREATE USER MAPPING when only wrapper validator is in extensions", () => { - const change = userMappingChange("create", { - user: "postgres", - server: "clerk_server", - wrapper_handler: null, - wrapper_validator: "extensions.wasm_fdw_validator", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("preserves CREATE SERVER bound to user postgres_fdw wrapper", () => { - const change = serverChange("create", { - name: "live_risk_server", - owner: "postgres", - foreign_data_wrapper: "postgres_fdw", - ...userWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - test("preserves server ACL when postgres_fdw handler lives in extensions", () => { - const change = serverPrivilegeChange({ - name: "user_server", - owner: "postgres", - }); - (change as unknown as { server: Record }).server = { - name: "user_server", - owner: "postgres", - wrapper_handler: "extensions.postgres_fdw_handler", - wrapper_validator: "extensions.postgres_fdw_validator", - }; - expect(evaluatePattern(filter, change)).toBe(true); - }); - - test("preserves CREATE FOREIGN TABLE on user postgres_fdw server", () => { - const change = foreignTableChange("create", { - schema: "live_risk", - name: "devices", - owner: "postgres", - server: "live_risk_server", - ...userWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - test("preserves CREATE SERVER when postgres_fdw handler lives in extensions", () => { - const change = serverChange("create", { - name: "user_pg_server", - owner: "postgres", - foreign_data_wrapper: "postgres_fdw", - ...extensionsPgFdwWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - test("preserves CREATE FOREIGN TABLE when postgres_fdw handler lives in extensions", () => { - const change = foreignTableChange("create", { - schema: "user_fdw_test", - name: "remote_row", - owner: "postgres", - server: "user_pg_server", - ...extensionsPgFdwWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - test("preserves CREATE USER MAPPING when postgres_fdw handler lives in extensions", () => { - const change = userMappingChange("create", { - user: "postgres", - server: "user_pg_server", - ...extensionsPgFdwWrapper, - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); - - // Exact-match guard at the dependent level too: a server bound to a wrapper - // whose handler merely shares the `wasm_fdw_handler` prefix must roundtrip. - test("preserves CREATE SERVER when wrapper handler extends the wasm_fdw_handler prefix", () => { - const change = serverChange("create", { - name: "custom_wasm_server", - owner: "postgres", - foreign_data_wrapper: "custom_wasm", - wrapper_handler: "extensions.wasm_fdw_handler_custom", - wrapper_validator: "extensions.wasm_fdw_validator_custom", - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); -}); - -describe("supabase integration filter — pgmq queue triggers", () => { - // Regression for the pgmq-1.4.4 cloud projects. `pgmq.create('')` - // materializes `pgmq.q_` and `pgmq.a_` at runtime — they are - // NOT created by `CREATE EXTENSION pgmq`. On a healthy install the trigger - // extractor's `extension_table_oids` join already drops these via the - // `pg_depend deptype='e'` row that newer pgmq versions record, but on - // pgmq 1.4.4 that row is never recorded, so user triggers on the queue - // tables leak into the diff and break `supabase db reset` with - // `relation "pgmq.q_" does not exist`. The filter must drop them - // at the supabase-integration level too, regardless of pg_depend state. - - test("suppresses CREATE trigger on pgmq.q_ calling a public function", () => { - const change = triggerChange("create", { - schema: "pgmq", - name: "after_insert_processed_milestones_queue", - table_name: "q_processed_milestones_queue", - function_schema: "public", - function_name: "move_data_from_queue", - owner: "postgres", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("suppresses DROP trigger on pgmq.a_ calling a public function", () => { - const change = triggerChange("drop", { - schema: "pgmq", - name: "after_insert_archive", - table_name: "a_processed_milestones_queue", - function_schema: "public", - function_name: "archive_handler", - owner: "postgres", - }); - expect(evaluatePattern(filter, change)).toBe(false); - }); - - test("preserves CREATE trigger on auth.users calling a public function", () => { - const change = triggerChange("create", { - schema: "auth", - name: "on_auth_user_created", - table_name: "users", - function_schema: "public", - function_name: "handle_new_user", - owner: "supabase_auth_admin", - }); - expect(evaluatePattern(filter, change)).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/integrations/supabase.ts b/packages/pg-delta/src/core/integrations/supabase.ts deleted file mode 100644 index e6245e7fa..000000000 --- a/packages/pg-delta/src/core/integrations/supabase.ts +++ /dev/null @@ -1,355 +0,0 @@ -/** - * Supabase integration - filtering and serialization rules for Supabase databases. - * - * This integration: - * - Filters out Supabase system schemas and roles - * - Includes user schemas and extensions - * - Skips authorization for schema creates owned by Supabase system roles - */ - -import type { IntegrationDSL } from "./integration-dsl.ts"; - -// Supabase system schemas that should be excluded -const SUPABASE_SYSTEM_SCHEMAS = [ - "_analytics", - "_realtime", - "_supavisor", - "auth", - "cron", - "etl", - "extensions", - "graphql", - "graphql_public", - "information_schema", - "net", - "pgbouncer", - "pgmq", - "pgmq_public", - "pgsodium", - "pgsodium_masks", - "pgtle", - "realtime", - "storage", - "supabase_functions", - "supabase_migrations", - "vault", -] as const; - -// Supabase system roles that should be excluded -const SUPABASE_SYSTEM_ROLES = [ - "anon", - "authenticated", - "authenticator", - "cli_login_postgres", - "dashboard_user", - "pgbouncer", - "pgsodium_keyholder", - "pgsodium_keyiduser", - "pgsodium_keymaker", - "pgtle_admin", - "service_role", - "supabase_admin", - "supabase_auth_admin", - "supabase_etl_admin", - "supabase_functions_admin", - "supabase_read_only_user", - "supabase_realtime_admin", - "supabase_replication_admin", - "supabase_storage_admin", - "supabase_superuser", -] as const; - -/** - * To generate the emptyCatalog snapshot, run catalog-export against a fresh - * supabase/postgres container: - * - * pgdelta catalog-export --target postgres://postgres:postgres@localhost:54322/postgres --output supabase-baseline.json - * - * Then import and assign the JSON content to the emptyCatalog field below. - */ -export const supabase: IntegrationDSL = { - // TODO: emptyCatalog: undefined -- populate by running catalog-export on a clean Supabase container - filter: { - or: [ - // Include user schema CREATE operations (only schemas not in system list) - { - and: [ - { - objectType: "schema", - operation: "create", - scope: "object", - }, - { - not: { - // Schema objects have name, not schema — use schema/name - "schema/name": [...SUPABASE_SYSTEM_SCHEMAS], - }, - }, - ], - }, - // Include extension CREATEs - { - objectType: "extension", - operation: "create", - scope: "object", - }, - // Include extension DROPs used to disable some extensions (eg: pg-net) - { - objectType: "extension", - operation: "drop", - scope: "object", - }, - // Include user-attached triggers on tables in Supabase-managed schemas. - // - // Triggers live in the schema of the table they fire on, so a user - // trigger on `auth.users` reports `trigger/schema = auth` and is - // otherwise indistinguishable from Supabase's own triggers via the - // schema-level deny list. Triggers also have no real owner — pg-delta - // surfaces the parent table's owner as `trigger/owner`, which for - // `auth.users` and `storage.objects` is always a Supabase system role, - // so the owner-level deny list catches them too. - // - // The trigger function, however, is genuinely user-owned: a customer - // who wants to run code on an auth event creates a function in - // `public` (or any non-managed schema) and points the trigger at it. - // Supabase's own auth/storage triggers either come from extensions - // (already filtered out at extract time via `pg_depend`) or call - // functions inside the same managed schema, so `function_schema` - // outside the managed list is a reliable user-defined marker. - { - and: [ - { objectType: "trigger" }, - { "trigger/schema": [...SUPABASE_SYSTEM_SCHEMAS] }, - { - not: { - "trigger/function_schema": [...SUPABASE_SYSTEM_SCHEMAS], - }, - }, - // Defensive fallback for dynamically-created pgmq queue / - // archive tables. `pgmq.q_` and `pgmq.a_` are - // materialized by `select pgmq.create('')`, NOT by - // `CREATE EXTENSION pgmq`, so emitting a user trigger against - // them fails locally with - // `relation "pgmq.q_" does not exist`. On a healthy - // install the trigger extractor's `extension_table_oids` join - // (packages/pg-delta/src/core/objects/trigger/trigger.model.ts) - // already drops these via the `pg_depend deptype='e'` row pgmq - // records during `pgmq.create()`; this rule covers projects - // where that row is missing (older pgmq, manual table - // rewrites, `pg_dump`/restore that loses extension deps, ...). - // pgmq 1.4.4 — the version Supabase Cloud currently ships — - // does not record the dependency at all. - { - not: { - and: [ - { "trigger/schema": "pgmq" }, - { - "trigger/table_name": { op: "regex", value: "^[qa]_" }, - }, - ], - }, - }, - ], - }, - // Exclude system objects - { - not: { - or: [ - // Objects in system schemas (*/schema matches table/schema, view/schema, etc.) - { - "*/schema": [...SUPABASE_SYSTEM_SCHEMAS], - }, - // Schema objects whose own name is a system schema - { - "schema/name": [...SUPABASE_SYSTEM_SCHEMAS], - }, - // Objects owned by system roles (*/owner matches table/owner, schema/owner, etc.) - { - "*/owner": [...SUPABASE_SYSTEM_ROLES], - }, - // Role objects whose own name is a system role - { - "role/name": [...SUPABASE_SYSTEM_ROLES], - }, - // Membership changes for system roles - { - and: [ - { - objectType: "role", - scope: "membership", - }, - { - member: [...SUPABASE_SYSTEM_ROLES], - }, - ], - }, - // Platform-managed foreign data wrapper ACL. - // `GRANT`/`REVOKE ... ON FOREIGN DATA WRAPPER` requires - // superuser. On Supabase Cloud `postgres` has the elevated - // rights to make this work, but the local Docker image does - // not, so `supabase db reset` aborts with - // `permission denied for foreign-data wrapper`. The - // `*/owner` rule above already covers wrappers owned by - // `supabase_admin`, but `pg_dump` rewrites OWNER TO clauses - // to whoever the dump runs under, so after a restore the - // FDW typically ends up owned by `postgres` and slips past - // the owner gate. A non-superuser `postgres` still can't - // grant on a FDW (this is true regardless of who owns the - // wrapper locally), so the ACL diff is not user-replayable. - // We don't apply the same blanket rule to `FOREIGN SERVER`: - // server GRANT/REVOKE doesn't require superuser, and - // user-created servers (e.g. a `dblink` server pointing to - // a peer DB) carry legitimate user ACL that should - // roundtrip — the existing `*/owner` rule already drops - // platform-managed servers. - { - and: [ - { objectType: "foreign_data_wrapper" }, - { scope: "privilege" }, - ], - }, - // Platform-managed foreign data wrappers — Wasm-based FDWs - // (e.g. `clerk`, `clerk_oauth`) provisioned via the `wrappers` - // extension. Supabase Cloud creates these as - // `CREATE FOREIGN DATA WRAPPER clerk_oauth HANDLER - // extensions.wasm_fdw_handler VALIDATOR - // extensions.wasm_fdw_validator` at project creation; replaying - // the DDL against a local image fails because the local - // environment has no equivalent pre-step. We can't rely on the - // FDW owner alone — after a dump/restore the owner is often - // rewritten away from `supabase_admin` — so match on the shared - // Wasm handler/validator (`extensions.wasm_fdw_handler` / - // `extensions.wasm_fdw_validator`) instead. - // - // Matching the bare `extensions.*` namespace would be too broad: - // contrib FDWs like `postgres_fdw` also install their - // handler/validator into `extensions` on Supabase, and those ARE - // available in the local image, so a user-created `postgres_fdw` - // wrapper (and its servers/foreign tables/user mappings) must - // still roundtrip. Keying on the `wasm_fdw_*` function names - // targets only the platform Wasm wrappers. - { - and: [ - { objectType: "foreign_data_wrapper" }, - { - or: [ - { - "foreign_data_wrapper/handler": { - op: "regex", - value: "^extensions\\.wasm_fdw_handler$", - }, - }, - { - "foreign_data_wrapper/validator": { - op: "regex", - value: "^extensions\\.wasm_fdw_validator$", - }, - }, - ], - }, - ], - }, - // Platform-managed Wasm FDW dependents (CLI-1470 follow-up). - // Suppressing the wrapper DDL alone leaves `CREATE SERVER` / - // `CREATE FOREIGN TABLE` / `CREATE USER MAPPING` that reference - // a wrapper local Docker never provisions (`clerk_oauth`, etc.). - // Match on the parent wrapper's Wasm handler/validator - // (`extensions.wasm_fdw_handler` / `extensions.wasm_fdw_validator`, - // joined at extract time) — the same discriminator used for the - // wrapper itself above. A bare `extensions.*` match would also - // drop user-created `postgres_fdw` servers/foreign tables/user - // mappings (whose handler installs into `extensions` but which - // the local image CAN provision), so keep it scoped to the Wasm - // function names. Server _privilege_ scope is excluded here — - // `GRANT/REVOKE ON SERVER` does not require superuser and remains - // user-declarative state (see CLI-1469 companion test). - { - and: [ - { objectType: "server" }, - { not: { scope: "privilege" } }, - { - or: [ - { - "{server,foreign_table,user_mapping}/wrapper_handler": { - op: "regex", - value: "^extensions\\.wasm_fdw_handler$", - }, - }, - { - "{server,foreign_table,user_mapping}/wrapper_validator": { - op: "regex", - value: "^extensions\\.wasm_fdw_validator$", - }, - }, - ], - }, - ], - }, - { - and: [ - { objectType: ["foreign_table", "user_mapping"] }, - { - or: [ - { - "{server,foreign_table,user_mapping}/wrapper_handler": { - op: "regex", - value: "^extensions\\.wasm_fdw_handler$", - }, - }, - { - "{server,foreign_table,user_mapping}/wrapper_validator": { - op: "regex", - value: "^extensions\\.wasm_fdw_validator$", - }, - }, - ], - }, - ], - }, - ], - }, - }, - ], - }, - serialize: [ - { - when: { - objectType: "schema", - operation: "create", - scope: "object", - "schema/owner": [...SUPABASE_SYSTEM_ROLES], - }, - options: { - skipAuthorization: true, - }, - }, - // Extensions whose install script creates its own target schema cannot - // tolerate `CREATE EXTENSION … WITH SCHEMA ` against a fresh - // database: Postgres resolves WITH SCHEMA before running the extension's - // script, so the schema referenced by the clause does not exist yet. - // These extensions also install into schemas listed in - // SUPABASE_SYSTEM_SCHEMAS, so pg-delta filters their CREATE SCHEMA out - // of the declarative plan — nothing else will pre-create the schema. - // Emitting a bare `CREATE EXTENSION ` lets the extension's own - // install script create the schema it expects. - // - // Note: other extensions install into SUPABASE_SYSTEM_SCHEMAS too - // (`pg_graphql` → `graphql`, `supabase_vault` → `vault`, - // `uuid-ossp`/`pgcrypto`/`pg_net`/`pg_stat_statements` → `extensions`), - // but those schemas are created by the supabase/postgres image baseline - // and survive `DROP EXTENSION … CASCADE`, so `CREATE EXTENSION … WITH - // SCHEMA ` finds the schema and succeeds. Only the three below - // have self-created schemas that are absent from the baseline. - { - when: { - objectType: "extension", - operation: "create", - scope: "object", - "extension/schema": ["pgmq", "pgsodium", "pgtle"], - }, - options: { - skipSchema: true, - }, - }, - ], -}; diff --git a/packages/pg-delta/src/core/objects/aggregate/aggregate.diff.test.ts b/packages/pg-delta/src/core/objects/aggregate/aggregate.diff.test.ts deleted file mode 100644 index 7aacd6a48..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/aggregate.diff.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../base.default-privileges.ts"; -import { diffAggregates } from "./aggregate.diff.ts"; -import { Aggregate } from "./aggregate.model.ts"; -import { AlterAggregateChangeOwner } from "./changes/aggregate.alter.ts"; -import { - CreateCommentOnAggregate, - DropCommentOnAggregate, -} from "./changes/aggregate.comment.ts"; -import { CreateAggregate } from "./changes/aggregate.create.ts"; -import { DropAggregate } from "./changes/aggregate.drop.ts"; -import { - GrantAggregatePrivileges, - RevokeAggregatePrivileges, - RevokeGrantOptionAggregatePrivileges, -} from "./changes/aggregate.privilege.ts"; - -type AggregateProps = ConstructorParameters[0]; - -const base: AggregateProps = { - schema: "public", - name: "agg_sum", - identity_arguments: "integer", - kind: "a", - aggkind: "n", - num_direct_args: 0, - return_type: "integer", - return_type_schema: "pg_catalog", - parallel_safety: "u", - is_strict: false, - transition_function: "pg_catalog.int4pl(integer,integer)", - state_data_type: "integer", - state_data_type_schema: "pg_catalog", - state_data_space: 0, - final_function: null, - final_function_extra_args: false, - final_function_modify: null, - combine_function: null, - serial_function: null, - deserial_function: null, - initial_condition: null, - moving_transition_function: null, - moving_inverse_function: null, - moving_state_data_type: null, - moving_state_data_type_schema: null, - moving_state_data_space: null, - moving_final_function: null, - moving_final_function_extra_args: false, - moving_final_function_modify: null, - moving_initial_condition: null, - sort_operator: null, - argument_count: 1, - argument_default_count: 0, - argument_names: null, - argument_types: ["integer"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - owner: "owner1", - comment: null, - privileges: [], -}; - -const makeAggregate = (override: Partial = {}) => - new Aggregate({ - ...base, - ...override, - privileges: override.privileges ?? [...base.privileges], - }); - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("aggregate.diff", () => { - test("create and drop emit expected changes", () => { - const aggregate = makeAggregate({ comment: "sum comment" }); - const created = diffAggregates( - testContext, - {}, - { [aggregate.stableId]: aggregate }, - ); - - expect(created[0]).toBeInstanceOf(CreateAggregate); - expect( - created.some((change) => change instanceof CreateCommentOnAggregate), - ).toBe(true); - - const dropped = diffAggregates( - testContext, - { [aggregate.stableId]: aggregate }, - {}, - ); - - expect(dropped[0]).toBeInstanceOf(DropAggregate); - }); - - test("alter owner produces change owner statement", () => { - const main = makeAggregate(); - const branch = makeAggregate({ owner: "owner2" }); - const changes = diffAggregates( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(AlterAggregateChangeOwner); - }); - - test("comment changes emit create/drop comment statements", () => { - const main = makeAggregate(); - const withComment = makeAggregate({ comment: "sum comment" }); - const addComment = diffAggregates( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - - expect(addComment[0]).toBeInstanceOf(CreateCommentOnAggregate); - - const dropComment = diffAggregates( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - - expect(dropComment[0]).toBeInstanceOf(DropCommentOnAggregate); - }); - - test("non-alterable changes force create or replace", () => { - const main = makeAggregate(); - const branch = makeAggregate({ return_type: "text" }); - const changes = diffAggregates( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(CreateAggregate); - expect((changes[0] as CreateAggregate).orReplace).toBe(true); - }); - - test("privilege diffs emit grant, revoke, and revoke grant option statements", () => { - const main = makeAggregate({ - privileges: [ - { grantee: "role_exec", privilege: "EXECUTE", grantable: false }, - { grantee: "role_with_option", privilege: "EXECUTE", grantable: true }, - { grantee: "role_removed", privilege: "EXECUTE", grantable: false }, - ], - }); - const branch = makeAggregate({ - privileges: [ - { grantee: "role_exec", privilege: "EXECUTE", grantable: true }, - { grantee: "role_with_option", privilege: "EXECUTE", grantable: false }, - { grantee: "role_new", privilege: "EXECUTE", grantable: false }, - ], - }); - - const changes = diffAggregates( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect( - changes.some((change) => change instanceof GrantAggregatePrivileges), - ).toBe(true); - expect( - changes.some((change) => change instanceof RevokeAggregatePrivileges), - ).toBe(true); - expect( - changes.some( - (change) => change instanceof RevokeGrantOptionAggregatePrivileges, - ), - ).toBe(true); - - const grantBase = changes.find( - (change) => - change instanceof GrantAggregatePrivileges && - change.grantee === "role_with_option", - ) as GrantAggregatePrivileges | undefined; - expect(grantBase?.privileges).toEqual([ - { - grantee: "role_with_option", - privilege: "EXECUTE", - grantable: false, - } as never, - ]); - - const revokeGrantOption = changes.find( - (change) => - change instanceof RevokeGrantOptionAggregatePrivileges && - change.grantee === "role_with_option", - ) as RevokeGrantOptionAggregatePrivileges | undefined; - expect(revokeGrantOption?.privilegeNames).toEqual(["EXECUTE"]); - - const revokePrivilege = changes.find( - (change) => - change instanceof RevokeAggregatePrivileges && - change.grantee === "role_removed", - ) as RevokeAggregatePrivileges | undefined; - expect(revokePrivilege?.privileges).toEqual([ - { - grantee: "role_removed", - privilege: "EXECUTE", - grantable: false, - } as never, - ]); - }); -}); diff --git a/packages/pg-delta/src/core/objects/aggregate/aggregate.diff.ts b/packages/pg-delta/src/core/objects/aggregate/aggregate.diff.ts deleted file mode 100644 index 92a6a8266..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/aggregate.diff.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { deepEqual, hasNonAlterableChanges } from "../utils.ts"; -import type { Aggregate } from "./aggregate.model.ts"; -import { AlterAggregateChangeOwner } from "./changes/aggregate.alter.ts"; -import { - CreateCommentOnAggregate, - DropCommentOnAggregate, -} from "./changes/aggregate.comment.ts"; -import { CreateAggregate } from "./changes/aggregate.create.ts"; -import { DropAggregate } from "./changes/aggregate.drop.ts"; -import { - GrantAggregatePrivileges, - RevokeAggregatePrivileges, - RevokeGrantOptionAggregatePrivileges, -} from "./changes/aggregate.privilege.ts"; -import { - CreateSecurityLabelOnAggregate, - DropSecurityLabelOnAggregate, -} from "./changes/aggregate.security-label.ts"; -import type { AggregateChange } from "./changes/aggregate.types.ts"; - -export function diffAggregates( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): AggregateChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: AggregateChange[] = []; - - for (const aggregateId of created) { - const aggregate = branch[aggregateId]; - changes.push(new CreateAggregate({ aggregate })); - - // OWNER: If the aggregate should be owned by someone other than the current user, - // emit ALTER AGGREGATE ... OWNER TO after creation - if (aggregate.owner !== ctx.currentUser) { - changes.push( - new AlterAggregateChangeOwner({ - aggregate, - owner: aggregate.owner, - }), - ); - } - - if (aggregate.comment !== null) { - changes.push(new CreateCommentOnAggregate({ aggregate })); - } - for (const label of aggregate.security_labels) { - changes.push( - new CreateSecurityLabelOnAggregate({ - aggregate, - securityLabel: label, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "aggregate", - aggregate.schema ?? "", - ); - const creatorFilteredDefaults = - aggregate.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - // Filter out PUBLIC's built-in default EXECUTE privilege (PostgreSQL grants it automatically) - // Reference: https://www.postgresql.org/docs/17/ddl-priv.html Table 5.2 - // This prevents generating unnecessary "GRANT EXECUTE TO PUBLIC" statements - const desiredPrivileges = filterPublicBuiltInDefaults( - "aggregate", - aggregate.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the aggregate owner as the reference. - const privilegeResults = diffPrivileges( - filterPublicBuiltInDefaults("aggregate", creatorFilteredDefaults), - desiredPrivileges, - aggregate.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - aggregate, - aggregate, - "aggregate", - { - Grant: GrantAggregatePrivileges, - Revoke: RevokeAggregatePrivileges, - RevokeGrantOption: RevokeGrantOptionAggregatePrivileges, - }, - ctx.version, - ) as AggregateChange[]), - ); - } - - for (const aggregateId of dropped) { - changes.push(new DropAggregate({ aggregate: main[aggregateId] })); - } - - for (const aggregateId of altered) { - const mainAggregate = main[aggregateId]; - const branchAggregate = branch[aggregateId]; - - const NON_ALTERABLE_FIELDS: Array = [ - "kind", - "aggkind", - "num_direct_args", - "return_type", - "return_type_schema", - "parallel_safety", - "is_strict", - "transition_function", - "state_data_type", - "state_data_type_schema", - "state_data_space", - "final_function", - "final_function_extra_args", - "final_function_modify", - "combine_function", - "serial_function", - "deserial_function", - "initial_condition", - "moving_transition_function", - "moving_inverse_function", - "moving_state_data_type", - "moving_state_data_type_schema", - "moving_state_data_space", - "moving_final_function", - "moving_final_function_extra_args", - "moving_final_function_modify", - "moving_initial_condition", - "sort_operator", - "argument_count", - "argument_default_count", - "argument_names", - "argument_types", - "all_argument_types", - "argument_modes", - "argument_defaults", - "identityArguments", - ]; - - const nonAlterableChanged = hasNonAlterableChanges( - mainAggregate, - branchAggregate, - NON_ALTERABLE_FIELDS, - { - argument_names: deepEqual, - argument_types: deepEqual, - all_argument_types: deepEqual, - argument_modes: deepEqual, - }, - ); - - if (nonAlterableChanged) { - changes.push( - new CreateAggregate({ aggregate: branchAggregate, orReplace: true }), - ); - continue; - } - - if (mainAggregate.owner !== branchAggregate.owner) { - changes.push( - new AlterAggregateChangeOwner({ - aggregate: mainAggregate, - owner: branchAggregate.owner, - }), - ); - } - - if (mainAggregate.comment !== branchAggregate.comment) { - if (branchAggregate.comment === null) { - changes.push(new DropCommentOnAggregate({ aggregate: mainAggregate })); - } else { - changes.push( - new CreateCommentOnAggregate({ aggregate: branchAggregate }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnAggregate | DropSecurityLabelOnAggregate - >( - mainAggregate.security_labels, - branchAggregate.security_labels, - (securityLabel) => - new CreateSecurityLabelOnAggregate({ - aggregate: branchAggregate, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnAggregate({ - aggregate: mainAggregate, - securityLabel, - }), - ), - ); - - // PRIVILEGES - // Filter out PUBLIC's built-in default EXECUTE privilege from main catalog - // (PostgreSQL grants it automatically, so we shouldn't compare it) - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "aggregate", - mainAggregate.privileges, - ); - // Filter out PUBLIC's built-in default EXECUTE privilege from branch catalog - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "aggregate", - branchAggregate.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchAggregate.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchAggregate, - mainAggregate, - "aggregate", - { - Grant: GrantAggregatePrivileges, - Revoke: RevokeAggregatePrivileges, - RevokeGrantOption: RevokeGrantOptionAggregatePrivileges, - }, - ctx.version, - ) as AggregateChange[]), - ); - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/aggregate/aggregate.model.ts b/packages/pg-delta/src/core/objects/aggregate/aggregate.model.ts deleted file mode 100644 index 066307ad5..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/aggregate.model.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -const AggregateKindSchema = z.enum([ - "n", // normal aggregate - "o", // ordered-set aggregate - "h", // hypothetical-set aggregate -]); - -const FunctionParallelSafetySchema = z.enum([ - "u", // UNSAFE - "s", // SAFE - "r", // RESTRICTED -]); - -const FunctionArgumentModeSchema = z.enum([ - "i", // IN parameter - "o", // OUT parameter - "b", // INOUT parameter - "v", // VARIADIC parameter - "t", // TABLE parameter -]); - -const FinalFunctionModifySchema = z.enum([ - "r", // READ_ONLY - "s", // SHAREABLE - "w", // READ_WRITE -]); - -const aggregatePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - identity_arguments: z.string(), - kind: z.literal("a"), - aggkind: AggregateKindSchema, - num_direct_args: z.number(), - return_type: z.string(), - return_type_schema: z.string().nullable(), - parallel_safety: FunctionParallelSafetySchema, - is_strict: z.boolean(), - transition_function: z.string(), - state_data_type: z.string(), - state_data_type_schema: z.string().nullable(), - state_data_space: z.number(), - final_function: z.string().nullable(), - final_function_extra_args: z.boolean(), - final_function_modify: FinalFunctionModifySchema.nullable(), - combine_function: z.string().nullable(), - serial_function: z.string().nullable(), - deserial_function: z.string().nullable(), - initial_condition: z.string().nullable(), - moving_transition_function: z.string().nullable(), - moving_inverse_function: z.string().nullable(), - moving_state_data_type: z.string().nullable(), - moving_state_data_type_schema: z.string().nullable(), - moving_state_data_space: z.number().nullable(), - moving_final_function: z.string().nullable(), - moving_final_function_extra_args: z.boolean(), - moving_final_function_modify: FinalFunctionModifySchema.nullable(), - moving_initial_condition: z.string().nullable(), - sort_operator: z.string().nullable(), - argument_count: z.number(), - argument_default_count: z.number(), - argument_names: z.array(z.string()).nullable(), - argument_types: z.array(z.string()).nullable(), - all_argument_types: z.array(z.string()).nullable(), - argument_modes: z.array(FunctionArgumentModeSchema).nullable(), - argument_defaults: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable(), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -type AggregatePrivilegeProps = PrivilegeProps; -type AggregateProps = z.infer; - -export class Aggregate extends BasePgModel { - public readonly schema: AggregateProps["schema"]; - public readonly name: AggregateProps["name"]; - public readonly identityArguments: AggregateProps["identity_arguments"]; - public readonly kind: AggregateProps["kind"]; - public readonly aggkind: AggregateProps["aggkind"]; - public readonly num_direct_args: AggregateProps["num_direct_args"]; - public readonly return_type: AggregateProps["return_type"]; - public readonly return_type_schema: AggregateProps["return_type_schema"]; - public readonly parallel_safety: AggregateProps["parallel_safety"]; - public readonly is_strict: AggregateProps["is_strict"]; - public readonly transition_function: AggregateProps["transition_function"]; - public readonly state_data_type: AggregateProps["state_data_type"]; - public readonly state_data_type_schema: AggregateProps["state_data_type_schema"]; - public readonly state_data_space: AggregateProps["state_data_space"]; - public readonly final_function: AggregateProps["final_function"]; - public readonly final_function_extra_args: AggregateProps["final_function_extra_args"]; - public readonly final_function_modify: AggregateProps["final_function_modify"]; - public readonly combine_function: AggregateProps["combine_function"]; - public readonly serial_function: AggregateProps["serial_function"]; - public readonly deserial_function: AggregateProps["deserial_function"]; - public readonly initial_condition: AggregateProps["initial_condition"]; - public readonly moving_transition_function: AggregateProps["moving_transition_function"]; - public readonly moving_inverse_function: AggregateProps["moving_inverse_function"]; - public readonly moving_state_data_type: AggregateProps["moving_state_data_type"]; - public readonly moving_state_data_type_schema: AggregateProps["moving_state_data_type_schema"]; - public readonly moving_state_data_space: AggregateProps["moving_state_data_space"]; - public readonly moving_final_function: AggregateProps["moving_final_function"]; - public readonly moving_final_function_extra_args: AggregateProps["moving_final_function_extra_args"]; - public readonly moving_final_function_modify: AggregateProps["moving_final_function_modify"]; - public readonly moving_initial_condition: AggregateProps["moving_initial_condition"]; - public readonly sort_operator: AggregateProps["sort_operator"]; - public readonly argument_count: AggregateProps["argument_count"]; - public readonly argument_default_count: AggregateProps["argument_default_count"]; - public readonly argument_names: AggregateProps["argument_names"]; - public readonly argument_types: AggregateProps["argument_types"]; - public readonly all_argument_types: AggregateProps["all_argument_types"]; - public readonly argument_modes: AggregateProps["argument_modes"]; - public readonly argument_defaults: AggregateProps["argument_defaults"]; - public readonly owner: AggregateProps["owner"]; - public readonly comment: AggregateProps["comment"]; - public readonly privileges: AggregatePrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: AggregateProps) { - super(); - - this.schema = props.schema; - this.name = props.name; - this.identityArguments = props.identity_arguments.trim(); - this.kind = props.kind; - this.aggkind = props.aggkind; - this.num_direct_args = props.num_direct_args; - this.return_type = props.return_type; - this.return_type_schema = props.return_type_schema; - this.parallel_safety = props.parallel_safety; - this.is_strict = props.is_strict; - this.transition_function = props.transition_function; - this.state_data_type = props.state_data_type; - this.state_data_type_schema = props.state_data_type_schema; - this.state_data_space = props.state_data_space; - this.final_function = props.final_function; - this.final_function_extra_args = props.final_function_extra_args; - this.final_function_modify = props.final_function_modify; - this.combine_function = props.combine_function; - this.serial_function = props.serial_function; - this.deserial_function = props.deserial_function; - this.initial_condition = props.initial_condition; - this.moving_transition_function = props.moving_transition_function; - this.moving_inverse_function = props.moving_inverse_function; - this.moving_state_data_type = props.moving_state_data_type; - this.moving_state_data_type_schema = props.moving_state_data_type_schema; - this.moving_state_data_space = props.moving_state_data_space; - this.moving_final_function = props.moving_final_function; - this.moving_final_function_extra_args = - props.moving_final_function_extra_args; - this.moving_final_function_modify = props.moving_final_function_modify; - this.moving_initial_condition = props.moving_initial_condition; - this.sort_operator = props.sort_operator; - this.argument_count = props.argument_count; - this.argument_default_count = props.argument_default_count; - this.argument_names = props.argument_names; - this.argument_types = props.argument_types; - this.all_argument_types = props.all_argument_types; - this.argument_modes = props.argument_modes; - this.argument_defaults = props.argument_defaults; - this.owner = props.owner; - this.comment = props.comment; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `aggregate:${string}` { - const normalized = this.identityArguments; - return `aggregate:${this.schema}.${this.name}(${normalized})`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - identity_arguments: this.identityArguments, - }; - } - - get dataFields() { - return { - kind: this.kind, - aggkind: this.aggkind, - num_direct_args: this.num_direct_args, - return_type: this.return_type, - return_type_schema: this.return_type_schema, - parallel_safety: this.parallel_safety, - is_strict: this.is_strict, - transition_function: this.transition_function, - state_data_type: this.state_data_type, - state_data_type_schema: this.state_data_type_schema, - state_data_space: this.state_data_space, - final_function: this.final_function, - final_function_extra_args: this.final_function_extra_args, - final_function_modify: this.final_function_modify, - combine_function: this.combine_function, - serial_function: this.serial_function, - deserial_function: this.deserial_function, - initial_condition: this.initial_condition, - moving_transition_function: this.moving_transition_function, - moving_inverse_function: this.moving_inverse_function, - moving_state_data_type: this.moving_state_data_type, - moving_state_data_type_schema: this.moving_state_data_type_schema, - moving_state_data_space: this.moving_state_data_space, - moving_final_function: this.moving_final_function, - moving_final_function_extra_args: this.moving_final_function_extra_args, - moving_final_function_modify: this.moving_final_function_modify, - moving_initial_condition: this.moving_initial_condition, - sort_operator: this.sort_operator, - argument_count: this.argument_count, - argument_default_count: this.argument_default_count, - argument_names: this.argument_names, - argument_types: this.argument_types, - all_argument_types: this.all_argument_types, - argument_modes: this.argument_modes, - argument_defaults: this.argument_defaults, - identity_arguments: this.identityArguments, - owner: this.owner, - comment: this.comment, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } -} - -export async function extractAggregates(pool: Pool): Promise { - const { rows: aggregateRows } = await pool.query(sql` -with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_proc'::regclass -) -select - p.pronamespace::regnamespace::text as schema, - quote_ident(p.proname) as name, - pg_catalog.pg_get_function_identity_arguments(p.oid) as identity_arguments, - p.prokind as kind, - a.aggkind, - a.aggnumdirectargs as num_direct_args, - format_type(p.prorettype, null) as return_type, - rt.typnamespace::regnamespace::text as return_type_schema, - p.proparallel as parallel_safety, - p.proisstrict as is_strict, - a.aggtransfn::regprocedure::text as transition_function, - format_type(a.aggtranstype, null) as state_data_type, - st.typnamespace::regnamespace::text as state_data_type_schema, - a.aggtransspace as state_data_space, - case when a.aggfinalfn = 0 then null else a.aggfinalfn::regprocedure::text end as final_function, - a.aggfinalextra as final_function_extra_args, - nullif(a.aggfinalmodify::text, ' ') as final_function_modify, - case when a.aggcombinefn = 0 then null else a.aggcombinefn::regprocedure::text end as combine_function, - case when a.aggserialfn = 0 then null else a.aggserialfn::regprocedure::text end as serial_function, - case when a.aggdeserialfn = 0 then null else a.aggdeserialfn::regprocedure::text end as deserial_function, - a.agginitval as initial_condition, - case when a.aggmtransfn = 0 then null else a.aggmtransfn::regprocedure::text end as moving_transition_function, - case when a.aggminvtransfn = 0 then null else a.aggminvtransfn::regprocedure::text end as moving_inverse_function, - case when a.aggmtranstype = 0 then null else format_type(a.aggmtranstype, null) end as moving_state_data_type, - case when a.aggmtranstype = 0 then null else mt.typnamespace::regnamespace::text end as moving_state_data_type_schema, - case when a.aggmtransfn = 0 then null else a.aggmtransspace end as moving_state_data_space, - case when a.aggmfinalfn = 0 then null else a.aggmfinalfn::regprocedure::text end as moving_final_function, - a.aggmfinalextra as moving_final_function_extra_args, - nullif(a.aggmfinalmodify::text, ' ') as moving_final_function_modify, - a.aggminitval as moving_initial_condition, - case when a.aggsortop = 0 then null else a.aggsortop::regoperator::text end as sort_operator, - p.pronargs as argument_count, - p.pronargdefaults as argument_default_count, - case when p.proargnames is null then null - else array(select quote_ident(n) from unnest(p.proargnames) as n) - end as argument_names, - array(select format_type(oid, null) from unnest(p.proargtypes) as oid) as argument_types, - array(select format_type(oid, null) from unnest(p.proallargtypes) as oid) as all_argument_types, - p.proargmodes as argument_modes, - pg_get_expr(p.proargdefaults, 0) as argument_defaults, - p.proowner::regrole::text as owner, - obj_description(p.oid, 'pg_proc') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = p.oid - and sl.classoid = 'pg_proc'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from - pg_catalog.pg_proc p - inner join pg_catalog.pg_aggregate a on a.aggfnoid = p.oid - left join pg_catalog.pg_type rt on rt.oid = p.prorettype - left join pg_catalog.pg_type st on st.oid = a.aggtranstype - left join pg_catalog.pg_type mt on mt.oid = a.aggmtranstype - left outer join extension_oids e on p.oid = e.objid -where - p.prokind = 'a' - and not p.pronamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null -order by - 1, 2, 3 - `); - - const validatedRows = aggregateRows.map((row: unknown) => - aggregatePropsSchema.parse(row), - ); - return validatedRows.map((row: AggregateProps) => new Aggregate(row)); -} diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.alter.test.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.alter.test.ts deleted file mode 100644 index 457860b26..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.alter.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Aggregate } from "../aggregate.model.ts"; -import { AlterAggregateChangeOwner } from "./aggregate.alter.ts"; - -type AggregateProps = ConstructorParameters[0]; - -const base: AggregateProps = { - schema: "public", - name: "agg_sum", - identity_arguments: "integer", - kind: "a", - aggkind: "n", - num_direct_args: 0, - return_type: "integer", - return_type_schema: "pg_catalog", - parallel_safety: "u", - is_strict: false, - transition_function: "pg_catalog.int4pl(integer,integer)", - state_data_type: "integer", - state_data_type_schema: "pg_catalog", - state_data_space: 0, - final_function: null, - final_function_extra_args: false, - final_function_modify: null, - combine_function: null, - serial_function: null, - deserial_function: null, - initial_condition: null, - moving_transition_function: null, - moving_inverse_function: null, - moving_state_data_type: null, - moving_state_data_type_schema: null, - moving_state_data_space: null, - moving_final_function: null, - moving_final_function_extra_args: false, - moving_final_function_modify: null, - moving_initial_condition: null, - sort_operator: null, - argument_count: 1, - argument_default_count: 0, - argument_names: null, - argument_types: ["integer"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - owner: "owner1", - comment: null, - privileges: [], -}; - -describe("aggregate.alter", () => { - test("serialize owner change", async () => { - const aggregate = new Aggregate(base); - const change = new AlterAggregateChangeOwner({ - aggregate, - owner: "owner2", - }); - - expect(change.requires).toEqual([aggregate.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER AGGREGATE public.agg_sum(integer) OWNER TO owner2", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.alter.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.alter.ts deleted file mode 100644 index d0e9338dc..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.alter.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Aggregate } from "../aggregate.model.ts"; -import { AlterAggregateChange } from "./aggregate.base.ts"; - -export type AlterAggregate = AlterAggregateChangeOwner; - -/** - * ALTER AGGREGATE ... OWNER TO ... - * - * @see https://www.postgresql.org/docs/17/sql-alteraggregate.html - */ -export class AlterAggregateChangeOwner extends AlterAggregateChange { - public readonly aggregate: Aggregate; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { aggregate: Aggregate; owner: string }) { - super(); - this.aggregate = props.aggregate; - this.owner = props.owner; - } - - get requires() { - return [this.aggregate.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const signature = this.aggregate.identityArguments; - const qualifiedName = `${this.aggregate.schema}.${this.aggregate.name}`; - const withArgs = signature.length > 0 ? `(${signature})` : "()"; - return `ALTER AGGREGATE ${qualifiedName}${withArgs} OWNER TO ${this.owner}`; - } -} diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.base.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.base.ts deleted file mode 100644 index 19a1b87e0..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Aggregate } from "../aggregate.model.ts"; - -abstract class BaseAggregateChange extends BaseChange { - abstract readonly aggregate: Aggregate; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "aggregate" as const; -} - -export abstract class CreateAggregateChange extends BaseAggregateChange { - readonly operation = "create" as const; -} - -export abstract class AlterAggregateChange extends BaseAggregateChange { - readonly operation = "alter" as const; -} - -export abstract class DropAggregateChange extends BaseAggregateChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.comment.test.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.comment.test.ts deleted file mode 100644 index 3961a3b05..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.comment.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Aggregate } from "../aggregate.model.ts"; -import { - CreateCommentOnAggregate, - DropCommentOnAggregate, -} from "./aggregate.comment.ts"; - -type AggregateProps = ConstructorParameters[0]; - -const base: AggregateProps = { - schema: "public", - name: "agg_sum", - identity_arguments: "integer", - kind: "a", - aggkind: "n", - num_direct_args: 0, - return_type: "integer", - return_type_schema: "pg_catalog", - parallel_safety: "u", - is_strict: false, - transition_function: "pg_catalog.int4pl(integer,integer)", - state_data_type: "integer", - state_data_type_schema: "pg_catalog", - state_data_space: 0, - final_function: null, - final_function_extra_args: false, - final_function_modify: null, - combine_function: null, - serial_function: null, - deserial_function: null, - initial_condition: null, - moving_transition_function: null, - moving_inverse_function: null, - moving_state_data_type: null, - moving_state_data_type_schema: null, - moving_state_data_space: null, - moving_final_function: null, - moving_final_function_extra_args: false, - moving_final_function_modify: null, - moving_initial_condition: null, - sort_operator: null, - argument_count: 1, - argument_default_count: 0, - argument_names: null, - argument_types: ["integer"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - owner: "owner1", - comment: null, - privileges: [], -}; - -const makeAggregate = (override: Partial = {}) => - new Aggregate({ - ...base, - ...override, - }); - -describe("aggregate.comment", () => { - test("create comment serializes and tracks dependencies", async () => { - const aggregate = makeAggregate({ comment: "aggregate's total" }); - const change = new CreateCommentOnAggregate({ aggregate }); - - expect(change.creates).toEqual([stableId.comment(aggregate.stableId)]); - expect(change.requires).toEqual([aggregate.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "COMMENT ON AGGREGATE public.agg_sum(integer) IS 'aggregate''s total'", - ); - }); - - test("drop comment serializes and tracks dependencies", async () => { - const aggregate = makeAggregate({ comment: "some comment" }); - const change = new DropCommentOnAggregate({ aggregate }); - - expect(change.drops).toEqual([stableId.comment(aggregate.stableId)]); - expect(change.requires).toEqual([ - stableId.comment(aggregate.stableId), - aggregate.stableId, - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "COMMENT ON AGGREGATE public.agg_sum(integer) IS NULL", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.comment.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.comment.ts deleted file mode 100644 index 621bd3b9b..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.comment.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Aggregate } from "../aggregate.model.ts"; -import { - CreateAggregateChange, - DropAggregateChange, -} from "./aggregate.base.ts"; - -export type CommentAggregate = - | CreateCommentOnAggregate - | DropCommentOnAggregate; - -export class CreateCommentOnAggregate extends CreateAggregateChange { - public readonly aggregate: Aggregate; - public readonly scope = "comment" as const; - - constructor(props: { aggregate: Aggregate }) { - super(); - this.aggregate = props.aggregate; - } - - get creates() { - return [stableId.comment(this.aggregate.stableId)]; - } - - get requires() { - return [this.aggregate.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const signature = this.aggregate.identityArguments; - const qualifiedName = `${this.aggregate.schema}.${this.aggregate.name}`; - const withArgs = signature.length > 0 ? `(${signature})` : "()"; - // biome-ignore lint/style/noNonNullAssertion: aggregate comment is non-null in this branch - return `COMMENT ON AGGREGATE ${qualifiedName}${withArgs} IS ${quoteLiteral(this.aggregate.comment!)}`; - } -} - -export class DropCommentOnAggregate extends DropAggregateChange { - public readonly aggregate: Aggregate; - public readonly scope = "comment" as const; - - constructor(props: { aggregate: Aggregate }) { - super(); - this.aggregate = props.aggregate; - } - - get drops() { - return [stableId.comment(this.aggregate.stableId)]; - } - - get requires() { - return [stableId.comment(this.aggregate.stableId), this.aggregate.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const signature = this.aggregate.identityArguments; - const qualifiedName = `${this.aggregate.schema}.${this.aggregate.name}`; - const withArgs = signature.length > 0 ? `(${signature})` : "()"; - return `COMMENT ON AGGREGATE ${qualifiedName}${withArgs} IS NULL`; - } -} diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.create.test.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.create.test.ts deleted file mode 100644 index a982bd74c..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.create.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Aggregate } from "../aggregate.model.ts"; -import { CreateAggregate } from "./aggregate.create.ts"; - -type AggregateProps = ConstructorParameters[0]; - -const base: AggregateProps = { - schema: "public", - name: "agg_sum", - identity_arguments: "integer", - kind: "a", - aggkind: "n", - num_direct_args: 0, - return_type: "integer", - return_type_schema: "pg_catalog", - parallel_safety: "u", - is_strict: false, - transition_function: "pg_catalog.int4pl(integer,integer)", - state_data_type: "integer", - state_data_type_schema: "pg_catalog", - state_data_space: 0, - final_function: null, - final_function_extra_args: false, - final_function_modify: null, - combine_function: null, - serial_function: null, - deserial_function: null, - initial_condition: null, - moving_transition_function: null, - moving_inverse_function: null, - moving_state_data_type: null, - moving_state_data_type_schema: null, - moving_state_data_space: null, - moving_final_function: null, - moving_final_function_extra_args: false, - moving_final_function_modify: null, - moving_initial_condition: null, - sort_operator: null, - argument_count: 1, - argument_default_count: 0, - argument_names: null, - argument_types: ["integer"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - owner: "owner1", - comment: null, - privileges: [], -}; - -const makeAggregate = (override: Partial = {}) => - new Aggregate({ - ...base, - ...override, - }); - -describe("aggregate.create", () => { - test("serialize minimal aggregate", async () => { - const aggregate = makeAggregate(); - const change = new CreateAggregate({ aggregate }); - - expect(change.creates).toEqual([aggregate.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toMatchInlineSnapshot( - `"CREATE AGGREGATE public.agg_sum(integer) (SFUNC = pg_catalog.int4pl, STYPE = integer)"`, - ); - }); - - test("serialize aggregate with optional clauses and or replace", async () => { - const aggregate = makeAggregate({ - name: "agg_full", - transition_function: "public.sum_int8(bigint,bigint)", - state_data_type: "bigint", - state_data_space: 8, - final_function: "public.finalize(bigint)", - final_function_extra_args: true, - final_function_modify: "w", - combine_function: "public.combine(bigint,bigint)", - serial_function: "public.serialize_state(internal)", - deserial_function: "public.deserialize_state(bytea,internal)", - initial_condition: "0", - moving_transition_function: "public.msum(bigint,bigint)", - moving_inverse_function: "public.minv(bigint,bigint)", - moving_state_data_type: "pg_catalog.bigint", - moving_state_data_space: 16, - moving_final_function: "public.mfinal(bigint)", - moving_final_function_extra_args: true, - moving_final_function_modify: "s", - moving_initial_condition: "0", - sort_operator: "pg_catalog.<(integer,integer)", - parallel_safety: "s", - is_strict: true, - aggkind: "h", - }); - - const change = new CreateAggregate({ aggregate, orReplace: true }); - - await assertValidSql(change.serialize()); - expect(change.serialize()).toMatchInlineSnapshot( - `"CREATE OR REPLACE AGGREGATE public.agg_full(integer) (SFUNC = public.sum_int8, STYPE = bigint, SSPACE = 8, FINALFUNC = public.finalize, FINALFUNC_EXTRA, FINALFUNC_MODIFY = READ_WRITE, COMBINEFUNC = public.combine, SERIALFUNC = public.serialize_state, DESERIALFUNC = public.deserialize_state, INITCOND = '0', MSFUNC = public.msum, MINVFUNC = public.minv, MSTYPE = pg_catalog.bigint, MSSPACE = 16, MFINALFUNC = public.mfinal, MFINALFUNC_EXTRA, MFINALFUNC_MODIFY = SHAREABLE, MINITCOND = '0', SORTOP = OPERATOR(pg_catalog.<), PARALLEL = SAFE, STRICT, HYPOTHETICAL)"`, - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.create.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.create.ts deleted file mode 100644 index 758deaa3f..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.create.ts +++ /dev/null @@ -1,330 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { - parseProcedureReference, - parseTypeString, - stableId, -} from "../../utils.ts"; -import type { Aggregate } from "../aggregate.model.ts"; -import { CreateAggregateChange } from "./aggregate.base.ts"; - -/** - * Create an aggregate. - * - * @see https://www.postgresql.org/docs/17/sql-createaggregate.html - */ -export class CreateAggregate extends CreateAggregateChange { - public readonly aggregate: Aggregate; - public readonly orReplace: boolean; - public readonly scope = "object" as const; - - constructor(props: { aggregate: Aggregate; orReplace?: boolean }) { - super(); - this.aggregate = props.aggregate; - this.orReplace = props.orReplace ?? false; - } - - get creates() { - return [this.aggregate.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.aggregate.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.aggregate.owner)); - - // Transition function dependency - const transProc = parseProcedureReference( - this.aggregate.transition_function, - ); - if (transProc) { - dependencies.add(stableId.procedure(transProc.schema, transProc.name)); - } - - // State data type dependency (if user-defined) - const stateType = parseTypeString(this.aggregate.state_data_type); - if (stateType) { - dependencies.add(stableId.type(stateType.schema, stateType.name)); - } - - // Final function dependency - if (this.aggregate.final_function) { - const finalProc = parseProcedureReference(this.aggregate.final_function); - if (finalProc) { - dependencies.add(stableId.procedure(finalProc.schema, finalProc.name)); - } - } - - // Combine function dependency - if (this.aggregate.combine_function) { - const combineProc = parseProcedureReference( - this.aggregate.combine_function, - ); - if (combineProc) { - dependencies.add( - stableId.procedure(combineProc.schema, combineProc.name), - ); - } - } - - // Serial function dependency - if (this.aggregate.serial_function) { - const serialProc = parseProcedureReference( - this.aggregate.serial_function, - ); - if (serialProc) { - dependencies.add( - stableId.procedure(serialProc.schema, serialProc.name), - ); - } - } - - // Deserial function dependency - if (this.aggregate.deserial_function) { - const deserialProc = parseProcedureReference( - this.aggregate.deserial_function, - ); - if (deserialProc) { - dependencies.add( - stableId.procedure(deserialProc.schema, deserialProc.name), - ); - } - } - - // Moving transition function dependency - if (this.aggregate.moving_transition_function) { - const movingTransProc = parseProcedureReference( - this.aggregate.moving_transition_function, - ); - if (movingTransProc) { - dependencies.add( - stableId.procedure(movingTransProc.schema, movingTransProc.name), - ); - } - } - - // Moving inverse function dependency - if (this.aggregate.moving_inverse_function) { - const movingInvProc = parseProcedureReference( - this.aggregate.moving_inverse_function, - ); - if (movingInvProc) { - dependencies.add( - stableId.procedure(movingInvProc.schema, movingInvProc.name), - ); - } - } - - // Moving state data type dependency (if user-defined) - if (this.aggregate.moving_state_data_type) { - const movingStateType = parseTypeString( - this.aggregate.moving_state_data_type, - ); - if (movingStateType) { - dependencies.add( - stableId.type(movingStateType.schema, movingStateType.name), - ); - } - } - - // Moving final function dependency - if (this.aggregate.moving_final_function) { - const movingFinalProc = parseProcedureReference( - this.aggregate.moving_final_function, - ); - if (movingFinalProc) { - dependencies.add( - stableId.procedure(movingFinalProc.schema, movingFinalProc.name), - ); - } - } - - // Return type dependency (if user-defined) - if (this.aggregate.return_type_schema) { - const returnType = parseTypeString(this.aggregate.return_type); - if (returnType) { - dependencies.add(stableId.type(returnType.schema, returnType.name)); - } - } - - // Argument type dependencies (if user-defined) - if (this.aggregate.argument_types) { - for (const argType of this.aggregate.argument_types) { - const parsedType = parseTypeString(argType); - if (parsedType) { - dependencies.add(stableId.type(parsedType.schema, parsedType.name)); - } - } - } - - // Note: Sort operator dependencies are complex (they reference operators which - // may reference types/functions). For now, we rely on pg_depend for these. - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const signature = this.aggregate.identityArguments; - const qualifiedName = `${this.aggregate.schema}.${this.aggregate.name}`; - const head = [ - "CREATE", - this.orReplace ? "OR REPLACE" : null, - "AGGREGATE", - `${qualifiedName}${signature ? `(${signature})` : "()"}`, - ] - .filter(Boolean) - .join(" "); - - const clauses: string[] = []; - - clauses.push(`SFUNC = ${formatProc(this.aggregate.transition_function)}`); - clauses.push(`STYPE = ${this.aggregate.state_data_type}`); - - if (this.aggregate.state_data_space > 0) { - clauses.push(`SSPACE = ${this.aggregate.state_data_space}`); - } - - if (this.aggregate.final_function) { - clauses.push(`FINALFUNC = ${formatProc(this.aggregate.final_function)}`); - } - if (this.aggregate.final_function_extra_args) { - clauses.push("FINALFUNC_EXTRA"); - } - // Only include FINALFUNC_MODIFY if it's explicitly set to a non-default value - // PostgreSQL defaults to 'r' (READ_ONLY) when not specified - if ( - this.aggregate.final_function_modify && - this.aggregate.final_function_modify !== "r" - ) { - clauses.push( - `FINALFUNC_MODIFY = ${formatModify(this.aggregate.final_function_modify)}`, - ); - } - - if (this.aggregate.combine_function) { - clauses.push( - `COMBINEFUNC = ${formatProc(this.aggregate.combine_function)}`, - ); - } - if (this.aggregate.serial_function) { - clauses.push( - `SERIALFUNC = ${formatProc(this.aggregate.serial_function)}`, - ); - } - if (this.aggregate.deserial_function) { - clauses.push( - `DESERIALFUNC = ${formatProc(this.aggregate.deserial_function)}`, - ); - } - - if (this.aggregate.initial_condition !== null) { - clauses.push( - `INITCOND = ${quoteLiteral(this.aggregate.initial_condition)}`, - ); - } - - if (this.aggregate.moving_transition_function) { - clauses.push( - `MSFUNC = ${formatProc(this.aggregate.moving_transition_function)}`, - ); - } - if (this.aggregate.moving_inverse_function) { - clauses.push( - `MINVFUNC = ${formatProc(this.aggregate.moving_inverse_function)}`, - ); - } - if (this.aggregate.moving_state_data_type) { - clauses.push(`MSTYPE = ${this.aggregate.moving_state_data_type}`); - } - if ( - this.aggregate.moving_state_data_space && - this.aggregate.moving_state_data_space > 0 - ) { - clauses.push(`MSSPACE = ${this.aggregate.moving_state_data_space}`); - } - if (this.aggregate.moving_final_function) { - clauses.push( - `MFINALFUNC = ${formatProc(this.aggregate.moving_final_function)}`, - ); - } - if (this.aggregate.moving_final_function_extra_args) { - clauses.push("MFINALFUNC_EXTRA"); - } - // Only include MFINALFUNC_MODIFY if it's explicitly set to a non-default value - // PostgreSQL defaults to 'r' (READ_ONLY) when not specified - if ( - this.aggregate.moving_final_function_modify && - this.aggregate.moving_final_function_modify !== "r" - ) { - clauses.push( - `MFINALFUNC_MODIFY = ${formatModify(this.aggregate.moving_final_function_modify)}`, - ); - } - if (this.aggregate.moving_initial_condition !== null) { - clauses.push( - `MINITCOND = ${quoteLiteral(this.aggregate.moving_initial_condition)}`, - ); - } - - if (this.aggregate.sort_operator) { - clauses.push(`SORTOP = ${formatOperator(this.aggregate.sort_operator)}`); - } - - if (this.aggregate.parallel_safety !== "u") { - clauses.push( - `PARALLEL = ${formatParallel(this.aggregate.parallel_safety)}`, - ); - } - - if (this.aggregate.is_strict) { - clauses.push("STRICT"); - } - - if (this.aggregate.aggkind === "h") { - clauses.push("HYPOTHETICAL"); - } - - const body = clauses.length ? `(${clauses.join(", ")})` : "()"; - - return `${head} ${body}`; - } -} - -function formatProc(proc: string): string { - const idx = proc.indexOf("("); - return idx === -1 ? proc : proc.slice(0, idx); -} - -function formatOperator(op: string): string { - const idx = op.indexOf("("); - const qualified = idx === -1 ? op : op.slice(0, idx); - return `OPERATOR(${qualified})`; -} - -function formatModify(code: string): string { - switch (code) { - case "r": - return "READ_ONLY"; - case "s": - return "SHAREABLE"; - case "w": - return "READ_WRITE"; - default: - return code; - } -} - -function formatParallel(code: string): string { - switch (code) { - case "s": - return "SAFE"; - case "r": - return "RESTRICTED"; - default: - return "UNSAFE"; - } -} diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.drop.test.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.drop.test.ts deleted file mode 100644 index 7590e7440..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.drop.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Aggregate } from "../aggregate.model.ts"; -import { DropAggregate } from "./aggregate.drop.ts"; - -type AggregateProps = ConstructorParameters[0]; - -const base: AggregateProps = { - schema: "public", - name: "agg_sum", - identity_arguments: "integer", - kind: "a", - aggkind: "n", - num_direct_args: 0, - return_type: "integer", - return_type_schema: "pg_catalog", - parallel_safety: "u", - is_strict: false, - transition_function: "pg_catalog.int4pl(integer,integer)", - state_data_type: "integer", - state_data_type_schema: "pg_catalog", - state_data_space: 0, - final_function: null, - final_function_extra_args: false, - final_function_modify: null, - combine_function: null, - serial_function: null, - deserial_function: null, - initial_condition: null, - moving_transition_function: null, - moving_inverse_function: null, - moving_state_data_type: null, - moving_state_data_type_schema: null, - moving_state_data_space: null, - moving_final_function: null, - moving_final_function_extra_args: false, - moving_final_function_modify: null, - moving_initial_condition: null, - sort_operator: null, - argument_count: 1, - argument_default_count: 0, - argument_names: null, - argument_types: ["integer"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - owner: "owner1", - comment: null, - privileges: [], -}; - -const makeAggregate = (override: Partial = {}) => - new Aggregate({ - ...base, - ...override, - }); - -describe("aggregate.drop", () => { - test("serialize drop for aggregate with arguments", async () => { - const aggregate = makeAggregate(); - const change = new DropAggregate({ aggregate }); - - expect(change.drops).toEqual([aggregate.stableId]); - expect(change.requires).toEqual([aggregate.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("DROP AGGREGATE public.agg_sum(integer)"); - }); - - test("serialize drop for aggregate without arguments", async () => { - const aggregate = makeAggregate({ - name: "agg_no_args", - identity_arguments: "", - argument_count: 0, - argument_types: [], - }); - const change = new DropAggregate({ aggregate }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP AGGREGATE public.agg_no_args(*)"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.drop.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.drop.ts deleted file mode 100644 index a1ac29fbc..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.drop.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Aggregate } from "../aggregate.model.ts"; -import { DropAggregateChange } from "./aggregate.base.ts"; - -/** - * Drop an aggregate. - * - * @see https://www.postgresql.org/docs/17/sql-dropaggregate.html - */ -export class DropAggregate extends DropAggregateChange { - public readonly aggregate: Aggregate; - public readonly scope = "object" as const; - - constructor(props: { aggregate: Aggregate }) { - super(); - this.aggregate = props.aggregate; - } - - get drops() { - return [this.aggregate.stableId]; - } - - get requires() { - return [this.aggregate.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const signature = this.aggregate.identityArguments; - const qualifiedName = `${this.aggregate.schema}.${this.aggregate.name}`; - const withArgs = signature.length > 0 ? `(${signature})` : "(*)"; - return `DROP AGGREGATE ${qualifiedName}${withArgs}`; - } -} diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.privilege.test.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.privilege.test.ts deleted file mode 100644 index 2386b2a26..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.privilege.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Aggregate } from "../aggregate.model.ts"; -import { - GrantAggregatePrivileges, - RevokeAggregatePrivileges, - RevokeGrantOptionAggregatePrivileges, -} from "./aggregate.privilege.ts"; - -type AggregateProps = ConstructorParameters[0]; - -const base: AggregateProps = { - schema: "public", - name: "agg_sum", - identity_arguments: "integer", - kind: "a", - aggkind: "n", - num_direct_args: 0, - return_type: "integer", - return_type_schema: "pg_catalog", - parallel_safety: "u", - is_strict: false, - transition_function: "pg_catalog.int4pl(integer,integer)", - state_data_type: "integer", - state_data_type_schema: "pg_catalog", - state_data_space: 0, - final_function: null, - final_function_extra_args: false, - final_function_modify: null, - combine_function: null, - serial_function: null, - deserial_function: null, - initial_condition: null, - moving_transition_function: null, - moving_inverse_function: null, - moving_state_data_type: null, - moving_state_data_type_schema: null, - moving_state_data_space: null, - moving_final_function: null, - moving_final_function_extra_args: false, - moving_final_function_modify: null, - moving_initial_condition: null, - sort_operator: null, - argument_count: 1, - argument_default_count: 0, - argument_names: null, - argument_types: ["integer"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - owner: "owner1", - comment: null, - privileges: [], -}; - -describe("aggregate.privilege", () => { - test("grant privileges without grant option", async () => { - const aggregate = new Aggregate(base); - const change = new GrantAggregatePrivileges({ - aggregate, - grantee: "role_exec", - privileges: [{ privilege: "EXECUTE", grantable: false }], - version: 170000, - }); - - expect(change.creates).toEqual([ - stableId.acl(aggregate.stableId, "role_exec"), - ]); - expect(change.requires).toEqual([ - aggregate.stableId, - stableId.role("role_exec"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "GRANT ALL ON FUNCTION public.agg_sum(integer) TO role_exec", - ); - }); - - test("grant privileges with grant option", async () => { - const aggregate = new Aggregate(base); - const change = new GrantAggregatePrivileges({ - aggregate, - grantee: "role_exec", - privileges: [{ privilege: "EXECUTE", grantable: true }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "GRANT ALL ON FUNCTION public.agg_sum(integer) TO role_exec WITH GRANT OPTION", - ); - }); - - // Regression for CLI-1471: ordered-set / hypothetical-set / variadic - // aggregates have `identity_arguments` that include `ORDER BY` or - // `VARIADIC` keywords. Those keywords are rejected by - // `GRANT ... ON FUNCTION (...)` (only positional argument types are - // accepted there), so the serializer must drop back to the - // `proargtypes`-derived `argument_types` list. - test("grant on ordered-set aggregate emits proargtypes signature", async () => { - const aggregate = new Aggregate({ - ...base, - name: "os_last", - aggkind: "o", - identity_arguments: "anyelement ORDER BY anyelement", - argument_types: ["anyelement", "anyelement"], - return_type: "anyelement", - transition_function: "public.os_last_sfunc(anyelement,anyelement)", - state_data_type: "anyelement", - argument_count: 2, - }); - const change = new GrantAggregatePrivileges({ - aggregate, - grantee: "role_exec", - privileges: [{ privilege: "EXECUTE", grantable: false }], - version: 170000, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "GRANT ALL ON FUNCTION public.os_last(anyelement, anyelement) TO role_exec", - ); - }); - - test("revoke on hypothetical-set aggregate emits proargtypes signature", async () => { - const aggregate = new Aggregate({ - ...base, - name: "hyp_rank", - aggkind: "h", - identity_arguments: 'VARIADIC "any" ORDER BY VARIADIC "any"', - argument_types: ['"any"'], - return_type: "bigint", - transition_function: - 'pg_catalog.ordered_set_transition_multi(internal,"any")', - state_data_type: "internal", - argument_count: 1, - }); - const change = new RevokeAggregatePrivileges({ - aggregate, - grantee: "role_old", - privileges: [{ privilege: "EXECUTE", grantable: false }], - version: 170000, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'REVOKE ALL ON FUNCTION public.hyp_rank("any") FROM role_old', - ); - }); - - test("revoke grant option on ordered-set aggregate emits proargtypes signature", async () => { - const aggregate = new Aggregate({ - ...base, - name: "os_last", - aggkind: "o", - identity_arguments: "anyelement ORDER BY anyelement", - argument_types: ["anyelement", "anyelement"], - return_type: "anyelement", - transition_function: "public.os_last_sfunc(anyelement,anyelement)", - state_data_type: "anyelement", - argument_count: 2, - }); - const change = new RevokeGrantOptionAggregatePrivileges({ - aggregate, - grantee: "role_with_option", - privilegeNames: ["EXECUTE"], - version: 170000, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "REVOKE GRANT OPTION FOR ALL ON FUNCTION public.os_last(anyelement, anyelement) FROM role_with_option", - ); - }); - - test("revoke privileges and grant option", async () => { - const aggregate = new Aggregate(base); - const revoke = new RevokeAggregatePrivileges({ - aggregate, - grantee: "role_old", - privileges: [{ privilege: "EXECUTE", grantable: false }], - version: 170000, - }); - - expect(revoke.drops).toEqual([ - stableId.acl(aggregate.stableId, "role_old"), - ]); - expect(revoke.requires).toEqual([ - stableId.acl(aggregate.stableId, "role_old"), - aggregate.stableId, - stableId.role("role_old"), - ]); - await assertValidSql(revoke.serialize()); - expect(revoke.serialize()).toBe( - "REVOKE ALL ON FUNCTION public.agg_sum(integer) FROM role_old", - ); - - const revokeGrantOption = new RevokeGrantOptionAggregatePrivileges({ - aggregate, - grantee: "role_with_option", - // testing deduplication of privilege names - privilegeNames: ["EXECUTE", "EXECUTE"], - version: 170000, - }); - - expect(revokeGrantOption.privilegeNames).toEqual(["EXECUTE"]); - expect(revokeGrantOption.requires).toEqual([ - stableId.acl(aggregate.stableId, "role_with_option"), - aggregate.stableId, - stableId.role("role_with_option"), - ]); - await assertValidSql(revokeGrantOption.serialize()); - expect(revokeGrantOption.serialize()).toBe( - "REVOKE GRANT OPTION FOR ALL ON FUNCTION public.agg_sum(integer) FROM role_with_option", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.privilege.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.privilege.ts deleted file mode 100644 index 20a5e5467..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.privilege.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { Aggregate } from "../aggregate.model.ts"; -import { AlterAggregateChange } from "./aggregate.base.ts"; - -export type AggregatePrivilege = - | GrantAggregatePrivileges - | RevokeAggregatePrivileges - | RevokeGrantOptionAggregatePrivileges; - -/** - * Build the signature `.()` for use inside - * `GRANT`/`REVOKE ... ON FUNCTION (...)`. - * - * The aggregate's `identityArguments` (from - * `pg_get_function_identity_arguments`) embeds `ORDER BY` for ordered-set - * and hypothetical-set aggregates (`aggkind` of `o`/`h`) and `VARIADIC` - * for variadic aggregates — both of which the GRANT parser rejects with - * a syntax error. PostgreSQL resolves the aggregate from the positional - * argument types alone, so use `argument_types` here regardless of - * `aggkind`. Other aggregate DDL (`ALTER AGGREGATE`, `COMMENT ON - * AGGREGATE`, `SECURITY LABEL ON AGGREGATE`, `DROP AGGREGATE`) accepts - * the identity form and keeps using it. - */ -function aggregateGrantSignature(aggregate: Aggregate): string { - const args = (aggregate.argument_types ?? []).join(", "); - return `${aggregate.schema}.${aggregate.name}(${args})`; -} - -export class GrantAggregatePrivileges extends AlterAggregateChange { - public readonly aggregate: Aggregate; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - aggregate: Aggregate; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.aggregate = props.aggregate; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.aggregate.stableId, this.grantee)]; - } - - get requires() { - return [this.aggregate.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantAggregatePrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("FUNCTION"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("FUNCTION", list, this.version); - const qualified = aggregateGrantSignature(this.aggregate); - return `GRANT ${privSql} ${kindPrefix} ${qualified} TO ${this.grantee}${withGrant}`; - } -} - -export class RevokeAggregatePrivileges extends AlterAggregateChange { - public readonly aggregate: Aggregate; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - aggregate: Aggregate; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.aggregate = props.aggregate; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.aggregate.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.aggregate.stableId, this.grantee), - this.aggregate.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("FUNCTION"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("FUNCTION", list, this.version); - const qualified = aggregateGrantSignature(this.aggregate); - return `REVOKE ${privSql} ${kindPrefix} ${qualified} FROM ${this.grantee}`; - } -} - -export class RevokeGrantOptionAggregatePrivileges extends AlterAggregateChange { - public readonly aggregate: Aggregate; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - aggregate: Aggregate; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.aggregate = props.aggregate; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.aggregate.stableId, this.grantee), - this.aggregate.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("FUNCTION"); - const privSql = formatObjectPrivilegeList( - "FUNCTION", - this.privilegeNames, - this.version, - ); - const qualified = aggregateGrantSignature(this.aggregate); - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${qualified} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.security-label.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.security-label.ts deleted file mode 100644 index 36ca2665a..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.security-label.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Aggregate } from "../aggregate.model.ts"; -import { - CreateAggregateChange, - DropAggregateChange, -} from "./aggregate.base.ts"; - -export type SecurityLabelAggregate = - | CreateSecurityLabelOnAggregate - | DropSecurityLabelOnAggregate; - -function aggregateIdentity(a: Aggregate): string { - return `${a.schema}.${a.name}(${a.identityArguments})`; -} - -export class CreateSecurityLabelOnAggregate extends CreateAggregateChange { - public readonly aggregate: Aggregate; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - aggregate: Aggregate; - securityLabel: SecurityLabelProps; - }) { - super(); - this.aggregate = props.aggregate; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.aggregate.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.aggregate.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON AGGREGATE", - aggregateIdentity(this.aggregate), - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnAggregate extends DropAggregateChange { - public readonly aggregate: Aggregate; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - aggregate: Aggregate; - securityLabel: SecurityLabelProps; - }) { - super(); - this.aggregate = props.aggregate; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.aggregate.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.aggregate.stableId, - this.securityLabel.provider, - ), - this.aggregate.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON AGGREGATE", - aggregateIdentity(this.aggregate), - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.types.ts b/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.types.ts deleted file mode 100644 index aaf14e256..000000000 --- a/packages/pg-delta/src/core/objects/aggregate/changes/aggregate.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterAggregate } from "./aggregate.alter.ts"; -import type { CommentAggregate } from "./aggregate.comment.ts"; -import type { CreateAggregate } from "./aggregate.create.ts"; -import type { DropAggregate } from "./aggregate.drop.ts"; -import type { AggregatePrivilege } from "./aggregate.privilege.ts"; -import type { SecurityLabelAggregate } from "./aggregate.security-label.ts"; - -/** Union of all aggregate-related change variants (`objectType: "aggregate"`). @category Change Types */ -export type AggregateChange = - | AlterAggregate - | CommentAggregate - | CreateAggregate - | DropAggregate - | AggregatePrivilege - | SecurityLabelAggregate; diff --git a/packages/pg-delta/src/core/objects/base.change.ts b/packages/pg-delta/src/core/objects/base.change.ts deleted file mode 100644 index a32ed69b6..000000000 --- a/packages/pg-delta/src/core/objects/base.change.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { SerializeOptions } from "../integrations/serialize/serialize.types.ts"; - -type ChangeOperation = "create" | "alter" | "drop"; - -/** - * Kinds of commit-visibility boundaries a change can force. - * - * Each kind names a PostgreSQL behavior where a statement's effects only - * become usable by later statements after the enclosing transaction commits. - * The token becomes the `reason` of the migration unit that follows the - * producer run, so adding a kind requires a matching arm in the renderer's - * `unitName` switch (the exhaustive switch enforces this at compile time). - */ -export type CommitBoundaryReason = "enum_value_visibility"; - -/** - * Abstract base class for all change objects. - * - * Every concrete change (e.g. `CreateTable`, `AlterView`) extends this class and - * provides an `operation`, `objectType`, and `scope`. The filter DSL flattens - * these properties — along with the model sub-object — into path/value pairs - * for pattern matching. - * - * @category Base - */ -export abstract class BaseChange { - /** - * The operation of the change. - */ - abstract readonly operation: ChangeOperation; - /** - * The type of the object targeted by the change. - */ - abstract readonly objectType: string; - /** - * The scope of the change. - */ - abstract readonly scope: string; - - /** - * True when the serialized statement cannot run inside a transaction block - * (PostgreSQL rejects it with SQLSTATE 25001, e.g. `CREATE INDEX - * CONCURRENTLY`, `CREATE SUBSCRIPTION` with `connect = true`). - * - * The planner emits such a change as its own single-statement migration - * unit with `transactionMode: "none"`, and `applyPlan` executes it without - * a `BEGIN`/`COMMIT` wrapper. Never derive this from the rendered SQL — - * declare it on the change class. - * - * Defaults to false. Override in subclasses whose statement PostgreSQL - * forbids inside a transaction block. - */ - get nonTransactional(): boolean { - return false; - } - - /** - * Non-null when this statement's effects only become usable by later - * statements after the enclosing transaction commits. The canonical case is - * `ALTER TYPE ... ADD VALUE`: using the new enum value in the same - * transaction fails with 55P04. - * - * This is a conservative boundary signal: the planner groups consecutive - * producers of the same kind into one unit, and pushes any other statement - * (different kind or non-producer) past a commit boundary, regardless of - * whether it references the produced effects. No consumer detection is - * attempted. Never derive this from the rendered SQL — declare it on the - * change class. - * - * Defaults to null. Override in subclasses whose effects PostgreSQL defers - * until commit. - */ - get commitBoundary(): CommitBoundaryReason | null { - return null; - } - - /** - * Stable identifiers this change creates. - * - * Defaults to an empty array. Override in subclasses that create objects. - */ - get creates(): string[] { - return []; - } - - /** - * Stable identifiers this change drops. - * - * Defaults to an empty array. Override in subclasses that remove objects. - */ - get drops(): string[] { - return []; - } - - /** - * Stable identifiers this change invalidates in place. - * - * Unlike `drops`, the object keeps its identity. This is an ordering-only - * signal for mutations that rewrite an existing object in a way that requires - * dependents bound to the old definition to be dropped before the mutation - * and rebuilt afterward. - * - * Defaults to an empty array. Override in subclasses that invalidate - * dependents without dropping the object. - */ - get invalidates(): string[] { - return []; - } - - /** - * Stable identifiers this change requires to exist beforehand. - * - * Defaults to an empty array. Override in subclasses that have prerequisites. - */ - get requires(): string[] { - return []; - } - - /** - * Serialize the change into a single SQL statement. - */ - abstract serialize(options?: SerializeOptions): string; -} - -/** - * Port of string literal quoting: doubles single quotes inside and wraps with single quotes - */ -export function quoteLiteral(value: string): string { - return `'${value.replace(/'/g, "''")}'`; -} diff --git a/packages/pg-delta/src/core/objects/base.default-privileges.ts b/packages/pg-delta/src/core/objects/base.default-privileges.ts deleted file mode 100644 index 9e3f0412e..000000000 --- a/packages/pg-delta/src/core/objects/base.default-privileges.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { PrivilegeProps } from "./base.privilege-diff.ts"; -import type { Role } from "./role/role.model.ts"; - -/** - * Maps object type names to PostgreSQL default privilege objtype codes. - * Used to look up default privileges for different object types. - */ -function objectTypeToObjtype(objectType: string): string | null { - switch (objectType) { - case "table": - return "r"; // Relations (tables) - case "view": - return "r"; // Views are also relations - case "materialized_view": - return "r"; // Materialized views are also relations - case "sequence": - return "S"; // Sequences - case "procedure": - case "function": - case "aggregate": - return "f"; // Functions/routines - case "type": - case "domain": - case "enum": - case "range": - case "composite_type": - return "T"; // Types - case "schema": - return "n"; // Schemas - default: - return null; - } -} - -/** - * Tracks the effective state of default privileges as changes are processed. - * This allows us to compute what default privileges would be in effect at any point - * in the migration script, accounting for ALTER DEFAULT PRIVILEGES statements. - */ -export class DefaultPrivilegeState { - private state: Map< - string, - Map>>> - > = new Map(); // role -> objtype -> schema -> grantee -> privileges - - constructor(initialRoles: Record) { - // Initialize state from roles' default_privileges - for (const [_roleId, role] of Object.entries(initialRoles)) { - const roleName = role.name; - if (!this.state.has(roleName)) { - this.state.set(roleName, new Map()); - } - // biome-ignore lint/style/noNonNullAssertion: roleName is guaranteed to be in the state - const roleState = this.state.get(roleName)!; - - for (const defPriv of role.default_privileges) { - if (!roleState.has(defPriv.objtype)) { - roleState.set(defPriv.objtype, new Map()); - } - // biome-ignore lint/style/noNonNullAssertion: objtype is guaranteed to be in the state - const objtypeState = roleState.get(defPriv.objtype)!; - - const schemaKey = defPriv.in_schema ?? null; - if (!objtypeState.has(schemaKey)) { - objtypeState.set(schemaKey, new Map()); - } - // biome-ignore lint/style/noNonNullAssertion: schemaKey is guaranteed to be in the state - const schemaState = objtypeState.get(schemaKey)!; - - if (!schemaState.has(defPriv.grantee)) { - schemaState.set(defPriv.grantee, new Set()); - } - // biome-ignore lint/style/noNonNullAssertion: grantee is guaranteed to be in the state - const privileges = schemaState.get(defPriv.grantee)!; - - for (const priv of defPriv.privileges) { - const key = `${priv.privilege}:${priv.grantable}`; - privileges.add(key); - } - } - } - } - - /** - * Apply a GrantRoleDefaultPrivileges change to the state. - */ - applyGrant( - roleName: string, - objtype: string, - inSchema: string | null, - grantee: string, - privileges: { privilege: string; grantable: boolean }[], - ): void { - if (!this.state.has(roleName)) { - this.state.set(roleName, new Map()); - } - // biome-ignore lint/style/noNonNullAssertion: roleName is guaranteed to be in the state - const roleState = this.state.get(roleName)!; - - if (!roleState.has(objtype)) { - roleState.set(objtype, new Map()); - } - // biome-ignore lint/style/noNonNullAssertion: objtype is guaranteed to be in the state - const objtypeState = roleState.get(objtype)!; - - const schemaKey = inSchema ?? null; - if (!objtypeState.has(schemaKey)) { - objtypeState.set(schemaKey, new Map()); - } - // biome-ignore lint/style/noNonNullAssertion: schemaKey is guaranteed to be in the state - const schemaState = objtypeState.get(schemaKey)!; - - if (!schemaState.has(grantee)) { - schemaState.set(grantee, new Set()); - } - // biome-ignore lint/style/noNonNullAssertion: grantee is guaranteed to be in the state - const privilegesSet = schemaState.get(grantee)!; - - for (const priv of privileges) { - const key = `${priv.privilege}:${priv.grantable}`; - privilegesSet.add(key); - } - } - - /** - * Apply a RevokeRoleDefaultPrivileges change to the state. - */ - applyRevoke( - roleName: string, - objtype: string, - inSchema: string | null, - grantee: string, - privileges: { privilege: string; grantable: boolean }[], - ): void { - const roleState = this.state.get(roleName); - if (!roleState) return; - - const objtypeState = roleState.get(objtype); - if (!objtypeState) return; - - const schemaKey = inSchema ?? null; - const schemaState = objtypeState.get(schemaKey); - if (!schemaState) return; - - const privilegesSet = schemaState.get(grantee); - if (!privilegesSet) return; - - for (const priv of privileges) { - const key = `${priv.privilege}:${priv.grantable}`; - privilegesSet.delete(key); - // Also remove base privilege if grantable was revoked - if (priv.grantable) { - const baseKey = `${priv.privilege}:false`; - privilegesSet.delete(baseKey); - } - } - } - - /** - * Get effective default privileges for a given object creation. - */ - getEffectiveDefaults( - currentUser: string, - objectType: string, - objectSchema: string, - ): PrivilegeProps[] { - const objtype = objectTypeToObjtype(objectType); - if (!objtype) return []; - - const roleState = this.state.get(currentUser); - if (!roleState) return []; - - const objtypeState = roleState.get(objtype); - if (!objtypeState) return []; - - const defaultPrivs: PrivilegeProps[] = []; - - // Check schema-specific first, then global (null schema) - const schemasToCheck = [objectSchema, null]; - for (const schemaKey of schemasToCheck) { - const schemaState = objtypeState.get(schemaKey); - if (!schemaState) continue; - - for (const [grantee, privilegesSet] of schemaState.entries()) { - for (const privKey of privilegesSet) { - const [privilege, grantableStr] = privKey.split(":"); - const grantable = grantableStr === "true"; - defaultPrivs.push({ - grantee, - privilege, - grantable, - columns: null, - }); - } - } - // Schema-specific takes precedence, so break after first match - if (schemaKey === objectSchema && schemaState.size > 0) { - break; - } - } - - return defaultPrivs; - } -} diff --git a/packages/pg-delta/src/core/objects/base.diff.ts b/packages/pg-delta/src/core/objects/base.diff.ts deleted file mode 100644 index 11e73f0dd..000000000 --- a/packages/pg-delta/src/core/objects/base.diff.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { BasePgModel } from "./base.model.ts"; - -export function diffObjects( - main: Record, - branch: Record, -) { - const mainIds = new Set(Object.keys(main)); - const branchIds = new Set(Object.keys(branch)); - - const created = [...branchIds.difference(mainIds)]; - const dropped = [...mainIds.difference(branchIds)]; - const altered = [...mainIds.intersection(branchIds)].filter((id) => { - const mainModel = main[id]; - const branchModel = branch[id]; - - return !mainModel.equals(branchModel); - }); - - return { created, dropped, altered }; -} diff --git a/packages/pg-delta/src/core/objects/base.model.test.ts b/packages/pg-delta/src/core/objects/base.model.test.ts deleted file mode 100644 index 45fd85511..000000000 --- a/packages/pg-delta/src/core/objects/base.model.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { BasePgModel } from "./base.model.ts"; - -class NormalizedTestModel extends BasePgModel { - private readonly id: string; - private readonly values: string[]; - - constructor(id: string, values: string[]) { - super(); - this.id = id; - this.values = values; - } - - get stableId() { - return this.id; - } - - get identityFields() { - return { id: this.id }; - } - - get dataFields() { - return { values: this.values }; - } - - override stableSnapshot() { - return { - identity: this.identityFields, - data: { - values: [...this.values].sort(), - }, - }; - } -} - -describe("BasePgModel.equals", () => { - test("uses stable snapshots for normalized equality", () => { - const main = new NormalizedTestModel("same", ["b", "a"]); - const branch = new NormalizedTestModel("same", ["a", "b"]); - - expect(main.equals(branch)).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/base.model.ts b/packages/pg-delta/src/core/objects/base.model.ts deleted file mode 100644 index a4e6543a6..000000000 --- a/packages/pg-delta/src/core/objects/base.model.ts +++ /dev/null @@ -1,87 +0,0 @@ -import z from "zod"; -import { securityLabelPropsSchema } from "./security-label.types.ts"; -import { deepEqual } from "./utils.ts"; - -export const columnPropsSchema = z.object({ - name: z.string(), - position: z.number(), - data_type: z.string(), - data_type_str: z.string(), - is_custom_type: z.boolean(), - custom_type_type: z.string().nullable(), - custom_type_category: z.string().nullable(), - custom_type_schema: z.string().nullable(), - custom_type_name: z.string().nullable(), - not_null: z.boolean(), - is_identity: z.boolean(), - is_identity_always: z.boolean(), - is_generated: z.boolean(), - collation: z.string().nullable(), - default: z.string().nullable(), - comment: z.string().nullable(), - security_labels: z.array(securityLabelPropsSchema).optional(), -}); - -export type ColumnProps = z.infer; - -export function normalizeColumns(columns: ColumnProps[]) { - return columns - .map((column) => { - const { position: _position, ...rest } = column; - return rest; - }) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -/** - * Interface for table-like objects that have columns (tables, views, materialized views). - * In PostgreSQL, these are relations with relkind in ('r', 'p', 'v', 'm'). - */ -export interface TableLikeObject { - readonly columns: ColumnProps[]; -} - -export abstract class BasePgModel { - /** - * Database-portable stable identifier for dependency resolution. - * This identifier remains constant across database dumps/restores and - * is used for cross-database dependency resolution. - */ - abstract get stableId(): string; - - /** - * Get all identity fields and their values. - * Subclasses should override this to return the identity fields. - */ - abstract get identityFields(): Record; - - /** - * Get all data fields and their values. - * Subclasses should override this to return the data fields. - */ - abstract get dataFields(): Record; - - /** - * Compare this object with another BasePgModel for equality based on the stableId and - * the data portion of the stable snapshot. By default, the snapshot's `data` comes - * from {@link dataFields}, but subclasses may override {@link stableSnapshot} to - * normalize or otherwise transform the data used for equality. - */ - equals(other: BasePgModel): boolean { - return ( - this.stableId === other.stableId && - deepEqual(this.stableSnapshot().data, other.stableSnapshot().data) - ); - } - - /** - * Stable representation used for equality/fingerprints. - * Subclasses can override to normalize unstable fields. - */ - stableSnapshot() { - return { - identity: this.identityFields, - data: this.dataFields, - }; - } -} diff --git a/packages/pg-delta/src/core/objects/base.privilege-diff.ts b/packages/pg-delta/src/core/objects/base.privilege-diff.ts deleted file mode 100644 index c810d7729..000000000 --- a/packages/pg-delta/src/core/objects/base.privilege-diff.ts +++ /dev/null @@ -1,447 +0,0 @@ -import z from "zod"; -import type { Change } from "../change.types.ts"; -import type { BaseChange } from "./base.change.ts"; - -/** - * Privilege properties that all privilege objects share. - */ -export const privilegePropsSchema = z.object({ - grantee: z.string(), - privilege: z.string(), - grantable: z.boolean(), - columns: z.array(z.string()).nullable().optional(), -}); - -export type PrivilegeProps = z.infer; - -/** - * Result of privilege diffing for a single grantee - */ -interface PrivilegeDiffResult { - grants: T[]; - revokes: T[]; - revokeGrantOption: string[]; -} - -/** - * Groups privileges by grantee for efficient diffing - */ -function groupPrivilegesByGrantee( - privileges: T[], -): Map { - const byGrantee = new Map(); - - for (const privilege of privileges) { - const existing = byGrantee.get(privilege.grantee) || []; - existing.push(privilege); - byGrantee.set(privilege.grantee, existing); - } - - return byGrantee; -} - -/** - * Diffs privileges for a single grantee between main and branch - */ -function diffPrivilegesForGrantee( - mainPrivs: T[], - branchPrivs: T[], -): PrivilegeDiffResult { - // Create comparison key - always include columns (null for object-level privileges) - const toKey = (p: T) => { - const cols = p.columns || []; - return `${p.privilege}:${p.grantable}:${cols.sort().join(",")}`; - }; - - // Create key-to-object mappings to retain original data structures - const mainKeyToObj = new Map(mainPrivs.map((p) => [toKey(p), p])); - const branchKeyToObj = new Map(branchPrivs.map((p) => [toKey(p), p])); - - const aSet = new Set(mainPrivs.map(toKey)); - const bSet = new Set(branchPrivs.map(toKey)); - - const grants: T[] = []; - const revokes: T[] = []; - const revokeGrantOption: string[] = []; - - // Find privileges to grant - for (const key of bSet) { - if (!aSet.has(key)) { - const obj = branchKeyToObj.get(key); - if (obj) grants.push(obj); - } - } - - // Find privileges to revoke - for (const key of aSet) { - if (!bSet.has(key)) { - const obj = mainKeyToObj.get(key); - if (!obj) continue; - - const wasGrantable = obj.grantable; - - // Upgrade: base -> with grant option (no base revoke) - const upgradedKey = key.replace(":false", ":true"); - const upgraded = !wasGrantable && bSet.has(upgradedKey); - if (upgraded) continue; - - // If only grantable flipped from true to false, emit REVOKE GRANT OPTION FOR - const stillHasBase = checkStillHasBase(branchPrivs, obj.privilege, key); - if (wasGrantable && stillHasBase) { - revokeGrantOption.push(obj.privilege); - } else { - revokes.push(obj); - } - } - } - - return { grants, revokes, revokeGrantOption }; -} - -/** - * Check if a privilege still exists in the target set - */ -function checkStillHasBase( - targetPrivs: T[], - privilege: string, - key: string, -): boolean { - const [, , columnsStr] = key.split(":"); - return targetPrivs.some( - (p) => - p.privilege === privilege && - (p.columns || []).sort().join(",") === columnsStr, - ); -} - -/** - * Groups privileges by grantable flag for efficient SQL generation - */ -function groupPrivilegesByGrantable( - privileges: T[], -): Map { - const groups = new Map(); - - for (const privilege of privileges) { - const arr = groups.get(privilege.grantable) ?? []; - arr.push(privilege); - groups.set(privilege.grantable, arr); - } - - return groups; -} - -/** - * Groups privileges by columns and grantable flag - */ -function groupPrivilegesByColumns( - privileges: T[], -): Map> }> { - const groups = new Map< - string, - { columns?: string[]; byGrant: Map> } - >(); - - for (const privilege of privileges) { - const key = privilege.columns ? privilege.columns.sort().join(",") : ""; - - if (!groups.has(key)) { - groups.set(key, { - columns: privilege.columns ? [...privilege.columns] : undefined, - byGrant: new Map(), - }); - } - - const group = groups.get(key); - if (!group) continue; - - if (!group.byGrant.has(privilege.grantable)) { - group.byGrant.set(privilege.grantable, new Set()); - } - - const privSet = group.byGrant.get(privilege.grantable); - if (!privSet) continue; - - privSet.add(privilege.privilege); - } - - return groups; -} - -/** - * Filters out PUBLIC's built-in default privileges that PostgreSQL automatically grants - * when creating certain object types. This prevents generating unnecessary GRANT statements - * for privileges that PostgreSQL grants automatically. - * - * Reference: PostgreSQL 17 Documentation, Table 5.2 "Summary of Access Privileges" - * https://www.postgresql.org/docs/17/ddl-priv.html - * - * Objects with default PUBLIC privileges: - * - Functions/Procedures/Aggregates: EXECUTE - * - Types/Domains/Enums/Ranges/Composite Types: USAGE - * - Languages: USAGE - * - * Objects WITHOUT default PUBLIC privileges (so we should generate GRANT statements): - * - Tables, Views, Materialized Views, Sequences, Schemas, etc. - */ -export function filterPublicBuiltInDefaults( - objectType: Change["objectType"], - privileges: T[], -): T[] { - // Only filter PUBLIC privileges - return privileges.filter((priv) => { - if (priv.grantee !== "PUBLIC") { - return true; // Keep all non-PUBLIC privileges - } - - // Check if this is a built-in default privilege for this object type - switch (objectType) { - case "procedure": - case "aggregate": - // Functions/Procedures/Aggregates: EXECUTE is granted to PUBLIC by default - // Filter it out so we don't generate unnecessary GRANT EXECUTE TO PUBLIC - return priv.privilege !== "EXECUTE"; - - case "domain": - case "enum": - case "range": - case "composite_type": - // Types/Domains/Enums/Ranges/Composite Types: USAGE is granted to PUBLIC by default - // Filter it out so we don't generate unnecessary GRANT USAGE TO PUBLIC - return priv.privilege !== "USAGE"; - - case "language": - // Languages: USAGE is granted to PUBLIC by default - // Filter it out so we don't generate unnecessary GRANT USAGE TO PUBLIC - return priv.privilege !== "USAGE"; - - default: - // For other object types (tables, views, sequences, schemas, etc.), - // PUBLIC has NO default privileges, so we should keep all PUBLIC privileges - // and generate GRANT statements for them - return true; - } - }); -} - -/** - * Filter out owner privileges from a privilege list. - * Owner privileges are implicit (owner always has ALL) and shouldn't be compared. - */ -function filterOwnerPrivileges( - privileges: T[], - owner: string, -): T[] { - return privileges.filter((p) => p.grantee !== owner); -} - -/** - * Generic privilege diffing function that works for any object type - */ -export function diffPrivileges( - mainPrivileges: T[], - branchPrivileges: T[], - owner?: string, -): Map> { - // Filter out owner privileges if owner is provided - const mainFiltered = owner - ? filterOwnerPrivileges(mainPrivileges, owner) - : mainPrivileges; - const branchFiltered = owner - ? filterOwnerPrivileges(branchPrivileges, owner) - : branchPrivileges; - - const mainByGrantee = groupPrivilegesByGrantee(mainFiltered); - const branchByGrantee = groupPrivilegesByGrantee(branchFiltered); - - // Get all grantees - const allGrantees = new Set([ - ...mainByGrantee.keys(), - ...branchByGrantee.keys(), - ]); - - const results = new Map>(); - - for (const grantee of allGrantees) { - const mainPrivs = mainByGrantee.get(grantee) || []; - const branchPrivs = branchByGrantee.get(grantee) || []; - - const result = diffPrivilegesForGrantee(mainPrivs, branchPrivs); - results.set(grantee, result); - } - - return results; -} - -// ==== Privilege Change Emission Helpers ==== -// -// These helpers convert the output of `diffPrivileges` (per-grantee grant / -// revoke / revoke-grant-option lists) into concrete Change instances so that -// individual object diffs don't have to repeat the same iteration and grouping -// logic. -// -// Callers pass a `PrivilegeChangeFactories` bag containing the three class -// constructors (Grant, Revoke, RevokeGrantOption) for their object type. The -// helpers instantiate them with a common props shape: an object reference keyed -// by `objectKey`, a `grantee`, `privileges` or `privilegeNames`, an optional -// `version`, and (for column-level privileges) optional `columns`. - -/** - * Factory constructors for Grant / Revoke / RevokeGrantOption change classes. - * Every object type provides its own concrete classes. The `any` props type - * is intentional: the helpers build props with a computed `[objectKey]` key - * whose name varies per object type, so no single concrete type can unify - * all call sites without an unsafe cast elsewhere. - */ -interface PrivilegeChangeFactories { - Grant: new ( - // biome-ignore lint/suspicious/noExplicitAny: factory accepts heterogeneous prop bags keyed by object type - props: any, - ) => BaseChange; - Revoke: new ( - // biome-ignore lint/suspicious/noExplicitAny: factory accepts heterogeneous prop bags keyed by object type - props: any, - ) => BaseChange; - RevokeGrantOption: new ( - // biome-ignore lint/suspicious/noExplicitAny: factory accepts heterogeneous prop bags keyed by object type - props: any, - ) => BaseChange; -} - -/** - * Emit privilege changes for object-level privileges (schema, sequence, - * procedure, etc.). - * - * For each grantee in `privilegeResults` the function groups grants and revokes - * by the `grantable` flag and pushes one change per group. Revoke-grant-option - * entries produce a single change carrying `privilegeNames`. - * - * `grantTarget` is the *branch* object (the desired state) while `revokeTarget` - * is the *main* object (the current state), so that GRANTs reference the - * newly-created/altered object and REVOKEs reference the existing one. - */ -export function emitObjectPrivilegeChanges( - privilegeResults: Map>, - grantTarget: unknown, - revokeTarget: unknown, - objectKey: string, - factories: PrivilegeChangeFactories, - version?: number, -): BaseChange[] { - const changes: BaseChange[] = []; - - for (const [grantee, result] of privilegeResults) { - for (const [, revokes] of groupPrivilegesByGrantable(result.revokes)) { - changes.push( - new factories.Revoke({ - [objectKey]: revokeTarget, - privileges: revokes, - grantee, - version, - }), - ); - } - if (result.revokeGrantOption.length > 0) { - changes.push( - new factories.RevokeGrantOption({ - [objectKey]: revokeTarget, - privilegeNames: result.revokeGrantOption, - grantee, - version, - }), - ); - } - for (const [, grants] of groupPrivilegesByGrantable(result.grants)) { - changes.push( - new factories.Grant({ - [objectKey]: grantTarget, - privileges: grants, - grantee, - version, - }), - ); - } - } - - return changes; -} - -/** - * Emit privilege changes for column-level privileges (table, view, - * materialized view). - * - * Like {@link emitObjectPrivilegeChanges} but groups by column set (via - * `groupPrivilegesByColumns`) instead of only by grantable. For - * revoke-grant-option the column sets come from `sourcePrivileges` so that - * `REVOKE GRANT OPTION FOR` is emitted per column set that originally carried - * the privilege. - */ -export function emitColumnPrivilegeChanges( - privilegeResults: Map>, - grantTarget: unknown, - revokeTarget: unknown, - objectKey: string, - factories: PrivilegeChangeFactories, - sourcePrivileges: PrivilegeProps[], - version?: number, -): BaseChange[] { - const changes: BaseChange[] = []; - - for (const [grantee, result] of privilegeResults) { - for (const [, group] of groupPrivilegesByColumns(result.revokes)) { - const allPrivileges = new Set(); - for (const [, privSet] of group.byGrant) { - for (const priv of privSet) { - allPrivileges.add(priv); - } - } - changes.push( - new factories.Revoke({ - [objectKey]: revokeTarget, - privileges: [...allPrivileges].map((p) => ({ - privilege: p, - grantable: false, - })), - grantee, - columns: group.columns, - version, - }), - ); - } - if (result.revokeGrantOption.length > 0) { - const sourcePrivsForGrantee = sourcePrivileges.filter( - (p) => p.grantee === grantee, - ); - for (const [, group] of groupPrivilegesByColumns( - sourcePrivsForGrantee.filter((p) => - result.revokeGrantOption.includes(p.privilege), - ), - )) { - changes.push( - new factories.RevokeGrantOption({ - [objectKey]: revokeTarget, - privilegeNames: result.revokeGrantOption, - grantee, - columns: group.columns, - version, - }), - ); - } - } - for (const [, group] of groupPrivilegesByColumns(result.grants)) { - for (const [grantable, privSet] of group.byGrant) { - changes.push( - new factories.Grant({ - [objectKey]: grantTarget, - privileges: [...privSet].map((p) => ({ privilege: p, grantable })), - grantee, - columns: group.columns, - version, - }), - ); - } - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/base.privilege.ts b/packages/pg-delta/src/core/objects/base.privilege.ts deleted file mode 100644 index 77883cf5e..000000000 --- a/packages/pg-delta/src/core/objects/base.privilege.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Base utilities and helpers for object privilege changes. - * These functions support GRANT/REVOKE operations across different database objects. - */ - -import type { PrivilegeProps } from "./base.privilege-diff.ts"; - -/** - * Returns the complete set of available privileges for a given object kind. - * This is used to determine whether a privilege list represents "ALL PRIVILEGES". - * - * @param kind - The PostgreSQL object kind (TABLE, VIEW, SEQUENCE, etc.) - * @param version - The PostgreSQL version number (e.g., 170000 for 17.0.0) - * @returns An array of privilege names available for this object kind - */ -function objectPrivilegeUniverse( - kind: string, - version: number | undefined, -): string[] { - switch (kind) { - case "TABLE": { - const includesMaintain = (version ?? 170000) >= 170000; - return [ - "DELETE", - "INSERT", - ...(includesMaintain ? (["MAINTAIN"] as const) : []), - "REFERENCES", - "SELECT", - "TRIGGER", - "TRUNCATE", - "UPDATE", - ]; - } - case "VIEW": { - // Per PostgreSQL docs, views are table-like and share the table privilege set - // for GRANT/REVOKE purposes. MAINTAIN exists on PostgreSQL >= 17 - const includesMaintain = (version ?? 170000) >= 170000; - return [ - "DELETE", - "INSERT", - ...(includesMaintain ? (["MAINTAIN"] as const) : []), - "REFERENCES", - "SELECT", - "TRIGGER", - "TRUNCATE", - "UPDATE", - ]; - } - case "MATERIALIZED VIEW": { - // Materialized views support the same table-like privileges as tables/views - // Per PostgreSQL docs, materialized views are table-like for GRANT/REVOKE purposes - const includesMaintain = (version ?? 170000) >= 170000; - return [ - "DELETE", - "INSERT", - ...(includesMaintain ? (["MAINTAIN"] as const) : []), - "REFERENCES", - "SELECT", - "TRIGGER", - "TRUNCATE", - "UPDATE", - ]; - } - case "SEQUENCE": - return ["SELECT", "UPDATE", "USAGE"].sort(); - case "SCHEMA": - return ["CREATE", "USAGE"].sort(); - case "LANGUAGE": - return ["USAGE"]; - case "FUNCTION": - case "PROCEDURE": - case "ROUTINE": - return ["EXECUTE"]; - case "TYPE": - case "DOMAIN": - return ["USAGE"]; - case "FOREIGN DATA WRAPPER": - return ["USAGE"]; - case "SERVER": - return ["USAGE"]; - case "FOREIGN TABLE": { - const includesMaintain = (version ?? 170000) >= 170000; - return [ - "DELETE", - "INSERT", - ...(includesMaintain ? (["MAINTAIN"] as const) : []), - "REFERENCES", - "SELECT", - "TRIGGER", - "TRUNCATE", - "UPDATE", - ]; - } - default: - return []; - } -} - -/** - * Checks if a privilege list represents the full set of privileges for an object kind. - * This determines whether we can use "ALL PRIVILEGES" shorthand in SQL. - * - * @param kind - The PostgreSQL object kind - * @param list - Array of privilege names to check - * @param version - The PostgreSQL version number - * @returns true if the list contains all available privileges for this object kind - */ -function isFullObjectPrivilegeSet( - kind: string, - list: string[], - version: number | undefined, -): boolean { - const uniqSorted = [...new Set(list)].sort(); - const fullSorted = [...objectPrivilegeUniverse(kind, version)].sort(); - if (uniqSorted.length !== fullSorted.length) return false; - for (let i = 0; i < uniqSorted.length; i++) { - if (uniqSorted[i] !== fullSorted[i]) return false; - } - return true; -} - -/** - * Formats a list of privileges for use in GRANT/REVOKE statements. - * If the list represents all privileges, returns "ALL", otherwise returns a comma-separated list. - * - * @param kind - The PostgreSQL object kind - * @param list - Array of privilege names to format - * @param version - The PostgreSQL version number - * @returns A SQL-formatted privilege list (either "ALL" or "PRIV1, PRIV2, ...") - */ -export function formatObjectPrivilegeList( - kind: string, - list: string[], - version: number | undefined, -): string { - const uniqSorted = [...new Set(list)].sort(); - return isFullObjectPrivilegeSet(kind, uniqSorted, version) - ? "ALL" - : uniqSorted.join(", "); -} - -/** - * Gets the SQL keyword prefix for a given object kind in GRANT/REVOKE statements. - * - * @param objectKind - The PostgreSQL object kind - * @returns The SQL prefix (e.g., "ON SCHEMA", "ON DOMAIN", "ON") - */ -export function getObjectKindPrefix(objectKind: string): string { - switch (objectKind) { - case "FUNCTION": - return "ON FUNCTION"; - case "PROCEDURE": - return "ON PROCEDURE"; - case "ROUTINE": - return "ON ROUTINE"; - case "LANGUAGE": - return "ON LANGUAGE"; - case "SCHEMA": - return "ON SCHEMA"; - case "SEQUENCE": - return "ON SEQUENCE"; - case "DOMAIN": - return "ON DOMAIN"; - case "TYPE": - return "ON TYPE"; - case "FOREIGN TABLE": - return "ON TABLE"; - default: - return "ON"; - } -} - -export function normalizePrivileges(privileges: PrivilegeProps[]) { - return privileges - .map((privilege) => ({ - grantee: privilege.grantee, - privilege: privilege.privilege, - grantable: privilege.grantable, - columns: privilege.columns - ? [...privilege.columns].sort() - : privilege.columns, - })) - .sort((a, b) => { - if (a.grantee !== b.grantee) return a.grantee.localeCompare(b.grantee); - if (a.privilege !== b.privilege) - return a.privilege.localeCompare(b.privilege); - const colA = a.columns?.join(",") ?? ""; - const colB = b.columns?.join(",") ?? ""; - return colA.localeCompare(colB); - }); -} diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.alter.test.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.alter.test.ts deleted file mode 100644 index 21f8bcf66..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.alter.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Collation } from "../collation.model.ts"; -import { - AlterCollationChangeOwner, - AlterCollationRefreshVersion, -} from "./collation.alter.ts"; - -describe.concurrent("collation", () => { - describe("alter", () => { - test("change owner", async () => { - const collation = new Collation({ - schema: "public", - name: "test", - provider: "c", - is_deterministic: true, - encoding: 1, - collate: "en_US", - locale: "en_US", - version: "1.0", - ctype: "test", - icu_rules: "test", - comment: null, - owner: "old_owner", - }); - - const change = new AlterCollationChangeOwner({ - collation, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER COLLATION public.test OWNER TO new_owner", - ); - }); - - test("refresh version", async () => { - const collation = new Collation({ - schema: "public", - name: "test", - provider: "c", - is_deterministic: true, - encoding: 1, - collate: "en_US", - locale: "en_US", - ctype: "test", - icu_rules: "test", - comment: null, - owner: "test", - version: "1.0", - }); - - const change = new AlterCollationRefreshVersion({ - collation, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER COLLATION public.test REFRESH VERSION", - ); - }); - - // replace behavior moved into collation.diff.ts as separate Drop + Create - }); -}); diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.alter.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.alter.ts deleted file mode 100644 index bf903db67..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.alter.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Collation } from "../collation.model.ts"; -import { AlterCollationChange } from "./collation.base.ts"; - -/** - * Alter a collation. - * - * @see https://www.postgresql.org/docs/17/sql-altercollation.html - * - * Synopsis - * ```sql - * ALTER COLLATION name REFRESH VERSION - * ALTER COLLATION name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ALTER COLLATION name RENAME TO new_name - * ``` - */ - -export type AlterCollation = - | AlterCollationChangeOwner - | AlterCollationRefreshVersion; - -/** - * ALTER COLLATION ... OWNER TO ... - */ -export class AlterCollationChangeOwner extends AlterCollationChange { - public readonly collation: Collation; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { collation: Collation; owner: string }) { - super(); - this.collation = props.collation; - this.owner = props.owner; - } - - get requires() { - return [this.collation.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER COLLATION", - `${this.collation.schema}.${this.collation.name}`, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * ALTER COLLATION ... REFRESH VERSION - */ -export class AlterCollationRefreshVersion extends AlterCollationChange { - public readonly collation: Collation; - public readonly scope = "object" as const; - - constructor(props: { collation: Collation }) { - super(); - this.collation = props.collation; - } - - get requires() { - return [this.collation.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER COLLATION", - `${this.collation.schema}.${this.collation.name}`, - "REFRESH VERSION", - ].join(" "); - } -} - -/** - * Replace a collation by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER COLLATION change. - */ -// NOTE: ReplaceCollation has been removed. Non-alterable property changes -// are modeled as separate DropCollation + CreateCollation changes in collation.diff.ts. diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.base.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.base.ts deleted file mode 100644 index 2e2327daf..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Collation } from "../collation.model.ts"; - -abstract class BaseCollationChange extends BaseChange { - abstract readonly collation: Collation; - abstract readonly scope: "object" | "comment"; - readonly objectType = "collation" as const; -} - -export abstract class CreateCollationChange extends BaseCollationChange { - readonly operation = "create" as const; -} - -export abstract class AlterCollationChange extends BaseCollationChange { - readonly operation = "alter" as const; -} - -export abstract class DropCollationChange extends BaseCollationChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.comment.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.comment.ts deleted file mode 100644 index 3da783f29..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.comment.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Collation } from "../collation.model.ts"; -import { - CreateCollationChange, - DropCollationChange, -} from "./collation.base.ts"; - -export type CommentCollation = - | CreateCommentOnCollation - | DropCommentOnCollation; - -/** - * Create/drop comments on collations. - */ -export class CreateCommentOnCollation extends CreateCollationChange { - public readonly collation: Collation; - public readonly scope = "comment" as const; - - constructor(props: { collation: Collation }) { - super(); - this.collation = props.collation; - } - - get creates() { - return [stableId.comment(this.collation.stableId)]; - } - - get requires() { - return [this.collation.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON COLLATION", - `${this.collation.schema}.${this.collation.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: collation comment is not nullable in this case - quoteLiteral(this.collation.comment!), - ].join(" "); - } -} - -export class DropCommentOnCollation extends DropCollationChange { - public readonly collation: Collation; - public readonly scope = "comment" as const; - - constructor(props: { collation: Collation }) { - super(); - this.collation = props.collation; - } - - get requires() { - return [this.collation.stableId, stableId.comment(this.collation.stableId)]; - } - - get drops() { - return [stableId.comment(this.collation.stableId)]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON COLLATION", - `${this.collation.schema}.${this.collation.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.create.test.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.create.test.ts deleted file mode 100644 index 10c01b662..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.create.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Collation } from "../collation.model.ts"; -import { CreateCollation } from "./collation.create.ts"; - -describe("collation", () => { - test("create minimal", async () => { - const collation = new Collation({ - schema: "public", - name: "test", - provider: "d", - is_deterministic: true, - encoding: 1, - collate: "C", - locale: null, - version: null, - ctype: "C", - icu_rules: null, - owner: "owner", - comment: null, - }); - - const change = new CreateCollation({ collation }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - `CREATE COLLATION public.test (LC_COLLATE = 'C', LC_CTYPE = 'C')`, - ); - }); - - test("create with all options", async () => { - const collation = new Collation({ - schema: "public", - name: "test", - provider: "i", - is_deterministic: false, - encoding: 1, - collate: "en_US", - locale: "en_US", - version: "1.0", - ctype: "en_US", - icu_rules: "& A < a <<< à", - owner: "owner", - comment: null, - }); - - const change = new CreateCollation({ collation }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - `CREATE COLLATION public.test (LOCALE = 'en_US', LC_COLLATE = 'en_US', LC_CTYPE = 'en_US', PROVIDER = icu, DETERMINISTIC = false, RULES = '& A < a <<< à', VERSION = '1.0')`, - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.create.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.create.ts deleted file mode 100644 index 7cbd27b7e..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.create.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Collation } from "../collation.model.ts"; -import { CreateCollationChange } from "./collation.base.ts"; - -/** - * Create a collation. - * - * @see https://www.postgresql.org/docs/17/sql-createcollation.html - * - * Synopsis - * ```sql - * CREATE COLLATION [ IF NOT EXISTS ] name ( - * [ LOCALE = locale, ] - * [ LC_COLLATE = lc_collate, ] - * [ LC_CTYPE = lc_ctype, ] - * [ PROVIDER = provider, ] - * [ DETERMINISTIC = boolean, ] - * [ RULES = rules, ] - * [ VERSION = version ] - * ) - * - * CREATE COLLATION [ IF NOT EXISTS ] name FROM existing_collation - * ``` - */ -export class CreateCollation extends CreateCollationChange { - public readonly collation: Collation; - public readonly scope = "object" as const; - - constructor(props: { collation: Collation }) { - super(); - this.collation = props.collation; - } - - get creates() { - return [this.collation.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.collation.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.collation.owner)); - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE COLLATION"]; - - // Add schema and name (already quoted in model extraction) - parts.push(`${this.collation.schema}.${this.collation.name}`); - - // Add properties - const properties: string[] = []; - - // LOCALE - if (this.collation.locale) { - properties.push(`LOCALE = ${quoteLiteral(this.collation.locale)}`); - } - - // LC_COLLATE - if (this.collation.collate) { - properties.push(`LC_COLLATE = ${quoteLiteral(this.collation.collate)}`); - } - - // LC_CTYPE - if (this.collation.ctype) { - properties.push(`LC_CTYPE = ${quoteLiteral(this.collation.ctype)}`); - } - - // PROVIDER - const providerMap: Record = { - c: "libc", - i: "icu", - b: "builtin", - }; - // provider 'd' means default provider in catalog; omit PROVIDER clause - if (this.collation.provider !== "d") { - properties.push(`PROVIDER = ${providerMap[this.collation.provider]}`); - } - - // DETERMINISTIC - // DETERMINISTIC (only emit when false; true is default in PG) - if (this.collation.is_deterministic === false) { - properties.push(`DETERMINISTIC = false`); - } - - // RULES (ICU rules) - if (this.collation.icu_rules) { - properties.push(`RULES = ${quoteLiteral(this.collation.icu_rules)}`); - } - - // VERSION - if (this.collation.version) { - properties.push(`VERSION = ${quoteLiteral(this.collation.version)}`); - } - - parts.push(["(", properties.join(", "), ")"].join("")); - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.drop.test.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.drop.test.ts deleted file mode 100644 index 62fa90a35..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.drop.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Collation } from "../collation.model.ts"; -import { DropCollation } from "./collation.drop.ts"; - -describe("collation", () => { - test("drop", async () => { - const collation = new Collation({ - schema: "public", - name: "test", - provider: "c", - is_deterministic: true, - encoding: 1, - collate: "en_US", - locale: "en_US", - version: "1.0", - ctype: "test", - icu_rules: "test", - owner: "test", - comment: null, - }); - - const change = new DropCollation({ - collation, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP COLLATION public.test"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.drop.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.drop.ts deleted file mode 100644 index 9c86fb72a..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.drop.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Collation } from "../collation.model.ts"; -import { DropCollationChange } from "./collation.base.ts"; - -/** - * Drop a collation. - * - * @see https://www.postgresql.org/docs/17/sql-dropcollation.html - * - * Synopsis - * ```sql - * DROP COLLATION [ IF EXISTS ] name [ CASCADE | RESTRICT ] - * ``` - */ -export class DropCollation extends DropCollationChange { - public readonly collation: Collation; - public readonly scope = "object" as const; - - constructor(props: { collation: Collation }) { - super(); - this.collation = props.collation; - } - - get requires() { - return [this.collation.stableId]; - } - - get drops() { - return [this.collation.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "DROP COLLATION", - `${this.collation.schema}.${this.collation.name}`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/collation/changes/collation.types.ts b/packages/pg-delta/src/core/objects/collation/changes/collation.types.ts deleted file mode 100644 index ba82c7804..000000000 --- a/packages/pg-delta/src/core/objects/collation/changes/collation.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AlterCollation } from "./collation.alter.ts"; -import type { CommentCollation } from "./collation.comment.ts"; -import type { CreateCollation } from "./collation.create.ts"; -import type { DropCollation } from "./collation.drop.ts"; - -/** Union of all collation-related change variants (`objectType: "collation"`). @category Change Types */ -export type CollationChange = - | AlterCollation - | CommentCollation - | CreateCollation - | DropCollation; diff --git a/packages/pg-delta/src/core/objects/collation/collation.diff.test.ts b/packages/pg-delta/src/core/objects/collation/collation.diff.test.ts deleted file mode 100644 index bfca79e03..000000000 --- a/packages/pg-delta/src/core/objects/collation/collation.diff.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { - AlterCollationChangeOwner, - AlterCollationRefreshVersion, -} from "./changes/collation.alter.ts"; -import { CreateCollation } from "./changes/collation.create.ts"; -import { DropCollation } from "./changes/collation.drop.ts"; -import { diffCollations } from "./collation.diff.ts"; -import { Collation, type CollationProps } from "./collation.model.ts"; - -const ctx: Pick = { - currentUser: "postgres", -}; - -describe.concurrent("collation.diff", () => { - test("create and drop", () => { - const props: CollationProps = { - schema: "public", - name: "c1", - provider: "c", - is_deterministic: true, - encoding: 1, - collate: "en_US", - ctype: "en_US", - locale: "en_US", - icu_rules: null, - version: "1.0", - owner: "postgres", - comment: null, - }; - const c = new Collation(props); - - const created = diffCollations(ctx, {}, { [c.stableId]: c }); - expect(created).toHaveLength(1); - expect(created[0]).toBeInstanceOf(CreateCollation); - - const dropped = diffCollations(ctx, { [c.stableId]: c }, {}); - expect(dropped).toHaveLength(1); - expect(dropped[0]).toBeInstanceOf(DropCollation); - }); - - test("alter: refresh version and change owner", () => { - const base: Omit = { - schema: "public", - name: "c1", - provider: "c", - is_deterministic: true, - encoding: 1, - collate: "en_US", - ctype: "en_US", - locale: "en_US", - icu_rules: null, - comment: null, - }; - const main = new Collation({ ...base, version: "1.0", owner: "o1" }); - const branch = new Collation({ ...base, version: "2.0", owner: "o2" }); - - const changes = diffCollations( - ctx, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterCollationRefreshVersion)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterCollationChangeOwner)).toBe( - true, - ); - }); - - test("drop + create when non-alterable changes", () => { - const base: Omit = { - schema: "public", - name: "c1", - is_deterministic: true, - encoding: 1, - collate: "en_US", - ctype: "en_US", - locale: "en_US", - icu_rules: null, - comment: null, - version: "1.0", - owner: "o1", - }; - const main = new Collation({ ...base, provider: "c" }); - const branch = new Collation({ ...base, provider: "i" }); - const changes = diffCollations( - ctx, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropCollation); - expect(changes[1]).toBeInstanceOf(CreateCollation); - }); -}); diff --git a/packages/pg-delta/src/core/objects/collation/collation.diff.ts b/packages/pg-delta/src/core/objects/collation/collation.diff.ts deleted file mode 100644 index 727146c5d..000000000 --- a/packages/pg-delta/src/core/objects/collation/collation.diff.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { hasNonAlterableChanges } from "../utils.ts"; -import { - AlterCollationChangeOwner, - AlterCollationRefreshVersion, -} from "./changes/collation.alter.ts"; -import { - CreateCommentOnCollation, - DropCommentOnCollation, -} from "./changes/collation.comment.ts"; -import { CreateCollation } from "./changes/collation.create.ts"; -import { DropCollation } from "./changes/collation.drop.ts"; -import type { CollationChange } from "./changes/collation.types.ts"; -import type { Collation } from "./collation.model.ts"; - -/** - * Diff two sets of collations from main and branch catalogs. - * - * @param ctx - Context containing currentUser - * @param main - The collations in the main catalog. - * @param branch - The collations in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffCollations( - ctx: Pick, - main: Record, - branch: Record, -): CollationChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: CollationChange[] = []; - - for (const collationId of created) { - const coll = branch[collationId]; - changes.push(new CreateCollation({ collation: coll })); - - // OWNER: If the collation should be owned by someone other than the current user, - // emit ALTER COLLATION ... OWNER TO after creation - if (coll.owner !== ctx.currentUser) { - changes.push( - new AlterCollationChangeOwner({ - collation: coll, - owner: coll.owner, - }), - ); - } - - if (coll.comment !== null) { - changes.push(new CreateCommentOnCollation({ collation: coll })); - } - } - - for (const collationId of dropped) { - changes.push(new DropCollation({ collation: main[collationId] })); - } - - for (const collationId of altered) { - const mainCollation = main[collationId]; - const branchCollation = branch[collationId]; - - // Check if non-alterable properties have changed - // These require dropping and recreating the collation - const NON_ALTERABLE_FIELDS: Array = [ - "provider", - "is_deterministic", - "encoding", - "collate", - "ctype", - "locale", - "icu_rules", - ]; - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainCollation, - branchCollation, - NON_ALTERABLE_FIELDS, - ); - - if (nonAlterablePropsChanged) { - // Replace the entire collation (drop + create) - changes.push( - new DropCollation({ collation: mainCollation }), - new CreateCollation({ collation: branchCollation }), - ); - } else { - // Only alterable properties changed - check each one - - // VERSION - if (mainCollation.version !== branchCollation.version) { - changes.push( - new AlterCollationRefreshVersion({ - collation: mainCollation, - }), - ); - } - - // OWNER - if (mainCollation.owner !== branchCollation.owner) { - changes.push( - new AlterCollationChangeOwner({ - collation: mainCollation, - owner: branchCollation.owner, - }), - ); - } - - // COMMENT - if (mainCollation.comment !== branchCollation.comment) { - if (branchCollation.comment === null) { - changes.push( - new DropCommentOnCollation({ collation: mainCollation }), - ); - } else { - changes.push( - new CreateCommentOnCollation({ collation: branchCollation }), - ); - } - } - - // Note: Collation renaming would also use ALTER COLLATION ... RENAME TO ... - // But since our Collation model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/collation/collation.model.ts b/packages/pg-delta/src/core/objects/collation/collation.model.ts deleted file mode 100644 index 362d3e83b..000000000 --- a/packages/pg-delta/src/core/objects/collation/collation.model.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { extractVersion } from "../../context.ts"; -import { BasePgModel } from "../base.model.ts"; - -/** - * Collation provider codes as stored in pg_collation.collprovider - */ -const CollationProviderSchema = z.enum([ - "d", // database default provider (omit PROVIDER clause) - "b", // builtin - "c", // libc - "i", // icu -]); - -/** - * All properties exposed by CREATE COLLATION statement are included in diff output. - * https://www.postgresql.org/docs/current/sql-createcollation.html - * - * ALTER COLLATION statement can only be generated for a subset of properties: - * - version, name, owner, schema - * https://www.postgresql.org/docs/current/sql-altercollation.html - * - * Other properties require dropping and creating a new collation. - */ -const collationPropsSchema = z.object({ - schema: z.string(), - name: z.string(), - provider: CollationProviderSchema, - is_deterministic: z.boolean(), - encoding: z.number(), - collate: z.string(), - ctype: z.string(), - locale: z.string().nullable(), - icu_rules: z.string().nullable(), - version: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable(), -}); - -export type CollationProps = z.infer; - -export class Collation extends BasePgModel { - public readonly schema: CollationProps["schema"]; - public readonly name: CollationProps["name"]; - public readonly provider: CollationProps["provider"]; - public readonly is_deterministic: CollationProps["is_deterministic"]; - public readonly encoding: CollationProps["encoding"]; - public readonly collate: CollationProps["collate"]; - public readonly ctype: CollationProps["ctype"]; - public readonly locale: CollationProps["locale"]; - public readonly icu_rules: CollationProps["icu_rules"]; - public readonly version: CollationProps["version"]; - public readonly owner: CollationProps["owner"]; - public readonly comment: CollationProps["comment"]; - - constructor(props: CollationProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.provider = props.provider; - this.is_deterministic = props.is_deterministic; - this.encoding = props.encoding; - this.collate = props.collate; - this.ctype = props.ctype; - this.locale = props.locale; - this.icu_rules = props.icu_rules; - this.version = props.version; - this.owner = props.owner; - this.comment = props.comment; - } - - get stableId(): `collation:${string}` { - return `collation:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - provider: this.provider, - is_deterministic: this.is_deterministic, - encoding: this.encoding, - collate: this.collate, - ctype: this.ctype, - locale: this.locale, - icu_rules: this.icu_rules, - version: this.version, - owner: this.owner, - comment: this.comment, - }; - } -} - -export async function extractCollations(pool: Pool): Promise { - const version = await extractVersion(pool); - const isPostgres17OrGreater = version >= 170000; - const isPostgres16OrGreater = version >= 160000; - - let collationRows: CollationProps[]; - - if (isPostgres17OrGreater) { - const result = await pool.query(sql` - with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_collation'::regclass - ) - select - c.collnamespace::regnamespace::text as schema, - quote_ident(c.collname) as name, - c.collprovider as provider, - c.collisdeterministic as is_deterministic, - c.collencoding as encoding, - c.collcollate as collate, - c.collctype as ctype, - c.colllocale as locale, - c.collicurules as icu_rules, - c.collversion as version, - c.collowner::regrole::text as owner, - obj_description(c.oid, 'pg_collation') as comment - from - pg_catalog.pg_collation c - left outer join extension_oids e on c.oid = e.objid - where not c.collnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null - order by - 1, 2 - `); - collationRows = result.rows; - } else if (isPostgres16OrGreater) { - // On postgres 16 there colllocale column was named colliculocale - const result = await pool.query(sql` - with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_collation'::regclass - ) - select - c.collnamespace::regnamespace::text as schema, - quote_ident(c.collname) as name, - c.collprovider as provider, - c.collisdeterministic as is_deterministic, - c.collencoding as encoding, - c.collcollate as collate, - c.collctype as ctype, - colliculocale as locale, - c.collicurules as icu_rules, - c.collversion as version, - c.collowner::regrole::text as owner, - obj_description(c.oid, 'pg_collation') as comment - from - pg_catalog.pg_collation c - left outer join extension_oids e on c.oid = e.objid - -- - where not c.collnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null - -- - order by - 1, 2 - `); - collationRows = result.rows; - } else { - // On postgres 15 icu_rules does not exist - const result = await pool.query(sql` - with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_collation'::regclass - ) - select - c.collnamespace::regnamespace::text as schema, - quote_ident(c.collname) as name, - c.collprovider as provider, - c.collisdeterministic as is_deterministic, - c.collencoding as encoding, - c.collcollate as collate, - c.collctype as ctype, - colliculocale as locale, - null as icu_rules, - c.collversion as version, - c.collowner::regrole::text as owner, - obj_description(c.oid, 'pg_collation') as comment - from - pg_catalog.pg_collation c - left outer join extension_oids e on c.oid = e.objid - -- - where not c.collnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null - -- - order by - 1, 2 - `); - collationRows = result.rows; - } - - // Validate and parse each row using the Zod schema - const validatedRows = collationRows.map((row: unknown) => - collationPropsSchema.parse(row), - ); - return validatedRows.map((row: CollationProps) => new Collation(row)); -} diff --git a/packages/pg-delta/src/core/objects/diff-context.ts b/packages/pg-delta/src/core/objects/diff-context.ts deleted file mode 100644 index 8322288a0..000000000 --- a/packages/pg-delta/src/core/objects/diff-context.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { DefaultPrivilegeState } from "./base.default-privileges.ts"; -import type { Role } from "./role/role.model.ts"; - -/** - * Unified context built by `diffCatalogs` and passed to per-object diff - * functions. Each diff declares only the keys it reads via - * `Pick`, so every signature documents its actual - * requirements. The full object is always assignable to every narrower Pick. - */ -export interface ObjectDiffContext { - version: number; - currentUser: string; - defaultPrivilegeState: DefaultPrivilegeState; - mainRoles: Record; - skipDefaultPrivilegeSubtraction?: boolean; -} diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.alter.test.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.alter.test.ts deleted file mode 100644 index 5e959e7a0..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.alter.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Domain, type DomainProps } from "../domain.model.ts"; -import { - AlterDomainAddConstraint, - AlterDomainChangeOwner, - AlterDomainDropConstraint, - AlterDomainDropDefault, - AlterDomainDropNotNull, - AlterDomainSetDefault, - AlterDomainSetNotNull, - AlterDomainValidateConstraint, -} from "./domain.alter.ts"; - -describe.concurrent("domain", () => { - describe("alter", () => { - test("set default", async () => { - const props: Omit = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - owner: "test", - comment: null, - constraints: [], - privileges: [], - }; - const domain = new Domain({ - ...props, - default_value: null, - }); - - const change = new AlterDomainSetDefault({ - domain, - defaultValue: "42", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain SET DEFAULT 42", - ); - }); - - test("drop default", async () => { - const props: Omit = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - owner: "test", - comment: null, - constraints: [], - - privileges: [], - }; - const domain = new Domain({ - ...props, - default_value: "42", - }); - - const change = new AlterDomainDropDefault({ - domain, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain DROP DEFAULT", - ); - }); - - test("set not null", async () => { - const props: Omit = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - - privileges: [], - }; - const domain = new Domain({ - ...props, - not_null: false, - }); - - const change = new AlterDomainSetNotNull({ - domain, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain SET NOT NULL", - ); - }); - - test("drop not null", async () => { - const props: Omit = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - - privileges: [], - }; - const domain = new Domain({ - ...props, - not_null: true, - }); - - const change = new AlterDomainDropNotNull({ - domain, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain DROP NOT NULL", - ); - }); - - test("change owner", async () => { - const props: Omit = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - comment: null, - constraints: [], - - privileges: [], - }; - const domain = new Domain({ - ...props, - owner: "old_owner", - }); - - const change = new AlterDomainChangeOwner({ - domain, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain OWNER TO new_owner", - ); - }); - - test("add constraint", async () => { - const props: DomainProps = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - - privileges: [], - }; - const domain = new Domain(props); - - const change = new AlterDomainAddConstraint({ - domain, - constraint: { - name: "test_check", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE > 0", - }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain ADD CONSTRAINT test_check CHECK (VALUE > 0)", - ); - }); - - test("add constraint not valid", async () => { - const props: DomainProps = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - - privileges: [], - }; - const domain = new Domain(props); - - const change = new AlterDomainAddConstraint({ - domain, - constraint: { - name: "test_check", - validated: false, - is_local: true, - no_inherit: false, - check_expression: "VALUE > 0", - }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain ADD CONSTRAINT test_check CHECK (VALUE > 0) NOT VALID", - ); - }); - - test("drop constraint", async () => { - const props: DomainProps = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - - privileges: [], - }; - const domain = new Domain(props); - - const change = new AlterDomainDropConstraint({ - domain, - constraint: { - name: "test_check", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE > 0", - }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain DROP CONSTRAINT test_check", - ); - }); - - test("validate constraint", async () => { - const props: DomainProps = { - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - - privileges: [], - }; - const domain = new Domain(props); - - const change = new AlterDomainValidateConstraint({ - domain, - constraint: { - name: "test_check", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE > 0", - }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER DOMAIN public.test_domain VALIDATE CONSTRAINT test_check", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.alter.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.alter.ts deleted file mode 100644 index bd3455f17..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.alter.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Domain, DomainConstraintProps } from "../domain.model.ts"; -import { AlterDomainChange, DropDomainChange } from "./domain.base.ts"; - -/** - * Alter a domain. - * - * @see https://www.postgresql.org/docs/17/sql-alterdomain.html - * - * Synopsis - * ```sql - * ALTER DOMAIN name - * { SET DEFAULT expression | DROP DEFAULT } - * ALTER DOMAIN name - * { SET | DROP } NOT NULL - * ALTER DOMAIN name - * ADD domain_constraint [ NOT VALID ] - * ALTER DOMAIN name - * DROP CONSTRAINT [ IF EXISTS ] constraint_name [ RESTRICT | CASCADE ] - * ALTER DOMAIN name - * RENAME CONSTRAINT constraint_name TO new_constraint_name - * ALTER DOMAIN name - * VALIDATE CONSTRAINT constraint_name - * ALTER DOMAIN name - * OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ALTER DOMAIN name - * RENAME TO new_name - * ALTER DOMAIN name - * SET SCHEMA new_schema - * - * where domain_constraint is: - * - * [ CONSTRAINT constraint_name ] - * { NOT NULL | CHECK (expression) } - * ``` - */ - -export type AlterDomain = - | AlterDomainAddConstraint - | AlterDomainChangeOwner - | AlterDomainDropConstraint - | AlterDomainDropDefault - | AlterDomainDropNotNull - | AlterDomainSetDefault - | AlterDomainSetNotNull - | AlterDomainValidateConstraint; - -/** - * ALTER DOMAIN ... SET DEFAULT ... - */ -export class AlterDomainSetDefault extends AlterDomainChange { - public readonly domain: Domain; - public readonly defaultValue: string; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain; defaultValue: string }) { - super(); - this.domain = props.domain; - this.defaultValue = props.defaultValue; - } - - get requires() { - return [this.domain.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER DOMAIN ${this.domain.schema}.${this.domain.name} SET DEFAULT ${this.defaultValue}`; - } -} - -/** - * ALTER DOMAIN ... DROP DEFAULT - */ -export class AlterDomainDropDefault extends AlterDomainChange { - public readonly domain: Domain; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain }) { - super(); - this.domain = props.domain; - } - - get requires() { - return [this.domain.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER DOMAIN ${this.domain.schema}.${this.domain.name} DROP DEFAULT`; - } -} - -/** - * ALTER DOMAIN ... SET NOT NULL - */ -export class AlterDomainSetNotNull extends AlterDomainChange { - public readonly domain: Domain; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain }) { - super(); - this.domain = props.domain; - } - - get requires() { - return [this.domain.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER DOMAIN ${this.domain.schema}.${this.domain.name} SET NOT NULL`; - } -} - -/** - * ALTER DOMAIN ... DROP NOT NULL - */ -export class AlterDomainDropNotNull extends AlterDomainChange { - public readonly domain: Domain; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain }) { - super(); - this.domain = props.domain; - } - - get requires() { - return [this.domain.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER DOMAIN ${this.domain.schema}.${this.domain.name} DROP NOT NULL`; - } -} - -/** - * ALTER DOMAIN ... OWNER TO ... - */ -export class AlterDomainChangeOwner extends AlterDomainChange { - public readonly domain: Domain; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain; owner: string }) { - super(); - this.domain = props.domain; - this.owner = props.owner; - } - - get requires() { - return [this.domain.stableId, stableId.role(this.owner)]; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER DOMAIN ${this.domain.schema}.${this.domain.name} OWNER TO ${this.owner}`; - } -} - -/** - * ALTER DOMAIN ... ADD CONSTRAINT ... [ NOT VALID ] - */ -export class AlterDomainAddConstraint extends AlterDomainChange { - public readonly domain: Domain; - public readonly constraint: DomainConstraintProps; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain; constraint: DomainConstraintProps }) { - super(); - this.domain = props.domain; - this.constraint = props.constraint; - } - - get creates() { - return [ - stableId.constraint( - this.domain.schema, - this.domain.name, - this.constraint.name, - ), - ]; - } - - get requires() { - return [this.domain.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const domainName = `${this.domain.schema}.${this.domain.name}`; - const parts: string[] = [ - "ALTER DOMAIN", - domainName, - "ADD CONSTRAINT", - this.constraint.name, - ]; - if (this.constraint.check_expression) { - parts.push(`CHECK (${this.constraint.check_expression})`); - } - if (!this.constraint.validated) { - parts.push("NOT VALID"); - } - return parts.join(" "); - } -} - -/** - * ALTER DOMAIN ... DROP CONSTRAINT ... - */ -export class AlterDomainDropConstraint extends DropDomainChange { - public readonly domain: Domain; - public readonly constraint: DomainConstraintProps; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain; constraint: DomainConstraintProps }) { - super(); - this.domain = props.domain; - this.constraint = props.constraint; - } - - get requires() { - return [ - this.domain.stableId, - stableId.constraint( - this.domain.schema, - this.domain.name, - this.constraint.name, - ), - ]; - } - - get drops() { - return [ - stableId.constraint( - this.domain.schema, - this.domain.name, - this.constraint.name, - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - const domainName = `${this.domain.schema}.${this.domain.name}`; - return [ - "ALTER DOMAIN", - domainName, - "DROP CONSTRAINT", - this.constraint.name, - ].join(" "); - } -} - -// Constraint renames are modeled as drop+add because the constraint name -// is part of the identity used in diffing and dependency resolution. - -/** - * ALTER DOMAIN ... VALIDATE CONSTRAINT ... - */ -export class AlterDomainValidateConstraint extends AlterDomainChange { - public readonly domain: Domain; - public readonly constraint: DomainConstraintProps; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain; constraint: DomainConstraintProps }) { - super(); - this.domain = props.domain; - this.constraint = props.constraint; - } - - get requires() { - return [ - this.domain.stableId, - stableId.constraint( - this.domain.schema, - this.domain.name, - this.constraint.name, - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - const domainName = `${this.domain.schema}.${this.domain.name}`; - return [ - "ALTER DOMAIN", - domainName, - "VALIDATE CONSTRAINT", - this.constraint.name, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.base.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.base.ts deleted file mode 100644 index c31b4ed9d..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Domain } from "../domain.model.ts"; - -abstract class BaseDomainChange extends BaseChange { - abstract readonly domain: Domain; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "domain" as const; -} - -export abstract class CreateDomainChange extends BaseDomainChange { - readonly operation = "create" as const; -} - -export abstract class AlterDomainChange extends BaseDomainChange { - readonly operation = "alter" as const; -} - -export abstract class DropDomainChange extends BaseDomainChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.comment.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.comment.ts deleted file mode 100644 index d3ca6f9d9..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.comment.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Domain } from "../domain.model.ts"; -import { CreateDomainChange, DropDomainChange } from "./domain.base.ts"; - -export type CommentDomain = CreateCommentOnDomain | DropCommentOnDomain; - -/** - * Create/drop comments on domains. - */ -export class CreateCommentOnDomain extends CreateDomainChange { - public readonly domain: Domain; - public readonly scope = "comment" as const; - - constructor(props: { domain: Domain }) { - super(); - this.domain = props.domain; - } - - get creates() { - return [stableId.comment(this.domain.stableId)]; - } - - get requires() { - return [this.domain.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON DOMAIN", - `${this.domain.schema}.${this.domain.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: domain comment is not nullable in this case - quoteLiteral(this.domain.comment!), - ].join(" "); - } -} - -export class DropCommentOnDomain extends DropDomainChange { - public readonly domain: Domain; - public readonly scope = "comment" as const; - - constructor(props: { domain: Domain }) { - super(); - this.domain = props.domain; - } - - get requires() { - return [stableId.comment(this.domain.stableId), this.domain.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON DOMAIN", - `${this.domain.schema}.${this.domain.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.create.test.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.create.test.ts deleted file mode 100644 index daeeb2ea9..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.create.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Domain } from "../domain.model.ts"; -import { CreateDomain } from "./domain.create.ts"; - -describe("domain", () => { - test("create minimal", async () => { - const domain = new Domain({ - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - base_type_str: "integer", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - privileges: [], - }); - - const change = new CreateDomain({ domain }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE DOMAIN public.test_domain AS integer", - ); - }); - - test("create with all options", async () => { - const domain = new Domain({ - schema: "public", - name: "test_domain_all", - base_type: "text", - base_type_schema: "custom", - base_type_str: "text", - not_null: true, - type_modifier: null, - array_dimensions: 2, - collation: "mycoll", - default_bin: null, - default_value: "'hello'", - owner: "test", - comment: null, - constraints: [ - { - name: "c1", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE <> ''", - }, - ], - privileges: [], - }); - - const change = new CreateDomain({ domain }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - `CREATE DOMAIN public.test_domain_all AS custom.text[][] COLLATE mycoll DEFAULT 'hello' NOT NULL CHECK (VALUE <> '')`, - ); - }); - - test("create with already schema-qualified base type (format_type)", async () => { - const domain = new Domain({ - schema: "app", - name: "email_address", - base_type: "citext", - base_type_schema: "extensions", - base_type_str: "extensions.citext", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - privileges: [], - }); - const change = new CreateDomain({ domain }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "CREATE DOMAIN app.email_address AS extensions.citext", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts deleted file mode 100644 index 1e4c2a66d..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.create.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - isUserDefinedTypeSchema, - parseTypeString, - stableId, -} from "../../utils.ts"; -import type { Domain } from "../domain.model.ts"; -import { CreateDomainChange } from "./domain.base.ts"; - -/** - * Create a domain. - * - * @see https://www.postgresql.org/docs/17/sql-createdomain.html - * - * Synopsis - * ```sql - * CREATE DOMAIN name [ AS ] data_type - * [ COLLATE collation ] - * [ DEFAULT expression ] - * [ domain_constraint [ ... ] ] - * - * where domain_constraint is: - * - * [ CONSTRAINT constraint_name ] - * { NOT NULL | NULL | CHECK (expression) } - * ``` - */ -export class CreateDomain extends CreateDomainChange { - public readonly domain: Domain; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain }) { - super(); - this.domain = props.domain; - } - - get creates() { - const creates: Array< - `domain:${string}` | `constraint:${string}.${string}.${string}` - > = [this.domain.stableId]; - - for (const constraint of this.domain.constraints) { - if (constraint.check_expression && constraint.validated) { - creates.push( - stableId.constraint( - this.domain.schema, - this.domain.name, - constraint.name, - ), - ); - } - } - - return creates; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.domain.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.domain.owner)); - - // Base type dependency (if user-defined) - if ( - this.domain.base_type_schema && - isUserDefinedTypeSchema(this.domain.base_type_schema) - ) { - dependencies.add( - stableId.type(this.domain.base_type_schema, this.domain.base_type), - ); - } - - // Collation dependency (if non-default and user-defined) - if (this.domain.collation) { - const unquotedCollation = this.domain.collation.replace(/^"|"$/g, ""); - const collationParts = unquotedCollation.split("."); - if (collationParts.length === 2) { - const [collationSchema, collationName] = collationParts; - if (isUserDefinedTypeSchema(collationSchema)) { - dependencies.add(stableId.collation(collationSchema, collationName)); - } - } - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = []; - - // Schema-qualified name - const domainName = `${this.domain.schema}.${this.domain.name}`; - - // Base type (use formatted string for type+typmod and add schema if needed) - let baseType = this.domain.base_type_str as string; - const alreadyQualified = parseTypeString(baseType); - if ( - !alreadyQualified && - this.domain.base_type_schema && - this.domain.base_type_schema !== "pg_catalog" - ) { - baseType = `${this.domain.base_type_schema}.${baseType}`; - } - - // Array dimensions - if (this.domain.array_dimensions && this.domain.array_dimensions > 0) { - baseType += "[]".repeat(this.domain.array_dimensions); - } - - parts.push(`CREATE DOMAIN ${domainName} AS ${baseType}`); - - // Collation - if (this.domain.collation) { - parts.push(`COLLATE ${this.domain.collation}`); - } - - // Default value - if (this.domain.default_value) { - parts.push(`DEFAULT ${this.domain.default_value}`); - } - - // NOT NULL constraint - if (this.domain.not_null) { - parts.push("NOT NULL"); - } - - // Inline CHECK constraints that are already validated - if (this.domain.constraints && this.domain.constraints.length > 0) { - for (const c of this.domain.constraints) { - if (c.check_expression && c.validated !== false) { - parts.push(`CHECK (${c.check_expression})`); - } - } - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.drop.test.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.drop.test.ts deleted file mode 100644 index 01233b2db..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.drop.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Domain } from "../domain.model.ts"; -import { DropDomain } from "./domain.drop.ts"; - -describe("domain", () => { - test("drop", async () => { - const domain = new Domain({ - schema: "public", - name: "test_domain", - base_type: "integer", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "test", - comment: null, - constraints: [], - privileges: [], - }); - - const change = new DropDomain({ - domain, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP DOMAIN public.test_domain"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.drop.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.drop.ts deleted file mode 100644 index 998fbbac7..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Domain } from "../domain.model.ts"; -import { DropDomainChange } from "./domain.base.ts"; - -/** - * Drop a domain. - * - * @see https://www.postgresql.org/docs/17/sql-dropdomain.html - * - * Synopsis - * ```sql - * DROP DOMAIN [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropDomain extends DropDomainChange { - public readonly domain: Domain; - public readonly scope = "object" as const; - - constructor(props: { domain: Domain }) { - super(); - this.domain = props.domain; - } - - get requires() { - return [this.domain.stableId]; - } - - get drops() { - return [this.domain.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return `DROP DOMAIN ${this.domain.schema}.${this.domain.name}`; - } -} diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.privilege.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.privilege.ts deleted file mode 100644 index 8aff6e287..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.privilege.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { Domain } from "../domain.model.ts"; -import { AlterDomainChange } from "./domain.base.ts"; - -export type DomainPrivilege = - | GrantDomainPrivileges - | RevokeDomainPrivileges - | RevokeGrantOptionDomainPrivileges; - -/** - * Grant privileges on a domain. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { USAGE | ALL [ PRIVILEGES ] } - * ON DOMAIN domain_name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantDomainPrivileges extends AlterDomainChange { - public readonly domain: Domain; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - domain: Domain; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.domain = props.domain; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.domain.stableId, this.grantee)]; - } - - get requires() { - return [this.domain.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantDomainPrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("DOMAIN"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("DOMAIN", list, this.version); - const domainName = `${this.domain.schema}.${this.domain.name}`; - return `GRANT ${privSql} ${kindPrefix} ${domainName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a domain. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { USAGE | ALL [ PRIVILEGES ] } - * ON DOMAIN domain_name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeDomainPrivileges extends AlterDomainChange { - public readonly domain: Domain; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - domain: Domain; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.domain = props.domain; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.domain.stableId, this.grantee)]; - } - - get requires() { - return [this.domain.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("DOMAIN"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("DOMAIN", list, this.version); - const domainName = `${this.domain.schema}.${this.domain.name}`; - return `REVOKE ${privSql} ${kindPrefix} ${domainName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a domain. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionDomainPrivileges extends AlterDomainChange { - public readonly domain: Domain; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - domain: Domain; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.domain = props.domain; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - this.domain.stableId, - stableId.role(this.grantee), - stableId.acl(this.domain.stableId, this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("DOMAIN"); - const privSql = formatObjectPrivilegeList( - "DOMAIN", - this.privilegeNames, - this.version, - ); - const domainName = `${this.domain.schema}.${this.domain.name}`; - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${domainName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.security-label.test.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.security-label.test.ts deleted file mode 100644 index 0f7cbe9ec..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.security-label.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Domain, type DomainProps } from "../domain.model.ts"; -import { - CreateSecurityLabelOnDomain, - DropSecurityLabelOnDomain, -} from "./domain.security-label.ts"; - -const makeDomain = (): Domain => - new Domain({ - schema: "public", - name: "email_t", - base_type: "text", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: -1, - array_dimensions: 0, - collation: null, - default_bin: null, - default_value: null, - owner: "postgres", - comment: null, - constraints: [], - privileges: [], - } as DomainProps); - -describe("domain.security-label", () => { - test("create serializes", async () => { - const domain = makeDomain(); - const change = new CreateSecurityLabelOnDomain({ - domain, - securityLabel: { provider: "dummy", label: "classified" }, - }); - expect(change.scope).toBe("security_label"); - expect(change.creates).toEqual([ - stableId.securityLabel(domain.stableId, "dummy"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON DOMAIN public.email_t IS 'classified'", - ); - }); - - test("drop serializes to IS NULL", async () => { - const domain = makeDomain(); - const change = new DropSecurityLabelOnDomain({ - domain, - securityLabel: { provider: "dummy", label: "x" }, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON DOMAIN public.email_t IS NULL", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.security-label.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.security-label.ts deleted file mode 100644 index df5ea3067..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.security-label.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Domain } from "../domain.model.ts"; -import { CreateDomainChange, DropDomainChange } from "./domain.base.ts"; - -export type SecurityLabelDomain = - | CreateSecurityLabelOnDomain - | DropSecurityLabelOnDomain; - -export class CreateSecurityLabelOnDomain extends CreateDomainChange { - public readonly domain: Domain; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { domain: Domain; securityLabel: SecurityLabelProps }) { - super(); - this.domain = props.domain; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel(this.domain.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [this.domain.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON DOMAIN", - `${this.domain.schema}.${this.domain.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnDomain extends DropDomainChange { - public readonly domain: Domain; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { domain: Domain; securityLabel: SecurityLabelProps }) { - super(); - this.domain = props.domain; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel(this.domain.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [ - stableId.securityLabel(this.domain.stableId, this.securityLabel.provider), - this.domain.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON DOMAIN", - `${this.domain.schema}.${this.domain.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/domain/changes/domain.types.ts b/packages/pg-delta/src/core/objects/domain/changes/domain.types.ts deleted file mode 100644 index 6adc98e4f..000000000 --- a/packages/pg-delta/src/core/objects/domain/changes/domain.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterDomain } from "./domain.alter.ts"; -import type { CommentDomain } from "./domain.comment.ts"; -import type { CreateDomain } from "./domain.create.ts"; -import type { DropDomain } from "./domain.drop.ts"; -import type { DomainPrivilege } from "./domain.privilege.ts"; -import type { SecurityLabelDomain } from "./domain.security-label.ts"; - -/** Union of all domain-related change variants (`objectType: "domain"`). @category Change Types */ -export type DomainChange = - | AlterDomain - | CommentDomain - | CreateDomain - | DropDomain - | DomainPrivilege - | SecurityLabelDomain; diff --git a/packages/pg-delta/src/core/objects/domain/domain.diff.test.ts b/packages/pg-delta/src/core/objects/domain/domain.diff.test.ts deleted file mode 100644 index 27f6bcbdb..000000000 --- a/packages/pg-delta/src/core/objects/domain/domain.diff.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../base.default-privileges.ts"; -import { - AlterDomainAddConstraint, - AlterDomainChangeOwner, - AlterDomainDropConstraint, - AlterDomainDropDefault, - AlterDomainDropNotNull, - AlterDomainSetDefault, - AlterDomainSetNotNull, - AlterDomainValidateConstraint, -} from "./changes/domain.alter.ts"; -import { CreateDomain } from "./changes/domain.create.ts"; -import { DropDomain } from "./changes/domain.drop.ts"; -import { diffDomains } from "./domain.diff.ts"; -import { Domain, type DomainProps } from "./domain.model.ts"; - -const base: DomainProps = { - schema: "public", - name: "d1", - base_type: "int4", - base_type_schema: "pg_catalog", - not_null: false, - type_modifier: null, - array_dimensions: null, - collation: null, - default_bin: null, - default_value: null, - owner: "o1", - comment: null, - constraints: [], - privileges: [], -}; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("domain.diff", () => { - test("create and drop", () => { - const d = new Domain(base); - const created = diffDomains(testContext, {}, { [d.stableId]: d }); - expect(created[0]).toBeInstanceOf(CreateDomain); - const dropped = diffDomains(testContext, { [d.stableId]: d }, {}); - expect(dropped[0]).toBeInstanceOf(DropDomain); - }); - - test("create with constraints results in add (+validate if needed) after create", () => { - const d = new Domain({ - ...base, - constraints: [ - { - name: "c_valid", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE > 0", - }, - { - name: "c_not_valid", - validated: false, - is_local: true, - no_inherit: false, - check_expression: "VALUE < 100", - }, - ], - }); - - const created = diffDomains(testContext, {}, { [d.stableId]: d }); - expect(created[0]).toBeInstanceOf(CreateDomain); - // Expect ADD for both constraints - expect(created.some((c) => c instanceof AlterDomainAddConstraint)).toBe( - true, - ); - // Expect VALIDATE for the unvalidated one - expect( - created.some((c) => c instanceof AlterDomainValidateConstraint), - ).toBe(true); - }); - - test("alter default set/drop and not null set/drop and owner", () => { - const main = new Domain(base); - const branch1 = new Domain({ ...base, default_value: "1" }); - const changes1 = diffDomains( - testContext, - { [main.stableId]: main }, - { [branch1.stableId]: branch1 }, - ); - expect(changes1[0]).toBeInstanceOf(AlterDomainSetDefault); - - const branch2 = new Domain({ ...base, not_null: true }); - const changes2 = diffDomains( - testContext, - { [main.stableId]: main }, - { [branch2.stableId]: branch2 }, - ); - expect(changes2[0]).toBeInstanceOf(AlterDomainSetNotNull); - - const branch3 = new Domain({ ...base, owner: "o2" }); - const changes3 = diffDomains( - testContext, - { [main.stableId]: main }, - { [branch3.stableId]: branch3 }, - ); - expect(changes3.some((c) => c instanceof AlterDomainChangeOwner)).toBe( - true, - ); - - const main4 = new Domain({ ...base, default_value: "1" }); - const branch4 = new Domain({ ...base, default_value: null }); - const changes4 = diffDomains( - testContext, - { [main4.stableId]: main4 }, - { [branch4.stableId]: branch4 }, - ); - expect(changes4[0]).toBeInstanceOf(AlterDomainDropDefault); - - const main5 = new Domain({ ...base, not_null: true }); - const branch5 = new Domain({ ...base, not_null: false }); - const changes5 = diffDomains( - testContext, - { [main5.stableId]: main5 }, - { [branch5.stableId]: branch5 }, - ); - expect(changes5[0]).toBeInstanceOf(AlterDomainDropNotNull); - }); - - test("alter constraint drop+add+validate when branch constraint is not validated", () => { - const main = new Domain({ - ...base, - constraints: [ - { - name: "c_check", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE > 0", - }, - ], - }); - const branch = new Domain({ - ...base, - constraints: [ - { - name: "c_check", - validated: false, // changed: not validated - is_local: true, - no_inherit: false, - check_expression: "VALUE >= 0", // changed expression - }, - ], - }); - - const changes = diffDomains( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.length).toBe(3); - expect(changes[0]).toBeInstanceOf(AlterDomainDropConstraint); - expect(changes[1]).toBeInstanceOf(AlterDomainAddConstraint); - expect(changes[2]).toBeInstanceOf(AlterDomainValidateConstraint); - }); - - test("alter constraint drop+add without validate when branch constraint is validated", () => { - const main = new Domain({ - ...base, - constraints: [ - { - name: "c_check", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE > 0", - }, - ], - }); - const branch = new Domain({ - ...base, - constraints: [ - { - name: "c_check", - validated: true, // remains validated - is_local: true, - no_inherit: false, - check_expression: "VALUE >= 0", // changed expression - }, - ], - }); - - const changes = diffDomains( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.length).toBe(2); - expect(changes[0]).toBeInstanceOf(AlterDomainDropConstraint); - expect(changes[1]).toBeInstanceOf(AlterDomainAddConstraint); - }); - - test("alter: add new validated constraint produces add only", () => { - const main = new Domain(base); - const branch = new Domain({ - ...base, - constraints: [ - { - name: "c_new", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE <> ''", - }, - ], - }); - - const changes = diffDomains( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.length).toBe(1); - expect(changes[0]).toBeInstanceOf(AlterDomainAddConstraint); - }); - - test("alter: add new not validated constraint produces add + validate", () => { - const main = new Domain(base); - const branch = new Domain({ - ...base, - constraints: [ - { - name: "c_new", - validated: false, - is_local: true, - no_inherit: false, - check_expression: "VALUE <> ''", - }, - ], - }); - - const changes = diffDomains( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.length).toBe(2); - expect(changes[0]).toBeInstanceOf(AlterDomainAddConstraint); - expect(changes[1]).toBeInstanceOf(AlterDomainValidateConstraint); - }); - - test("alter: drop existing constraint produces drop only", () => { - const main = new Domain({ - ...base, - constraints: [ - { - name: "c_drop", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE <> ''", - }, - ], - }); - const branch = new Domain({ - ...base, - constraints: [], - }); - - const changes = diffDomains( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.length).toBe(1); - expect(changes[0]).toBeInstanceOf(AlterDomainDropConstraint); - }); -}); diff --git a/packages/pg-delta/src/core/objects/domain/domain.diff.ts b/packages/pg-delta/src/core/objects/domain/domain.diff.ts deleted file mode 100644 index 122c88b45..000000000 --- a/packages/pg-delta/src/core/objects/domain/domain.diff.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { - AlterDomainAddConstraint, - AlterDomainChangeOwner, - AlterDomainDropConstraint, - AlterDomainDropDefault, - AlterDomainDropNotNull, - AlterDomainSetDefault, - AlterDomainSetNotNull, - AlterDomainValidateConstraint, -} from "./changes/domain.alter.ts"; -import { - CreateCommentOnDomain, - DropCommentOnDomain, -} from "./changes/domain.comment.ts"; -import { CreateDomain } from "./changes/domain.create.ts"; -import { DropDomain } from "./changes/domain.drop.ts"; -import { - GrantDomainPrivileges, - RevokeDomainPrivileges, - RevokeGrantOptionDomainPrivileges, -} from "./changes/domain.privilege.ts"; -import { - CreateSecurityLabelOnDomain, - DropSecurityLabelOnDomain, -} from "./changes/domain.security-label.ts"; -import type { DomainChange } from "./changes/domain.types.ts"; -import type { Domain } from "./domain.model.ts"; - -/** - * Diff two sets of domains from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The domains in the main catalog. - * @param branch - The domains in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffDomains( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): DomainChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: DomainChange[] = []; - - for (const domainId of created) { - const newDomain = branch[domainId]; - changes.push(new CreateDomain({ domain: newDomain })); - - // OWNER: If the domain should be owned by someone other than the current user, - // emit ALTER DOMAIN ... OWNER TO after creation - if (newDomain.owner !== ctx.currentUser) { - changes.push( - new AlterDomainChangeOwner({ - domain: newDomain, - owner: newDomain.owner, - }), - ); - } - - if (newDomain.comment !== null) { - changes.push(new CreateCommentOnDomain({ domain: newDomain })); - } - for (const label of newDomain.security_labels) { - changes.push( - new CreateSecurityLabelOnDomain({ - domain: newDomain, - securityLabel: label, - }), - ); - } - // For unvalidated constraints, CREATE DOMAIN cannot specify NOT VALID. - // Add them after creation and validate to match branch state semantics. - // For already validated constraints, they are emitted inline in CREATE DOMAIN. - if (newDomain.constraints && newDomain.constraints.length > 0) { - for (const c of newDomain.constraints) { - if (c.validated === false) { - changes.push( - new AlterDomainAddConstraint({ domain: newDomain, constraint: c }), - ); - changes.push( - new AlterDomainValidateConstraint({ - domain: newDomain, - constraint: c, - }), - ); - } - } - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "domain", - newDomain.schema ?? "", - ); - const creatorFilteredDefaults = - newDomain.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - // Filter out PUBLIC's built-in default USAGE privilege (PostgreSQL grants it automatically) - // Reference: https://www.postgresql.org/docs/17/ddl-priv.html Table 5.2 - // This prevents generating unnecessary "GRANT USAGE TO PUBLIC" statements - const desiredPrivileges = filterPublicBuiltInDefaults( - "domain", - newDomain.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the domain owner as the reference. - const privilegeResults = diffPrivileges( - filterPublicBuiltInDefaults("domain", creatorFilteredDefaults), - desiredPrivileges, - newDomain.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - newDomain, - newDomain, - "domain", - { - Grant: GrantDomainPrivileges, - Revoke: RevokeDomainPrivileges, - RevokeGrantOption: RevokeGrantOptionDomainPrivileges, - }, - ctx.version, - ) as DomainChange[]), - ); - } - - for (const domainId of dropped) { - changes.push(new DropDomain({ domain: main[domainId] })); - } - - for (const domainId of altered) { - const mainDomain = main[domainId]; - const branchDomain = branch[domainId]; - - // DEFAULT - if (mainDomain.default_value !== branchDomain.default_value) { - if (branchDomain.default_value === null) { - changes.push(new AlterDomainDropDefault({ domain: mainDomain })); - } else { - changes.push( - new AlterDomainSetDefault({ - domain: mainDomain, - defaultValue: branchDomain.default_value, - }), - ); - } - } - - // NOT NULL - if (mainDomain.not_null !== branchDomain.not_null) { - if (branchDomain.not_null) { - changes.push(new AlterDomainSetNotNull({ domain: mainDomain })); - } else { - changes.push(new AlterDomainDropNotNull({ domain: mainDomain })); - } - } - - // DOMAIN CONSTRAINTS - const mainByName = new Map(mainDomain.constraints.map((c) => [c.name, c])); - const branchByName = new Map( - branchDomain.constraints.map((c) => [c.name, c]), - ); - - // Note: Constraint renames are modeled as drop+add because name is part - // of the identity we diff on. No dedicated rename class is generated here. - - // Created - for (const [name, c] of branchByName) { - if (!mainByName.has(name)) { - changes.push( - new AlterDomainAddConstraint({ - domain: branchDomain, - constraint: c, - }), - ); - if (!c.validated) { - changes.push( - new AlterDomainValidateConstraint({ - domain: branchDomain, - constraint: c, - }), - ); - } - } - } - - // Dropped - for (const [name, c] of mainByName) { - if (!branchByName.has(name)) { - changes.push( - new AlterDomainDropConstraint({ - domain: mainDomain, - constraint: c, - }), - ); - } - } - - // Altered (drop + add for now) - for (const [name, mainC] of mainByName) { - const branchC = branchByName.get(name); - if (!branchC) continue; - const changed = - mainC.validated !== branchC.validated || - mainC.is_local !== branchC.is_local || - mainC.no_inherit !== branchC.no_inherit || - mainC.check_expression !== branchC.check_expression; - if (changed) { - changes.push( - new AlterDomainDropConstraint({ - domain: mainDomain, - constraint: mainC, - }), - ); - changes.push( - new AlterDomainAddConstraint({ - domain: branchDomain, - constraint: branchC, - }), - ); - if (!branchC.validated) { - changes.push( - new AlterDomainValidateConstraint({ - domain: branchDomain, - constraint: branchC, - }), - ); - } - } - } - - // OWNER - if (mainDomain.owner !== branchDomain.owner) { - changes.push( - new AlterDomainChangeOwner({ - domain: mainDomain, - owner: branchDomain.owner, - }), - ); - } - - // COMMENT - if (mainDomain.comment !== branchDomain.comment) { - if (branchDomain.comment === null) { - changes.push(new DropCommentOnDomain({ domain: mainDomain })); - } else { - changes.push(new CreateCommentOnDomain({ domain: branchDomain })); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnDomain | DropSecurityLabelOnDomain - >( - mainDomain.security_labels, - branchDomain.security_labels, - (securityLabel) => - new CreateSecurityLabelOnDomain({ - domain: branchDomain, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnDomain({ - domain: mainDomain, - securityLabel, - }), - ), - ); - - // PRIVILEGES - // Filter out PUBLIC's built-in default USAGE privilege from main catalog - // (PostgreSQL grants it automatically, so we shouldn't compare it) - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "domain", - mainDomain.privileges, - ); - // Filter out PUBLIC's built-in default USAGE privilege from branch catalog - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "domain", - branchDomain.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchDomain.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchDomain, - mainDomain, - "domain", - { - Grant: GrantDomainPrivileges, - Revoke: RevokeDomainPrivileges, - RevokeGrantOption: RevokeGrantOptionDomainPrivileges, - }, - ctx.version, - ) as DomainChange[]), - ); - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/domain/domain.model.ts b/packages/pg-delta/src/core/objects/domain/domain.model.ts deleted file mode 100644 index d928ed65a..000000000 --- a/packages/pg-delta/src/core/objects/domain/domain.model.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -const domainConstraintPropsSchema = z.object({ - name: z.string(), - validated: z.boolean(), - is_local: z.boolean(), - no_inherit: z.boolean(), - check_expression: z.string().nullable(), -}); - -const domainPropsSchema = z.object({ - schema: z.string(), - name: z.string(), - base_type: z.string(), - base_type_schema: z.string(), - base_type_str: z.string().optional(), - not_null: z.boolean(), - type_modifier: z.number().nullable(), - array_dimensions: z.number().nullable(), - collation: z.string().nullable(), - default_bin: z.string().nullable(), - default_value: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable(), - constraints: z.array(domainConstraintPropsSchema), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -export type DomainConstraintProps = z.infer; -type DomainPrivilegeProps = PrivilegeProps; -export type DomainProps = z.infer; - -/** - * A domain is a user-defined data type that is based on another underlying type. - * - * @see https://www.postgresql.org/docs/17/domains.html - */ -export class Domain extends BasePgModel { - public readonly schema: DomainProps["schema"]; - public readonly name: DomainProps["name"]; - public readonly base_type: DomainProps["base_type"]; - public readonly base_type_schema: DomainProps["base_type_schema"]; - public readonly base_type_str?: DomainProps["base_type_str"]; - public readonly not_null: DomainProps["not_null"]; - public readonly type_modifier: DomainProps["type_modifier"]; - public readonly array_dimensions: DomainProps["array_dimensions"]; - public readonly collation: DomainProps["collation"]; - public readonly default_bin: DomainProps["default_bin"]; - public readonly default_value: DomainProps["default_value"]; - public readonly owner: DomainProps["owner"]; - public readonly comment: DomainProps["comment"]; - public readonly constraints: DomainConstraintProps[]; - public readonly privileges: DomainPrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: DomainProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.base_type = props.base_type; - this.base_type_schema = props.base_type_schema; - this.base_type_str = props.base_type_str; - this.not_null = props.not_null; - this.type_modifier = props.type_modifier; - this.array_dimensions = props.array_dimensions; - this.collation = props.collation; - this.default_bin = props.default_bin; - this.default_value = props.default_value; - this.owner = props.owner; - this.comment = props.comment; - this.constraints = props.constraints; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `domain:${string}` { - return `domain:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - base_type: this.base_type, - base_type_schema: this.base_type_schema, - not_null: this.not_null, - type_modifier: this.type_modifier, - array_dimensions: this.array_dimensions, - collation: this.collation, - default_bin: this.default_bin, - default_value: this.default_value, - owner: this.owner, - comment: this.comment, - constraints: this.constraints, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } -} - -/** - * Extract all domains from the database. - * - * @param sql - The SQL client. - * @returns A list of domains. - */ -export async function extractDomains(pool: Pool): Promise { - const { rows: domainRows } = await pool.query(sql` - with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_type'::regclass - ) - select - t.typnamespace::regnamespace::text as schema, - quote_ident(t.typname) as name, - bt.typname as base_type, - bt.typnamespace::regnamespace::text as base_type_schema, - format_type(t.typbasetype, t.typtypmod) as base_type_str, - t.typnotnull as not_null, - t.typtypmod as type_modifier, - t.typndims as array_dimensions, - case when t.typcollation <> bt.typcollation then quote_ident(c.collname) else null end as collation, - pg_get_expr(t.typdefaultbin, 0) as default_bin, - t.typdefault as default_value, - t.typowner::regrole::text as owner, - obj_description(t.oid, 'pg_type') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'name', quote_ident(con.conname), - 'validated', con.convalidated, - 'is_local', con.conislocal, - 'no_inherit', con.connoinherit, - 'check_expression', pg_get_expr(con.conbin, 0) - ) - order by con.conname - ) - from pg_catalog.pg_constraint con - where con.contypid = t.oid - ), '[]' - ) as constraints, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(COALESCE(t.typacl, acldefault('T', t.typowner))) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = t.oid - and sl.classoid = 'pg_type'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels - from - pg_catalog.pg_type t - inner join pg_catalog.pg_type bt on bt.oid = t.typbasetype - left join pg_catalog.pg_collation c on c.oid = t.typcollation - left outer join extension_oids e on t.oid = e.objid - where not t.typnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null - and t.typtype = 'd' - order by - 1, 2 - `); - // Validate and parse each row using the Zod schema - const validatedRows = domainRows.map((row: unknown) => - domainPropsSchema.parse(row), - ); - return validatedRows.map((row: DomainProps) => new Domain(row)); -} diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.alter.test.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.alter.test.ts deleted file mode 100644 index bf60762b3..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.alter.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { EventTrigger } from "../event-trigger.model.ts"; -import { - AlterEventTriggerChangeOwner, - AlterEventTriggerSetEnabled, -} from "./event-trigger.alter.ts"; - -describe("event trigger alter change", () => { - const baseEventTrigger = new EventTrigger({ - name: "ddl_logger", - event: "ddl_command_start", - function_schema: "public", - function_name: "log_ddl", - enabled: "O", - tags: null, - owner: "postgres", - comment: null, - }); - - test("serialize owner change", async () => { - const change = new AlterEventTriggerChangeOwner({ - eventTrigger: baseEventTrigger, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER EVENT TRIGGER ddl_logger OWNER TO new_owner", - ); - }); - - test("serialize disable", async () => { - const change = new AlterEventTriggerSetEnabled({ - eventTrigger: baseEventTrigger, - enabled: "D", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("ALTER EVENT TRIGGER ddl_logger DISABLE"); - }); - - test("serialize enable always", async () => { - const change = new AlterEventTriggerSetEnabled({ - eventTrigger: baseEventTrigger, - enabled: "A", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER EVENT TRIGGER ddl_logger ENABLE ALWAYS", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.alter.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.alter.ts deleted file mode 100644 index 1c2a93ed3..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.alter.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { EventTrigger } from "../event-trigger.model.ts"; -import { AlterEventTriggerChange } from "./event-trigger.base.ts"; - -/** - * Alter an event trigger. - * - * @see https://www.postgresql.org/docs/17/sql-altereventtrigger.html - * - * Synopsis - * ```sql - * ALTER EVENT TRIGGER name DISABLE - * ALTER EVENT TRIGGER name ENABLE [ REPLICA | ALWAYS ] - * ALTER EVENT TRIGGER name OWNER TO { newowner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ALTER EVENT TRIGGER name RENAME TO newname - * ``` - */ - -export type AlterEventTrigger = - | AlterEventTriggerChangeOwner - | AlterEventTriggerSetEnabled; - -/** - * ALTER EVENT TRIGGER ... OWNER TO ... - */ -export class AlterEventTriggerChangeOwner extends AlterEventTriggerChange { - public readonly eventTrigger: EventTrigger; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { eventTrigger: EventTrigger; owner: string }) { - super(); - this.eventTrigger = props.eventTrigger; - this.owner = props.owner; - } - - get requires() { - return [this.eventTrigger.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER EVENT TRIGGER", - this.eventTrigger.name, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -const ENABLED_SQL = { - O: "ENABLE", - D: "DISABLE", - R: "ENABLE REPLICA", - A: "ENABLE ALWAYS", -} as const; - -/** - * ALTER EVENT TRIGGER ... ENABLE/DISABLE ... - */ -export class AlterEventTriggerSetEnabled extends AlterEventTriggerChange { - public readonly eventTrigger: EventTrigger; - public readonly enabled: EventTrigger["enabled"]; - public readonly scope = "object" as const; - - constructor(props: { - eventTrigger: EventTrigger; - enabled: EventTrigger["enabled"]; - }) { - super(); - this.eventTrigger = props.eventTrigger; - this.enabled = props.enabled; - } - - get requires() { - return [this.eventTrigger.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const clause = ENABLED_SQL[this.enabled]; - return ["ALTER EVENT TRIGGER", this.eventTrigger.name, clause].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.base.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.base.ts deleted file mode 100644 index ab3130faf..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { EventTrigger } from "../event-trigger.model.ts"; - -abstract class BaseEventTriggerChange extends BaseChange { - abstract readonly eventTrigger: EventTrigger; - abstract readonly scope: "object" | "comment" | "security_label"; - readonly objectType = "event_trigger" as const; -} - -export abstract class CreateEventTriggerChange extends BaseEventTriggerChange { - readonly operation = "create" as const; -} - -export abstract class AlterEventTriggerChange extends BaseEventTriggerChange { - readonly operation = "alter" as const; -} - -export abstract class DropEventTriggerChange extends BaseEventTriggerChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.comment.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.comment.ts deleted file mode 100644 index e03b22b59..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.comment.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { EventTrigger } from "../event-trigger.model.ts"; -import { - CreateEventTriggerChange, - DropEventTriggerChange, -} from "./event-trigger.base.ts"; - -export type CommentEventTrigger = - | CreateCommentOnEventTrigger - | DropCommentOnEventTrigger; - -export class CreateCommentOnEventTrigger extends CreateEventTriggerChange { - public readonly eventTrigger: EventTrigger; - public readonly scope = "comment" as const; - - constructor(props: { eventTrigger: EventTrigger }) { - super(); - this.eventTrigger = props.eventTrigger; - } - - get creates() { - return [stableId.comment(this.eventTrigger.stableId)]; - } - - get requires() { - return [this.eventTrigger.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON EVENT TRIGGER", - this.eventTrigger.name, - "IS", - // biome-ignore lint/style/noNonNullAssertion: comment creation implies non-null - quoteLiteral(this.eventTrigger.comment!), - ].join(" "); - } -} - -export class DropCommentOnEventTrigger extends DropEventTriggerChange { - public readonly eventTrigger: EventTrigger; - public readonly scope = "comment" as const; - - constructor(props: { eventTrigger: EventTrigger }) { - super(); - this.eventTrigger = props.eventTrigger; - } - - get drops() { - return [stableId.comment(this.eventTrigger.stableId)]; - } - - get requires() { - return [ - stableId.comment(this.eventTrigger.stableId), - this.eventTrigger.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return ["COMMENT ON EVENT TRIGGER", this.eventTrigger.name, "IS NULL"].join( - " ", - ); - } -} diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.create.test.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.create.test.ts deleted file mode 100644 index 6876e37a2..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.create.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { EventTrigger } from "../event-trigger.model.ts"; -import { CreateEventTrigger } from "./event-trigger.create.ts"; - -describe("event trigger create change", () => { - test("serialize create event trigger", async () => { - const eventTrigger = new EventTrigger({ - name: "ddl_logger", - event: "ddl_command_start", - function_schema: "public", - function_name: "log_ddl", - enabled: "O", - tags: ["CREATE TABLE", "ALTER TABLE"], - owner: "postgres", - comment: null, - }); - - const change = new CreateEventTrigger({ eventTrigger }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE EVENT TRIGGER ddl_logger ON ddl_command_start WHEN TAG IN ('CREATE TABLE', 'ALTER TABLE') EXECUTE FUNCTION public.log_ddl()", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.create.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.create.ts deleted file mode 100644 index d595d68fc..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.create.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { parseProcedureReference, stableId } from "../../utils.ts"; -import type { EventTrigger } from "../event-trigger.model.ts"; -import { CreateEventTriggerChange } from "./event-trigger.base.ts"; - -/** - * Create an event trigger. - * - * @see https://www.postgresql.org/docs/17/sql-createeventtrigger.html - * - * Synopsis - * ```sql - * CREATE EVENT TRIGGER name - * ON event - * [ WHEN TAG IN (tag [, ...]) [ AND ... ] ] - * EXECUTE { FUNCTION | PROCEDURE } function_name() - * ``` - */ -export class CreateEventTrigger extends CreateEventTriggerChange { - public readonly eventTrigger: EventTrigger; - public readonly scope = "object" as const; - - constructor(props: { eventTrigger: EventTrigger }) { - super(); - this.eventTrigger = props.eventTrigger; - } - - get creates() { - return [this.eventTrigger.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Owner dependency - dependencies.add(stableId.role(this.eventTrigger.owner)); - - // Function dependency - // Note: Event triggers call functions with no arguments, so we can build the stableId - const procRef = parseProcedureReference( - `${this.eventTrigger.function_schema}.${this.eventTrigger.function_name}()`, - ); - if (procRef) { - // Event trigger functions have no arguments, so stableId is procedure:schema.name() - dependencies.add(stableId.procedure(procRef.schema, procRef.name)); - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = [ - "CREATE EVENT TRIGGER", - this.eventTrigger.name, - "ON", - this.eventTrigger.event, - ]; - - const tags = this.eventTrigger.tags; - if (tags && tags.length > 0) { - const tagList = tags.map((tag) => quoteLiteral(tag)).join(", "); - parts.push("WHEN TAG IN", `(${tagList})`); - } - - parts.push( - "EXECUTE FUNCTION", - `${this.eventTrigger.function_schema}.${this.eventTrigger.function_name}()`, - ); - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.drop.test.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.drop.test.ts deleted file mode 100644 index d1f0ab989..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.drop.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { EventTrigger } from "../event-trigger.model.ts"; -import { DropEventTrigger } from "./event-trigger.drop.ts"; - -describe("event trigger drop change", () => { - test("serialize drop event trigger", async () => { - const eventTrigger = new EventTrigger({ - name: "ddl_logger", - event: "ddl_command_start", - function_schema: "public", - function_name: "log_ddl", - enabled: "O", - tags: null, - owner: "postgres", - comment: null, - }); - - const change = new DropEventTrigger({ eventTrigger }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP EVENT TRIGGER ddl_logger"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.drop.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.drop.ts deleted file mode 100644 index 490e0fb50..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { EventTrigger } from "../event-trigger.model.ts"; -import { DropEventTriggerChange } from "./event-trigger.base.ts"; - -/** - * Drop an event trigger. - * - * @see https://www.postgresql.org/docs/17/sql-dropeventtrigger.html - * - * Synopsis - * ```sql - * DROP EVENT TRIGGER [ IF EXISTS ] name [ CASCADE | RESTRICT ] - * ``` - */ -export class DropEventTrigger extends DropEventTriggerChange { - public readonly eventTrigger: EventTrigger; - public readonly scope = "object" as const; - - constructor(props: { eventTrigger: EventTrigger }) { - super(); - this.eventTrigger = props.eventTrigger; - } - - get drops() { - return [this.eventTrigger.stableId]; - } - - get requires() { - return [this.eventTrigger.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP EVENT TRIGGER", this.eventTrigger.name].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.security-label.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.security-label.ts deleted file mode 100644 index 00ecd16bf..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.security-label.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { EventTrigger } from "../event-trigger.model.ts"; -import { - CreateEventTriggerChange, - DropEventTriggerChange, -} from "./event-trigger.base.ts"; - -export type SecurityLabelEventTrigger = - | CreateSecurityLabelOnEventTrigger - | DropSecurityLabelOnEventTrigger; - -export class CreateSecurityLabelOnEventTrigger extends CreateEventTriggerChange { - public readonly eventTrigger: EventTrigger; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - eventTrigger: EventTrigger; - securityLabel: SecurityLabelProps; - }) { - super(); - this.eventTrigger = props.eventTrigger; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.eventTrigger.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.eventTrigger.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON EVENT TRIGGER", - this.eventTrigger.name, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnEventTrigger extends DropEventTriggerChange { - public readonly eventTrigger: EventTrigger; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - eventTrigger: EventTrigger; - securityLabel: SecurityLabelProps; - }) { - super(); - this.eventTrigger = props.eventTrigger; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.eventTrigger.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.eventTrigger.stableId, - this.securityLabel.provider, - ), - this.eventTrigger.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON EVENT TRIGGER", - this.eventTrigger.name, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.types.ts b/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.types.ts deleted file mode 100644 index 216a60196..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/changes/event-trigger.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { AlterEventTrigger } from "./event-trigger.alter.ts"; -import type { CommentEventTrigger } from "./event-trigger.comment.ts"; -import type { CreateEventTrigger } from "./event-trigger.create.ts"; -import type { DropEventTrigger } from "./event-trigger.drop.ts"; -import type { SecurityLabelEventTrigger } from "./event-trigger.security-label.ts"; - -/** Union of all event-trigger-related change variants (`objectType: "event_trigger"`). @category Change Types */ -export type EventTriggerChange = - | AlterEventTrigger - | CommentEventTrigger - | CreateEventTrigger - | DropEventTrigger - | SecurityLabelEventTrigger; diff --git a/packages/pg-delta/src/core/objects/event-trigger/event-trigger.diff.test.ts b/packages/pg-delta/src/core/objects/event-trigger/event-trigger.diff.test.ts deleted file mode 100644 index 47ce1ba4b..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/event-trigger.diff.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { - AlterEventTriggerChangeOwner, - AlterEventTriggerSetEnabled, -} from "./changes/event-trigger.alter.ts"; -import { - CreateCommentOnEventTrigger, - DropCommentOnEventTrigger, -} from "./changes/event-trigger.comment.ts"; -import { CreateEventTrigger } from "./changes/event-trigger.create.ts"; -import { DropEventTrigger } from "./changes/event-trigger.drop.ts"; -import { diffEventTriggers } from "./event-trigger.diff.ts"; -import { EventTrigger, type EventTriggerProps } from "./event-trigger.model.ts"; - -const ctx: Pick = { - currentUser: "postgres", -}; - -const base: EventTriggerProps = { - name: "ddl_logger", - event: "ddl_command_start", - function_schema: "public", - function_name: "log_ddl", - enabled: "O", - tags: ["CREATE TABLE"], - owner: "postgres", - comment: null, -}; - -describe.concurrent("event-trigger.diff", () => { - test("create and drop event trigger", () => { - const eventTrigger = new EventTrigger(base); - - const created = diffEventTriggers( - ctx, - {}, - { [eventTrigger.stableId]: eventTrigger }, - ); - expect(created).toHaveLength(1); - expect(created[0]).toBeInstanceOf(CreateEventTrigger); - - const dropped = diffEventTriggers( - ctx, - { [eventTrigger.stableId]: eventTrigger }, - {}, - ); - expect(dropped).toHaveLength(1); - expect(dropped[0]).toBeInstanceOf(DropEventTrigger); - }); - - test("replace when non-alterable fields change", () => { - const mainEventTrigger = new EventTrigger(base); - const branchEventTrigger = new EventTrigger({ - ...base, - function_name: "log_ddl_v2", - }); - - const changes = diffEventTriggers( - ctx, - { [mainEventTrigger.stableId]: mainEventTrigger }, - { [branchEventTrigger.stableId]: branchEventTrigger }, - ); - - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropEventTrigger); - expect(changes[1]).toBeInstanceOf(CreateEventTrigger); - }); - - test("alter enabled state", () => { - const mainEventTrigger = new EventTrigger(base); - const branchEventTrigger = new EventTrigger({ - ...base, - enabled: "D", - }); - - const changes = diffEventTriggers( - ctx, - { [mainEventTrigger.stableId]: mainEventTrigger }, - { [branchEventTrigger.stableId]: branchEventTrigger }, - ); - - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(AlterEventTriggerSetEnabled); - }); - - test("alter owner", () => { - const mainEventTrigger = new EventTrigger(base); - const branchEventTrigger = new EventTrigger({ - ...base, - owner: "new_owner", - }); - - const changes = diffEventTriggers( - ctx, - { [mainEventTrigger.stableId]: mainEventTrigger }, - { [branchEventTrigger.stableId]: branchEventTrigger }, - ); - - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(AlterEventTriggerChangeOwner); - }); - - test("comment changes", () => { - const mainEventTrigger = new EventTrigger(base); - const branchWithComment = new EventTrigger({ - ...base, - comment: "logs ddl commands", - }); - const branchWithoutComment = new EventTrigger({ - ...base, - comment: null, - }); - - const createCommentChanges = diffEventTriggers( - ctx, - { [mainEventTrigger.stableId]: mainEventTrigger }, - { [branchWithComment.stableId]: branchWithComment }, - ); - expect(createCommentChanges).toHaveLength(1); - expect(createCommentChanges[0]).toBeInstanceOf(CreateCommentOnEventTrigger); - - const dropCommentChanges = diffEventTriggers( - ctx, - { [branchWithComment.stableId]: branchWithComment }, - { [branchWithoutComment.stableId]: branchWithoutComment }, - ); - expect(dropCommentChanges).toHaveLength(1); - expect(dropCommentChanges[0]).toBeInstanceOf(DropCommentOnEventTrigger); - }); -}); diff --git a/packages/pg-delta/src/core/objects/event-trigger/event-trigger.diff.ts b/packages/pg-delta/src/core/objects/event-trigger/event-trigger.diff.ts deleted file mode 100644 index 024bbbb2f..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/event-trigger.diff.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { deepEqual, hasNonAlterableChanges } from "../utils.ts"; -import { - AlterEventTriggerChangeOwner, - AlterEventTriggerSetEnabled, -} from "./changes/event-trigger.alter.ts"; -import { - CreateCommentOnEventTrigger, - DropCommentOnEventTrigger, -} from "./changes/event-trigger.comment.ts"; -import { CreateEventTrigger } from "./changes/event-trigger.create.ts"; -import { DropEventTrigger } from "./changes/event-trigger.drop.ts"; -import { - CreateSecurityLabelOnEventTrigger, - DropSecurityLabelOnEventTrigger, -} from "./changes/event-trigger.security-label.ts"; -import type { EventTriggerChange } from "./changes/event-trigger.types.ts"; -import type { EventTrigger } from "./event-trigger.model.ts"; - -/** - * Diff two sets of event triggers from main and branch catalogs. - * - * @param ctx - Context containing currentUser - * @param main - The event triggers in the main catalog. - * @param branch - The event triggers in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffEventTriggers( - ctx: Pick, - main: Record, - branch: Record, -): EventTriggerChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: EventTriggerChange[] = []; - - for (const eventTriggerId of created) { - const eventTrigger = branch[eventTriggerId]; - changes.push(new CreateEventTrigger({ eventTrigger })); - - // OWNER: If the event trigger should be owned by someone other than the current user, - // emit ALTER EVENT TRIGGER ... OWNER TO after creation - if (eventTrigger.owner !== ctx.currentUser) { - changes.push( - new AlterEventTriggerChangeOwner({ - eventTrigger, - owner: eventTrigger.owner, - }), - ); - } - - if (eventTrigger.comment !== null) { - changes.push(new CreateCommentOnEventTrigger({ eventTrigger })); - } - for (const label of eventTrigger.security_labels) { - changes.push( - new CreateSecurityLabelOnEventTrigger({ - eventTrigger, - securityLabel: label, - }), - ); - } - } - - for (const eventTriggerId of dropped) { - changes.push(new DropEventTrigger({ eventTrigger: main[eventTriggerId] })); - } - - for (const eventTriggerId of altered) { - const mainEventTrigger = main[eventTriggerId]; - const branchEventTrigger = branch[eventTriggerId]; - - const NON_ALTERABLE_FIELDS: Array = [ - "event", - "function_schema", - "function_name", - "tags", - ]; - - const shouldReplace = hasNonAlterableChanges( - mainEventTrigger, - branchEventTrigger, - NON_ALTERABLE_FIELDS, - { tags: deepEqual }, - ); - - if (shouldReplace) { - changes.push( - new DropEventTrigger({ eventTrigger: mainEventTrigger }), - new CreateEventTrigger({ eventTrigger: branchEventTrigger }), - ); - if (branchEventTrigger.comment !== null) { - changes.push( - new CreateCommentOnEventTrigger({ - eventTrigger: branchEventTrigger, - }), - ); - } - continue; - } - - if (mainEventTrigger.enabled !== branchEventTrigger.enabled) { - changes.push( - new AlterEventTriggerSetEnabled({ - eventTrigger: mainEventTrigger, - enabled: branchEventTrigger.enabled, - }), - ); - } - - if (mainEventTrigger.owner !== branchEventTrigger.owner) { - changes.push( - new AlterEventTriggerChangeOwner({ - eventTrigger: mainEventTrigger, - owner: branchEventTrigger.owner, - }), - ); - } - - if (mainEventTrigger.comment !== branchEventTrigger.comment) { - if (branchEventTrigger.comment === null) { - changes.push( - new DropCommentOnEventTrigger({ - eventTrigger: mainEventTrigger, - }), - ); - } else { - changes.push( - new CreateCommentOnEventTrigger({ - eventTrigger: branchEventTrigger, - }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnEventTrigger | DropSecurityLabelOnEventTrigger - >( - mainEventTrigger.security_labels, - branchEventTrigger.security_labels, - (securityLabel) => - new CreateSecurityLabelOnEventTrigger({ - eventTrigger: branchEventTrigger, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnEventTrigger({ - eventTrigger: mainEventTrigger, - securityLabel, - }), - ), - ); - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/event-trigger/event-trigger.model.ts b/packages/pg-delta/src/core/objects/event-trigger/event-trigger.model.ts deleted file mode 100644 index c4c2021d3..000000000 --- a/packages/pg-delta/src/core/objects/event-trigger/event-trigger.model.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -const EventTriggerEnabledSchema = z.enum([ - "O", // ORIGIN - trigger fires in origin mode - "D", // DISABLED - trigger does not fire - "R", // REPLICA - trigger fires only in replica session - "A", // ALWAYS - trigger fires regardless of replication mode -]); - -const eventTriggerPropsSchema = z.object({ - name: z.string(), - event: z.string(), - function_schema: z.string(), - function_name: z.string(), - enabled: EventTriggerEnabledSchema, - tags: z.array(z.string()).nullable(), - owner: z.string(), - comment: z.string().nullable(), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -export type EventTriggerProps = z.infer; - -export class EventTrigger extends BasePgModel { - public readonly name: EventTriggerProps["name"]; - public readonly event: EventTriggerProps["event"]; - public readonly function_schema: EventTriggerProps["function_schema"]; - public readonly function_name: EventTriggerProps["function_name"]; - public readonly enabled: EventTriggerProps["enabled"]; - public readonly tags: EventTriggerProps["tags"]; - public readonly owner: EventTriggerProps["owner"]; - public readonly comment: EventTriggerProps["comment"]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: EventTriggerProps) { - super(); - - // Identity fields - this.name = props.name; - - // Data fields - this.event = props.event; - this.function_schema = props.function_schema; - this.function_name = props.function_name; - this.enabled = props.enabled; - this.tags = props.tags; - this.owner = props.owner; - this.comment = props.comment; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `eventTrigger:${string}` { - return `eventTrigger:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - return { - event: this.event, - function_schema: this.function_schema, - function_name: this.function_name, - enabled: this.enabled, - tags: this.tags, - owner: this.owner, - comment: this.comment, - security_labels: this.security_labels, - }; - } -} - -export async function extractEventTriggers( - pool: Pool, -): Promise { - const { rows } = await pool.query(sql` -with extension_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_event_trigger'::regclass -) -select - quote_ident(et.evtname) as name, - et.evtevent as event, - p.pronamespace::regnamespace::text as function_schema, - quote_ident(p.proname) as function_name, - et.evtenabled as enabled, - et.evttags as tags, - et.evtowner::regrole::text as owner, - obj_description(et.oid, 'pg_event_trigger') as comment, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = et.oid - and sl.classoid = 'pg_event_trigger'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from pg_catalog.pg_event_trigger et -join pg_catalog.pg_proc p on p.oid = et.evtfoid -left join extension_oids e on e.objid = et.oid -where e.objid is null -order by 1 - `); - - const validatedRows = rows.map((row: unknown) => - eventTriggerPropsSchema.parse(row), - ); - - return validatedRows.map((row: EventTriggerProps) => new EventTrigger(row)); -} diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.alter.test.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.alter.test.ts deleted file mode 100644 index fa66118be..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.alter.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Extension, type ExtensionProps } from "../extension.model.ts"; -import { - AlterExtensionSetSchema, - AlterExtensionUpdateVersion, -} from "./extension.alter.ts"; - -describe.concurrent("extension", () => { - describe("alter", () => { - test("update version", async () => { - const props: Omit = { - name: "test_extension", - schema: "public", - relocatable: true, - owner: "test", - comment: null, - members: [], - }; - const extension = new Extension({ - ...props, - version: "1.0", - }); - - const change = new AlterExtensionUpdateVersion({ - extension, - version: "2.0", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER EXTENSION test_extension UPDATE TO '2.0'", - ); - }); - - test("set schema", async () => { - const props: Omit = { - name: "test_extension", - relocatable: true, - version: "1.0", - owner: "test", - comment: null, - members: [], - }; - const extension = new Extension({ - ...props, - schema: "public", - }); - - const change = new AlterExtensionSetSchema({ - extension, - schema: "extensions", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER EXTENSION test_extension SET SCHEMA extensions", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.alter.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.alter.ts deleted file mode 100644 index 085779fa5..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.alter.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Extension } from "../extension.model.ts"; -import { AlterExtensionChange } from "./extension.base.ts"; - -/** - * Alter an extension. - * - * @see https://www.postgresql.org/docs/17/sql-alterextension.html - * - * Synopsis - * ```sql - * ALTER EXTENSION name UPDATE [ TO new_version ] - * ALTER EXTENSION name SET SCHEMA new_schema - * ALTER EXTENSION name ADD member_object - * ALTER EXTENSION name DROP member_object - * ``` - */ - -export type AlterExtension = - | AlterExtensionSetSchema - | AlterExtensionUpdateVersion; - -/** - * ALTER EXTENSION ... UPDATE TO ... - */ -export class AlterExtensionUpdateVersion extends AlterExtensionChange { - public readonly extension: Extension; - public readonly version: string; - public readonly scope = "object" as const; - - constructor(props: { extension: Extension; version: string }) { - super(); - this.extension = props.extension; - this.version = props.version; - } - - get requires() { - return [this.extension.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER EXTENSION", - this.extension.name, - "UPDATE TO", - quoteLiteral(this.version), - ].join(" "); - } -} - -/** - * ALTER EXTENSION ... SET SCHEMA ... - */ -export class AlterExtensionSetSchema extends AlterExtensionChange { - public readonly extension: Extension; - public readonly schema: string; - public readonly scope = "object" as const; - - constructor(props: { extension: Extension; schema: string }) { - super(); - this.extension = props.extension; - this.schema = props.schema; - } - - get requires() { - return [this.extension.stableId, stableId.schema(this.schema)]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER EXTENSION", - this.extension.name, - "SET SCHEMA", - this.schema, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.base.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.base.ts deleted file mode 100644 index 178d48004..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Extension } from "../extension.model.ts"; - -abstract class BaseExtensionChange extends BaseChange { - abstract readonly extension: Extension; - abstract readonly scope: "object" | "comment"; - readonly objectType = "extension" as const; -} - -export abstract class CreateExtensionChange extends BaseExtensionChange { - readonly operation = "create" as const; -} - -export abstract class AlterExtensionChange extends BaseExtensionChange { - readonly operation = "alter" as const; -} - -export abstract class DropExtensionChange extends BaseExtensionChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.comment.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.comment.ts deleted file mode 100644 index 2b1f605da..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.comment.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Extension } from "../extension.model.ts"; -import { - CreateExtensionChange, - DropExtensionChange, -} from "./extension.base.ts"; - -export type CommentExtension = - | CreateCommentOnExtension - | DropCommentOnExtension; - -/** - * Create/drop comments on extensions. - */ -export class CreateCommentOnExtension extends CreateExtensionChange { - public readonly extension: Extension; - public readonly scope = "comment" as const; - - constructor(props: { extension: Extension }) { - super(); - this.extension = props.extension; - } - - get creates() { - return [stableId.comment(this.extension.stableId)]; - } - - get requires() { - return [this.extension.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON EXTENSION", - this.extension.name, - "IS", - // biome-ignore lint/style/noNonNullAssertion: extension comment is not nullable here - quoteLiteral(this.extension.comment!), - ].join(" "); - } -} - -export class DropCommentOnExtension extends DropExtensionChange { - public readonly extension: Extension; - public readonly scope = "comment" as const; - - constructor(props: { extension: Extension }) { - super(); - this.extension = props.extension; - } - - get drops() { - return [stableId.comment(this.extension.stableId)]; - } - - get requires() { - return [stableId.comment(this.extension.stableId), this.extension.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["COMMENT ON EXTENSION", this.extension.name, "IS NULL"].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.create.test.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.create.test.ts deleted file mode 100644 index 073d40788..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.create.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Extension } from "../extension.model.ts"; -import { CreateExtension } from "./extension.create.ts"; - -describe("extension", () => { - test("create", async () => { - const extension = new Extension({ - name: "test_extension", - schema: "public", - relocatable: true, - version: "1.0", - owner: "test", - comment: null, - members: [], - }); - - const change = new CreateExtension({ - extension, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - `CREATE EXTENSION test_extension WITH SCHEMA public`, - ); - }); - - test("create with skipSchema omits WITH SCHEMA", async () => { - const extension = new Extension({ - name: "test_extension", - schema: "public", - relocatable: true, - version: "1.0", - owner: "test", - comment: null, - members: [], - }); - - const change = new CreateExtension({ - extension, - }); - - await assertValidSql(change.serialize({ skipSchema: true })); - - expect(change.serialize({ skipSchema: true })).toBe( - `CREATE EXTENSION test_extension`, - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.create.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.create.ts deleted file mode 100644 index 0af0fa2cb..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.create.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ExtensionSerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Extension } from "../extension.model.ts"; -import { CreateExtensionChange } from "./extension.base.ts"; - -/** - * Create an extension. - * - * @see https://www.postgresql.org/docs/17/sql-createextension.html - * - * Synopsis - * ```sql - * CREATE EXTENSION [ IF NOT EXISTS ] extension_name - * [ WITH ] [ SCHEMA schema_name ] - * [ VERSION version ] - * [ CASCADE ] - * ``` - */ -export class CreateExtension extends CreateExtensionChange { - public readonly extension: Extension; - public readonly scope = "object" as const; - - constructor(props: { extension: Extension }) { - super(); - this.extension = props.extension; - } - - get creates() { - return [this.extension.stableId, ...this.extension.members]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.extension.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.extension.owner)); - - return Array.from(dependencies); - } - - serialize(options?: ExtensionSerializeOptions): string { - const parts: string[] = ["CREATE EXTENSION"]; - - // Add extension name - parts.push(this.extension.name); - - // Add schema - if (!options?.skipSchema) { - parts.push("WITH SCHEMA", this.extension.schema); - } - - // Add version - // TODO: Omit version for now as versions can differ between main and branch - // if (this.extension.version) { - // parts.push("VERSION", quoteLiteral(this.extension.version)); - // } - - // TODO: Add CASCADE if the extension has dependencies - // parts.push("CASCADE"); - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.drop.test.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.drop.test.ts deleted file mode 100644 index 07e7579fc..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.drop.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Extension } from "../extension.model.ts"; -import { DropExtension } from "./extension.drop.ts"; - -describe("extension", () => { - test("drop", async () => { - const extension = new Extension({ - name: "test_extension", - schema: "public", - relocatable: true, - version: "1.0", - owner: "test", - comment: null, - members: [], - }); - - const change = new DropExtension({ - extension, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP EXTENSION test_extension"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.drop.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.drop.ts deleted file mode 100644 index f6beaac90..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Extension } from "../extension.model.ts"; -import { DropExtensionChange } from "./extension.base.ts"; - -/** - * Drop an extension. - * - * @see https://www.postgresql.org/docs/17/sql-dropextension.html - * - * Synopsis - * ```sql - * DROP EXTENSION [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropExtension extends DropExtensionChange { - public readonly extension: Extension; - public readonly scope = "object" as const; - - constructor(props: { extension: Extension }) { - super(); - this.extension = props.extension; - } - - get drops() { - return [this.extension.stableId, ...this.extension.members]; - } - - get requires() { - return [this.extension.stableId, ...this.extension.members]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP EXTENSION", this.extension.name].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/extension/changes/extension.types.ts b/packages/pg-delta/src/core/objects/extension/changes/extension.types.ts deleted file mode 100644 index 87ef67b1c..000000000 --- a/packages/pg-delta/src/core/objects/extension/changes/extension.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AlterExtension } from "./extension.alter.ts"; -import type { CommentExtension } from "./extension.comment.ts"; -import type { CreateExtension } from "./extension.create.ts"; -import type { DropExtension } from "./extension.drop.ts"; - -/** Union of all extension-related change variants (`objectType: "extension"`). @category Change Types */ -export type ExtensionChange = - | AlterExtension - | CommentExtension - | CreateExtension - | DropExtension; diff --git a/packages/pg-delta/src/core/objects/extension/extension.diff.test.ts b/packages/pg-delta/src/core/objects/extension/extension.diff.test.ts deleted file mode 100644 index 36dc7ae97..000000000 --- a/packages/pg-delta/src/core/objects/extension/extension.diff.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { AlterExtensionSetSchema } from "./changes/extension.alter.ts"; -import { CreateExtension } from "./changes/extension.create.ts"; -import { DropExtension } from "./changes/extension.drop.ts"; -import { diffExtensions } from "./extension.diff.ts"; -import { Extension, type ExtensionProps } from "./extension.model.ts"; - -const base: ExtensionProps = { - name: "pgcrypto", - schema: "public", - relocatable: true, - version: "1.0", - owner: "o1", - comment: null, - members: [], -}; - -describe.concurrent("extension.diff", () => { - test("create and drop", () => { - const e = new Extension(base); - const created = diffExtensions({}, { [e.stableId]: e }); - expect(created[0]).toBeInstanceOf(CreateExtension); - const dropped = diffExtensions({ [e.stableId]: e }, {}); - expect(dropped[0]).toBeInstanceOf(DropExtension); - }); - - test("alter: version, schema", () => { - const main = new Extension(base); - const branch = new Extension({ - ...base, - version: "1.1", - schema: "utils", - }); - const changes = diffExtensions( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterExtensionSetSchema)).toBe( - true, - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/extension/extension.diff.ts b/packages/pg-delta/src/core/objects/extension/extension.diff.ts deleted file mode 100644 index 71e794c91..000000000 --- a/packages/pg-delta/src/core/objects/extension/extension.diff.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { AlterExtensionSetSchema } from "./changes/extension.alter.ts"; -import { - CreateCommentOnExtension, - DropCommentOnExtension, -} from "./changes/extension.comment.ts"; -import { CreateExtension } from "./changes/extension.create.ts"; -import { DropExtension } from "./changes/extension.drop.ts"; -import type { ExtensionChange } from "./changes/extension.types.ts"; -import type { Extension } from "./extension.model.ts"; - -/** - * Diff two sets of extensions from main and branch catalogs. - * - * @param main - The extensions in the main catalog. - * @param branch - The extensions in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffExtensions( - main: Record, - branch: Record, -): ExtensionChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: ExtensionChange[] = []; - - for (const extensionId of created) { - const ext = branch[extensionId]; - changes.push(new CreateExtension({ extension: ext })); - if (ext.comment !== null) { - changes.push(new CreateCommentOnExtension({ extension: ext })); - } - } - - for (const extensionId of dropped) { - changes.push(new DropExtension({ extension: main[extensionId] })); - } - - for (const extensionId of altered) { - const mainExtension = main[extensionId]; - const branchExtension = branch[extensionId]; - - const schemaChanged = mainExtension.schema !== branchExtension.schema; - if (schemaChanged && !mainExtension.relocatable) { - // Cannot ALTER schema if not relocatable: must replace - changes.push( - new DropExtension({ extension: mainExtension }), - new CreateExtension({ extension: branchExtension }), - ); - continue; - } - - // VERSION - // TODO: Omit version for now as versions can differ between main and branch - // if (mainExtension.version !== branchExtension.version) { - // changes.push( - // new AlterExtensionUpdateVersion({ - // extension: mainExtension, - // version: branchExtension.version, - // }), - // ); - // } - - // SCHEMA (only if relocatable) - if (schemaChanged && mainExtension.relocatable) { - changes.push( - new AlterExtensionSetSchema({ - extension: mainExtension, - schema: branchExtension.schema, - }), - ); - } - - // COMMENT - if (mainExtension.comment !== branchExtension.comment) { - if (branchExtension.comment === null) { - changes.push(new DropCommentOnExtension({ extension: mainExtension })); - } else { - changes.push( - new CreateCommentOnExtension({ extension: branchExtension }), - ); - } - } - - // Note: Extension renaming would also use ALTER EXTENSION ... RENAME TO ... - // Name is identity; renames are handled as drop + create by diffObjects() - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/extension/extension.model.test.ts b/packages/pg-delta/src/core/objects/extension/extension.model.test.ts deleted file mode 100644 index d86fd9261..000000000 --- a/packages/pg-delta/src/core/objects/extension/extension.model.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Pool } from "pg"; -import { Extension, extractExtensions } from "./extension.model.ts"; - -describe("Extension", () => { - test("stableId returns extension:", () => { - const ext = new Extension({ - name: "plpgsql", - schema: "pg_catalog", - relocatable: false, - version: "1.0", - owner: "postgres", - comment: null, - members: [], - }); - expect(ext.stableId).toBe("extension:plpgsql"); - }); - - test("identityFields returns name", () => { - const ext = new Extension({ - name: "vector", - schema: "extensions", - relocatable: true, - version: "0.7.0", - owner: "postgres", - comment: "vector type", - members: ["type:extensions.vector"], - }); - expect(ext.identityFields).toEqual({ name: "vector" }); - }); - - test("dataFields returns schema, relocatable, version, owner, comment", () => { - const ext = new Extension({ - name: "pgcrypto", - schema: "public", - relocatable: false, - version: "1.3", - owner: "postgres", - comment: "cryptographic functions", - members: ["procedure:public.gen_random_uuid()"], - }); - expect(ext.dataFields).toEqual({ - schema: "public", - relocatable: false, - version: "1.3", - owner: "postgres", - comment: "cryptographic functions", - }); - }); -}); - -describe("extractExtensions", () => { - test("returns empty array when pool returns no rows", async () => { - const pool = { - query: async () => ({ rows: [] }), - } as unknown as Pool; - const result = await extractExtensions(pool); - expect(result).toEqual([]); - }); - - test("returns Extension instances for valid rows", async () => { - const pool = { - query: async () => ({ - rows: [ - { - name: "plpgsql", - schema: "pg_catalog", - relocatable: false, - version: "1.0", - owner: "postgres", - comment: null, - members: [], - }, - { - name: "vector", - schema: "extensions", - relocatable: true, - version: "0.7.0", - owner: "postgres", - comment: null, - members: ["type:extensions.vector", "table:extensions.vector"], - }, - ], - }), - } as unknown as Pool; - const result = await extractExtensions(pool); - expect(result).toHaveLength(2); - expect(result[0]).toBeInstanceOf(Extension); - expect(result[0].name).toBe("plpgsql"); - expect(result[0].stableId).toBe("extension:plpgsql"); - expect(result[0].members).toEqual([]); - expect(result[1].name).toBe("vector"); - expect(result[1].members).toEqual([ - "type:extensions.vector", - "table:extensions.vector", - ]); - }); -}); diff --git a/packages/pg-delta/src/core/objects/extension/extension.model.ts b/packages/pg-delta/src/core/objects/extension/extension.model.ts deleted file mode 100644 index b68f8f1b7..000000000 --- a/packages/pg-delta/src/core/objects/extension/extension.model.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; - -/** - * All properties exposed by CREATE EXTENSION statement are included in diff output. - * https://www.postgresql.org/docs/current/sql-createextension.html - * - * ALTER EXTENSION statement can be generated for changes to the following properties: - * - version (limited to available ones), schema (only if relocatable) - * https://www.postgresql.org/docs/current/sql-alterextension.html - * - * Adding or dropping member objects are not supported. For eg. pgmq allows detaching - * user defined queues by removing its entry from pg_depend. If the detached table - * lives in an excluded schema like pg_catalog, it will not be diffed. - * - * The extension's configuration tables are not diffed. - * - extconfig, extcondition - * https://www.postgresql.org/docs/current/catalog-pg-extension.html - */ -const extensionPropsSchema = z.object({ - name: z.string(), - schema: z.string(), - relocatable: z.boolean(), - version: z.string(), - owner: z.string(), - comment: z.string().nullable(), - members: z.array(z.string()), -}); - -export type ExtensionProps = z.infer; - -export class Extension extends BasePgModel { - public readonly name: ExtensionProps["name"]; - public readonly schema: ExtensionProps["schema"]; - public readonly relocatable: ExtensionProps["relocatable"]; - public readonly version: ExtensionProps["version"]; - public readonly owner: ExtensionProps["owner"]; - public readonly comment: ExtensionProps["comment"]; - public readonly members: ExtensionProps["members"]; - - constructor(props: ExtensionProps) { - super(); - - // Identity fields - this.name = props.name; - - // Data fields - this.schema = props.schema; - this.relocatable = props.relocatable; - this.version = props.version; - this.owner = props.owner; - this.comment = props.comment; - this.members = props.members; - } - - get stableId(): `extension:${string}` { - // Extension names are unique per database; schema is relocatable - return `extension:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - return { - schema: this.schema, - relocatable: this.relocatable, - version: this.version, - owner: this.owner, - comment: this.comment, - }; - } -} - -// TODO: fetch extension dependencies so we can determine when to use CASCADE on creation -export async function extractExtensions(pool: Pool): Promise { - const { rows: extensionRows } = await pool.query(sql` - with extension_rows as ( - select - e.oid, - quote_ident(e.extname) as name, - e.extnamespace::regnamespace::text as schema, - e.extrelocatable as relocatable, - e.extversion as version, - e.extowner::regrole::text as owner, - obj_description(e.oid, 'pg_extension') as comment - from - pg_catalog.pg_extension e - ), extension_members_raw as ( - select - er.oid as extension_oid, - d.classid, - d.objid, - d.objsubid - from - extension_rows er - join pg_depend d on d.refclassid = 'pg_extension'::regclass - and d.refobjid = er.oid - where - d.deptype = 'e' - ), ids as ( - select distinct - classid, - objid, - coalesce(nullif(objsubid, 0), 0)::int2 as objsubid - from extension_members_raw - ), objects as ( - select 'pg_namespace'::regclass as classid, n.oid as objid, 0::int2 as objsubid, - format('schema:%I', n.nspname) as stable_id - from pg_namespace n - join ids i on i.classid = 'pg_namespace'::regclass and i.objid = n.oid and i.objsubid = 0 - - union all - - select 'pg_class'::regclass, c.oid, 0::int2, - case c.relkind - when 'r' then format('table:%I.%I', ns.nspname, c.relname) - when 'p' then format('table:%I.%I', ns.nspname, c.relname) - when 'v' then format('view:%I.%I', ns.nspname, c.relname) - when 'm' then format('materializedView:%I.%I', ns.nspname, c.relname) - when 'S' then format('sequence:%I.%I', ns.nspname, c.relname) - when 'i' then format('index:%I.%I.%I', ns.nspname, tbl.relname, c.relname) - when 'c' then format('type:%I.%I', ns.nspname, c.relname) - else format('unknown:%s.%s', 'pg_class', c.oid::text) - end as stable_id - from pg_class c - join pg_namespace ns on ns.oid = c.relnamespace - left join pg_index idx on idx.indexrelid = c.oid - left join pg_class tbl on tbl.oid = idx.indrelid - join ids i on i.classid = 'pg_class'::regclass and i.objid = c.oid and i.objsubid = 0 - - union all - - select 'pg_class'::regclass, a.attrelid, a.attnum, - format('column:%I.%I.%I', ns.nspname, c.relname, a.attname) as stable_id - from pg_attribute a - join pg_class c on c.oid = a.attrelid - join pg_namespace ns on ns.oid = c.relnamespace - join ids i on i.classid = 'pg_class'::regclass and i.objid = a.attrelid and i.objsubid = a.attnum - where a.attnum > 0 and not a.attisdropped - - union all - - select 'pg_type'::regclass, t.oid, 0::int2, - case t.typtype - when 'd' then format('domain:%I.%I', ns.nspname, t.typname) - when 'e' then format('type:%I.%I', ns.nspname, t.typname) - when 'r' then format('type:%I.%I', ns.nspname, t.typname) - when 'm' then format('multirange:%I.%I', ns.nspname, t.typname) - when 'c' then - case - when r.oid is not null and r.relkind in ('r','p','f') then format('table:%I.%I', rns.nspname, r.relname) - when r.oid is not null and r.relkind = 'v' then format('view:%I.%I', rns.nspname, r.relname) - when r.oid is not null and r.relkind = 'm' then format('materializedView:%I.%I', rns.nspname, r.relname) - else format('type:%I.%I', ns.nspname, t.typname) - end - when 'p' then format('pseudoType:%I.%I', ns.nspname, t.typname) - else format('type:%I.%I', ns.nspname, t.typname) - end as stable_id - from pg_type t - join pg_namespace ns on ns.oid = t.typnamespace - left join pg_class r on r.oid = t.typrelid - left join pg_namespace rns on rns.oid = r.relnamespace - join ids i on i.classid = 'pg_type'::regclass and i.objid = t.oid and i.objsubid = 0 - - union all - - select 'pg_constraint'::regclass, c.oid, 0::int2, - case - when c.contypid <> 0 then format('constraint:%I.%I.%I', ns.nspname, ty.typname, c.conname) - when c.conrelid <> 0 then format('constraint:%I.%I.%I', tbl_ns.nspname, tbl.relname, c.conname) - else format('constraint:%s', c.oid::text) - end as stable_id - from pg_constraint c - left join pg_type ty on ty.oid = c.contypid - left join pg_namespace ns on ns.oid = ty.typnamespace - left join pg_class tbl on tbl.oid = c.conrelid - left join pg_namespace tbl_ns on tbl_ns.oid = tbl.relnamespace - join ids i on i.classid = 'pg_constraint'::regclass and i.objid = c.oid and i.objsubid = 0 - - union all - - select 'pg_proc'::regclass, p.oid, 0::int2, - format( - 'procedure:%I.%I(%s)', - ns.nspname, - p.proname, - coalesce((select string_agg(format_type(oid, null), ',' order by ord) - from unnest(p.proargtypes) with ordinality as t(oid, ord)), '') - ) as stable_id - from pg_proc p - join pg_namespace ns on ns.oid = p.pronamespace - join ids i on i.classid = 'pg_proc'::regclass and i.objid = p.oid and i.objsubid = 0 - - union all - - select 'pg_trigger'::regclass, tg.oid, 0::int2, - format('trigger:%I.%I.%I', ns.nspname, tbl.relname, tg.tgname) as stable_id - from pg_trigger tg - join pg_class tbl on tbl.oid = tg.tgrelid - join pg_namespace ns on ns.oid = tbl.relnamespace - join ids i on i.classid = 'pg_trigger'::regclass and i.objid = tg.oid and i.objsubid = 0 - - union all - - select 'pg_collation'::regclass, c.oid, 0::int2, - format('collation:%I.%I', ns.nspname, c.collname) as stable_id - from pg_collation c - join pg_namespace ns on ns.oid = c.collnamespace - join ids i on i.classid = 'pg_collation'::regclass and i.objid = c.oid and i.objsubid = 0 - - union all - - select 'pg_event_trigger'::regclass, et.oid, 0::int2, - format('eventTrigger:%I', et.evtname) as stable_id - from pg_event_trigger et - join ids i on i.classid = 'pg_event_trigger'::regclass and i.objid = et.oid and i.objsubid = 0 - - union all - - select 'pg_ts_config'::regclass, cfg.oid, 0::int2, - format('tsConfig:%I.%I', ns.nspname, cfg.cfgname) as stable_id - from pg_ts_config cfg - join pg_namespace ns on ns.oid = cfg.cfgnamespace - join ids i on i.classid = 'pg_ts_config'::regclass and i.objid = cfg.oid and i.objsubid = 0 - - union all - - select 'pg_ts_dict'::regclass, dict.oid, 0::int2, - format('tsDict:%I.%I', ns.nspname, dict.dictname) as stable_id - from pg_ts_dict dict - join pg_namespace ns on ns.oid = dict.dictnamespace - join ids i on i.classid = 'pg_ts_dict'::regclass and i.objid = dict.oid and i.objsubid = 0 - - union all - - select 'pg_ts_template'::regclass, tmpl.oid, 0::int2, - format('tsTemplate:%I.%I', ns.nspname, tmpl.tmplname) as stable_id - from pg_ts_template tmpl - join pg_namespace ns on ns.oid = tmpl.tmplnamespace - join ids i on i.classid = 'pg_ts_template'::regclass and i.objid = tmpl.oid and i.objsubid = 0 - ), extension_members as ( - select - em.extension_oid, - obj.stable_id - from extension_members_raw em - join objects obj - on obj.classid = em.classid - and obj.objid = em.objid - and obj.objsubid = coalesce(nullif(em.objsubid, 0), 0) - ) - select - er.name, - er.schema, - er.relocatable, - er.version, - er.owner, - er.comment, - coalesce( - ( - select json_agg(em.stable_id order by em.stable_id) - from extension_members em - where em.extension_oid = er.oid - ), '[]'::json - ) as members - from extension_rows er - order by - er.name - `); - // Validate and parse each row using the Zod schema - const validatedRows = extensionRows.map((row: unknown) => - extensionPropsSchema.parse(row), - ); - return validatedRows.map((row: ExtensionProps) => new Extension(row)); -} diff --git a/packages/pg-delta/src/core/objects/extract-with-retry.test.ts b/packages/pg-delta/src/core/objects/extract-with-retry.test.ts deleted file mode 100644 index 185acf401..000000000 --- a/packages/pg-delta/src/core/objects/extract-with-retry.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { - extractWithDefinitionRetry, - resolveExtractRetries, -} from "./extract-with-retry.ts"; - -type Row = { id: string; definition: string | null }; - -const hasNullDefinition = (r: Row) => r.definition === null; - -describe("resolveExtractRetries", () => { - const originalEnv = process.env.PGDELTA_EXTRACT_RETRIES; - afterEach(() => { - if (originalEnv === undefined) { - process.env.PGDELTA_EXTRACT_RETRIES = undefined; - delete process.env.PGDELTA_EXTRACT_RETRIES; - } else { - process.env.PGDELTA_EXTRACT_RETRIES = originalEnv; - } - }); - - test("defaults to 1 when option and env are unset", () => { - delete process.env.PGDELTA_EXTRACT_RETRIES; - expect(resolveExtractRetries()).toBe(1); - }); - - test("uses option when provided", () => { - process.env.PGDELTA_EXTRACT_RETRIES = "5"; - expect(resolveExtractRetries(0)).toBe(0); - expect(resolveExtractRetries(1)).toBe(1); - expect(resolveExtractRetries(7)).toBe(7); - }); - - test("falls back to env when option is undefined", () => { - process.env.PGDELTA_EXTRACT_RETRIES = "4"; - expect(resolveExtractRetries()).toBe(4); - }); - - test("clamps negative values to 0", () => { - delete process.env.PGDELTA_EXTRACT_RETRIES; - expect(resolveExtractRetries(-3)).toBe(0); - process.env.PGDELTA_EXTRACT_RETRIES = "-9"; - expect(resolveExtractRetries()).toBe(0); - }); - - test("ignores non-numeric env values", () => { - process.env.PGDELTA_EXTRACT_RETRIES = "not-a-number"; - expect(resolveExtractRetries()).toBe(1); - }); - - test("ignores empty env string", () => { - process.env.PGDELTA_EXTRACT_RETRIES = ""; - expect(resolveExtractRetries()).toBe(1); - }); -}); - -describe("extractWithDefinitionRetry", () => { - test("returns first attempt when no row has null definition", async () => { - let attempts = 0; - const rows = await extractWithDefinitionRetry({ - label: "test", - query: async () => { - attempts++; - return [{ id: "a", definition: "OK" }]; - }, - hasNullDefinition, - options: { retries: 2, backoffMs: 0 }, - }); - expect(attempts).toBe(1); - expect(rows).toEqual([{ id: "a", definition: "OK" }]); - }); - - test("retries when definition is null and succeeds on attempt 2", async () => { - let attempts = 0; - const rows = await extractWithDefinitionRetry({ - label: "test", - query: async () => { - attempts++; - if (attempts === 1) { - return [ - { id: "a", definition: "OK" }, - { id: "b", definition: null }, - ]; - } - return [{ id: "a", definition: "OK" }]; - }, - hasNullDefinition, - options: { retries: 2, backoffMs: 0 }, - }); - expect(attempts).toBe(2); - expect(rows).toEqual([{ id: "a", definition: "OK" }]); - }); - - test("returns last-attempt rows (with offenders) once retries are exhausted", async () => { - let attempts = 0; - const rows = await extractWithDefinitionRetry({ - label: "test", - query: async () => { - attempts++; - return [ - { id: "a", definition: "OK" }, - { id: "b", definition: null }, - ]; - }, - hasNullDefinition, - options: { retries: 2, backoffMs: 0 }, - }); - expect(attempts).toBe(3); - expect(rows).toEqual([ - { id: "a", definition: "OK" }, - { id: "b", definition: null }, - ]); - }); - - test("retries: 0 disables retrying entirely", async () => { - let attempts = 0; - const rows = await extractWithDefinitionRetry({ - label: "test", - query: async () => { - attempts++; - return [{ id: "b", definition: null }]; - }, - hasNullDefinition, - options: { retries: 0, backoffMs: 0 }, - }); - expect(attempts).toBe(1); - expect(rows).toEqual([{ id: "b", definition: null }]); - }); - - test("retries: 5 attempts up to 6 times before giving up", async () => { - let attempts = 0; - await extractWithDefinitionRetry({ - label: "test", - query: async () => { - attempts++; - return [{ id: "b", definition: null }]; - }, - hasNullDefinition, - options: { retries: 5, backoffMs: 0 }, - }); - expect(attempts).toBe(6); - }); -}); diff --git a/packages/pg-delta/src/core/objects/extract-with-retry.ts b/packages/pg-delta/src/core/objects/extract-with-retry.ts deleted file mode 100644 index b26f10b9c..000000000 --- a/packages/pg-delta/src/core/objects/extract-with-retry.ts +++ /dev/null @@ -1,87 +0,0 @@ -import debug from "debug"; - -const log = debug("pg-delta:extract"); - -const DEFAULT_RETRIES = 1; -const DEFAULT_BACKOFF_MS = 50; - -export interface ExtractRetryOptions { - /** - * Number of retry attempts to make when a `pg_get_*def()` call returns NULL - * for at least one row. Total attempts is `retries + 1`. Negative values are - * clamped to 0. When this option is undefined the value is read from the - * `PGDELTA_EXTRACT_RETRIES` environment variable, falling back to a default - * of 1 (i.e. the first attempt plus one retry, 2 attempts total). - */ - retries?: number; - /** - * Delay between retry attempts in milliseconds; the actual wait is - * `backoffMs * attemptNumber` (linear). Defaults to 50. Set to 0 in tests. - */ - backoffMs?: number; -} - -export function resolveExtractRetries(option?: number): number { - if (typeof option === "number" && Number.isFinite(option)) { - return Math.max(0, Math.floor(option)); - } - const envVal = process.env.PGDELTA_EXTRACT_RETRIES; - if (envVal !== undefined && envVal !== "") { - const n = Number(envVal); - if (Number.isFinite(n)) return Math.max(0, Math.floor(n)); - } - return DEFAULT_RETRIES; -} - -const sleep = (ms: number) => - ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve(); - -/** - * Runs `query()` up to `retries + 1` times, retrying as long as at least one - * row in the result satisfies `hasNullDefinition`. The retry exists because - * `pg_get_def()` can return NULL transiently when the underlying catalog - * row is dropped concurrently or the catalog state is in flux; in practice a - * second attempt either no longer sees the dropped row or succeeds in - * resolving the definition. - * - * Returns the rows from the first attempt with no offenders, or — once - * retries are exhausted — the rows from the final attempt (still containing - * offenders). The caller is responsible for the final filter so this helper - * works for both flat schemas (definition on the row) and nested schemas - * (definition on a child collection, e.g. table constraints). - */ -export async function extractWithDefinitionRetry(params: { - label: string; - query: () => Promise; - hasNullDefinition: (row: TRow) => boolean; - options?: ExtractRetryOptions; -}): Promise { - const retries = resolveExtractRetries(params.options?.retries); - const backoffMs = params.options?.backoffMs ?? DEFAULT_BACKOFF_MS; - const maxAttempts = retries + 1; - - let rows: TRow[] = []; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - rows = await params.query(); - const offenders = rows.filter(params.hasNullDefinition).length; - if (offenders === 0) return rows; - if (attempt < maxAttempts) { - log( - "%s: pg_get_*def() returned NULL for %d row(s) on attempt %d/%d; retrying", - params.label, - offenders, - attempt, - maxAttempts, - ); - await sleep(backoffMs * attempt); - } else { - log( - "%s: pg_get_*def() returned NULL for %d row(s) after %d attempt(s); skipping", - params.label, - offenders, - maxAttempts, - ); - } - } - return rows; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper.types.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper.types.ts deleted file mode 100644 index 1ca1a9f3b..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ForeignDataWrapperChange as FDWChange } from "./foreign-data-wrapper/changes/foreign-data-wrapper.types.ts"; -import type { ForeignTableChange } from "./foreign-table/changes/foreign-table.types.ts"; -import type { ServerChange } from "./server/changes/server.types.ts"; -import type { UserMappingChange } from "./user-mapping/changes/user-mapping.types.ts"; - -/** Union of all foreign-data-wrapper-related change variants (`objectType: "foreign_data_wrapper" | "server" | "foreign_table" | "user_mapping"`). @category Change Types */ -export type ForeignDataWrapperChange = - | FDWChange - | ServerChange - | UserMappingChange - | ForeignTableChange; diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.test.ts deleted file mode 100644 index 5f586df49..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { - ForeignDataWrapper, - type ForeignDataWrapperProps, -} from "../foreign-data-wrapper.model.ts"; -import { - AlterForeignDataWrapperChangeOwner, - AlterForeignDataWrapperSetOptions, -} from "./foreign-data-wrapper.alter.ts"; - -describe.concurrent("foreign-data-wrapper", () => { - describe("alter", () => { - test("change owner", async () => { - const props: ForeignDataWrapperProps = { - name: "test_fdw", - owner: "old_owner", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const fdw = new ForeignDataWrapper(props); - const change = new AlterForeignDataWrapperChangeOwner({ - foreignDataWrapper: fdw, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN DATA WRAPPER test_fdw OWNER TO new_owner", - ); - }); - - test("set options ADD", async () => { - const props: ForeignDataWrapperProps = { - name: "test_fdw", - owner: "test", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const fdw = new ForeignDataWrapper(props); - const change = new AlterForeignDataWrapperSetOptions({ - foreignDataWrapper: fdw, - options: [ - { action: "ADD", option: "host", value: "localhost" }, - { action: "ADD", option: "port", value: "5432" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (ADD host 'localhost', ADD port '5432')", - ); - }); - - test("set options SET", async () => { - const props: ForeignDataWrapperProps = { - name: "test_fdw", - owner: "test", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const fdw = new ForeignDataWrapper(props); - const change = new AlterForeignDataWrapperSetOptions({ - foreignDataWrapper: fdw, - options: [{ action: "SET", option: "host", value: "newhost" }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (SET host 'newhost')", - ); - }); - - test("set options DROP", async () => { - const props: ForeignDataWrapperProps = { - name: "test_fdw", - owner: "test", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const fdw = new ForeignDataWrapper(props); - const change = new AlterForeignDataWrapperSetOptions({ - foreignDataWrapper: fdw, - options: [{ action: "DROP", option: "host" }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (DROP host)", - ); - }); - - test("set options mixed ADD/SET/DROP", async () => { - const props: ForeignDataWrapperProps = { - name: "test_fdw", - owner: "test", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const fdw = new ForeignDataWrapper(props); - const change = new AlterForeignDataWrapperSetOptions({ - foreignDataWrapper: fdw, - options: [ - { action: "ADD", option: "use_remote_estimate", value: "true" }, - { action: "SET", option: "fetch_size", value: "200" }, - { action: "DROP", option: "fdw_tuple_cost" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (ADD use_remote_estimate 'true', SET fetch_size '200', DROP fdw_tuple_cost)", - ); - }); - - test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => { - const props: ForeignDataWrapperProps = { - name: "leaky_fdw", - owner: "postgres", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const fdw = new ForeignDataWrapper(props); - const change = new AlterForeignDataWrapperSetOptions({ - foreignDataWrapper: fdw, - options: [ - { action: "ADD", option: "password", value: "shared-fdw-secret" }, - { action: "SET", option: "use_remote_estimate", value: "true" }, - { action: "ADD", option: "api_key", value: "leaked-api-key" }, - ], - }); - - await assertValidSql(change.serialize()); - - const sql = change.serialize(); - expect(sql).not.toContain("shared-fdw-secret"); - expect(sql).not.toContain("leaked-api-key"); - expect(sql).toContain("SET use_remote_estimate 'true'"); - expect(sql).toContain("ADD password '__OPTION_PASSWORD__'"); - expect(sql).toContain("ADD api_key '__OPTION_API_KEY__'"); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts deleted file mode 100644 index 2973d259e..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import { redactOptionValue } from "../../sensitive-options.ts"; -import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts"; -import { AlterForeignDataWrapperChange } from "./foreign-data-wrapper.base.ts"; - -/** - * Alter a foreign data wrapper. - * - * @see https://www.postgresql.org/docs/17/sql-alterforeigndatawrapper.html - * - * Synopsis - * ```sql - * ALTER FOREIGN DATA WRAPPER name - * [ OPTIONS ( [ ADD | SET | DROP ] option ['value'] [, ... ] ) ] - * ALTER FOREIGN DATA WRAPPER name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ``` - */ - -export type AlterForeignDataWrapper = - | AlterForeignDataWrapperChangeOwner - | AlterForeignDataWrapperSetOptions; - -/** - * ALTER FOREIGN DATA WRAPPER ... OWNER TO ... - */ -export class AlterForeignDataWrapperChangeOwner extends AlterForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { - foreignDataWrapper: ForeignDataWrapper; - owner: string; - }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - this.owner = props.owner; - } - - get requires() { - return [this.foreignDataWrapper.stableId, stableId.role(this.owner)]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER FOREIGN DATA WRAPPER", - this.foreignDataWrapper.name, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * ALTER FOREIGN DATA WRAPPER ... OPTIONS ( ADD | SET | DROP ... ) - */ -export class AlterForeignDataWrapperSetOptions extends AlterForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly options: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }>; - public readonly scope = "object" as const; - - constructor(props: { - foreignDataWrapper: ForeignDataWrapper; - options: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }>; - }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - this.options = props.options; - } - - get requires() { - return [this.foreignDataWrapper.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const optionParts: string[] = []; - for (const opt of this.options) { - if (opt.action === "DROP") { - optionParts.push(`DROP ${opt.option}`); - } else { - const value = - opt.value !== undefined - ? quoteLiteral(redactOptionValue(opt.option, opt.value)) - : "''"; - optionParts.push(`${opt.action} ${opt.option} ${value}`); - } - } - - return [ - "ALTER FOREIGN DATA WRAPPER", - this.foreignDataWrapper.name, - "OPTIONS", - `(${optionParts.join(", ")})`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.base.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.base.ts deleted file mode 100644 index a530b432e..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../../base.change.ts"; -import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts"; - -abstract class BaseForeignDataWrapperChange extends BaseChange { - abstract readonly foreignDataWrapper: ForeignDataWrapper; - abstract readonly scope: "object" | "comment" | "privilege"; - readonly objectType = "foreign_data_wrapper" as const; -} - -export abstract class CreateForeignDataWrapperChange extends BaseForeignDataWrapperChange { - readonly operation = "create" as const; -} - -export abstract class AlterForeignDataWrapperChange extends BaseForeignDataWrapperChange { - readonly operation = "alter" as const; -} - -export abstract class DropForeignDataWrapperChange extends BaseForeignDataWrapperChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.comment.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.comment.ts deleted file mode 100644 index 67fabab2d..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.comment.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts"; -import { - CreateForeignDataWrapperChange, - DropForeignDataWrapperChange, -} from "./foreign-data-wrapper.base.ts"; - -/** - * Create/drop comments on foreign data wrappers. - */ - -export type CommentForeignDataWrapper = - | CreateCommentOnForeignDataWrapper - | DropCommentOnForeignDataWrapper; - -export class CreateCommentOnForeignDataWrapper extends CreateForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly scope = "comment" as const; - - constructor(props: { foreignDataWrapper: ForeignDataWrapper }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - } - - get creates() { - return [stableId.comment(this.foreignDataWrapper.stableId)]; - } - - get requires() { - return [this.foreignDataWrapper.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON FOREIGN DATA WRAPPER", - this.foreignDataWrapper.name, - "IS", - // biome-ignore lint/style/noNonNullAssertion: comment is not nullable in this case - quoteLiteral(this.foreignDataWrapper.comment!), - ].join(" "); - } -} - -export class DropCommentOnForeignDataWrapper extends DropForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly scope = "comment" as const; - - constructor(props: { foreignDataWrapper: ForeignDataWrapper }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - } - - get drops() { - return [stableId.comment(this.foreignDataWrapper.stableId)]; - } - - get requires() { - return [ - stableId.comment(this.foreignDataWrapper.stableId), - this.foreignDataWrapper.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON FOREIGN DATA WRAPPER", - this.foreignDataWrapper.name, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.test.ts deleted file mode 100644 index b3cdc670d..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts"; -import { CreateForeignDataWrapper } from "./foreign-data-wrapper.create.ts"; - -describe("foreign-data-wrapper", () => { - test("create basic", async () => { - const fdw = new ForeignDataWrapper({ - name: "test_fdw", - owner: "test", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN DATA WRAPPER test_fdw NO HANDLER NO VALIDATOR", - ); - }); - - test("create with handler", async () => { - const fdw = new ForeignDataWrapper({ - name: "test_fdw", - owner: "test", - handler: "public.handler_func", - validator: null, - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN DATA WRAPPER test_fdw HANDLER public.handler_func NO VALIDATOR", - ); - }); - - test("create with validator", async () => { - const fdw = new ForeignDataWrapper({ - name: "test_fdw", - owner: "test", - handler: null, - validator: "public.validator_func", - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN DATA WRAPPER test_fdw NO HANDLER VALIDATOR public.validator_func", - ); - }); - - test("create with handler and validator", async () => { - const fdw = new ForeignDataWrapper({ - name: "test_fdw", - owner: "test", - handler: "public.handler_func", - validator: "public.validator_func", - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN DATA WRAPPER test_fdw HANDLER public.handler_func VALIDATOR public.validator_func", - ); - }); - - test("create with options", async () => { - const fdw = new ForeignDataWrapper({ - name: "test_fdw", - owner: "test", - handler: null, - validator: null, - options: ["host", "localhost", "port", "5432"], - comment: null, - privileges: [], - }); - - const change = new CreateForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN DATA WRAPPER test_fdw NO HANDLER NO VALIDATOR OPTIONS (host 'localhost', port '5432')", - ); - }); - - test("create with all properties", async () => { - const fdw = new ForeignDataWrapper({ - name: "test_fdw", - owner: "test", - handler: "public.handler_func", - validator: "public.validator_func", - options: ["host", "localhost", "port", "5432"], - comment: null, - privileges: [], - }); - - const change = new CreateForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN DATA WRAPPER test_fdw HANDLER public.handler_func VALIDATOR public.validator_func OPTIONS (host 'localhost', port '5432')", - ); - }); - - test("create with schema-qualified handler (no function args in output)", async () => { - const fdw = new ForeignDataWrapper({ - name: "test_fdw", - owner: "test", - handler: "extensions.iceberg_fdw_handler", - validator: "extensions.iceberg_fdw_validator", - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN DATA WRAPPER test_fdw HANDLER extensions.iceberg_fdw_handler VALIDATOR extensions.iceberg_fdw_validator", - ); - }); - - test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => { - // FDW-level OPTIONS set defaults that flow down to every server using - // the wrapper, so a shared `password` or `api_key` here must redact. - const fdw = new ForeignDataWrapper({ - name: "leaky_fdw", - owner: "postgres", - handler: null, - validator: null, - options: [ - "use_remote_estimate", - "true", - "password", - "shared-fdw-secret", - "api_key", - "leaked-api-key", - ], - comment: null, - privileges: [], - }); - - const change = new CreateForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - const sql = change.serialize(); - expect(sql).not.toContain("shared-fdw-secret"); - expect(sql).not.toContain("leaked-api-key"); - expect(sql).toContain("use_remote_estimate 'true'"); - expect(sql).toContain("password '__OPTION_PASSWORD__'"); - expect(sql).toContain("api_key '__OPTION_API_KEY__'"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts deleted file mode 100644 index 2ead8f639..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import { redactOptionValue } from "../../sensitive-options.ts"; -import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts"; -import { CreateForeignDataWrapperChange } from "./foreign-data-wrapper.base.ts"; - -/** - * Create a foreign data wrapper. - * - * @see https://www.postgresql.org/docs/17/sql-createforeigndatawrapper.html - * - * Synopsis - * ```sql - * CREATE FOREIGN DATA WRAPPER name - * [ HANDLER handler_function | NO HANDLER ] - * [ VALIDATOR validator_function | NO VALIDATOR ] - * [ OPTIONS ( option 'value' [, ... ] ) ] - * ``` - */ -export class CreateForeignDataWrapper extends CreateForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly scope = "object" as const; - - constructor(props: { foreignDataWrapper: ForeignDataWrapper }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - } - - get creates() { - return [this.foreignDataWrapper.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Owner dependency - dependencies.add(stableId.role(this.foreignDataWrapper.owner)); - - // Handler function dependency (if specified) - if (this.foreignDataWrapper.handler) { - // Handler is stored as "schema.function_name(args)" - // We need to parse it to get the procedure stableId - // For now, we'll skip this dependency as it's complex to parse - // TODO: Parse handler function reference to add procedure dependency - } - - // Validator function dependency (if specified) - if (this.foreignDataWrapper.validator) { - // Similar to handler - // TODO: Parse validator function reference to add procedure dependency - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE FOREIGN DATA WRAPPER"]; - - // Add FDW name - parts.push(this.foreignDataWrapper.name); - - // Add HANDLER clause - if (this.foreignDataWrapper.handler) { - parts.push("HANDLER", this.foreignDataWrapper.handler); - } else { - parts.push("NO HANDLER"); - } - - // Add VALIDATOR clause - if (this.foreignDataWrapper.validator) { - parts.push("VALIDATOR", this.foreignDataWrapper.validator); - } else { - parts.push("NO VALIDATOR"); - } - - // Add OPTIONS clause - if ( - this.foreignDataWrapper.options && - this.foreignDataWrapper.options.length > 0 - ) { - const optionPairs: string[] = []; - for (let i = 0; i < this.foreignDataWrapper.options.length; i += 2) { - const key = this.foreignDataWrapper.options[i]; - const value = this.foreignDataWrapper.options[i + 1]; - if (key === undefined || value === undefined) continue; - optionPairs.push( - `${key} ${quoteLiteral(redactOptionValue(key, value))}`, - ); - } - if (optionPairs.length > 0) { - parts.push(`OPTIONS (${optionPairs.join(", ")})`); - } - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.drop.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.drop.test.ts deleted file mode 100644 index c3f164a8d..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.drop.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts"; -import { DropForeignDataWrapper } from "./foreign-data-wrapper.drop.ts"; - -describe("foreign-data-wrapper", () => { - test("drop", async () => { - const fdw = new ForeignDataWrapper({ - name: "test_fdw", - owner: "test", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }); - - const change = new DropForeignDataWrapper({ - foreignDataWrapper: fdw, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP FOREIGN DATA WRAPPER test_fdw"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.drop.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.drop.ts deleted file mode 100644 index 596203aef..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.drop.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts"; -import { DropForeignDataWrapperChange } from "./foreign-data-wrapper.base.ts"; - -/** - * Drop a foreign data wrapper. - * - * @see https://www.postgresql.org/docs/17/sql-dropforeigndatawrapper.html - * - * Synopsis - * ```sql - * DROP FOREIGN DATA WRAPPER [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropForeignDataWrapper extends DropForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly scope = "object" as const; - - constructor(props: { foreignDataWrapper: ForeignDataWrapper }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - } - - get drops() { - return [this.foreignDataWrapper.stableId]; - } - - get requires() { - return [this.foreignDataWrapper.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP FOREIGN DATA WRAPPER", this.foreignDataWrapper.name].join( - " ", - ); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.privilege.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.privilege.ts deleted file mode 100644 index e8fd5bd35..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.privilege.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { formatObjectPrivilegeList } from "../../../base.privilege.ts"; -import { stableId } from "../../../utils.ts"; -import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts"; -import { AlterForeignDataWrapperChange } from "./foreign-data-wrapper.base.ts"; - -export type ForeignDataWrapperPrivilege = - | GrantForeignDataWrapperPrivileges - | RevokeForeignDataWrapperPrivileges - | RevokeGrantOptionForeignDataWrapperPrivileges; - -/** - * Grant privileges on a foreign data wrapper. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { USAGE | ALL [ PRIVILEGES ] } - * ON FOREIGN DATA WRAPPER name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantForeignDataWrapperPrivileges extends AlterForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - foreignDataWrapper: ForeignDataWrapper; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.foreignDataWrapper.stableId, this.grantee)]; - } - - get requires() { - return [this.foreignDataWrapper.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantForeignDataWrapperPrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList( - "FOREIGN DATA WRAPPER", - list, - this.version, - ); - return `GRANT ${privSql} ON FOREIGN DATA WRAPPER ${this.foreignDataWrapper.name} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a foreign data wrapper. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { USAGE | ALL [ PRIVILEGES ] } - * ON FOREIGN DATA WRAPPER name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeForeignDataWrapperPrivileges extends AlterForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - foreignDataWrapper: ForeignDataWrapper; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - return [stableId.acl(this.foreignDataWrapper.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.foreignDataWrapper.stableId, this.grantee), - this.foreignDataWrapper.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList( - "FOREIGN DATA WRAPPER", - list, - this.version, - ); - return `REVOKE ${privSql} ON FOREIGN DATA WRAPPER ${this.foreignDataWrapper.name} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a foreign data wrapper. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionForeignDataWrapperPrivileges extends AlterForeignDataWrapperChange { - public readonly foreignDataWrapper: ForeignDataWrapper; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - foreignDataWrapper: ForeignDataWrapper; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.foreignDataWrapper = props.foreignDataWrapper; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.foreignDataWrapper.stableId, this.grantee), - this.foreignDataWrapper.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const privSql = formatObjectPrivilegeList( - "FOREIGN DATA WRAPPER", - this.privilegeNames, - this.version, - ); - return `REVOKE GRANT OPTION FOR ${privSql} ON FOREIGN DATA WRAPPER ${this.foreignDataWrapper.name} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.types.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.types.ts deleted file mode 100644 index 72ac6a5f0..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { AlterForeignDataWrapper } from "./foreign-data-wrapper.alter.ts"; -import type { CommentForeignDataWrapper } from "./foreign-data-wrapper.comment.ts"; -import type { CreateForeignDataWrapper } from "./foreign-data-wrapper.create.ts"; -import type { DropForeignDataWrapper } from "./foreign-data-wrapper.drop.ts"; -import type { ForeignDataWrapperPrivilege } from "./foreign-data-wrapper.privilege.ts"; - -/** Union of all FDW wrapper-level change variants (`objectType: "foreign_data_wrapper"`). @category Change Types */ -export type ForeignDataWrapperChange = - | AlterForeignDataWrapper - | CommentForeignDataWrapper - | CreateForeignDataWrapper - | DropForeignDataWrapper - | ForeignDataWrapperPrivilege; diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.diff.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.diff.test.ts deleted file mode 100644 index 76b8fad32..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.diff.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../../base.default-privileges.ts"; -import { - AlterForeignDataWrapperChangeOwner, - AlterForeignDataWrapperSetOptions, -} from "./changes/foreign-data-wrapper.alter.ts"; -import { - CreateCommentOnForeignDataWrapper, - DropCommentOnForeignDataWrapper, -} from "./changes/foreign-data-wrapper.comment.ts"; -import { CreateForeignDataWrapper } from "./changes/foreign-data-wrapper.create.ts"; -import { DropForeignDataWrapper } from "./changes/foreign-data-wrapper.drop.ts"; -import { - GrantForeignDataWrapperPrivileges, - RevokeForeignDataWrapperPrivileges, - RevokeGrantOptionForeignDataWrapperPrivileges, -} from "./changes/foreign-data-wrapper.privilege.ts"; -import { diffForeignDataWrappers } from "./foreign-data-wrapper.diff.ts"; -import { - ForeignDataWrapper, - type ForeignDataWrapperProps, -} from "./foreign-data-wrapper.model.ts"; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("foreign-data-wrapper.diff", () => { - test("create and drop", () => { - const props: ForeignDataWrapperProps = { - name: "fdw1", - owner: "o1", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const fdw = new ForeignDataWrapper(props); - - const created = diffForeignDataWrappers( - testContext, - {}, - { - [fdw.stableId]: fdw, - }, - ); - expect(created[0]).toBeInstanceOf(CreateForeignDataWrapper); - - const dropped = diffForeignDataWrappers( - testContext, - { - [fdw.stableId]: fdw, - }, - {}, - ); - expect(dropped[0]).toBeInstanceOf(DropForeignDataWrapper); - }); - - test("alter: owner change", () => { - const main = new ForeignDataWrapper({ - name: "fdw1", - owner: "o1", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }); - const branch = new ForeignDataWrapper({ - name: "fdw1", - owner: "o2", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }); - - const changes = diffForeignDataWrappers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterForeignDataWrapperChangeOwner), - ).toBe(true); - }); - - test("alter: options changes", () => { - const main = new ForeignDataWrapper({ - name: "fdw1", - owner: "o1", - handler: null, - validator: null, - options: ["host", "localhost"], - comment: null, - privileges: [], - }); - const branch = new ForeignDataWrapper({ - name: "fdw1", - owner: "o1", - handler: null, - validator: null, - options: ["host", "newhost", "port", "5432"], - comment: null, - privileges: [], - }); - - const changes = diffForeignDataWrappers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - const optionsChange = changes.find( - (c) => c instanceof AlterForeignDataWrapperSetOptions, - ) as AlterForeignDataWrapperSetOptions | undefined; - expect(optionsChange).toBeDefined(); - expect(optionsChange?.options.length).toBeGreaterThan(0); - }); - - test("handler change triggers drop and create", () => { - const main = new ForeignDataWrapper({ - name: "fdw1", - owner: "o1", - handler: "public.old_handler", - validator: null, - options: null, - comment: null, - privileges: [], - }); - const branch = new ForeignDataWrapper({ - name: "fdw1", - owner: "o1", - handler: "public.new_handler", - validator: null, - options: null, - comment: null, - privileges: [], - }); - - const changes = diffForeignDataWrappers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - // Handler change should trigger drop + create - expect(changes.some((c) => c instanceof DropForeignDataWrapper)).toBe(true); - expect(changes.some((c) => c instanceof CreateForeignDataWrapper)).toBe( - true, - ); - }); - - test("validator change triggers drop and create", () => { - const main = new ForeignDataWrapper({ - name: "fdw1", - owner: "o1", - handler: null, - validator: "public.old_validator", - options: null, - comment: null, - privileges: [], - }); - const branch = new ForeignDataWrapper({ - name: "fdw1", - owner: "o1", - handler: null, - validator: "public.new_validator", - options: null, - comment: null, - privileges: [], - }); - - const changes = diffForeignDataWrappers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - // Validator change should trigger drop + create - expect(changes.some((c) => c instanceof DropForeignDataWrapper)).toBe(true); - expect(changes.some((c) => c instanceof CreateForeignDataWrapper)).toBe( - true, - ); - }); - - test("created with privileges emits grant", () => { - const fdw = new ForeignDataWrapper({ - name: "fdw1", - owner: "o1", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: false }, - ], - }); - const changes = diffForeignDataWrappers( - testContext, - {}, - { [fdw.stableId]: fdw }, - ); - expect(changes[0]).toBeInstanceOf(CreateForeignDataWrapper); - expect( - changes.some((c) => c instanceof GrantForeignDataWrapperPrivileges), - ).toBe(true); - }); - - test("altered comment emits create/drop comment", () => { - const base: ForeignDataWrapperProps = { - name: "fdw1", - owner: "o1", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const main = new ForeignDataWrapper(base); - const withComment = new ForeignDataWrapper({ - ...base, - comment: "my fdw", - }); - - const addComment = diffForeignDataWrappers( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment[0]).toBeInstanceOf(CreateCommentOnForeignDataWrapper); - - const dropComment = diffForeignDataWrappers( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment[0]).toBeInstanceOf(DropCommentOnForeignDataWrapper); - }); - - test("altered privileges emit grant, revoke, and revoke grant option", () => { - const base: ForeignDataWrapperProps = { - name: "fdw1", - owner: "o1", - handler: null, - validator: null, - options: null, - comment: null, - privileges: [], - }; - const main = new ForeignDataWrapper({ - ...base, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: false }, - { grantee: "role_with_option", privilege: "USAGE", grantable: true }, - { grantee: "role_removed", privilege: "USAGE", grantable: false }, - ], - }); - const branch = new ForeignDataWrapper({ - ...base, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: true }, - { grantee: "role_with_option", privilege: "USAGE", grantable: false }, - { grantee: "role_new", privilege: "USAGE", grantable: false }, - ], - }); - const changes = diffForeignDataWrappers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof GrantForeignDataWrapperPrivileges), - ).toBe(true); - expect( - changes.some((c) => c instanceof RevokeForeignDataWrapperPrivileges), - ).toBe(true); - expect( - changes.some( - (c) => c instanceof RevokeGrantOptionForeignDataWrapperPrivileges, - ), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.diff.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.diff.ts deleted file mode 100644 index 659977620..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.diff.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { diffObjects } from "../../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../../diff-context.ts"; -import { - AlterForeignDataWrapperChangeOwner, - AlterForeignDataWrapperSetOptions, -} from "./changes/foreign-data-wrapper.alter.ts"; -import { - CreateCommentOnForeignDataWrapper, - DropCommentOnForeignDataWrapper, -} from "./changes/foreign-data-wrapper.comment.ts"; -import { CreateForeignDataWrapper } from "./changes/foreign-data-wrapper.create.ts"; -import { DropForeignDataWrapper } from "./changes/foreign-data-wrapper.drop.ts"; -import { - GrantForeignDataWrapperPrivileges, - RevokeForeignDataWrapperPrivileges, - RevokeGrantOptionForeignDataWrapperPrivileges, -} from "./changes/foreign-data-wrapper.privilege.ts"; -import type { ForeignDataWrapperChange } from "./changes/foreign-data-wrapper.types.ts"; -import type { ForeignDataWrapper } from "./foreign-data-wrapper.model.ts"; - -/** - * Diff two sets of foreign data wrappers from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The foreign data wrappers in the main catalog. - * @param branch - The foreign data wrappers in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffForeignDataWrappers( - ctx: Pick, - main: Record, - branch: Record, -): ForeignDataWrapperChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: ForeignDataWrapperChange[] = []; - - for (const fdwId of created) { - const createdFdw = branch[fdwId]; - changes.push( - new CreateForeignDataWrapper({ foreignDataWrapper: createdFdw }), - ); - - // OWNER: If the FDW should be owned by someone other than the current user, - // emit ALTER FOREIGN DATA WRAPPER ... OWNER TO after creation - if (createdFdw.owner !== ctx.currentUser) { - changes.push( - new AlterForeignDataWrapperChangeOwner({ - foreignDataWrapper: createdFdw, - owner: createdFdw.owner, - }), - ); - } - - if (createdFdw.comment !== null) { - changes.push( - new CreateCommentOnForeignDataWrapper({ - foreignDataWrapper: createdFdw, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // Foreign Data Wrappers don't have default privileges, so we compare against empty array - const effectiveDefaults: Array<{ - grantee: string; - privilege: string; - grantable: boolean; - }> = []; - const desiredPrivileges = filterPublicBuiltInDefaults( - "foreign_data_wrapper", - createdFdw.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - const privilegeResults = diffPrivileges( - effectiveDefaults, - desiredPrivileges, - createdFdw.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - createdFdw, - createdFdw, - "foreignDataWrapper", - { - Grant: GrantForeignDataWrapperPrivileges, - Revoke: RevokeForeignDataWrapperPrivileges, - RevokeGrantOption: RevokeGrantOptionForeignDataWrapperPrivileges, - }, - ctx.version, - ) as ForeignDataWrapperChange[]), - ); - } - - for (const fdwId of dropped) { - changes.push( - new DropForeignDataWrapper({ foreignDataWrapper: main[fdwId] }), - ); - } - - for (const fdwId of altered) { - const mainFdw = main[fdwId]; - const branchFdw = branch[fdwId]; - - // OWNER - if (mainFdw.owner !== branchFdw.owner) { - changes.push( - new AlterForeignDataWrapperChangeOwner({ - foreignDataWrapper: mainFdw, - owner: branchFdw.owner, - }), - ); - } - - // HANDLER - if changed, need to recreate (not directly alterable) - if (mainFdw.handler !== branchFdw.handler) { - changes.push(new DropForeignDataWrapper({ foreignDataWrapper: mainFdw })); - changes.push( - new CreateForeignDataWrapper({ foreignDataWrapper: branchFdw }), - ); - if (branchFdw.comment !== null) { - changes.push( - new CreateCommentOnForeignDataWrapper({ - foreignDataWrapper: branchFdw, - }), - ); - } - continue; - } - - // VALIDATOR - if changed, need to recreate (not directly alterable) - if (mainFdw.validator !== branchFdw.validator) { - changes.push(new DropForeignDataWrapper({ foreignDataWrapper: mainFdw })); - changes.push( - new CreateForeignDataWrapper({ foreignDataWrapper: branchFdw }), - ); - if (branchFdw.comment !== null) { - changes.push( - new CreateCommentOnForeignDataWrapper({ - foreignDataWrapper: branchFdw, - }), - ); - } - continue; - } - - // OPTIONS - const optionsChanged = diffOptions(mainFdw.options, branchFdw.options); - if (optionsChanged.length > 0) { - changes.push( - new AlterForeignDataWrapperSetOptions({ - foreignDataWrapper: mainFdw, - options: optionsChanged, - }), - ); - } - - // COMMENT - if (mainFdw.comment !== branchFdw.comment) { - if (branchFdw.comment === null) { - changes.push( - new DropCommentOnForeignDataWrapper({ foreignDataWrapper: mainFdw }), - ); - } else { - changes.push( - new CreateCommentOnForeignDataWrapper({ - foreignDataWrapper: branchFdw, - }), - ); - } - } - - // PRIVILEGES - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "foreign_data_wrapper", - mainFdw.privileges, - ); - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "foreign_data_wrapper", - branchFdw.privileges, - ); - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchFdw.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchFdw, - mainFdw, - "foreignDataWrapper", - { - Grant: GrantForeignDataWrapperPrivileges, - Revoke: RevokeForeignDataWrapperPrivileges, - RevokeGrantOption: RevokeGrantOptionForeignDataWrapperPrivileges, - }, - ctx.version, - ) as ForeignDataWrapperChange[]), - ); - - // Note: FDW renaming would also use ALTER FOREIGN DATA WRAPPER ... RENAME TO ... - // But since our ForeignDataWrapper model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - - return changes; -} - -/** - * Diff options arrays to determine ADD/SET/DROP operations. - * Options are stored as [key1, value1, key2, value2, ...] - */ -function diffOptions( - mainOptions: string[] | null, - branchOptions: string[] | null, -): Array<{ action: "ADD" | "SET" | "DROP"; option: string; value?: string }> { - const mainMap = new Map(); - const branchMap = new Map(); - - // Parse main options - if (mainOptions) { - for (let i = 0; i < mainOptions.length; i += 2) { - if (i + 1 < mainOptions.length) { - mainMap.set(mainOptions[i], mainOptions[i + 1]); - } - } - } - - // Parse branch options - if (branchOptions) { - for (let i = 0; i < branchOptions.length; i += 2) { - if (i + 1 < branchOptions.length) { - branchMap.set(branchOptions[i], branchOptions[i + 1]); - } - } - } - - const changes: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }> = []; - - // Find options to ADD or SET - for (const [key, value] of branchMap) { - const mainValue = mainMap.get(key); - if (mainValue === undefined) { - changes.push({ action: "ADD", option: key, value }); - } else if (mainValue !== value) { - changes.push({ action: "SET", option: key, value }); - } - } - - // Find options to DROP - for (const [key] of mainMap) { - if (!branchMap.has(key)) { - changes.push({ action: "DROP", option: key }); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts deleted file mode 100644 index 4cd8e76db..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../../base.privilege-diff.ts"; - -/** - * All properties exposed by CREATE FOREIGN DATA WRAPPER statement are included in diff output. - * https://www.postgresql.org/docs/17/sql-createforeigndatawrapper.html - * - * ALTER FOREIGN DATA WRAPPER statement can be generated for changes to the following properties: - * - owner, handler, validator, options - * https://www.postgresql.org/docs/17/sql-alterforeigndatawrapper.html - * - * Foreign Data Wrappers are not schema-qualified (no schema property). - */ -const foreignDataWrapperPropsSchema = z.object({ - name: z.string(), - owner: z.string(), - handler: z.string().nullable(), - validator: z.string().nullable(), - options: z.array(z.string()).nullable(), - comment: z.string().nullable(), - privileges: z.array(privilegePropsSchema), -}); - -type ForeignDataWrapperPrivilegeProps = PrivilegeProps; -export type ForeignDataWrapperProps = z.infer< - typeof foreignDataWrapperPropsSchema ->; - -export class ForeignDataWrapper extends BasePgModel { - public readonly name: ForeignDataWrapperProps["name"]; - public readonly owner: ForeignDataWrapperProps["owner"]; - public readonly handler: ForeignDataWrapperProps["handler"]; - public readonly validator: ForeignDataWrapperProps["validator"]; - public readonly options: ForeignDataWrapperProps["options"]; - public readonly comment: ForeignDataWrapperProps["comment"]; - public readonly privileges: ForeignDataWrapperPrivilegeProps[]; - - constructor(props: ForeignDataWrapperProps) { - super(); - - // Identity fields - this.name = props.name; - - // Data fields - this.owner = props.owner; - this.handler = props.handler; - this.validator = props.validator; - this.options = props.options; - this.comment = props.comment; - this.privileges = props.privileges; - } - - get stableId(): `foreignDataWrapper:${string}` { - return `foreignDataWrapper:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - return { - owner: this.owner, - handler: this.handler, - validator: this.validator, - options: this.options, - comment: this.comment, - privileges: this.privileges, - }; - } -} - -/** - * Extract `pg_foreign_data_wrapper` rows into `ForeignDataWrapper` models. - * - * The returned models carry option values **verbatim** from - * `pg_foreign_data_wrapper.fdwoptions`, which means a wrapper that ships - * shared credentials (`password`, `api_key`, …) would expose them - * cleartext in memory. Always route through `extractCatalog` (which - * calls `normalizeCatalog`) before emitting options to any output - * channel — see CLI-1467 and - * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`. - */ -export async function extractForeignDataWrappers( - pool: Pool, -): Promise { - const { rows: fdwRows } = await pool.query(sql` - with extension_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_foreign_data_wrapper'::regclass - ) - select - quote_ident(fdw.fdwname) as name, - fdw.fdwowner::regrole::text as owner, - case - when fdw.fdwhandler = 0 then null - else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname) - end as handler, - case - when fdw.fdwvalidator = 0 then null - else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname) - end as validator, - coalesce(fdw.fdwoptions, array[]::text[]) as options, - obj_description(fdw.oid, 'pg_foreign_data_wrapper') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(fdw.fdwacl) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges - from - pg_catalog.pg_foreign_data_wrapper fdw - left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler - left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator - left join extension_oids e on fdw.oid = e.objid - where - not fdw.fdwname like any(array['pg\\_%']) - and e.objid is null - order by - fdw.fdwname - `); - - // Validate and parse each row using the Zod schema - const validatedRows = fdwRows.map((row: unknown) => { - const parsed = foreignDataWrapperPropsSchema.parse(row); - // Parse options from PostgreSQL format ['key=value'] to ['key', 'value'] - if (parsed.options && parsed.options.length > 0) { - const parsedOptions: string[] = []; - for (const opt of parsed.options) { - const eqIndex = opt.indexOf("="); - if (eqIndex > 0) { - parsedOptions.push(opt.substring(0, eqIndex)); - parsedOptions.push(opt.substring(eqIndex + 1)); - } - } - parsed.options = parsedOptions.length > 0 ? parsedOptions : null; - } - return parsed; - }); - return validatedRows.map( - (row: ForeignDataWrapperProps) => new ForeignDataWrapper(row), - ); -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.test.ts deleted file mode 100644 index 4cdddb440..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { - ForeignTable, - type ForeignTableProps, -} from "../foreign-table.model.ts"; -import { - AlterForeignTableAddColumn, - AlterForeignTableAlterColumnDropDefault, - AlterForeignTableAlterColumnDropNotNull, - AlterForeignTableAlterColumnSetDefault, - AlterForeignTableAlterColumnSetNotNull, - AlterForeignTableAlterColumnType, - AlterForeignTableChangeOwner, - AlterForeignTableDropColumn, - AlterForeignTableSetOptions, -} from "./foreign-table.alter.ts"; - -describe.concurrent("foreign-table", () => { - describe("alter", () => { - const baseTableProps: ForeignTableProps = { - schema: "public", - name: "test_table", - owner: "test", - server: "test_server", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }; - - test("change owner", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableChangeOwner({ - foreignTable, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table OWNER TO new_owner", - ); - }); - - test("add column", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAddColumn({ - foreignTable, - column: { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ADD COLUMN name text", - ); - }); - - test("add column with NOT NULL", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAddColumn({ - foreignTable, - column: { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ADD COLUMN name text NOT NULL", - ); - }); - - test("add column with DEFAULT", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAddColumn({ - foreignTable, - column: { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "'default_value'", - comment: null, - }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ADD COLUMN name text DEFAULT 'default_value'", - ); - }); - - test("add column with NOT NULL and DEFAULT", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAddColumn({ - foreignTable, - column: { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "'default_value'", - comment: null, - }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ADD COLUMN name text NOT NULL DEFAULT 'default_value'", - ); - }); - - test("drop column", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableDropColumn({ - foreignTable, - columnName: "id", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table DROP COLUMN id", - ); - }); - - test("alter column type", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAlterColumnType({ - foreignTable, - columnName: "id", - dataType: "bigint", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ALTER COLUMN id TYPE bigint", - ); - }); - - test("alter column set default", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAlterColumnSetDefault({ - foreignTable, - columnName: "id", - defaultValue: "0", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ALTER COLUMN id SET DEFAULT 0", - ); - }); - - test("alter column drop default", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAlterColumnDropDefault({ - foreignTable, - columnName: "id", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ALTER COLUMN id DROP DEFAULT", - ); - }); - - test("alter column set not null", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAlterColumnSetNotNull({ - foreignTable, - columnName: "id", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ALTER COLUMN id SET NOT NULL", - ); - }); - - test("alter column drop not null", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableAlterColumnDropNotNull({ - foreignTable, - columnName: "id", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table ALTER COLUMN id DROP NOT NULL", - ); - }); - - test("set options ADD", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableSetOptions({ - foreignTable, - options: [ - { action: "ADD", option: "schema_name", value: "remote_schema" }, - { action: "ADD", option: "table_name", value: "remote_table" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table OPTIONS (ADD schema_name 'remote_schema', ADD table_name 'remote_table')", - ); - }); - - test("set options SET", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableSetOptions({ - foreignTable, - options: [ - { action: "SET", option: "schema_name", value: "new_schema" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table OPTIONS (SET schema_name 'new_schema')", - ); - }); - - test("set options DROP", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableSetOptions({ - foreignTable, - options: [{ action: "DROP", option: "schema_name" }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table OPTIONS (DROP schema_name)", - ); - }); - - test("set options mixed ADD/SET/DROP", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableSetOptions({ - foreignTable, - options: [ - { action: "ADD", option: "schema_name", value: "remote_schema" }, - { action: "SET", option: "table_name", value: "updated_table" }, - { action: "DROP", option: "column_name" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FOREIGN TABLE public.test_table OPTIONS (ADD schema_name 'remote_schema', SET table_name 'updated_table', DROP column_name)", - ); - }); - - test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => { - const foreignTable = new ForeignTable(baseTableProps); - const change = new AlterForeignTableSetOptions({ - foreignTable, - options: [ - { action: "ADD", option: "password", value: "table-shared-secret" }, - { action: "SET", option: "schema_name", value: "remote_schema" }, - { action: "ADD", option: "api_key", value: "leaked-api-key" }, - ], - }); - - await assertValidSql(change.serialize()); - - const sql = change.serialize(); - expect(sql).not.toContain("table-shared-secret"); - expect(sql).not.toContain("leaked-api-key"); - expect(sql).toContain("SET schema_name 'remote_schema'"); - expect(sql).toContain("ADD password '__OPTION_PASSWORD__'"); - expect(sql).toContain("ADD api_key '__OPTION_API_KEY__'"); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts deleted file mode 100644 index 56ba49240..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts +++ /dev/null @@ -1,346 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import type { ColumnProps } from "../../../base.model.ts"; -import { stableId } from "../../../utils.ts"; -import { redactOptionValue } from "../../sensitive-options.ts"; -import type { ForeignTable } from "../foreign-table.model.ts"; -import { AlterForeignTableChange } from "./foreign-table.base.ts"; - -/** - * Alter a foreign table. - * - * @see https://www.postgresql.org/docs/17/sql-alterforeigntable.html - * - * Synopsis - * ```sql - * ALTER FOREIGN TABLE [ IF EXISTS ] [ ONLY ] name [ * ] - * action [, ... ] - * where action is one of: - * ADD [ COLUMN ] [ IF NOT EXISTS ] column_name data_type [ OPTIONS ( option 'value' [, ... ] ) ] [ COLLATE collation ] [ column_constraint [ ... ] ] - * DROP [ COLUMN ] [ IF EXISTS ] column_name [ RESTRICT | CASCADE ] - * ALTER [ COLUMN ] column_name [ SET DATA ] TYPE data_type [ COLLATE collation ] - * ALTER [ COLUMN ] column_name SET DEFAULT expression - * ALTER [ COLUMN ] column_name DROP DEFAULT - * ALTER [ COLUMN ] column_name { SET | DROP } NOT NULL - * ALTER [ COLUMN ] column_name OPTIONS ( [ ADD | SET | DROP ] option ['value'] [, ... ] ) - * OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * OPTIONS ( [ ADD | SET | DROP ] option ['value'] [, ... ] ) - * ``` - */ - -export type AlterForeignTable = - | AlterForeignTableChangeOwner - | AlterForeignTableAddColumn - | AlterForeignTableDropColumn - | AlterForeignTableAlterColumnType - | AlterForeignTableAlterColumnSetDefault - | AlterForeignTableAlterColumnDropDefault - | AlterForeignTableAlterColumnSetNotNull - | AlterForeignTableAlterColumnDropNotNull - | AlterForeignTableSetOptions; - -/** - * ALTER FOREIGN TABLE ... OWNER TO ... - */ -export class AlterForeignTableChangeOwner extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { foreignTable: ForeignTable; owner: string }) { - super(); - this.foreignTable = props.foreignTable; - this.owner = props.owner; - } - - get requires() { - return [this.foreignTable.stableId, stableId.role(this.owner)]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * ALTER FOREIGN TABLE ... ADD COLUMN ... - */ -export class AlterForeignTableAddColumn extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { foreignTable: ForeignTable; column: ColumnProps }) { - super(); - this.foreignTable = props.foreignTable; - this.column = props.column; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const parts = [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "ADD COLUMN", - this.column.name, - this.column.data_type_str, - ]; - - if (this.column.not_null) { - parts.push("NOT NULL"); - } - - if (this.column.default) { - parts.push("DEFAULT", this.column.default); - } - - return parts.join(" "); - } -} - -/** - * ALTER FOREIGN TABLE ... DROP COLUMN ... - */ -export class AlterForeignTableDropColumn extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly columnName: string; - public readonly scope = "object" as const; - - constructor(props: { foreignTable: ForeignTable; columnName: string }) { - super(); - this.foreignTable = props.foreignTable; - this.columnName = props.columnName; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "DROP COLUMN", - this.columnName, - ].join(" "); - } -} - -/** - * ALTER FOREIGN TABLE ... ALTER COLUMN ... TYPE ... - */ -export class AlterForeignTableAlterColumnType extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly columnName: string; - public readonly dataType: string; - public readonly scope = "object" as const; - - constructor(props: { - foreignTable: ForeignTable; - columnName: string; - dataType: string; - }) { - super(); - this.foreignTable = props.foreignTable; - this.columnName = props.columnName; - this.dataType = props.dataType; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "ALTER COLUMN", - this.columnName, - "TYPE", - this.dataType, - ].join(" "); - } -} - -/** - * ALTER FOREIGN TABLE ... ALTER COLUMN ... SET DEFAULT ... - */ -export class AlterForeignTableAlterColumnSetDefault extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly columnName: string; - public readonly defaultValue: string; - public readonly scope = "object" as const; - - constructor(props: { - foreignTable: ForeignTable; - columnName: string; - defaultValue: string; - }) { - super(); - this.foreignTable = props.foreignTable; - this.columnName = props.columnName; - this.defaultValue = props.defaultValue; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "ALTER COLUMN", - this.columnName, - "SET DEFAULT", - this.defaultValue, - ].join(" "); - } -} - -/** - * ALTER FOREIGN TABLE ... ALTER COLUMN ... DROP DEFAULT - */ -export class AlterForeignTableAlterColumnDropDefault extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly columnName: string; - public readonly scope = "object" as const; - - constructor(props: { foreignTable: ForeignTable; columnName: string }) { - super(); - this.foreignTable = props.foreignTable; - this.columnName = props.columnName; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "ALTER COLUMN", - this.columnName, - "DROP DEFAULT", - ].join(" "); - } -} - -/** - * ALTER FOREIGN TABLE ... ALTER COLUMN ... SET NOT NULL - */ -export class AlterForeignTableAlterColumnSetNotNull extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly columnName: string; - public readonly scope = "object" as const; - - constructor(props: { foreignTable: ForeignTable; columnName: string }) { - super(); - this.foreignTable = props.foreignTable; - this.columnName = props.columnName; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "ALTER COLUMN", - this.columnName, - "SET NOT NULL", - ].join(" "); - } -} - -/** - * ALTER FOREIGN TABLE ... ALTER COLUMN ... DROP NOT NULL - */ -export class AlterForeignTableAlterColumnDropNotNull extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly columnName: string; - public readonly scope = "object" as const; - - constructor(props: { foreignTable: ForeignTable; columnName: string }) { - super(); - this.foreignTable = props.foreignTable; - this.columnName = props.columnName; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "ALTER COLUMN", - this.columnName, - "DROP NOT NULL", - ].join(" "); - } -} - -/** - * ALTER FOREIGN TABLE ... OPTIONS ( ADD | SET | DROP ... ) - */ -export class AlterForeignTableSetOptions extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly options: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }>; - public readonly scope = "object" as const; - - constructor(props: { - foreignTable: ForeignTable; - options: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }>; - }) { - super(); - this.foreignTable = props.foreignTable; - this.options = props.options; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const optionParts: string[] = []; - for (const opt of this.options) { - if (opt.action === "DROP") { - optionParts.push(`DROP ${opt.option}`); - } else { - const value = - opt.value !== undefined - ? quoteLiteral(redactOptionValue(opt.option, opt.value)) - : "''"; - optionParts.push(`${opt.action} ${opt.option} ${value}`); - } - } - - return [ - "ALTER FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "OPTIONS", - `(${optionParts.join(", ")})`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.base.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.base.ts deleted file mode 100644 index 3dc61561f..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../../base.change.ts"; -import type { ForeignTable } from "../foreign-table.model.ts"; - -abstract class BaseForeignTableChange extends BaseChange { - abstract readonly foreignTable: ForeignTable; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "foreign_table" as const; -} - -export abstract class CreateForeignTableChange extends BaseForeignTableChange { - readonly operation = "create" as const; -} - -export abstract class AlterForeignTableChange extends BaseForeignTableChange { - readonly operation = "alter" as const; -} - -export abstract class DropForeignTableChange extends BaseForeignTableChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.comment.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.comment.ts deleted file mode 100644 index 0d9ff2d40..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.comment.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import type { ForeignTable } from "../foreign-table.model.ts"; -import { - CreateForeignTableChange, - DropForeignTableChange, -} from "./foreign-table.base.ts"; - -/** - * Create/drop comments on foreign tables. - */ - -export type CommentForeignTable = - | CreateCommentOnForeignTable - | DropCommentOnForeignTable; - -export class CreateCommentOnForeignTable extends CreateForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly scope = "comment" as const; - - constructor(props: { foreignTable: ForeignTable }) { - super(); - this.foreignTable = props.foreignTable; - } - - get creates() { - return [stableId.comment(this.foreignTable.stableId)]; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: comment is not nullable in this case - quoteLiteral(this.foreignTable.comment!), - ].join(" "); - } -} - -export class DropCommentOnForeignTable extends DropForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly scope = "comment" as const; - - constructor(props: { foreignTable: ForeignTable }) { - super(); - this.foreignTable = props.foreignTable; - } - - get drops() { - return [stableId.comment(this.foreignTable.stableId)]; - } - - get requires() { - return [ - stableId.comment(this.foreignTable.stableId), - this.foreignTable.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.test.ts deleted file mode 100644 index 699b22f7c..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { ForeignTable } from "../foreign-table.model.ts"; -import { CreateForeignTable } from "./foreign-table.create.ts"; - -describe("foreign-table", () => { - test("create basic", async () => { - const foreignTable = new ForeignTable({ - schema: "public", - name: "test_table", - owner: "test", - server: "test_server", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const change = new CreateForeignTable({ - foreignTable, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN TABLE public.test_table (id integer) SERVER test_server", - ); - }); - - test("create with multiple columns", async () => { - const foreignTable = new ForeignTable({ - schema: "public", - name: "test_table", - owner: "test", - server: "test_server", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const change = new CreateForeignTable({ - foreignTable, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN TABLE public.test_table (id integer, name text) SERVER test_server", - ); - }); - - test("create with options", async () => { - const foreignTable = new ForeignTable({ - schema: "public", - name: "test_table", - owner: "test", - server: "test_server", - options: ["schema_name", "remote_schema", "table_name", "remote_table"], - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const change = new CreateForeignTable({ - foreignTable, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN TABLE public.test_table (id integer) SERVER test_server OPTIONS (schema_name 'remote_schema', table_name 'remote_table')", - ); - }); - - test("create with all properties", async () => { - const foreignTable = new ForeignTable({ - schema: "public", - name: "test_table", - owner: "test", - server: "test_server", - options: ["schema_name", "remote_schema", "table_name", "remote_table"], - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const change = new CreateForeignTable({ - foreignTable, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE FOREIGN TABLE public.test_table (id integer, name text) SERVER test_server OPTIONS (schema_name 'remote_schema', table_name 'remote_table')", - ); - }); - - test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => { - // Foreign tables don't usually carry credentials, but a wrapper is - // free to define one — make sure the redaction policy still applies. - const foreignTable = new ForeignTable({ - schema: "public", - name: "leaky_table", - owner: "postgres", - server: "test_server", - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - options: [ - "schema_name", - "remote_schema", - "api_key", - "leaked-api-key", - "password", - "table-shared-secret", - ], - comment: null, - privileges: [], - }); - - const change = new CreateForeignTable({ - foreignTable, - }); - - await assertValidSql(change.serialize()); - - const sql = change.serialize(); - expect(sql).not.toContain("leaked-api-key"); - expect(sql).not.toContain("table-shared-secret"); - expect(sql).toContain("schema_name 'remote_schema'"); - expect(sql).toContain("api_key '__OPTION_API_KEY__'"); - expect(sql).toContain("password '__OPTION_PASSWORD__'"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts deleted file mode 100644 index 79438d99f..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import { redactOptionValue } from "../../sensitive-options.ts"; -import type { ForeignTable } from "../foreign-table.model.ts"; -import { CreateForeignTableChange } from "./foreign-table.base.ts"; - -/** - * Create a foreign table. - * - * @see https://www.postgresql.org/docs/17/sql-createforeigntable.html - * - * Synopsis - * ```sql - * CREATE FOREIGN TABLE [ IF NOT EXISTS ] table_name - * ( [ { column_name data_type [ OPTIONS ( option 'value' [, ... ] ) ] [ COLLATE collation ] [ column_constraint [ ... ] ] | table_constraint } [, ... ] ] ) - * SERVER server_name - * [ OPTIONS ( option 'value' [, ... ] ) ] - * ``` - */ -export class CreateForeignTable extends CreateForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly scope = "object" as const; - - constructor(props: { foreignTable: ForeignTable }) { - super(); - this.foreignTable = props.foreignTable; - } - - get creates() { - return [this.foreignTable.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.foreignTable.schema)); - - // Server dependency - dependencies.add(stableId.server(this.foreignTable.server)); - - // Owner dependency - dependencies.add(stableId.role(this.foreignTable.owner)); - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE FOREIGN TABLE"]; - - // Add schema and name - parts.push(`${this.foreignTable.schema}.${this.foreignTable.name}`); - - // Add columns - const columnDefs: string[] = []; - for (const col of this.foreignTable.columns) { - const colParts: string[] = [col.name, col.data_type_str]; - columnDefs.push(colParts.join(" ")); - } - parts.push(`(${columnDefs.join(", ")})`); - - // Add SERVER clause - parts.push("SERVER", this.foreignTable.server); - - // Add OPTIONS clause (table-level) - if (this.foreignTable.options && this.foreignTable.options.length > 0) { - const optionPairs: string[] = []; - for (let i = 0; i < this.foreignTable.options.length; i += 2) { - const key = this.foreignTable.options[i]; - const value = this.foreignTable.options[i + 1]; - if (key === undefined || value === undefined) continue; - optionPairs.push( - `${key} ${quoteLiteral(redactOptionValue(key, value))}`, - ); - } - if (optionPairs.length > 0) { - parts.push(`OPTIONS (${optionPairs.join(", ")})`); - } - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.drop.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.drop.test.ts deleted file mode 100644 index 3ccb3beaf..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.drop.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { ForeignTable } from "../foreign-table.model.ts"; -import { DropForeignTable } from "./foreign-table.drop.ts"; - -describe("foreign-table", () => { - test("drop", async () => { - const foreignTable = new ForeignTable({ - schema: "public", - name: "test_table", - owner: "test", - server: "test_server", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const change = new DropForeignTable({ - foreignTable, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP FOREIGN TABLE public.test_table"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.drop.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.drop.ts deleted file mode 100644 index e6164f83d..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.drop.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { ForeignTable } from "../foreign-table.model.ts"; -import { DropForeignTableChange } from "./foreign-table.base.ts"; - -/** - * Drop a foreign table. - * - * @see https://www.postgresql.org/docs/17/sql-dropforeigntable.html - * - * Synopsis - * ```sql - * DROP FOREIGN TABLE [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropForeignTable extends DropForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly scope = "object" as const; - - constructor(props: { foreignTable: ForeignTable }) { - super(); - this.foreignTable = props.foreignTable; - } - - get drops() { - return [this.foreignTable.stableId]; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "DROP FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.privilege.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.privilege.ts deleted file mode 100644 index ec1171589..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.privilege.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../../base.privilege.ts"; -import { stableId } from "../../../utils.ts"; -import type { ForeignTable } from "../foreign-table.model.ts"; -import { AlterForeignTableChange } from "./foreign-table.base.ts"; - -export type ForeignTablePrivilege = - | GrantForeignTablePrivileges - | RevokeForeignTablePrivileges - | RevokeGrantOptionForeignTablePrivileges; - -/** - * Grant privileges on a foreign table. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER } [, ...] | ALL [ PRIVILEGES ] } - * ON FOREIGN TABLE table_name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantForeignTablePrivileges extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - foreignTable: ForeignTable; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.foreignTable = props.foreignTable; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.foreignTable.stableId, this.grantee)]; - } - - get requires() { - return [this.foreignTable.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantForeignTablePrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("FOREIGN TABLE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList( - "FOREIGN TABLE", - list, - this.version, - ); - const tableName = `${this.foreignTable.schema}.${this.foreignTable.name}`; - return `GRANT ${privSql} ${kindPrefix} ${tableName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a foreign table. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER } [, ...] | ALL [ PRIVILEGES ] } - * ON FOREIGN TABLE table_name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeForeignTablePrivileges extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - foreignTable: ForeignTable; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.foreignTable = props.foreignTable; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - return [stableId.acl(this.foreignTable.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.foreignTable.stableId, this.grantee), - this.foreignTable.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("FOREIGN TABLE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList( - "FOREIGN TABLE", - list, - this.version, - ); - const tableName = `${this.foreignTable.schema}.${this.foreignTable.name}`; - return `REVOKE ${privSql} ${kindPrefix} ${tableName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a foreign table. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionForeignTablePrivileges extends AlterForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - foreignTable: ForeignTable; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.foreignTable = props.foreignTable; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.foreignTable.stableId, this.grantee), - this.foreignTable.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("FOREIGN TABLE"); - const privSql = formatObjectPrivilegeList( - "FOREIGN TABLE", - this.privilegeNames, - this.version, - ); - const tableName = `${this.foreignTable.schema}.${this.foreignTable.name}`; - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${tableName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.security-label.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.security-label.ts deleted file mode 100644 index 0db3c9665..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.security-label.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { quoteLiteral } from "../../../base.change.ts"; -import type { SecurityLabelProps } from "../../../security-label.types.ts"; -import { stableId } from "../../../utils.ts"; -import type { ForeignTable } from "../foreign-table.model.ts"; -import { - CreateForeignTableChange, - DropForeignTableChange, -} from "./foreign-table.base.ts"; - -export type SecurityLabelForeignTable = - | CreateSecurityLabelOnForeignTable - | DropSecurityLabelOnForeignTable; - -export class CreateSecurityLabelOnForeignTable extends CreateForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - foreignTable: ForeignTable; - securityLabel: SecurityLabelProps; - }) { - super(); - this.foreignTable = props.foreignTable; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.foreignTable.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.foreignTable.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnForeignTable extends DropForeignTableChange { - public readonly foreignTable: ForeignTable; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - foreignTable: ForeignTable; - securityLabel: SecurityLabelProps; - }) { - super(); - this.foreignTable = props.foreignTable; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.foreignTable.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.foreignTable.stableId, - this.securityLabel.provider, - ), - this.foreignTable.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON FOREIGN TABLE", - `${this.foreignTable.schema}.${this.foreignTable.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.types.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.types.ts deleted file mode 100644 index 25f4d9ec6..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterForeignTable } from "./foreign-table.alter.ts"; -import type { CommentForeignTable } from "./foreign-table.comment.ts"; -import type { CreateForeignTable } from "./foreign-table.create.ts"; -import type { DropForeignTable } from "./foreign-table.drop.ts"; -import type { ForeignTablePrivilege } from "./foreign-table.privilege.ts"; -import type { SecurityLabelForeignTable } from "./foreign-table.security-label.ts"; - -/** Union of all foreign-table-related change variants (`objectType: "foreign_table"`). @category Change Types */ -export type ForeignTableChange = - | AlterForeignTable - | CommentForeignTable - | CreateForeignTable - | DropForeignTable - | ForeignTablePrivilege - | SecurityLabelForeignTable; diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.diff.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.diff.test.ts deleted file mode 100644 index 42665eace..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.diff.test.ts +++ /dev/null @@ -1,813 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../../base.default-privileges.ts"; -import { - AlterForeignTableAddColumn, - AlterForeignTableAlterColumnDropDefault, - AlterForeignTableAlterColumnDropNotNull, - AlterForeignTableAlterColumnSetDefault, - AlterForeignTableAlterColumnSetNotNull, - AlterForeignTableAlterColumnType, - AlterForeignTableChangeOwner, - AlterForeignTableDropColumn, - AlterForeignTableSetOptions, -} from "./changes/foreign-table.alter.ts"; -import { CreateForeignTable } from "./changes/foreign-table.create.ts"; -import { DropForeignTable } from "./changes/foreign-table.drop.ts"; -import { diffForeignTables } from "./foreign-table.diff.ts"; -import { ForeignTable, type ForeignTableProps } from "./foreign-table.model.ts"; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("foreign-table.diff", () => { - test("create and drop", () => { - const props: ForeignTableProps = { - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }; - const table = new ForeignTable(props); - - const created = diffForeignTables( - testContext, - {}, - { - [table.stableId]: table, - }, - ); - expect(created[0]).toBeInstanceOf(CreateForeignTable); - - const dropped = diffForeignTables( - testContext, - { [table.stableId]: table }, - {}, - ); - expect(dropped[0]).toBeInstanceOf(DropForeignTable); - }); - - test("alter: owner change", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o2", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterForeignTableChangeOwner)).toBe( - true, - ); - }); - - test("alter: add column", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterForeignTableAddColumn)).toBe( - true, - ); - }); - - test("alter: drop column", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - { - name: "name", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterForeignTableDropColumn)).toBe( - true, - ); - }); - - test("alter: column type change", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "bigint", - data_type_str: "bigint", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterForeignTableAlterColumnType), - ).toBe(true); - }); - - test("alter: column set default", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "0", - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterForeignTableAlterColumnSetDefault), - ).toBe(true); - }); - - test("alter: column drop default", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "0", - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterForeignTableAlterColumnDropDefault), - ).toBe(true); - }); - - test("alter: column set not null", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterForeignTableAlterColumnSetNotNull), - ).toBe(true); - }); - - test("alter: column drop not null", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterForeignTableAlterColumnDropNotNull), - ).toBe(true); - }); - - test("alter: options changes", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: ["schema_name", "remote_schema"], - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: ["schema_name", "new_schema", "table_name", "remote_table"], - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - const optionsChange = changes.find( - (c) => c instanceof AlterForeignTableSetOptions, - ) as AlterForeignTableSetOptions | undefined; - expect(optionsChange).toBeDefined(); - expect(optionsChange?.options.length).toBeGreaterThan(0); - }); - - test("server change triggers drop and create", () => { - const main = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv1", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const branch = new ForeignTable({ - schema: "public", - name: "ft1", - owner: "o1", - server: "srv2", - options: null, - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffForeignTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - // Server change should trigger drop + create - expect(changes.some((c) => c instanceof DropForeignTable)).toBe(true); - expect(changes.some((c) => c instanceof CreateForeignTable)).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.diff.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.diff.ts deleted file mode 100644 index 3d4dc69a1..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.diff.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { diffObjects } from "../../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../../diff-context.ts"; -import { diffSecurityLabels } from "../../security-label.types.ts"; -import { - AlterForeignTableAddColumn, - AlterForeignTableAlterColumnDropDefault, - AlterForeignTableAlterColumnDropNotNull, - AlterForeignTableAlterColumnSetDefault, - AlterForeignTableAlterColumnSetNotNull, - AlterForeignTableAlterColumnType, - AlterForeignTableChangeOwner, - AlterForeignTableDropColumn, - AlterForeignTableSetOptions, -} from "./changes/foreign-table.alter.ts"; -import { - CreateCommentOnForeignTable, - DropCommentOnForeignTable, -} from "./changes/foreign-table.comment.ts"; -import { CreateForeignTable } from "./changes/foreign-table.create.ts"; -import { DropForeignTable } from "./changes/foreign-table.drop.ts"; -import { - GrantForeignTablePrivileges, - RevokeForeignTablePrivileges, - RevokeGrantOptionForeignTablePrivileges, -} from "./changes/foreign-table.privilege.ts"; -import { - CreateSecurityLabelOnForeignTable, - DropSecurityLabelOnForeignTable, -} from "./changes/foreign-table.security-label.ts"; -import type { ForeignTableChange } from "./changes/foreign-table.types.ts"; -import type { ForeignTable } from "./foreign-table.model.ts"; - -/** - * Diff two sets of foreign tables from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The foreign tables in the main catalog. - * @param branch - The foreign tables in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffForeignTables( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): ForeignTableChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: ForeignTableChange[] = []; - - for (const tableId of created) { - const createdTable = branch[tableId]; - changes.push(new CreateForeignTable({ foreignTable: createdTable })); - - // OWNER: If the table should be owned by someone other than the current user, - // emit ALTER FOREIGN TABLE ... OWNER TO after creation - if (createdTable.owner !== ctx.currentUser) { - changes.push( - new AlterForeignTableChangeOwner({ - foreignTable: createdTable, - owner: createdTable.owner, - }), - ); - } - - if (createdTable.comment !== null) { - changes.push( - new CreateCommentOnForeignTable({ foreignTable: createdTable }), - ); - } - for (const label of createdTable.security_labels) { - changes.push( - new CreateSecurityLabelOnForeignTable({ - foreignTable: createdTable, - securityLabel: label, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "foreign_table", - createdTable.schema ?? "", - ); - const creatorFilteredDefaults = - createdTable.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - const desiredPrivileges = filterPublicBuiltInDefaults( - "foreign_table", - createdTable.privileges, - ); - const privilegeResults = diffPrivileges( - filterPublicBuiltInDefaults("foreign_table", creatorFilteredDefaults), - desiredPrivileges, - createdTable.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - createdTable, - createdTable, - "foreignTable", - { - Grant: GrantForeignTablePrivileges, - Revoke: RevokeForeignTablePrivileges, - RevokeGrantOption: RevokeGrantOptionForeignTablePrivileges, - }, - ctx.version, - ) as ForeignTableChange[]), - ); - } - - for (const tableId of dropped) { - changes.push(new DropForeignTable({ foreignTable: main[tableId] })); - } - - for (const tableId of altered) { - const mainTable = main[tableId]; - const branchTable = branch[tableId]; - - // OWNER - if (mainTable.owner !== branchTable.owner) { - changes.push( - new AlterForeignTableChangeOwner({ - foreignTable: mainTable, - owner: branchTable.owner, - }), - ); - } - - // SERVER - if changed, need to recreate (not directly alterable) - if (mainTable.server !== branchTable.server) { - changes.push(new DropForeignTable({ foreignTable: mainTable })); - changes.push(new CreateForeignTable({ foreignTable: branchTable })); - if (branchTable.comment !== null) { - changes.push( - new CreateCommentOnForeignTable({ foreignTable: branchTable }), - ); - } - continue; - } - - // COLUMNS - const mainColumnsByName = new Map( - mainTable.columns.map((c) => [c.name, c]), - ); - const branchColumnsByName = new Map( - branchTable.columns.map((c) => [c.name, c]), - ); - - // Added columns - for (const [name, col] of branchColumnsByName) { - if (!mainColumnsByName.has(name)) { - changes.push( - new AlterForeignTableAddColumn({ - foreignTable: mainTable, - column: col, - }), - ); - } - } - - // Dropped columns - for (const [name] of mainColumnsByName) { - if (!branchColumnsByName.has(name)) { - changes.push( - new AlterForeignTableDropColumn({ - foreignTable: mainTable, - columnName: name, - }), - ); - } - } - - // Altered columns - for (const [name, mainCol] of mainColumnsByName) { - const branchCol = branchColumnsByName.get(name); - if (!branchCol) continue; - - // Type change - if (mainCol.data_type_str !== branchCol.data_type_str) { - changes.push( - new AlterForeignTableAlterColumnType({ - foreignTable: mainTable, - columnName: name, - dataType: branchCol.data_type_str, - }), - ); - } - - // Default change - if (mainCol.default !== branchCol.default) { - if (branchCol.default === null) { - changes.push( - new AlterForeignTableAlterColumnDropDefault({ - foreignTable: mainTable, - columnName: name, - }), - ); - } else { - changes.push( - new AlterForeignTableAlterColumnSetDefault({ - foreignTable: mainTable, - columnName: name, - defaultValue: branchCol.default, - }), - ); - } - } - - // NOT NULL change - if (mainCol.not_null !== branchCol.not_null) { - if (branchCol.not_null) { - changes.push( - new AlterForeignTableAlterColumnSetNotNull({ - foreignTable: mainTable, - columnName: name, - }), - ); - } else { - changes.push( - new AlterForeignTableAlterColumnDropNotNull({ - foreignTable: mainTable, - columnName: name, - }), - ); - } - } - } - - // OPTIONS - const optionsChanged = diffOptions(mainTable.options, branchTable.options); - if (optionsChanged.length > 0) { - changes.push( - new AlterForeignTableSetOptions({ - foreignTable: mainTable, - options: optionsChanged, - }), - ); - } - - // COMMENT - if (mainTable.comment !== branchTable.comment) { - if (branchTable.comment === null) { - changes.push( - new DropCommentOnForeignTable({ foreignTable: mainTable }), - ); - } else { - changes.push( - new CreateCommentOnForeignTable({ foreignTable: branchTable }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnForeignTable | DropSecurityLabelOnForeignTable - >( - mainTable.security_labels, - branchTable.security_labels, - (securityLabel) => - new CreateSecurityLabelOnForeignTable({ - foreignTable: branchTable, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnForeignTable({ - foreignTable: mainTable, - securityLabel, - }), - ), - ); - - // PRIVILEGES - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "foreign_table", - mainTable.privileges, - ); - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "foreign_table", - branchTable.privileges, - ); - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchTable.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchTable, - mainTable, - "foreignTable", - { - Grant: GrantForeignTablePrivileges, - Revoke: RevokeForeignTablePrivileges, - RevokeGrantOption: RevokeGrantOptionForeignTablePrivileges, - }, - ctx.version, - ) as ForeignTableChange[]), - ); - - // Note: Foreign table renaming would also use ALTER FOREIGN TABLE ... RENAME TO ... - // But since our ForeignTable model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - - return changes; -} - -/** - * Diff options arrays to determine ADD/SET/DROP operations. - * Options are stored as [key1, value1, key2, value2, ...] - */ -function diffOptions( - mainOptions: string[] | null, - branchOptions: string[] | null, -): Array<{ action: "ADD" | "SET" | "DROP"; option: string; value?: string }> { - const mainMap = new Map(); - const branchMap = new Map(); - - // Parse main options - if (mainOptions) { - for (let i = 0; i < mainOptions.length; i += 2) { - if (i + 1 < mainOptions.length) { - mainMap.set(mainOptions[i], mainOptions[i + 1]); - } - } - } - - // Parse branch options - if (branchOptions) { - for (let i = 0; i < branchOptions.length; i += 2) { - if (i + 1 < branchOptions.length) { - branchMap.set(branchOptions[i], branchOptions[i + 1]); - } - } - } - - const changes: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }> = []; - - // Find options to ADD or SET - for (const [key, value] of branchMap) { - const mainValue = mainMap.get(key); - if (mainValue === undefined) { - changes.push({ action: "ADD", option: key, value }); - } else if (mainValue !== value) { - changes.push({ action: "SET", option: key, value }); - } - } - - // Find options to DROP - for (const [key] of mainMap) { - if (!branchMap.has(key)) { - changes.push({ action: "DROP", option: key }); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts deleted file mode 100644 index 800592202..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { - BasePgModel, - columnPropsSchema, - type TableLikeObject, -} from "../../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../../base.privilege-diff.ts"; -import { - normalizeSecurityLabels, - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../../security-label.types.ts"; - -/** - * All properties exposed by CREATE FOREIGN TABLE statement are included in diff output. - * https://www.postgresql.org/docs/17/sql-createforeigntable.html - * - * ALTER FOREIGN TABLE statement can be generated for changes to the following properties: - * - owner, columns, options - * https://www.postgresql.org/docs/17/sql-alterforeigntable.html - * - * Foreign tables are schema-qualified and similar to regular tables but reference a server. - */ -const foreignTablePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - owner: z.string(), - server: z.string(), - options: z.array(z.string()).nullable(), - comment: z.string().nullable(), - columns: z.array(columnPropsSchema), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), - // Parent FDW handler/validator — filter metadata only, not in dataFields. - wrapper_handler: z.string().nullable().optional(), - wrapper_validator: z.string().nullable().optional(), -}); - -type ForeignTablePrivilegeProps = PrivilegeProps; -export type ForeignTableProps = z.infer; - -export class ForeignTable extends BasePgModel implements TableLikeObject { - public readonly schema: ForeignTableProps["schema"]; - public readonly name: ForeignTableProps["name"]; - public readonly owner: ForeignTableProps["owner"]; - public readonly server: ForeignTableProps["server"]; - public readonly options: ForeignTableProps["options"]; - public readonly comment: ForeignTableProps["comment"]; - public readonly columns: ForeignTableProps["columns"]; - public readonly privileges: ForeignTablePrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - public readonly wrapper_handler: ForeignTableProps["wrapper_handler"]; - public readonly wrapper_validator: ForeignTableProps["wrapper_validator"]; - - constructor(props: ForeignTableProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.owner = props.owner; - this.server = props.server; - this.options = props.options; - this.comment = props.comment; - this.columns = props.columns; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - this.wrapper_handler = props.wrapper_handler ?? null; - this.wrapper_validator = props.wrapper_validator ?? null; - } - - get stableId(): `foreignTable:${string}` { - return `foreignTable:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - owner: this.owner, - server: this.server, - options: this.options, - comment: this.comment, - columns: this.columns, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } - - override stableSnapshot() { - const normalizeColumns = () => - [...this.columns] - .map((col) => { - const { position: _pos, ...rest } = col as unknown as Record< - string, - unknown - >; - return rest; - }) - .sort((a, b) => { - const nameA = (a.name as string | undefined) ?? ""; - const nameB = (b.name as string | undefined) ?? ""; - return nameA.localeCompare(nameB); - }); - - return { - identity: this.identityFields, - data: { - ...this.dataFields, - columns: normalizeColumns(), - security_labels: normalizeSecurityLabels(this.security_labels), - }, - }; - } -} - -/** - * Extract `pg_foreign_table` rows into `ForeignTable` models. - * - * The returned models carry option values **verbatim** from - * `pg_foreign_table.ftoptions`, which means a wrapper that puts - * credentials at the table level (uncommon but possible) would expose - * them cleartext in memory. Always route through `extractCatalog` - * (which calls `normalizeCatalog`) before emitting options to any - * output channel — see CLI-1467 and - * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`. - */ -export async function extractForeignTables( - pool: Pool, -): Promise { - const { rows: tableRows } = await pool.query(sql` - with extension_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass - ), foreign_tables as ( - select - c.relnamespace::regnamespace::text as schema, - quote_ident(c.relname) as name, - c.relowner::regrole::text as owner, - quote_ident(srv.srvname) as server, - coalesce(ft.ftoptions, array[]::text[]) as options, - c.oid as oid, - case - when fdw.fdwhandler = 0 then null - else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname) - end as wrapper_handler, - case - when fdw.fdwvalidator = 0 then null - else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname) - end as wrapper_validator - from - pg_class c - inner join pg_foreign_table ft on ft.ftrelid = c.oid - inner join pg_foreign_server srv on srv.oid = ft.ftserver - inner join pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw - left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler - left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator - left outer join extension_oids e1 on c.oid = e1.objid - where - c.relkind = 'f' - and not c.relnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e1.objid is null - and not fdw.fdwname like any(array['pg\\_%']) - ) - select - ft.schema, - ft.name, - ft.owner, - ft.server, - ft.options, - ft.wrapper_handler, - ft.wrapper_validator, - obj_description(ft.oid, 'pg_class') as comment, - coalesce(json_agg( - case when a.attname is not null then - json_build_object( - 'name', quote_ident(a.attname), - 'position', a.attnum, - 'data_type', a.atttypid::regtype::text, - 'data_type_str', format_type(a.atttypid, a.atttypmod), - 'is_custom_type', ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema'), - 'custom_type_type', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typtype else null end, - 'custom_type_category', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typcategory else null end, - 'custom_type_schema', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typnamespace::regnamespace else null end, - 'custom_type_name', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then quote_ident(ty.typname) else null end, - 'not_null', a.attnotnull, - 'is_identity', a.attidentity != '', - 'is_identity_always', a.attidentity = 'a', - 'is_generated', a.attgenerated != '', - 'collation', ( - select quote_ident(c2.collname) - from pg_collation c2, pg_type t2 - where c2.oid = a.attcollation - and t2.oid = a.atttypid - and a.attcollation <> t2.typcollation - ), - 'default', pg_get_expr(ad.adbin, ad.adrelid), - 'comment', col_description(a.attrelid, a.attnum) - ) - end - order by a.attnum - ) filter (where a.attname is not null), '[]') as columns, - coalesce(( - select json_agg( - json_build_object( - 'grantee', case when grp.grantee = 0 then 'PUBLIC' else grp.grantee::regrole::text end, - 'privilege', grp.privilege_type, - 'grantable', grp.is_grantable, - 'columns', case when grp.cols is not null and array_length(grp.cols,1) > 0 - then grp.cols - else null end - ) - order by grp.grantee, grp.privilege_type - ) - from ( - select - x.grantee, - x.privilege_type, - bool_or(x.is_grantable) as is_grantable, - array_agg(quote_ident(src.attname) order by src.attname) - filter (where src.attname is not null) as cols - from ( - -- one row for object ACL + one row per column ACL - select null::name as attname, ft.oid as relacl_oid, ( - select COALESCE(c_rel.relacl, acldefault('r', c_rel.relowner)) from pg_class c_rel where c_rel.oid = ft.oid - ) as acl - union all - select a2.attname, ft.oid as relacl_oid, a2.attacl - from pg_attribute a2 - where a2.attrelid = ft.oid - and a2.attnum > 0 - and not a2.attisdropped - and a2.attacl is not null - ) as src - join lateral aclexplode(src.acl) as x(grantor, grantee, privilege_type, is_grantable) on true - group by x.grantee, x.privilege_type - ) as grp - ), '[]') as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = ft.oid - and sl.classoid = 'pg_class'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels - from - foreign_tables ft - left join pg_attribute a on a.attrelid = ft.oid and a.attnum > 0 and not a.attisdropped - left join pg_attrdef ad on a.attrelid = ad.adrelid and a.attnum = ad.adnum - left join pg_type ty on ty.oid = a.atttypid - group by - ft.oid, - ft.schema, - ft.name, - ft.owner, - ft.server, - ft.options, - ft.wrapper_handler, - ft.wrapper_validator - order by - ft.schema, ft.name - `); - - // Validate and parse each row using the Zod schema - const validatedRows = tableRows.map((row: unknown) => { - const parsed = foreignTablePropsSchema.parse(row); - // Parse options from PostgreSQL format ['key=value'] to ['key', 'value'] - if (parsed.options && parsed.options.length > 0) { - const parsedOptions: string[] = []; - for (const opt of parsed.options) { - const eqIndex = opt.indexOf("="); - if (eqIndex > 0) { - parsedOptions.push(opt.substring(0, eqIndex)); - parsedOptions.push(opt.substring(eqIndex + 1)); - } - } - parsed.options = parsedOptions.length > 0 ? parsedOptions : null; - } - return parsed; - }); - return validatedRows.map((row: ForeignTableProps) => new ForeignTable(row)); -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.test.ts deleted file mode 100644 index 286603775..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - redactOptionValue, - redactSensitiveOptionPairs, -} from "./sensitive-options.ts"; - -describe("sensitive-options", () => { - test("preserves allowlisted connection / behavior options (case-insensitive)", () => { - // One assertion per allowlist family so an accidental drop is caught - // here instead of silently turning real plans into placeholder soup. - expect(redactOptionValue("host", "prod.example.com")).toBe( - "prod.example.com", - ); - expect(redactOptionValue("HOST", "prod.example.com")).toBe( - "prod.example.com", - ); - expect(redactOptionValue("port", "5432")).toBe("5432"); - expect(redactOptionValue("dbname", "appdb")).toBe("appdb"); - expect(redactOptionValue("user", "fdw_reader")).toBe("fdw_reader"); - expect(redactOptionValue("sslmode", "require")).toBe("require"); - expect(redactOptionValue("fetch_size", "200")).toBe("200"); - expect(redactOptionValue("schema_name", "public")).toBe("public"); - expect(redactOptionValue("region", "us-east-1")).toBe("us-east-1"); - }); - - test("redacts unknown / credential-shaped keys to the placeholder", () => { - // None of these are in the allowlist; the policy is default-redact, so - // they all collapse to `__OPTION___` regardless of the value. - expect(redactOptionValue("password", "supersecret")).toBe( - "__OPTION_PASSWORD__", - ); - expect(redactOptionValue("PASSWORD", "supersecret")).toBe( - "__OPTION_PASSWORD__", - ); - expect(redactOptionValue("passfile", "/etc/passfile")).toBe( - "__OPTION_PASSFILE__", - ); - expect(redactOptionValue("sslpassword", "x")).toBe( - "__OPTION_SSLPASSWORD__", - ); - expect(redactOptionValue("api_key", "x")).toBe("__OPTION_API_KEY__"); - expect(redactOptionValue("aws_secret_access_key", "x")).toBe( - "__OPTION_AWS_SECRET_ACCESS_KEY__", - ); - // An unrecognized FDW option key — default-redact catches it even - // though we have not enumerated this wrapper. - expect(redactOptionValue("brand_new_wrapper_token", "x")).toBe( - "__OPTION_BRAND_NEW_WRAPPER_TOKEN__", - ); - }); - - test("matching is exact (not substring)", () => { - // `host` is allowlisted but `host_addr` is not — substring matches must - // not promote unknown keys into the allowlist. - expect(redactOptionValue("host_addr", "10.0.0.1")).toBe( - "__OPTION_HOST_ADDR__", - ); - // Inverse direction: `password_validator_extension` is not in the - // allowlist, and must not be accidentally allowlisted because some - // future loose-match scheme thought "password" looked similar. - expect( - redactOptionValue("password_validator_extension", "passwordcheck"), - ).toBe("__OPTION_PASSWORD_VALIDATOR_EXTENSION__"); - }); - - test("redactSensitiveOptionPairs preserves safe keys and redacts the rest", () => { - expect( - redactSensitiveOptionPairs([ - "host", - "localhost", - "port", - "5432", - "password", - "supersecret", - "passfile", - "/etc/secrets/passfile", - "brand_new_wrapper_token", - "leaked", - ]), - ).toEqual([ - "host", - "localhost", - "port", - "5432", - "password", - "__OPTION_PASSWORD__", - "passfile", - "__OPTION_PASSFILE__", - "brand_new_wrapper_token", - "__OPTION_BRAND_NEW_WRAPPER_TOKEN__", - ]); - }); - - test("redactSensitiveOptionPairs handles null and empty input", () => { - expect(redactSensitiveOptionPairs(null)).toBeNull(); - expect(redactSensitiveOptionPairs([])).toEqual([]); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts deleted file mode 100644 index 48de9bae5..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Sensitive-option redaction for foreign-data-wrapper objects. - * - * Foreign servers (`pg_foreign_server.srvoptions`) and user mappings - * (`pg_user_mapping.umoptions`) store libpq/FDW credentials in cleartext. - * Any code path that emits these option values verbatim — plan SQL, catalog - * snapshots, declarative export, fingerprints — leaks the credentials to - * disk, stdout, CI logs, and version control. - * - * The redaction policy is **allowlist-based**: replace every option value - * with `__OPTION___` unless the option key appears in - * {@link SAFE_OPTION_KEYS}. Failure mode of a missing entry is "the plan - * shows the placeholder instead of the real value" — annoying, but safe; - * a denylist's failure mode was secrets leaking, which is the bug we are - * fixing (CLI-1467). - * - * Match is case-insensitive but exact — substrings do not match, so an - * option key like `password_validator_extension` will be redacted unless - * explicitly allowlisted. When a new wrapper introduces a non-credential - * key we want to surface in plans, add it here. - */ - -const SAFE_OPTION_KEYS = new Set([ - // libpq connection params (non-credential subset). - // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS - "host", - "hostaddr", - "port", - "dbname", - "user", - "sslmode", - "sslcompression", - "sslcert", - "sslkey", - "sslrootcert", - "sslcrl", - "sslcrldir", - "sslsni", - "requirepeer", - "krbsrvname", - "gsslib", - "sspi", - "gssencmode", - "gssdelegation", - "channel_binding", - "target_session_attrs", - "application_name", - "fallback_application_name", - "connect_timeout", - "client_encoding", - "options", - "keepalives", - "keepalives_idle", - "keepalives_interval", - "keepalives_count", - "tcp_user_timeout", - "replication", - "load_balance_hosts", - // postgres_fdw behavior tuning. - // https://www.postgresql.org/docs/current/postgres-fdw.html#POSTGRES-FDW-OPTIONS-CONNECTION - "use_remote_estimate", - "fdw_startup_cost", - "fdw_tuple_cost", - "fetch_size", - "batch_size", - "async_capable", - "analyze_sampling", - "parallel_commit", - "parallel_abort", - "extensions", - "updatable", - "truncatable", - "schema_name", - "table_name", - "column_name", - // Common shape for table-like FDWs (file_fdw, cloud-storage wrappers). - "schema", - "database", - "table", - "format", - "header", - "delimiter", - "quote", - "escape", - "encoding", - "compression", - // Cloud / Supabase Wrappers non-credential shape. - // https://github.com/supabase/wrappers - "region", - "endpoint", - "bucket", - "prefix", - "location", - "project_id", - "dataset_id", - "dataset", - "workspace", - "organization", - "api_version", -]); - -function redactedOptionPlaceholder(key: string): string { - return `__OPTION_${key.toUpperCase()}__`; -} - -export function redactOptionValue(key: string, value: string): string { - return SAFE_OPTION_KEYS.has(key.toLowerCase()) - ? value - : redactedOptionPlaceholder(key); -} - -/** - * Redact non-allowlisted values in a flat `[key, value, key, value, ...]` - * options array — the shape used by the {@link Server}, - * {@link UserMapping}, {@link ForeignDataWrapper}, and {@link ForeignTable} - * models. - * - * Returns `null` for `null` input, and otherwise returns an array of the - * same length as the input with sensitive values replaced. - */ -export function redactSensitiveOptionPairs( - options: readonly string[] | null, -): string[] | null { - if (options === null) return null; - const result: string[] = []; - for (let i = 0; i < options.length; i += 2) { - const key = options[i]; - const value = options[i + 1]; - if (key === undefined || value === undefined) continue; - result.push(key, redactOptionValue(key, value)); - } - return result; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.alter.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.alter.test.ts deleted file mode 100644 index 3aa975fbf..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.alter.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Server, type ServerProps } from "../server.model.ts"; -import { - AlterServerChangeOwner, - AlterServerSetOptions, - AlterServerSetVersion, -} from "./server.alter.ts"; - -describe.concurrent("server", () => { - describe("alter", () => { - test("change owner", async () => { - const props: ServerProps = { - name: "test_server", - owner: "old_owner", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - const change = new AlterServerChangeOwner({ - server, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SERVER test_server OWNER TO new_owner", - ); - }); - - test("set version", async () => { - const props: ServerProps = { - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - const change = new AlterServerSetVersion({ - server, - version: "2.0", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("ALTER SERVER test_server VERSION '2.0'"); - }); - - test("set version to null", async () => { - const props: ServerProps = { - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: "1.0", - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - const change = new AlterServerSetVersion({ - server, - version: null, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("ALTER SERVER test_server VERSION ''"); - }); - - test("set options ADD", async () => { - const props: ServerProps = { - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - const change = new AlterServerSetOptions({ - server, - options: [ - { action: "ADD", option: "host", value: "localhost" }, - { action: "ADD", option: "port", value: "5432" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SERVER test_server OPTIONS (ADD host 'localhost', ADD port '5432')", - ); - }); - - test("set options SET", async () => { - const props: ServerProps = { - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - const change = new AlterServerSetOptions({ - server, - options: [{ action: "SET", option: "host", value: "newhost" }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SERVER test_server OPTIONS (SET host 'newhost')", - ); - }); - - test("set options DROP", async () => { - const props: ServerProps = { - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - const change = new AlterServerSetOptions({ - server, - options: [{ action: "DROP", option: "host" }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SERVER test_server OPTIONS (DROP host)", - ); - }); - - test("set options mixed ADD/SET/DROP", async () => { - const props: ServerProps = { - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - const change = new AlterServerSetOptions({ - server, - options: [ - { action: "ADD", option: "host", value: "localhost" }, - { action: "SET", option: "port", value: "5433" }, - { action: "DROP", option: "dbname" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SERVER test_server OPTIONS (ADD host 'localhost', SET port '5433', DROP dbname)", - ); - }); - - test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => { - const props: ServerProps = { - name: "live_risk_server", - owner: "postgres", - foreign_data_wrapper: "postgres_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - const change = new AlterServerSetOptions({ - server, - options: [ - { action: "ADD", option: "password", value: "server-shared-secret" }, - { action: "SET", option: "host", value: "remote.example.com" }, - { - action: "ADD", - option: "passfile", - value: "/etc/secrets/passfile", - }, - ], - }); - - await assertValidSql(change.serialize()); - - const sql = change.serialize(); - expect(sql).not.toContain("server-shared-secret"); - expect(sql).not.toContain("/etc/secrets/passfile"); - expect(sql).toContain("SET host 'remote.example.com'"); - expect(sql).toContain("ADD password '__OPTION_PASSWORD__'"); - expect(sql).toContain("ADD passfile '__OPTION_PASSFILE__'"); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.alter.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.alter.ts deleted file mode 100644 index 850d29684..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.alter.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import { redactOptionValue } from "../../sensitive-options.ts"; -import type { Server } from "../server.model.ts"; -import { AlterServerChange } from "./server.base.ts"; - -/** - * Alter a server. - * - * @see https://www.postgresql.org/docs/17/sql-alterserver.html - * - * Synopsis - * ```sql - * ALTER SERVER name [ VERSION 'new_version' ] - * [ OPTIONS ( [ ADD | SET | DROP ] option ['value'] [, ... ] ) ] - * ALTER SERVER name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ``` - */ - -export type AlterServer = - | AlterServerChangeOwner - | AlterServerSetOptions - | AlterServerSetVersion; - -/** - * ALTER SERVER ... OWNER TO ... - */ -export class AlterServerChangeOwner extends AlterServerChange { - public readonly server: Server; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { server: Server; owner: string }) { - super(); - this.server = props.server; - this.owner = props.owner; - } - - get requires() { - return [this.server.stableId, stableId.role(this.owner)]; - } - - serialize(_options?: SerializeOptions): string { - return ["ALTER SERVER", this.server.name, "OWNER TO", this.owner].join(" "); - } -} - -/** - * ALTER SERVER ... VERSION ... - */ -export class AlterServerSetVersion extends AlterServerChange { - public readonly server: Server; - public readonly version: string | null; - public readonly scope = "object" as const; - - constructor(props: { server: Server; version: string | null }) { - super(); - this.server = props.server; - this.version = props.version; - } - - get requires() { - return [this.server.stableId]; - } - - serialize(_options?: SerializeOptions): string { - if (this.version === null) { - // PostgreSQL doesn't support removing version, but we'll handle it - return ["ALTER SERVER", this.server.name, "VERSION", "''"].join(" "); - } - return [ - "ALTER SERVER", - this.server.name, - "VERSION", - quoteLiteral(this.version), - ].join(" "); - } -} - -/** - * ALTER SERVER ... OPTIONS ( ADD | SET | DROP ... ) - */ -export class AlterServerSetOptions extends AlterServerChange { - public readonly server: Server; - public readonly options: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }>; - public readonly scope = "object" as const; - - constructor(props: { - server: Server; - options: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }>; - }) { - super(); - this.server = props.server; - this.options = props.options; - } - - get requires() { - return [this.server.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const optionParts: string[] = []; - for (const opt of this.options) { - if (opt.action === "DROP") { - optionParts.push(`DROP ${opt.option}`); - } else { - const value = - opt.value !== undefined - ? quoteLiteral(redactOptionValue(opt.option, opt.value)) - : "''"; - optionParts.push(`${opt.action} ${opt.option} ${value}`); - } - } - - return [ - "ALTER SERVER", - this.server.name, - "OPTIONS", - `(${optionParts.join(", ")})`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.base.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.base.ts deleted file mode 100644 index 9a863d7bb..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../../base.change.ts"; -import type { Server } from "../server.model.ts"; - -abstract class BaseServerChange extends BaseChange { - abstract readonly server: Server; - abstract readonly scope: "object" | "comment" | "privilege"; - readonly objectType = "server" as const; -} - -export abstract class CreateServerChange extends BaseServerChange { - readonly operation = "create" as const; -} - -export abstract class AlterServerChange extends BaseServerChange { - readonly operation = "alter" as const; -} - -export abstract class DropServerChange extends BaseServerChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.comment.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.comment.ts deleted file mode 100644 index 104f9879b..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.comment.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import type { Server } from "../server.model.ts"; -import { CreateServerChange, DropServerChange } from "./server.base.ts"; - -/** - * Create/drop comments on servers. - */ - -export type CommentServer = CreateCommentOnServer | DropCommentOnServer; - -export class CreateCommentOnServer extends CreateServerChange { - public readonly server: Server; - public readonly scope = "comment" as const; - - constructor(props: { server: Server }) { - super(); - this.server = props.server; - } - - get creates() { - return [stableId.comment(this.server.stableId)]; - } - - get requires() { - return [this.server.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON SERVER", - this.server.name, - "IS", - // biome-ignore lint/style/noNonNullAssertion: comment is not nullable in this case - quoteLiteral(this.server.comment!), - ].join(" "); - } -} - -export class DropCommentOnServer extends DropServerChange { - public readonly server: Server; - public readonly scope = "comment" as const; - - constructor(props: { server: Server }) { - super(); - this.server = props.server; - } - - get drops() { - return [stableId.comment(this.server.stableId)]; - } - - get requires() { - return [stableId.comment(this.server.stableId), this.server.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["COMMENT ON SERVER", this.server.name, "IS NULL"].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.create.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.create.test.ts deleted file mode 100644 index 9f2083102..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.create.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Server } from "../server.model.ts"; -import { CreateServer } from "./server.create.ts"; - -describe("server", () => { - test("create basic", async () => { - const server = new Server({ - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateServer({ - server, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw", - ); - }); - - test("create with type", async () => { - const server = new Server({ - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: "postgres_fdw", - version: null, - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateServer({ - server, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE SERVER test_server TYPE 'postgres_fdw' FOREIGN DATA WRAPPER test_fdw", - ); - }); - - test("create with version", async () => { - const server = new Server({ - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: "1.0", - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateServer({ - server, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE SERVER test_server VERSION '1.0' FOREIGN DATA WRAPPER test_fdw", - ); - }); - - test("create with type and version", async () => { - const server = new Server({ - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: "postgres_fdw", - version: "1.0", - options: null, - comment: null, - privileges: [], - }); - - const change = new CreateServer({ - server, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE SERVER test_server TYPE 'postgres_fdw' VERSION '1.0' FOREIGN DATA WRAPPER test_fdw", - ); - }); - - test("create with options", async () => { - const server = new Server({ - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: ["host", "localhost", "port", "5432"], - comment: null, - privileges: [], - }); - - const change = new CreateServer({ - server, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw OPTIONS (host 'localhost', port '5432')", - ); - }); - - test("create with all properties", async () => { - const server = new Server({ - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: "postgres_fdw", - version: "1.0", - options: ["host", "localhost", "port", "5432"], - comment: null, - privileges: [], - }); - - const change = new CreateServer({ - server, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE SERVER test_server TYPE 'postgres_fdw' VERSION '1.0' FOREIGN DATA WRAPPER test_fdw OPTIONS (host 'localhost', port '5432')", - ); - }); - - test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => { - const server = new Server({ - name: "live_risk_server", - owner: "postgres", - foreign_data_wrapper: "postgres_fdw", - type: null, - version: null, - options: [ - "host", - "remote.example.com", - "port", - "5432", - "password", - "server-shared-secret", - "passfile", - "/etc/secrets/passfile", - ], - comment: null, - privileges: [], - }); - - const change = new CreateServer({ - server, - }); - - await assertValidSql(change.serialize()); - - const sql = change.serialize(); - expect(sql).not.toContain("server-shared-secret"); - expect(sql).not.toContain("/etc/secrets/passfile"); - expect(sql).toContain("host 'remote.example.com'"); - expect(sql).toContain("port '5432'"); - expect(sql).toContain("password '__OPTION_PASSWORD__'"); - expect(sql).toContain("passfile '__OPTION_PASSFILE__'"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.create.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.create.ts deleted file mode 100644 index f22c624f4..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.create.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import { redactOptionValue } from "../../sensitive-options.ts"; -import type { Server } from "../server.model.ts"; -import { CreateServerChange } from "./server.base.ts"; - -/** - * Create a server. - * - * @see https://www.postgresql.org/docs/17/sql-createserver.html - * - * Synopsis - * ```sql - * CREATE SERVER [ IF NOT EXISTS ] server_name [ TYPE 'server_type' ] [ VERSION 'server_version' ] - * FOREIGN DATA WRAPPER fdw_name - * [ OPTIONS ( option 'value' [, ... ] ) ] - * ``` - */ -export class CreateServer extends CreateServerChange { - public readonly server: Server; - public readonly scope = "object" as const; - - constructor(props: { server: Server }) { - super(); - this.server = props.server; - } - - get creates() { - return [this.server.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Foreign Data Wrapper dependency - dependencies.add( - stableId.foreignDataWrapper(this.server.foreign_data_wrapper), - ); - - // Owner dependency - dependencies.add(stableId.role(this.server.owner)); - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE SERVER"]; - - // Add server name - parts.push(this.server.name); - - // Add TYPE clause - if (this.server.type) { - parts.push("TYPE", quoteLiteral(this.server.type)); - } - - // Add VERSION clause - if (this.server.version) { - parts.push("VERSION", quoteLiteral(this.server.version)); - } - - // Add FOREIGN DATA WRAPPER clause - parts.push("FOREIGN DATA WRAPPER", this.server.foreign_data_wrapper); - - // Add OPTIONS clause - if (this.server.options && this.server.options.length > 0) { - const optionPairs: string[] = []; - for (let i = 0; i < this.server.options.length; i += 2) { - const key = this.server.options[i]; - const value = this.server.options[i + 1]; - if (key === undefined || value === undefined) continue; - optionPairs.push( - `${key} ${quoteLiteral(redactOptionValue(key, value))}`, - ); - } - if (optionPairs.length > 0) { - parts.push(`OPTIONS (${optionPairs.join(", ")})`); - } - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.drop.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.drop.test.ts deleted file mode 100644 index 0e89799ae..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.drop.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Server } from "../server.model.ts"; -import { DropServer } from "./server.drop.ts"; - -describe("server", () => { - test("drop", async () => { - const server = new Server({ - name: "test_server", - owner: "test", - foreign_data_wrapper: "test_fdw", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }); - - const change = new DropServer({ - server, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP SERVER test_server"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.drop.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.drop.ts deleted file mode 100644 index 1b72a2f00..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { Server } from "../server.model.ts"; -import { DropServerChange } from "./server.base.ts"; - -/** - * Drop a server. - * - * @see https://www.postgresql.org/docs/17/sql-dropserver.html - * - * Synopsis - * ```sql - * DROP SERVER [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropServer extends DropServerChange { - public readonly server: Server; - public readonly scope = "object" as const; - - constructor(props: { server: Server }) { - super(); - this.server = props.server; - } - - get drops() { - return [this.server.stableId]; - } - - get requires() { - return [this.server.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP SERVER", this.server.name].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.privilege.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.privilege.ts deleted file mode 100644 index d9021e558..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.privilege.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { formatObjectPrivilegeList } from "../../../base.privilege.ts"; -import { stableId } from "../../../utils.ts"; -import type { Server } from "../server.model.ts"; -import { AlterServerChange } from "./server.base.ts"; - -export type ServerPrivilege = - | GrantServerPrivileges - | RevokeServerPrivileges - | RevokeGrantOptionServerPrivileges; - -/** - * Grant privileges on a server. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { USAGE | ALL [ PRIVILEGES ] } - * ON SERVER server_name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantServerPrivileges extends AlterServerChange { - public readonly server: Server; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - server: Server; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.server = props.server; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.server.stableId, this.grantee)]; - } - - get requires() { - return [this.server.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantServerPrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("SERVER", list, this.version); - return `GRANT ${privSql} ON SERVER ${this.server.name} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a server. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { USAGE | ALL [ PRIVILEGES ] } - * ON SERVER server_name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeServerPrivileges extends AlterServerChange { - public readonly server: Server; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - server: Server; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.server = props.server; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - return [stableId.acl(this.server.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.server.stableId, this.grantee), - this.server.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("SERVER", list, this.version); - return `REVOKE ${privSql} ON SERVER ${this.server.name} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a server. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionServerPrivileges extends AlterServerChange { - public readonly server: Server; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - server: Server; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.server = props.server; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.server.stableId, this.grantee), - this.server.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const privSql = formatObjectPrivilegeList( - "SERVER", - this.privilegeNames, - this.version, - ); - return `REVOKE GRANT OPTION FOR ${privSql} ON SERVER ${this.server.name} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.types.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.types.ts deleted file mode 100644 index 7ffa204b7..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/changes/server.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { AlterServer } from "./server.alter.ts"; -import type { CommentServer } from "./server.comment.ts"; -import type { CreateServer } from "./server.create.ts"; -import type { DropServer } from "./server.drop.ts"; -import type { ServerPrivilege } from "./server.privilege.ts"; - -/** Union of all server-related change variants (`objectType: "server"`). @category Change Types */ -export type ServerChange = - | AlterServer - | CommentServer - | CreateServer - | DropServer - | ServerPrivilege; diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.diff.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.diff.test.ts deleted file mode 100644 index 7ed1a0184..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.diff.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../../base.default-privileges.ts"; -import { - AlterServerChangeOwner, - AlterServerSetOptions, - AlterServerSetVersion, -} from "./changes/server.alter.ts"; -import { - CreateCommentOnServer, - DropCommentOnServer, -} from "./changes/server.comment.ts"; -import { CreateServer } from "./changes/server.create.ts"; -import { DropServer } from "./changes/server.drop.ts"; -import { - GrantServerPrivileges, - RevokeGrantOptionServerPrivileges, - RevokeServerPrivileges, -} from "./changes/server.privilege.ts"; -import { diffServers } from "./server.diff.ts"; -import { Server, type ServerProps } from "./server.model.ts"; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("server.diff", () => { - test("create and drop", () => { - const props: ServerProps = { - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const server = new Server(props); - - const created = diffServers(testContext, {}, { [server.stableId]: server }); - expect(created[0]).toBeInstanceOf(CreateServer); - - const dropped = diffServers(testContext, { [server.stableId]: server }, {}); - expect(dropped[0]).toBeInstanceOf(DropServer); - }); - - test("alter: owner change", () => { - const main = new Server({ - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }); - const branch = new Server({ - name: "srv1", - owner: "o2", - foreign_data_wrapper: "fdw1", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }); - - const changes = diffServers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterServerChangeOwner)).toBe(true); - }); - - test("alter: version change", () => { - const main = new Server({ - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: "1.0", - options: null, - comment: null, - privileges: [], - }); - const branch = new Server({ - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: "2.0", - options: null, - comment: null, - privileges: [], - }); - - const changes = diffServers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterServerSetVersion)).toBe(true); - }); - - test("alter: options changes", () => { - // Use non-env-dependent options to test that option changes still work - const main = new Server({ - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: null, - options: ["fetch_size", "100"], - comment: null, - privileges: [], - }); - const branch = new Server({ - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: null, - options: ["fetch_size", "200", "use_remote_estimate", "true"], - comment: null, - privileges: [], - }); - - const changes = diffServers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - const optionsChange = changes.find( - (c) => c instanceof AlterServerSetOptions, - ) as AlterServerSetOptions | undefined; - expect(optionsChange).toBeDefined(); - expect(optionsChange?.options.length).toBeGreaterThan(0); - }); - - test("type change triggers drop and create", () => { - const main = new Server({ - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: "old_type", - version: null, - options: null, - comment: null, - privileges: [], - }); - const branch = new Server({ - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: "new_type", - version: null, - options: null, - comment: null, - privileges: [], - }); - - const changes = diffServers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - // Type change should trigger drop + create - expect(changes.some((c) => c instanceof DropServer)).toBe(true); - expect(changes.some((c) => c instanceof CreateServer)).toBe(true); - }); - - test("created with privileges emits grant", () => { - const server = new Server({ - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: null, - options: null, - comment: null, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: false }, - ], - }); - const changes = diffServers(testContext, {}, { [server.stableId]: server }); - expect(changes[0]).toBeInstanceOf(CreateServer); - expect(changes.some((c) => c instanceof GrantServerPrivileges)).toBe(true); - }); - - test("altered comment emits create/drop comment", () => { - const base: ServerProps = { - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const main = new Server(base); - const withComment = new Server({ ...base, comment: "my server" }); - - const addComment = diffServers( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment[0]).toBeInstanceOf(CreateCommentOnServer); - - const dropComment = diffServers( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment[0]).toBeInstanceOf(DropCommentOnServer); - }); - - test("altered privileges emit grant, revoke, and revoke grant option", () => { - const base: ServerProps = { - name: "srv1", - owner: "o1", - foreign_data_wrapper: "fdw1", - type: null, - version: null, - options: null, - comment: null, - privileges: [], - }; - const main = new Server({ - ...base, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: false }, - { grantee: "role_with_option", privilege: "USAGE", grantable: true }, - { grantee: "role_removed", privilege: "USAGE", grantable: false }, - ], - }); - const branch = new Server({ - ...base, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: true }, - { grantee: "role_with_option", privilege: "USAGE", grantable: false }, - { grantee: "role_new", privilege: "USAGE", grantable: false }, - ], - }); - const changes = diffServers( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof GrantServerPrivileges)).toBe(true); - expect(changes.some((c) => c instanceof RevokeServerPrivileges)).toBe(true); - expect( - changes.some((c) => c instanceof RevokeGrantOptionServerPrivileges), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.diff.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.diff.ts deleted file mode 100644 index 3df8638dd..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.diff.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { diffObjects } from "../../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../../diff-context.ts"; -import { - AlterServerChangeOwner, - AlterServerSetOptions, - AlterServerSetVersion, -} from "./changes/server.alter.ts"; -import { - CreateCommentOnServer, - DropCommentOnServer, -} from "./changes/server.comment.ts"; -import { CreateServer } from "./changes/server.create.ts"; -import { DropServer } from "./changes/server.drop.ts"; -import { - GrantServerPrivileges, - RevokeGrantOptionServerPrivileges, - RevokeServerPrivileges, -} from "./changes/server.privilege.ts"; -import type { ServerChange } from "./changes/server.types.ts"; -import type { Server } from "./server.model.ts"; - -/** - * Diff two sets of servers from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The servers in the main catalog. - * @param branch - The servers in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffServers( - ctx: Pick, - main: Record, - branch: Record, -): ServerChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: ServerChange[] = []; - - for (const serverId of created) { - const createdServer = branch[serverId]; - changes.push(new CreateServer({ server: createdServer })); - - // OWNER: If the server should be owned by someone other than the current user, - // emit ALTER SERVER ... OWNER TO after creation - if (createdServer.owner !== ctx.currentUser) { - changes.push( - new AlterServerChangeOwner({ - server: createdServer, - owner: createdServer.owner, - }), - ); - } - - if (createdServer.comment !== null) { - changes.push(new CreateCommentOnServer({ server: createdServer })); - } - - // PRIVILEGES: Servers don't have default privileges - const effectiveDefaults: Array<{ - grantee: string; - privilege: string; - grantable: boolean; - }> = []; - const desiredPrivileges = filterPublicBuiltInDefaults( - "server", - createdServer.privileges, - ); - const privilegeResults = diffPrivileges( - effectiveDefaults, - desiredPrivileges, - createdServer.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - createdServer, - createdServer, - "server", - { - Grant: GrantServerPrivileges, - Revoke: RevokeServerPrivileges, - RevokeGrantOption: RevokeGrantOptionServerPrivileges, - }, - ctx.version, - ) as ServerChange[]), - ); - } - - for (const serverId of dropped) { - changes.push(new DropServer({ server: main[serverId] })); - } - - for (const serverId of altered) { - const mainServer = main[serverId]; - const branchServer = branch[serverId]; - - // OWNER - if (mainServer.owner !== branchServer.owner) { - changes.push( - new AlterServerChangeOwner({ - server: mainServer, - owner: branchServer.owner, - }), - ); - } - - // TYPE - if changed, need to recreate (not directly alterable) - if (mainServer.type !== branchServer.type) { - changes.push(new DropServer({ server: mainServer })); - changes.push(new CreateServer({ server: branchServer })); - if (branchServer.comment !== null) { - changes.push(new CreateCommentOnServer({ server: branchServer })); - } - continue; - } - - // VERSION - if (mainServer.version !== branchServer.version) { - changes.push( - new AlterServerSetVersion({ - server: mainServer, - version: branchServer.version, - }), - ); - } - - // OPTIONS - const optionsChanged = diffOptions( - mainServer.options, - branchServer.options, - ); - if (optionsChanged.length > 0) { - changes.push( - new AlterServerSetOptions({ - server: mainServer, - options: optionsChanged, - }), - ); - } - - // COMMENT - if (mainServer.comment !== branchServer.comment) { - if (branchServer.comment === null) { - changes.push(new DropCommentOnServer({ server: mainServer })); - } else { - changes.push(new CreateCommentOnServer({ server: branchServer })); - } - } - - // PRIVILEGES - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "server", - mainServer.privileges, - ); - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "server", - branchServer.privileges, - ); - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchServer.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchServer, - mainServer, - "server", - { - Grant: GrantServerPrivileges, - Revoke: RevokeServerPrivileges, - RevokeGrantOption: RevokeGrantOptionServerPrivileges, - }, - ctx.version, - ) as ServerChange[]), - ); - - // Note: Server renaming would also use ALTER SERVER ... RENAME TO ... - // But since our Server model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - - return changes; -} - -/** - * Diff options arrays to determine ADD/SET/DROP operations. - * Options are stored as [key1, value1, key2, value2, ...] - */ -function diffOptions( - mainOptions: string[] | null, - branchOptions: string[] | null, -): Array<{ action: "ADD" | "SET" | "DROP"; option: string; value?: string }> { - const mainMap = new Map(); - const branchMap = new Map(); - - // Parse main options - if (mainOptions) { - for (let i = 0; i < mainOptions.length; i += 2) { - if (i + 1 < mainOptions.length) { - mainMap.set(mainOptions[i], mainOptions[i + 1]); - } - } - } - - // Parse branch options - if (branchOptions) { - for (let i = 0; i < branchOptions.length; i += 2) { - if (i + 1 < branchOptions.length) { - branchMap.set(branchOptions[i], branchOptions[i + 1]); - } - } - } - - const changes: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }> = []; - - // Find options to ADD or SET - for (const [key, value] of branchMap) { - const mainValue = mainMap.get(key); - if (mainValue === undefined) { - changes.push({ action: "ADD", option: key, value }); - } else if (mainValue !== value) { - changes.push({ action: "SET", option: key, value }); - } - } - - // Find options to DROP - for (const [key] of mainMap) { - if (!branchMap.has(key)) { - changes.push({ action: "DROP", option: key }); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.model.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.model.ts deleted file mode 100644 index c707e6710..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/server/server.model.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../../base.privilege-diff.ts"; - -/** - * All properties exposed by CREATE SERVER statement are included in diff output. - * https://www.postgresql.org/docs/17/sql-createserver.html - * - * ALTER SERVER statement can be generated for changes to the following properties: - * - owner, type, version, options - * https://www.postgresql.org/docs/17/sql-alterserver.html - * - * Servers are not schema-qualified (no schema property). - */ -const serverPropsSchema = z.object({ - name: z.string(), - owner: z.string(), - foreign_data_wrapper: z.string(), - type: z.string().nullable(), - version: z.string().nullable(), - options: z.array(z.string()).nullable(), - comment: z.string().nullable(), - privileges: z.array(privilegePropsSchema), - // Parent FDW handler/validator — filter metadata only, not in dataFields. - wrapper_handler: z.string().nullable().optional(), - wrapper_validator: z.string().nullable().optional(), -}); - -type ServerPrivilegeProps = PrivilegeProps; -export type ServerProps = z.infer; - -export class Server extends BasePgModel { - public readonly name: ServerProps["name"]; - public readonly owner: ServerProps["owner"]; - public readonly foreign_data_wrapper: ServerProps["foreign_data_wrapper"]; - public readonly type: ServerProps["type"]; - public readonly version: ServerProps["version"]; - public readonly options: ServerProps["options"]; - public readonly comment: ServerProps["comment"]; - public readonly privileges: ServerPrivilegeProps[]; - public readonly wrapper_handler: ServerProps["wrapper_handler"]; - public readonly wrapper_validator: ServerProps["wrapper_validator"]; - - constructor(props: ServerProps) { - super(); - - // Identity fields - this.name = props.name; - - // Data fields - this.owner = props.owner; - this.foreign_data_wrapper = props.foreign_data_wrapper; - this.type = props.type; - this.version = props.version; - this.options = props.options; - this.comment = props.comment; - this.privileges = props.privileges; - this.wrapper_handler = props.wrapper_handler ?? null; - this.wrapper_validator = props.wrapper_validator ?? null; - } - - get stableId(): `server:${string}` { - return `server:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - return { - owner: this.owner, - foreign_data_wrapper: this.foreign_data_wrapper, - type: this.type, - version: this.version, - options: this.options, - comment: this.comment, - privileges: this.privileges, - }; - } -} - -/** - * Extract `pg_foreign_server` rows into `Server` models. - * - * The returned models carry option values **verbatim** from - * `pg_foreign_server.srvoptions`, which means cleartext secrets like - * `password` are present in memory. Always route through - * `extractCatalog` (which calls `normalizeCatalog`) before emitting - * options to any output channel — see CLI-1467 and - * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`. - */ -export async function extractServers(pool: Pool): Promise { - const { rows: serverRows } = await pool.query(sql` - select - quote_ident(srv.srvname) as name, - srv.srvowner::regrole::text as owner, - quote_ident(fdw.fdwname) as foreign_data_wrapper, - srv.srvtype as type, - srv.srvversion as version, - coalesce(srv.srvoptions, array[]::text[]) as options, - obj_description(srv.oid, 'pg_foreign_server') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(srv.srvacl) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges, - case - when fdw.fdwhandler = 0 then null - else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname) - end as wrapper_handler, - case - when fdw.fdwvalidator = 0 then null - else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname) - end as wrapper_validator - from - pg_catalog.pg_foreign_server srv - inner join pg_catalog.pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw - left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler - left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator - where - not fdw.fdwname like any(array['pg\\_%']) - order by - srv.srvname - `); - - // Validate and parse each row using the Zod schema - const validatedRows = serverRows.map((row: unknown) => { - const parsed = serverPropsSchema.parse(row); - // Parse options from PostgreSQL format ['key=value'] to ['key', 'value'] - if (parsed.options && parsed.options.length > 0) { - const parsedOptions: string[] = []; - for (const opt of parsed.options) { - const eqIndex = opt.indexOf("="); - if (eqIndex > 0) { - parsedOptions.push(opt.substring(0, eqIndex)); - parsedOptions.push(opt.substring(eqIndex + 1)); - } - } - parsed.options = parsedOptions.length > 0 ? parsedOptions : null; - } - return parsed; - }); - return validatedRows.map((row: ServerProps) => new Server(row)); -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.test.ts deleted file mode 100644 index 35931c679..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { UserMapping, type UserMappingProps } from "../user-mapping.model.ts"; -import { AlterUserMappingSetOptions } from "./user-mapping.alter.ts"; - -describe.concurrent("user-mapping", () => { - describe("alter", () => { - test("set options ADD", async () => { - const props: UserMappingProps = { - user: "test_user", - server: "test_server", - options: null, - }; - const userMapping = new UserMapping(props); - const change = new AlterUserMappingSetOptions({ - userMapping, - options: [ - { action: "ADD", option: "user", value: "remote_user" }, - { action: "ADD", option: "password", value: "secret" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER USER MAPPING FOR test_user SERVER test_server OPTIONS (ADD user 'remote_user', ADD password '__OPTION_PASSWORD__')", - ); - }); - - test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => { - const props: UserMappingProps = { - user: "postgres", - server: "live_risk_server", - options: null, - }; - const userMapping = new UserMapping(props); - const change = new AlterUserMappingSetOptions({ - userMapping, - options: [ - { action: "ADD", option: "password", value: "real-user-password" }, - { action: "SET", option: "user", value: "fdw_reader" }, - { - action: "ADD", - option: "passfile", - value: "/etc/secrets/passfile", - }, - { action: "SET", option: "sslpassword", value: "ssl-secret" }, - ], - }); - - await assertValidSql(change.serialize()); - - const sql = change.serialize(); - expect(sql).not.toContain("real-user-password"); - expect(sql).not.toContain("/etc/secrets/passfile"); - expect(sql).not.toContain("ssl-secret"); - expect(sql).toContain("SET user 'fdw_reader'"); - expect(sql).toContain("ADD password '__OPTION_PASSWORD__'"); - expect(sql).toContain("ADD passfile '__OPTION_PASSFILE__'"); - expect(sql).toContain("SET sslpassword '__OPTION_SSLPASSWORD__'"); - }); - - test("set options SET", async () => { - const props: UserMappingProps = { - user: "test_user", - server: "test_server", - options: null, - }; - const userMapping = new UserMapping(props); - const change = new AlterUserMappingSetOptions({ - userMapping, - options: [{ action: "SET", option: "password", value: "new_secret" }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER USER MAPPING FOR test_user SERVER test_server OPTIONS (SET password '__OPTION_PASSWORD__')", - ); - }); - - test("set options DROP", async () => { - const props: UserMappingProps = { - user: "test_user", - server: "test_server", - options: null, - }; - const userMapping = new UserMapping(props); - const change = new AlterUserMappingSetOptions({ - userMapping, - options: [{ action: "DROP", option: "password" }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER USER MAPPING FOR test_user SERVER test_server OPTIONS (DROP password)", - ); - }); - - test("set options mixed ADD/SET/DROP", async () => { - const props: UserMappingProps = { - user: "PUBLIC", - server: "test_server", - options: null, - }; - const userMapping = new UserMapping(props); - const change = new AlterUserMappingSetOptions({ - userMapping, - options: [ - { action: "ADD", option: "user", value: "remote_user" }, - { action: "SET", option: "sslmode", value: "require" }, - { action: "DROP", option: "application_name" }, - ], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER USER MAPPING FOR PUBLIC SERVER test_server OPTIONS (ADD user 'remote_user', SET sslmode 'require', DROP application_name)", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts deleted file mode 100644 index 33dafa632..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { redactOptionValue } from "../../sensitive-options.ts"; -import type { UserMapping } from "../user-mapping.model.ts"; -import { AlterUserMappingChange } from "./user-mapping.base.ts"; - -/** - * Alter a user mapping. - * - * @see https://www.postgresql.org/docs/17/sql-alterusermapping.html - * - * Synopsis - * ```sql - * ALTER USER MAPPING FOR { user_name | USER | CURRENT_ROLE | CURRENT_USER | PUBLIC | SESSION_USER } - * SERVER server_name - * OPTIONS ( [ ADD | SET | DROP ] option ['value'] [, ... ] ) - * ``` - */ - -export type AlterUserMapping = AlterUserMappingSetOptions; - -/** - * ALTER USER MAPPING ... OPTIONS ( ADD | SET | DROP ... ) - */ -export class AlterUserMappingSetOptions extends AlterUserMappingChange { - public readonly userMapping: UserMapping; - public readonly options: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }>; - public readonly scope = "object" as const; - - constructor(props: { - userMapping: UserMapping; - options: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }>; - }) { - super(); - this.userMapping = props.userMapping; - this.options = props.options; - } - - get requires() { - return [this.userMapping.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const optionParts: string[] = []; - for (const opt of this.options) { - if (opt.action === "DROP") { - optionParts.push(`DROP ${opt.option}`); - } else { - const value = - opt.value !== undefined - ? quoteLiteral(redactOptionValue(opt.option, opt.value)) - : "''"; - optionParts.push(`${opt.action} ${opt.option} ${value}`); - } - } - - return [ - "ALTER USER MAPPING FOR", - this.userMapping.user, - "SERVER", - this.userMapping.server, - "OPTIONS", - `(${optionParts.join(", ")})`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.base.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.base.ts deleted file mode 100644 index 6b996202b..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../../base.change.ts"; -import type { UserMapping } from "../user-mapping.model.ts"; - -abstract class BaseUserMappingChange extends BaseChange { - abstract readonly userMapping: UserMapping; - abstract readonly scope: "object"; - readonly objectType = "user_mapping" as const; -} - -export abstract class CreateUserMappingChange extends BaseUserMappingChange { - readonly operation = "create" as const; -} - -export abstract class AlterUserMappingChange extends BaseUserMappingChange { - readonly operation = "alter" as const; -} - -export abstract class DropUserMappingChange extends BaseUserMappingChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.test.ts deleted file mode 100644 index fc89f98f0..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { UserMapping } from "../user-mapping.model.ts"; -import { CreateUserMapping } from "./user-mapping.create.ts"; - -describe("user-mapping", () => { - test("create basic", async () => { - const userMapping = new UserMapping({ - user: "test_user", - server: "test_server", - options: null, - }); - - const change = new CreateUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE USER MAPPING FOR test_user SERVER test_server", - ); - }); - - test("create with PUBLIC user", async () => { - const userMapping = new UserMapping({ - user: "PUBLIC", - server: "test_server", - options: null, - }); - - const change = new CreateUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE USER MAPPING FOR PUBLIC SERVER test_server", - ); - }); - - test("create with CURRENT_USER", async () => { - const userMapping = new UserMapping({ - user: "CURRENT_USER", - server: "test_server", - options: null, - }); - - const change = new CreateUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE USER MAPPING FOR CURRENT_USER SERVER test_server", - ); - }); - - test("create with options", async () => { - const userMapping = new UserMapping({ - user: "test_user", - server: "test_server", - options: ["user", "remote_user", "password", "secret"], - }); - - const change = new CreateUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE USER MAPPING FOR test_user SERVER test_server OPTIONS (user 'remote_user', password '__OPTION_PASSWORD__')", - ); - }); - - test("create with all properties", async () => { - const userMapping = new UserMapping({ - user: "PUBLIC", - server: "test_server", - options: ["user", "remote_user", "password", "secret"], - }); - - const change = new CreateUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE USER MAPPING FOR PUBLIC SERVER test_server OPTIONS (user 'remote_user', password '__OPTION_PASSWORD__')", - ); - }); - - test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => { - const userMapping = new UserMapping({ - user: "postgres", - server: "live_risk_server", - options: [ - "user", - "fdw_reader", - "password", - "real-user-password", - "passfile", - "/etc/secrets/passfile", - "sslpassword", - "ssl-secret", - "passcode", - "krb-passcode", - ], - }); - - const change = new CreateUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - const sql = change.serialize(); - expect(sql).not.toContain("real-user-password"); - expect(sql).not.toContain("/etc/secrets/passfile"); - expect(sql).not.toContain("ssl-secret"); - expect(sql).not.toContain("krb-passcode"); - expect(sql).toContain("user 'fdw_reader'"); - expect(sql).toContain("password '__OPTION_PASSWORD__'"); - expect(sql).toContain("passfile '__OPTION_PASSFILE__'"); - expect(sql).toContain("sslpassword '__OPTION_SSLPASSWORD__'"); - expect(sql).toContain("passcode '__OPTION_PASSCODE__'"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts deleted file mode 100644 index 3c174b4e6..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import { redactOptionValue } from "../../sensitive-options.ts"; -import type { UserMapping } from "../user-mapping.model.ts"; -import { CreateUserMappingChange } from "./user-mapping.base.ts"; - -/** - * Create a user mapping. - * - * @see https://www.postgresql.org/docs/17/sql-createusermapping.html - * - * Synopsis - * ```sql - * CREATE USER MAPPING [ IF NOT EXISTS ] FOR { user_name | USER | CURRENT_ROLE | CURRENT_USER | PUBLIC | SESSION_USER } - * SERVER server_name - * [ OPTIONS ( option 'value' [, ... ] ) ] - * ``` - */ -export class CreateUserMapping extends CreateUserMappingChange { - public readonly userMapping: UserMapping; - public readonly scope = "object" as const; - - constructor(props: { userMapping: UserMapping }) { - super(); - this.userMapping = props.userMapping; - } - - get creates() { - return [this.userMapping.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Server dependency - dependencies.add(stableId.server(this.userMapping.server)); - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE USER MAPPING FOR"]; - - // Add user (can be CURRENT_USER, PUBLIC, etc.) - parts.push(this.userMapping.user); - - // Add SERVER clause - parts.push("SERVER", this.userMapping.server); - - // Add OPTIONS clause - if (this.userMapping.options && this.userMapping.options.length > 0) { - const optionPairs: string[] = []; - for (let i = 0; i < this.userMapping.options.length; i += 2) { - const key = this.userMapping.options[i]; - const value = this.userMapping.options[i + 1]; - if (key === undefined || value === undefined) continue; - optionPairs.push( - `${key} ${quoteLiteral(redactOptionValue(key, value))}`, - ); - } - if (optionPairs.length > 0) { - parts.push(`OPTIONS (${optionPairs.join(", ")})`); - } - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.drop.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.drop.test.ts deleted file mode 100644 index 373b584f0..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.drop.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { UserMapping } from "../user-mapping.model.ts"; -import { DropUserMapping } from "./user-mapping.drop.ts"; - -describe("user-mapping", () => { - test("drop", async () => { - const userMapping = new UserMapping({ - user: "test_user", - server: "test_server", - options: null, - }); - - const change = new DropUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "DROP USER MAPPING FOR test_user SERVER test_server", - ); - }); - - test("drop PUBLIC user mapping", async () => { - const userMapping = new UserMapping({ - user: "PUBLIC", - server: "test_server", - options: null, - }); - - const change = new DropUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "DROP USER MAPPING FOR PUBLIC SERVER test_server", - ); - }); - - test("drop CURRENT_USER mapping", async () => { - const userMapping = new UserMapping({ - user: "CURRENT_USER", - server: "test_server", - options: null, - }); - - const change = new DropUserMapping({ - userMapping, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "DROP USER MAPPING FOR CURRENT_USER SERVER test_server", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.drop.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.drop.ts deleted file mode 100644 index 936f68c38..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.drop.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { UserMapping } from "../user-mapping.model.ts"; -import { DropUserMappingChange } from "./user-mapping.base.ts"; - -/** - * Drop a user mapping. - * - * @see https://www.postgresql.org/docs/17/sql-dropusermapping.html - * - * Synopsis - * ```sql - * DROP USER MAPPING [ IF EXISTS ] FOR { user_name | USER | CURRENT_ROLE | CURRENT_USER | PUBLIC | SESSION_USER } - * SERVER server_name - * ``` - */ -export class DropUserMapping extends DropUserMappingChange { - public readonly userMapping: UserMapping; - public readonly scope = "object" as const; - - constructor(props: { userMapping: UserMapping }) { - super(); - this.userMapping = props.userMapping; - } - - get drops() { - return [this.userMapping.stableId]; - } - - get requires() { - return [this.userMapping.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "DROP USER MAPPING FOR", - this.userMapping.user, - "SERVER", - this.userMapping.server, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.types.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.types.ts deleted file mode 100644 index 29761a369..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AlterUserMapping } from "./user-mapping.alter.ts"; -import type { CreateUserMapping } from "./user-mapping.create.ts"; -import type { DropUserMapping } from "./user-mapping.drop.ts"; - -/** Union of all user-mapping-related change variants (`objectType: "user_mapping"`). @category Change Types */ -export type UserMappingChange = - | AlterUserMapping - | CreateUserMapping - | DropUserMapping; diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.diff.test.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.diff.test.ts deleted file mode 100644 index b0f52f16b..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.diff.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { AlterUserMappingSetOptions } from "./changes/user-mapping.alter.ts"; -import { CreateUserMapping } from "./changes/user-mapping.create.ts"; -import { DropUserMapping } from "./changes/user-mapping.drop.ts"; -import { diffUserMappings } from "./user-mapping.diff.ts"; -import { UserMapping, type UserMappingProps } from "./user-mapping.model.ts"; - -describe.concurrent("user-mapping.diff", () => { - test("create and drop", () => { - const props: UserMappingProps = { - user: "u1", - server: "srv1", - options: null, - }; - const mapping = new UserMapping(props); - - const created = diffUserMappings({}, { [mapping.stableId]: mapping }); - expect(created[0]).toBeInstanceOf(CreateUserMapping); - - const dropped = diffUserMappings({ [mapping.stableId]: mapping }, {}); - expect(dropped[0]).toBeInstanceOf(DropUserMapping); - }); - - test("alter: options changes", () => { - // With the simplified approach, SET actions are filtered out, but ADD actions are not. - // Adding a new option (password) should generate an ALTER statement. - const main = new UserMapping({ - user: "u1", - server: "srv1", - options: ["user", "remote_user"], - }); - const branch = new UserMapping({ - user: "u1", - server: "srv1", - options: ["user", "remote_user", "password", "secret"], - }); - - const changes = diffUserMappings( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - // ADD actions are not filtered, so ALTER should be generated - const optionsChange = changes.find( - (c) => c instanceof AlterUserMappingSetOptions, - ) as AlterUserMappingSetOptions | undefined; - expect(optionsChange).toBeDefined(); - expect(optionsChange?.options).toEqual([ - { action: "ADD", option: "password", value: "secret" }, - ]); - }); - - test("create with PUBLIC user", () => { - const mapping = new UserMapping({ - user: "PUBLIC", - server: "srv1", - options: null, - }); - - const created = diffUserMappings({}, { [mapping.stableId]: mapping }); - expect(created[0]).toBeInstanceOf(CreateUserMapping); - expect((created[0] as CreateUserMapping).userMapping.user).toBe("PUBLIC"); - }); - - test("create with CURRENT_USER", () => { - const mapping = new UserMapping({ - user: "CURRENT_USER", - server: "srv1", - options: null, - }); - - const created = diffUserMappings({}, { [mapping.stableId]: mapping }); - expect(created[0]).toBeInstanceOf(CreateUserMapping); - expect((created[0] as CreateUserMapping).userMapping.user).toBe( - "CURRENT_USER", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.diff.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.diff.ts deleted file mode 100644 index 9f1ff835b..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.diff.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { diffObjects } from "../../base.diff.ts"; -import { AlterUserMappingSetOptions } from "./changes/user-mapping.alter.ts"; -import { CreateUserMapping } from "./changes/user-mapping.create.ts"; -import { DropUserMapping } from "./changes/user-mapping.drop.ts"; -import type { UserMappingChange } from "./changes/user-mapping.types.ts"; -import type { UserMapping } from "./user-mapping.model.ts"; - -/** - * Diff two sets of user mappings from main and branch catalogs. - * - * @param main - The user mappings in the main catalog. - * @param branch - The user mappings in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffUserMappings( - main: Record, - branch: Record, -): UserMappingChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: UserMappingChange[] = []; - - for (const mappingId of created) { - const createdMapping = branch[mappingId]; - changes.push(new CreateUserMapping({ userMapping: createdMapping })); - } - - for (const mappingId of dropped) { - changes.push(new DropUserMapping({ userMapping: main[mappingId] })); - } - - for (const mappingId of altered) { - const mainMapping = main[mappingId]; - const branchMapping = branch[mappingId]; - - // OPTIONS - const optionsChanged = diffOptions( - mainMapping.options, - branchMapping.options, - ); - if (optionsChanged.length > 0) { - changes.push( - new AlterUserMappingSetOptions({ - userMapping: mainMapping, - options: optionsChanged, - }), - ); - } - } - - return changes; -} - -/** - * Diff options arrays to determine ADD/SET/DROP operations. - * Options are stored as [key1, value1, key2, value2, ...] - */ -function diffOptions( - mainOptions: string[] | null, - branchOptions: string[] | null, -): Array<{ action: "ADD" | "SET" | "DROP"; option: string; value?: string }> { - const mainMap = new Map(); - const branchMap = new Map(); - - // Parse main options - if (mainOptions) { - for (let i = 0; i < mainOptions.length; i += 2) { - if (i + 1 < mainOptions.length) { - mainMap.set(mainOptions[i], mainOptions[i + 1]); - } - } - } - - // Parse branch options - if (branchOptions) { - for (let i = 0; i < branchOptions.length; i += 2) { - if (i + 1 < branchOptions.length) { - branchMap.set(branchOptions[i], branchOptions[i + 1]); - } - } - } - - const changes: Array<{ - action: "ADD" | "SET" | "DROP"; - option: string; - value?: string; - }> = []; - - // Find options to ADD or SET - for (const [key, value] of branchMap) { - const mainValue = mainMap.get(key); - if (mainValue === undefined) { - changes.push({ action: "ADD", option: key, value }); - } else if (mainValue !== value) { - changes.push({ action: "SET", option: key, value }); - } - } - - // Find options to DROP - for (const [key] of mainMap) { - if (!branchMap.has(key)) { - changes.push({ action: "DROP", option: key }); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts b/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts deleted file mode 100644 index e4302ab02..000000000 --- a/packages/pg-delta/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../../base.model.ts"; - -/** - * All properties exposed by CREATE USER MAPPING statement are included in diff output. - * https://www.postgresql.org/docs/17/sql-createusermapping.html - * - * ALTER USER MAPPING statement can be generated for changes to the following properties: - * - options - * https://www.postgresql.org/docs/17/sql-alterusermapping.html - * - * User mappings are not schema-qualified (no schema property). - * User can be a role name, CURRENT_USER, PUBLIC, etc. - */ -const userMappingPropsSchema = z.object({ - user: z.string(), - server: z.string(), - options: z.array(z.string()).nullable(), - // Parent FDW handler/validator — filter metadata only, not in dataFields. - wrapper_handler: z.string().nullable().optional(), - wrapper_validator: z.string().nullable().optional(), -}); - -export type UserMappingProps = z.infer; - -export class UserMapping extends BasePgModel { - public readonly user: UserMappingProps["user"]; - public readonly server: UserMappingProps["server"]; - public readonly options: UserMappingProps["options"]; - public readonly wrapper_handler: UserMappingProps["wrapper_handler"]; - public readonly wrapper_validator: UserMappingProps["wrapper_validator"]; - - constructor(props: UserMappingProps) { - super(); - - // Identity fields - this.user = props.user; - this.server = props.server; - - // Data fields - this.options = props.options; - this.wrapper_handler = props.wrapper_handler ?? null; - this.wrapper_validator = props.wrapper_validator ?? null; - } - - get stableId(): `userMapping:${string}:${string}` { - return `userMapping:${this.server}:${this.user}`; - } - - get identityFields() { - return { - user: this.user, - server: this.server, - }; - } - - get dataFields() { - return { - options: this.options, - }; - } -} - -/** - * Extract user mappings into `UserMapping` models. - * - * Reads from the world-readable `pg_catalog.pg_user_mappings` system view - * instead of the superuser-only `pg_catalog.pg_user_mapping` catalog, so - * non-superuser roles (e.g. the `postgres` role on Supabase hosted projects) - * can extract a catalog — see supabase/cli#5826 / CLI-1919. The view exposes - * `umoptions` only to privileged readers (superuser, the server owner, or the - * mapped user with USAGE on the server); for anyone else it returns NULL, which - * we deliberately degrade to an empty option list rather than erroring. - * - * When options **are** visible, the returned models carry them **verbatim**, - * which means cleartext secrets like `password` are present in memory. Always - * route through `extractCatalog` (which calls `normalizeCatalog`) before - * emitting options to any output channel — see CLI-1467 and - * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`. - */ -export async function extractUserMappings(pool: Pool): Promise { - const { rows: mappingRows } = await pool.query(sql` - select - case - when um.umuser = 0 then 'PUBLIC' - else um.umuser::regrole::text - end as user, - quote_ident(srv.srvname) as server, - coalesce(um.umoptions, array[]::text[]) as options, - case - when fdw.fdwhandler = 0 then null - else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname) - end as wrapper_handler, - case - when fdw.fdwvalidator = 0 then null - else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname) - end as wrapper_validator - from - pg_catalog.pg_user_mappings um - inner join pg_catalog.pg_foreign_server srv on srv.oid = um.srvid - inner join pg_catalog.pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw - left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler - left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator - where - not fdw.fdwname like any(array['pg\\_%']) - order by - srv.srvname, um.umuser - `); - - // Validate and parse each row using the Zod schema - const validatedRows = mappingRows.map((row: unknown) => { - const parsed = userMappingPropsSchema.parse(row); - // Parse options from PostgreSQL format ['key=value'] to ['key', 'value'] - if (parsed.options && parsed.options.length > 0) { - const parsedOptions: string[] = []; - for (const opt of parsed.options) { - const eqIndex = opt.indexOf("="); - if (eqIndex > 0) { - parsedOptions.push(opt.substring(0, eqIndex)); - parsedOptions.push(opt.substring(eqIndex + 1)); - } - } - parsed.options = parsedOptions.length > 0 ? parsedOptions : null; - } - return parsed; - }); - return validatedRows.map((row: UserMappingProps) => new UserMapping(row)); -} diff --git a/packages/pg-delta/src/core/objects/index/changes/index.alter.test.ts b/packages/pg-delta/src/core/objects/index/changes/index.alter.test.ts deleted file mode 100644 index b9f7a0c61..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.alter.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Index, type IndexProps } from "../index.model.ts"; -import { - AlterIndexSetStatistics, - AlterIndexSetStorageParams, - AlterIndexSetTablespace, -} from "./index.alter.ts"; - -describe.concurrent("index", () => { - describe("alter", () => { - test("set storage params", async () => { - const props: Omit = { - schema: "public", - table_name: "test_table", - name: "test_index", - statistics_target: [0], - index_type: "btree", - tablespace: null, - is_owned_by_constraint: false, - is_unique: false, - is_primary: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [1], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: null, - partial_predicate: null, - table_relkind: "r", - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: - "CREATE INDEX test_index ON public.test_table USING btree (id)", - comment: null, - owner: "test", - }; - const index = new Index({ - ...props, - storage_params: [], - }); - - const change = new AlterIndexSetStorageParams({ - index, - paramsToSet: ["fillfactor=90"], - keysToReset: [], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER INDEX public.test_index SET (fillfactor=90)", - ); - }); - - test("reset and set storage params", async () => { - const props: Omit = { - schema: "public", - table_name: "test_table", - name: "test_index", - statistics_target: [0], - index_type: "btree", - tablespace: null, - is_unique: false, - is_primary: false, - is_owned_by_constraint: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [1], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: null, - partial_predicate: null, - table_relkind: "r", - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: - "CREATE INDEX test_index ON public.test_table USING btree (id)", - comment: null, - owner: "test", - }; - const index = new Index({ - ...props, - storage_params: ["fillfactor=70", "fastupdate=on"], - }); - - const change = new AlterIndexSetStorageParams({ - index, - paramsToSet: ["fillfactor=90"], - keysToReset: ["fastupdate"], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - [ - "ALTER INDEX public.test_index RESET (fastupdate)", - "ALTER INDEX public.test_index SET (fillfactor=90)", - ].join(";\n"), - ); - }); - - test("set statistics", async () => { - const props: Omit = { - schema: "public", - table_name: "test_table", - name: "test_index", - storage_params: [], - index_type: "btree", - tablespace: null, - is_unique: false, - is_primary: false, - is_exclusion: false, - is_owned_by_constraint: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [1], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: null, - partial_predicate: null, - table_relkind: "r", - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: - "CREATE INDEX test_index ON public.test_table USING btree (id)", - comment: null, - owner: "test", - }; - const index = new Index({ - ...props, - statistics_target: [0], - }); - - const change = new AlterIndexSetStatistics({ - index, - columnTargets: [{ columnNumber: 1, statistics: 100 }], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER INDEX public.test_index ALTER COLUMN 1 SET STATISTICS 100", - ); - }); - - test("set tablespace", async () => { - const props: Omit = { - schema: "public", - table_name: "test_table", - name: "test_index", - storage_params: [], - statistics_target: [0], - index_type: "btree", - is_unique: false, - is_primary: false, - is_owned_by_constraint: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [1], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: null, - partial_predicate: null, - table_relkind: "r", - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: - "CREATE INDEX test_index ON public.test_table USING btree (id)", - comment: null, - owner: "test", - }; - const index = new Index({ - ...props, - tablespace: null, - }); - - const change = new AlterIndexSetTablespace({ - index, - tablespace: "fast_space", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER INDEX public.test_index SET TABLESPACE fast_space", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/index/changes/index.alter.ts b/packages/pg-delta/src/core/objects/index/changes/index.alter.ts deleted file mode 100644 index c3d08d272..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.alter.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { BaseChange } from "../../base.change.ts"; -import type { Index } from "../index.model.ts"; -import { AlterIndexChange } from "./index.base.ts"; - -/** - * Alter an index. - * - * @see https://www.postgresql.org/docs/17/sql-alterindex.html - * - * Synopsis - * ```sql - * ALTER INDEX [ CONCURRENTLY ] [ IF EXISTS ] name SET TABLESPACE tablespace_name - * ALTER INDEX [ CONCURRENTLY ] [ IF EXISTS ] name SET ( storage_parameter = value [, ... ] ) - * ALTER INDEX [ CONCURRENTLY ] [ IF EXISTS ] name RESET ( storage_parameter [, ... ] ) - * ALTER INDEX [ CONCURRENTLY ] [ IF EXISTS ] name SET STATISTICS integer - * ALTER INDEX [ CONCURRENTLY ] [ IF EXISTS ] name ALTER [ COLUMN ] column_number SET STATISTICS integer - * ``` - */ - -export type AlterIndex = - | AlterIndexSetStatistics - | AlterIndexSetStorageParams - | AlterIndexSetTablespace; - -/** - * ALTER INDEX ... SET ( storage_parameter = value [, ... ] ) - */ -export class AlterIndexSetStorageParams extends AlterIndexChange { - public readonly index: Index; - public readonly paramsToSet: string[]; - public readonly keysToReset: string[]; - public readonly scope = "object" as const; - - constructor(props: { - index: Index; - paramsToSet: string[]; - keysToReset: string[]; - }) { - super(); - this.index = props.index; - this.paramsToSet = props.paramsToSet; - this.keysToReset = props.keysToReset; - } - - get requires() { - return [this.index.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const head = [ - "ALTER INDEX", - `${this.index.schema}.${this.index.name}`, - ].join(" "); - - const statements: string[] = []; - if (this.keysToReset.length > 0) { - statements.push(`${head} RESET (${this.keysToReset.join(", ")})`); - } - if (this.paramsToSet.length > 0) { - statements.push(`${head} SET (${this.paramsToSet.join(", ")})`); - } - - return statements.join(";\n"); - } -} - -/** - * ALTER INDEX ... SET STATISTICS ... - */ -export class AlterIndexSetStatistics extends BaseChange { - public readonly index: Index; - public readonly columnTargets: Array<{ - columnNumber: number; - statistics: number; - }>; - public readonly operation = "alter" as const; - public readonly scope = "object" as const; - public readonly objectType = "index" as const; - - constructor(props: { - index: Index; - columnTargets: Array<{ columnNumber: number; statistics: number }>; - }) { - super(); - this.index = props.index; - this.columnTargets = props.columnTargets; - } - - get requires() { - return [this.index.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const statements: string[] = []; - const head = [ - "ALTER INDEX", - `${this.index.schema}.${this.index.name}`, - ].join(" "); - - for (const { columnNumber, statistics } of this.columnTargets) { - statements.push( - `${head} ALTER COLUMN ${columnNumber} SET STATISTICS ${statistics.toString()}`, - ); - } - - return statements.join(";\n"); - } -} - -/** - * ALTER INDEX ... SET TABLESPACE ... - */ -export class AlterIndexSetTablespace extends BaseChange { - public readonly index: Index; - public readonly tablespace: string; - public readonly operation = "alter" as const; - public readonly scope = "object" as const; - public readonly objectType = "index" as const; - - constructor(props: { index: Index; tablespace: string }) { - super(); - this.index = props.index; - this.tablespace = props.tablespace; - } - - get requires() { - return [this.index.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER INDEX", - `${this.index.schema}.${this.index.name}`, - "SET TABLESPACE", - this.tablespace, - ].join(" "); - } -} - -/** - * Replace an index by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER INDEX change. - */ -// NOTE: ReplaceIndex removed. Non-alterable changes are emitted as DropIndex + CreateIndex in index.diff.ts. diff --git a/packages/pg-delta/src/core/objects/index/changes/index.base.ts b/packages/pg-delta/src/core/objects/index/changes/index.base.ts deleted file mode 100644 index c2ba049f4..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Index } from "../index.model.ts"; - -abstract class BaseIndexChange extends BaseChange { - abstract readonly index: Index; - abstract readonly scope: "object" | "comment"; - readonly objectType = "index" as const; -} - -export abstract class CreateIndexChange extends BaseIndexChange { - readonly operation = "create" as const; -} - -export abstract class AlterIndexChange extends BaseIndexChange { - readonly operation = "alter" as const; -} - -export abstract class DropIndexChange extends BaseIndexChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/index/changes/index.comment.ts b/packages/pg-delta/src/core/objects/index/changes/index.comment.ts deleted file mode 100644 index 22afc2076..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.comment.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Index } from "../index.model.ts"; -import { CreateIndexChange, DropIndexChange } from "./index.base.ts"; - -export type CommentIndex = CreateCommentOnIndex | DropCommentOnIndex; - -/** - * Create/drop comments on indexes. - */ -export class CreateCommentOnIndex extends CreateIndexChange { - public readonly index: Index; - public readonly scope = "comment" as const; - - constructor(props: { index: Index }) { - super(); - this.index = props.index; - } - - get creates() { - return [stableId.comment(this.index.stableId)]; - } - - get requires() { - return [this.index.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON INDEX", - `${this.index.schema}.${this.index.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: index comment is not nullable here - quoteLiteral(this.index.comment!), - ].join(" "); - } -} - -export class DropCommentOnIndex extends DropIndexChange { - public readonly index: Index; - public readonly scope = "comment" as const; - - constructor(props: { index: Index }) { - super(); - this.index = props.index; - } - - get drops() { - return [stableId.comment(this.index.stableId)]; - } - - get requires() { - return [stableId.comment(this.index.stableId), this.index.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON INDEX", - `${this.index.schema}.${this.index.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts b/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts deleted file mode 100644 index 17a36fd91..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import type { ColumnProps } from "../../base.model.ts"; -import { Index } from "../index.model.ts"; -import { CreateIndex } from "./index.create.ts"; - -describe("index", () => { - test("create", async () => { - const index = new Index({ - schema: "public", - table_name: "test_table", - name: "test_index", - storage_params: [], - statistics_target: [0], - index_type: "btree", - tablespace: null, - is_unique: false, - is_primary: false, - is_exclusion: false, - is_owned_by_constraint: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [1], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: null, - partial_predicate: null, - table_relkind: "r", - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: "CREATE INDEX test_index ON public.test_table (id)", - comment: null, - owner: "test", - }); - - const columns: ColumnProps[] = [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ]; - - const change = new CreateIndex({ index, indexableObject: { columns } }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE INDEX test_index ON public.test_table (id)", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/index/changes/index.create.ts b/packages/pg-delta/src/core/objects/index/changes/index.create.ts deleted file mode 100644 index ab1efbafc..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.create.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { TableLikeObject } from "../../base.model.ts"; -import { stableId } from "../../utils.ts"; -import type { Index } from "../index.model.ts"; -import { CreateIndexChange } from "./index.base.ts"; -import { checkIsSerializable } from "./utils.ts"; - -/** - * Create an index. - * - * @see https://www.postgresql.org/docs/17/sql-createindex.html - * - * Synopsis - * ```sql - * CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] name ] ON [ ONLY ] table_name [ USING method ] - * ( { column_name | ( expression ) } [ COLLATE collation ] [ opclass [ ( opclass_parameter = value [, ... ] ) ] ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ] [, ...] ) - * [ INCLUDE ( column_name [, ...] ) ] - * [ WITH ( storage_parameter [= value] [, ... ] ) ] - * [ TABLESPACE tablespace_name ] - * [ WHERE predicate ] - * ``` - */ - -export class CreateIndex extends CreateIndexChange { - public readonly index: Index; - public readonly indexableObject?: TableLikeObject; - public readonly scope = "object" as const; - - constructor(props: { index: Index; indexableObject?: TableLikeObject }) { - super(); - checkIsSerializable(props.index, props.indexableObject); - this.index = props.index; - this.indexableObject = props.indexableObject; - } - - get creates() { - return [this.index.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.index.schema)); - - // Table dependency - dependencies.add(stableId.table(this.index.schema, this.index.table_name)); - - // Owner dependency - dependencies.add(stableId.role(this.index.owner)); - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - let definition = this.index.definition; - - // btree being the default, we can omit it - definition = definition.replace(" USING btree", ""); - - // Remove "ON ONLY" for partitioned indexes to allow automatic propagation to partitions. - // Preserve "ON ONLY" for non-partitioned indexes on partitioned tables (explicit user intent). - if (this.index.is_partitioned_index) { - definition = definition.replace(/\s+ON\s+ONLY\s+/i, " ON "); - } - - return definition; - } -} diff --git a/packages/pg-delta/src/core/objects/index/changes/index.drop.test.ts b/packages/pg-delta/src/core/objects/index/changes/index.drop.test.ts deleted file mode 100644 index 57473296f..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.drop.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Index } from "../index.model.ts"; -import { DropIndex } from "./index.drop.ts"; - -describe("index", () => { - test("drop", async () => { - const index = new Index({ - schema: "public", - table_name: "test_table", - name: "test_index", - storage_params: [], - statistics_target: [0], - index_type: "btree", - tablespace: null, - is_unique: false, - is_primary: false, - is_owned_by_constraint: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [1], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: null, - partial_predicate: null, - table_relkind: "r", - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: "CREATE INDEX test_index ON public.test_table (id)", - comment: null, - owner: "test", - }); - - const change = new DropIndex({ - index, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP INDEX public.test_index"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/index/changes/index.drop.ts b/packages/pg-delta/src/core/objects/index/changes/index.drop.ts deleted file mode 100644 index 9204daffd..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Index } from "../index.model.ts"; -import { DropIndexChange } from "./index.base.ts"; - -/** - * Drop an index. - * - * @see https://www.postgresql.org/docs/17/sql-dropindex.html - * - * Synopsis - * ```sql - * DROP INDEX [ CONCURRENTLY ] [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropIndex extends DropIndexChange { - public readonly index: Index; - public readonly scope = "object" as const; - - constructor(props: { index: Index }) { - super(); - this.index = props.index; - } - - get drops() { - return [this.index.stableId]; - } - - get requires() { - return [this.index.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP INDEX", `${this.index.schema}.${this.index.name}`].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/index/changes/index.types.ts b/packages/pg-delta/src/core/objects/index/changes/index.types.ts deleted file mode 100644 index e82850eb9..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/index.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AlterIndex } from "./index.alter.ts"; -import type { CommentIndex } from "./index.comment.ts"; -import type { CreateIndex } from "./index.create.ts"; -import type { DropIndex } from "./index.drop.ts"; - -/** Union of all index-related change variants (`objectType: "index"`). @category Change Types */ -export type IndexChange = AlterIndex | CommentIndex | CreateIndex | DropIndex; diff --git a/packages/pg-delta/src/core/objects/index/changes/utils.ts b/packages/pg-delta/src/core/objects/index/changes/utils.ts deleted file mode 100644 index 459ec2a7e..000000000 --- a/packages/pg-delta/src/core/objects/index/changes/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { TableLikeObject } from "../../base.model.ts"; -import type { Index } from "../index.model.ts"; - -export function checkIsSerializable( - index: Index, - indexableObject?: TableLikeObject, -) { - if ( - index.index_expressions === null && - (indexableObject === undefined || indexableObject.columns.length === 0) - ) { - throw new Error( - "Index requires an indexableObject with columns when key_columns are used", - ); - } -} diff --git a/packages/pg-delta/src/core/objects/index/index.diff.test.ts b/packages/pg-delta/src/core/objects/index/index.diff.test.ts deleted file mode 100644 index a7d011f62..000000000 --- a/packages/pg-delta/src/core/objects/index/index.diff.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - AlterIndexSetStatistics, - AlterIndexSetStorageParams, - AlterIndexSetTablespace, -} from "./changes/index.alter.ts"; -import { CreateIndex } from "./changes/index.create.ts"; -import { DropIndex } from "./changes/index.drop.ts"; -import { diffIndexes } from "./index.diff.ts"; -import { Index, type IndexProps } from "./index.model.ts"; - -const base: IndexProps = { - schema: "public", - table_name: "t", - name: "ix", - storage_params: [], - statistics_target: [0], - index_type: "btree", - tablespace: null, - is_unique: false, - is_primary: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: "expression", - partial_predicate: null, - table_relkind: "r", - is_owned_by_constraint: false, - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: "CREATE INDEX ix ON t (expression)", - comment: null, - owner: "test", -}; - -describe.concurrent("index.diff", () => { - test("create and drop", () => { - const idx = new Index(base); - const created = diffIndexes({}, { [idx.stableId]: idx }, {}); - expect(created[0]).toBeInstanceOf(CreateIndex); - const dropped = diffIndexes( - { [idx.stableId]: idx }, - {}, - { - [idx.tableStableId]: { - columns: [], - }, - }, - ); - expect(dropped[0]).toBeInstanceOf(DropIndex); - }); - - test("alter storage params, statistics and tablespace", () => { - const main = new Index(base); - const branch = new Index({ - ...base, - storage_params: ["fillfactor=90"], - statistics_target: [100], - tablespace: "ts", - }); - const changes = diffIndexes( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - {}, - ); - expect(changes.some((c) => c instanceof AlterIndexSetStorageParams)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterIndexSetStatistics)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterIndexSetTablespace)).toBe( - true, - ); - }); - - test("create index with key columns and no index_expressions should fail if no indexableObject is provided", () => { - const main = new Index(base); - const branch = new Index({ - ...base, - key_columns: [1], - index_expressions: null, - }); - expect(() => - diffIndexes({ [main.stableId]: main }, { [branch.stableId]: branch }, {}), - ).toThrowError( - "Index requires an indexableObject with columns when key_columns are used", - ); - }); - - test("create index with key columns and valid indexableObject should work", () => { - const branch = new Index({ - ...base, - key_columns: [1], - index_expressions: null, - definition: "CREATE INDEX ix ON t (col1)", - }); - const changes = diffIndexes( - {}, - { [branch.stableId]: branch }, - { - [branch.tableStableId]: { - columns: [ - { - name: "col1", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - }, - }, - ); - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(CreateIndex); - }); - - test("drop and create when non-alterable property changes", () => { - const main = new Index(base); - const branch = new Index({ - ...base, - index_type: "hash", - is_unique: true, - }); - const changes = diffIndexes( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - {}, - ); - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropIndex); - expect(changes[1]).toBeInstanceOf(CreateIndex); - }); -}); diff --git a/packages/pg-delta/src/core/objects/index/index.diff.ts b/packages/pg-delta/src/core/objects/index/index.diff.ts deleted file mode 100644 index b57652b9a..000000000 --- a/packages/pg-delta/src/core/objects/index/index.diff.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import type { TableLikeObject } from "../base.model.ts"; -import { deepEqual, hasNonAlterableChanges } from "../utils.ts"; -import { - AlterIndexSetStatistics, - AlterIndexSetStorageParams, - AlterIndexSetTablespace, -} from "./changes/index.alter.ts"; -import { - CreateCommentOnIndex, - DropCommentOnIndex, -} from "./changes/index.comment.ts"; -import { CreateIndex } from "./changes/index.create.ts"; -import { DropIndex } from "./changes/index.drop.ts"; -import type { IndexChange } from "./changes/index.types.ts"; -import type { Index } from "./index.model.ts"; - -/** - * Diff two sets of indexes from main and branch catalogs. - * - * @param main - The indexes in the main catalog. - * @param branch - The indexes in the branch catalog. - * @param branchIndexableObjects - Table-like objects (tables, materialized views) in branch. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffIndexes( - main: Record, - branch: Record, - branchIndexableObjects: Record, -): IndexChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: IndexChange[] = []; - - for (const indexId of created) { - const index = branch[indexId]; - // Skip constraint-owned or primary indexes; they are created by constraint DDL - if (index.is_owned_by_constraint || index.is_primary) { - continue; - } - - // Skip index partitions - they are automatically created when the parent partitioned index is created - if (index.is_index_partition) { - continue; - } - - changes.push( - new CreateIndex({ - index, - indexableObject: branchIndexableObjects[index.tableStableId], - }), - ); - if (index.comment !== null) { - changes.push(new CreateCommentOnIndex({ index })); - } - } - - for (const indexId of dropped) { - const index = main[indexId]; - // Constraint-owned or primary indexes are handled by constraint/table drops - if ( - index.is_owned_by_constraint || - index.is_primary || - !branchIndexableObjects[index.tableStableId] - ) { - continue; - } - - // Skip index partitions - they are automatically dropped when the parent partitioned index is dropped - if (index.is_index_partition) { - continue; - } - - changes.push(new DropIndex({ index: main[indexId] })); - } - - for (const indexId of altered) { - const mainIndex = main[indexId]; - const branchIndex = branch[indexId]; - - // Constraint-owned or primary indexes are handled by constraint/table DDL - if (mainIndex.is_owned_by_constraint || mainIndex.is_primary) { - continue; - } - if (branchIndex.is_owned_by_constraint || branchIndex.is_primary) { - continue; - } - - // Skip index partitions - they are automatically updated when the parent partitioned index is updated - if (mainIndex.is_index_partition || branchIndex.is_index_partition) { - continue; - } - - // Check if non-alterable properties have changed - // These require dropping and recreating the index - // Note: key_columns is excluded because it contains attribute numbers that can differ - // between databases even when indexes are logically identical. The definition field - // already captures the logical structure using column names, so we compare by definition instead. - const NON_ALTERABLE_FIELDS: Array = [ - "index_type", - "is_unique", - "is_primary", - "is_exclusion", - "nulls_not_distinct", - "immediate", - "is_clustered", - "column_collations", - "operator_classes", - "column_options", - "index_expressions", - "partial_predicate", - "definition", // Compare by definition instead of key_columns - ]; - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainIndex, - branchIndex, - NON_ALTERABLE_FIELDS, - { - column_collations: deepEqual, - operator_classes: deepEqual, - column_options: deepEqual, - definition: (a, b) => { - // Normalize definitions by removing "USING btree" (default) for comparison - const normalize = (def: string) => - def.replace(/\s+USING\s+btree/gi, ""); - return normalize(a as string) === normalize(b as string); - }, - }, - ); - - if (nonAlterablePropsChanged) { - // Replace the entire index (drop + create) - changes.push( - new DropIndex({ index: mainIndex }), - new CreateIndex({ - index: branchIndex, - indexableObject: branchIndexableObjects[branchIndex.tableStableId], - }), - ); - } else { - // Only alterable properties changed - check each one - - // STORAGE PARAMS - if ( - JSON.stringify(mainIndex.storage_params) !== - JSON.stringify(branchIndex.storage_params) - ) { - const parseOptions = (options: string[]) => { - const map = new Map(); - for (const opt of options) { - const eqIndex = opt.indexOf("="); - const key = opt.slice(0, eqIndex); - const value = opt.slice(eqIndex + 1); - map.set(key, value); - } - return map; - }; - - const mainMap = parseOptions(mainIndex.storage_params); - const branchMap = parseOptions(branchIndex.storage_params); - - const keysToReset: string[] = []; - for (const key of mainMap.keys()) { - if (!branchMap.has(key)) { - keysToReset.push(key); - } - } - - const paramsToSet: string[] = []; - for (const [key, newValue] of branchMap.entries()) { - const oldValue = mainMap.get(key); - const changed = oldValue !== newValue; - if (changed) { - paramsToSet.push(`${key}=${newValue}`); - } - } - - changes.push( - new AlterIndexSetStorageParams({ - index: mainIndex, - paramsToSet, - keysToReset, - }), - ); - } - - // STATISTICS TARGET - if ( - JSON.stringify(mainIndex.statistics_target) !== - JSON.stringify(branchIndex.statistics_target) - ) { - const columnTargets: Array<{ - columnNumber: number; - statistics: number; - }> = []; - const mainTargets = mainIndex.statistics_target; - const branchTargets = branchIndex.statistics_target; - const length = Math.max(mainTargets.length, branchTargets.length); - for (let i = 0; i < length; i++) { - const oldVal = mainTargets[i]; - const newVal = branchTargets[i]; - if (oldVal !== newVal && newVal !== undefined) { - columnTargets.push({ columnNumber: i + 1, statistics: newVal }); - } - } - if (columnTargets.length > 0) { - changes.push( - new AlterIndexSetStatistics({ index: mainIndex, columnTargets }), - ); - } - } - - // TABLESPACE - if (mainIndex.tablespace !== branchIndex.tablespace) { - const nextTablespace = branchIndex.tablespace; - if (nextTablespace !== null) { - changes.push( - new AlterIndexSetTablespace({ - index: mainIndex, - tablespace: nextTablespace, - }), - ); - } - } - - // COMMENT - if (mainIndex.comment !== branchIndex.comment) { - if (branchIndex.comment === null) { - changes.push(new DropCommentOnIndex({ index: mainIndex })); - } else { - changes.push(new CreateCommentOnIndex({ index: branchIndex })); - } - } - - // Note: Index renaming would also use ALTER INDEX ... RENAME TO ... - // But since our Index model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/index/index.model.test.ts b/packages/pg-delta/src/core/objects/index/index.model.test.ts deleted file mode 100644 index 56f9bb5e1..000000000 --- a/packages/pg-delta/src/core/objects/index/index.model.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Pool } from "pg"; -import { extractIndexes, Index } from "./index.model.ts"; - -// Minimal fields required by indexPropsSchema; individual tests override the -// fields relevant to each scenario. -const baseRow = { - schema: "public", - table_name: '"users"', - storage_params: [] as string[], - statistics_target: [] as number[], - index_type: "btree", - tablespace: null, - is_unique: false, - is_primary: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [1], - column_collations: [null], - operator_classes: ["default"], - column_options: [0], - index_expressions: null, - partial_predicate: null, - is_owned_by_constraint: false, - table_relkind: "r" as const, - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - comment: null, - owner: "postgres", -}; - -const mockPool = (rows: unknown[]): Pool => - ({ query: async () => ({ rows }) }) as unknown as Pool; - -const mockPoolSequence = (...attempts: unknown[][]): Pool => { - let i = 0; - return { - query: async () => ({ - rows: attempts[Math.min(i++, attempts.length - 1)], - }), - } as unknown as Pool; -}; - -const NO_BACKOFF = { backoffMs: 0 } as const; - -describe("extractIndexes", () => { - test("skips rows where pg_get_indexdef returned NULL after exhausting retries", async () => { - const indexes = await extractIndexes( - mockPool([ - { - ...baseRow, - name: '"good_idx"', - definition: "CREATE INDEX good_idx ON users (id)", - }, - { ...baseRow, name: '"orphan_idx"', definition: null }, - ]), - NO_BACKOFF, - ); - - expect(indexes).toHaveLength(1); - expect(indexes[0]).toBeInstanceOf(Index); - expect(indexes[0]?.name).toBe('"good_idx"'); - expect(indexes[0]?.definition).toBe("CREATE INDEX good_idx ON users (id)"); - }); - - test("does not throw ZodError when the only row has a null definition", async () => { - await expect( - extractIndexes( - mockPool([{ ...baseRow, name: '"orphan"', definition: null }]), - NO_BACKOFF, - ), - ).resolves.toEqual([]); - }); - - test("returns all indexes when every row has a valid definition", async () => { - const indexes = await extractIndexes( - mockPool([ - { - ...baseRow, - name: '"a"', - definition: "CREATE INDEX a ON users (id)", - }, - { - ...baseRow, - name: '"b"', - definition: "CREATE INDEX b ON users (id)", - }, - ]), - NO_BACKOFF, - ); - expect(indexes.map((i) => i.name)).toEqual(['"a"', '"b"']); - }); - - test("recovers when pg_get_indexdef is NULL on first attempt but resolved on retry", async () => { - const indexes = await extractIndexes( - mockPoolSequence( - // attempt 1: definition is NULL (transient race) - [{ ...baseRow, name: '"racy_idx"', definition: null }], - // attempt 2: catalog scan no longer sees the dropped row, or - // pg_get_indexdef successfully resolves the definition - [ - { - ...baseRow, - name: '"racy_idx"', - definition: "CREATE INDEX racy_idx ON users (id)", - }, - ], - ), - { retries: 2, backoffMs: 0 }, - ); - expect(indexes).toHaveLength(1); - expect(indexes[0]?.name).toBe('"racy_idx"'); - expect(indexes[0]?.definition).toBe("CREATE INDEX racy_idx ON users (id)"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/index/index.model.ts b/packages/pg-delta/src/core/objects/index/index.model.ts deleted file mode 100644 index 2f343ae93..000000000 --- a/packages/pg-delta/src/core/objects/index/index.model.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type ExtractRetryOptions, - extractWithDefinitionRetry, -} from "../extract-with-retry.ts"; - -const TableRelkindSchema = z.enum([ - "r", // table (regular relation) - "m", // materialized view - "p", // partitioned table -]); - -const indexPropsSchema = z.object({ - schema: z.string(), - table_name: z.string(), - name: z.string(), - storage_params: z.array(z.string()), - statistics_target: z.array(z.number()), - index_type: z.string(), - tablespace: z.string().nullable(), - is_unique: z.boolean(), - is_primary: z.boolean(), - is_exclusion: z.boolean(), - nulls_not_distinct: z.boolean(), - immediate: z.boolean(), - is_clustered: z.boolean(), - is_replica_identity: z.boolean(), - key_columns: z.array(z.number()), - column_collations: z.array(z.string().nullable()), - operator_classes: z.array(z.string()), - column_options: z.array(z.number()), - index_expressions: z.string().nullable(), - partial_predicate: z.string().nullable(), - is_owned_by_constraint: z.boolean(), - table_relkind: TableRelkindSchema, // 'r' for table, 'm' for materialized view - is_partitioned_index: z.boolean(), - is_index_partition: z.boolean(), - parent_index_name: z.string().nullable(), - definition: z.string(), - comment: z.string().nullable(), - owner: z.string(), -}); - -// pg_get_indexdef(oid, colno, pretty) invokes pg_get_indexdef_worker with -// missing_ok = true, so it can return NULL when any internal system-cache lookup -// fails (race with concurrent DROP, role visibility edge cases, orphaned index -// metadata, recovery transients). An unreadable index cannot be diffed, so we -// accept NULL here and filter the row out with a debug log instead of crashing -// the whole catalog extraction. -const indexRowSchema = indexPropsSchema.extend({ - definition: z.string().nullable(), -}); - -/** - * All properties exposed by CREATE INDEX statement are included in diff output. - * https://www.postgresql.org/docs/current/sql-createindex.html - * - * ALTER INDEX statement can only be generated for a subset of properties: - * - name, storage param, statistics, tablespace, attach partition - * https://www.postgresql.org/docs/current/sql-alterindex.html - * - * Unsupported alter properties include - * - depends on extension (all extension dependencies are excluded) - * - * Other properties require dropping and creating a new index. - */ -export type IndexProps = z.infer; - -export class Index extends BasePgModel { - public readonly schema: IndexProps["schema"]; - public readonly table_name: IndexProps["table_name"]; - public readonly name: IndexProps["name"]; - public readonly storage_params: IndexProps["storage_params"]; - public readonly statistics_target: IndexProps["statistics_target"]; - public readonly index_type: IndexProps["index_type"]; - public readonly tablespace: IndexProps["tablespace"]; - public readonly is_unique: IndexProps["is_unique"]; - public readonly is_primary: IndexProps["is_primary"]; - public readonly is_exclusion: IndexProps["is_exclusion"]; - public readonly nulls_not_distinct: IndexProps["nulls_not_distinct"]; - public readonly immediate: IndexProps["immediate"]; - public readonly is_clustered: IndexProps["is_clustered"]; - public readonly is_replica_identity: IndexProps["is_replica_identity"]; - public readonly key_columns: IndexProps["key_columns"]; - public readonly column_collations: IndexProps["column_collations"]; - public readonly operator_classes: IndexProps["operator_classes"]; - public readonly column_options: IndexProps["column_options"]; - public readonly index_expressions: IndexProps["index_expressions"]; - public readonly partial_predicate: IndexProps["partial_predicate"]; - public readonly table_relkind: IndexProps["table_relkind"]; - public readonly is_owned_by_constraint: IndexProps["is_owned_by_constraint"]; - public readonly is_partitioned_index: IndexProps["is_partitioned_index"]; - public readonly is_index_partition: IndexProps["is_index_partition"]; - public readonly parent_index_name: IndexProps["parent_index_name"]; - public readonly definition: IndexProps["definition"]; - public readonly comment: IndexProps["comment"]; - public readonly owner: IndexProps["owner"]; - - constructor(props: IndexProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.table_name = props.table_name; - this.name = props.name; - - // Data fields - this.storage_params = props.storage_params; - this.statistics_target = props.statistics_target; - this.index_type = props.index_type; - this.tablespace = props.tablespace; - this.is_unique = props.is_unique; - this.is_primary = props.is_primary; - this.is_exclusion = props.is_exclusion; - this.nulls_not_distinct = props.nulls_not_distinct; - this.immediate = props.immediate; - this.is_clustered = props.is_clustered; - this.is_replica_identity = props.is_replica_identity; - this.key_columns = props.key_columns; - this.column_collations = props.column_collations; - this.operator_classes = props.operator_classes; - this.column_options = props.column_options; - this.index_expressions = props.index_expressions; - this.partial_predicate = props.partial_predicate; - this.table_relkind = props.table_relkind; - this.is_owned_by_constraint = props.is_owned_by_constraint; - this.is_partitioned_index = props.is_partitioned_index; - this.is_index_partition = props.is_index_partition; - this.parent_index_name = props.parent_index_name; - this.definition = props.definition; - this.comment = props.comment; - this.owner = props.owner; - } - - get stableId(): `index:${string}` { - return `index:${this.schema}.${this.table_name}.${this.name}`; - } - - get tableStableId(): `table:${string}` | `materializedView:${string}` { - // Materialized views use a different stableId prefix - if (this.table_relkind === "m") { - return `materializedView:${this.schema}.${this.table_name}`; - } - return `table:${this.schema}.${this.table_name}`; - } - - get identityFields() { - return { - schema: this.schema, - table_name: this.table_name, - name: this.name, - }; - } - - get dataFields() { - return { - storage_params: this.storage_params, - statistics_target: this.statistics_target, - index_type: this.index_type, - tablespace: this.tablespace, - is_unique: this.is_unique, - is_primary: this.is_primary, - is_exclusion: this.is_exclusion, - nulls_not_distinct: this.nulls_not_distinct, - immediate: this.immediate, - is_clustered: this.is_clustered, - // is_replica_identity excluded: the table's `replica_identity` / - // `replica_identity_index` is the source of truth, set via - // ALTER TABLE ... REPLICA IDENTITY USING INDEX. Including this flag here - // would trigger spurious DROP+CREATE of the index whenever the table's - // replica identity changes. - // key_columns excluded: contains attribute numbers that can differ between databases - // even when indexes are logically identical. The definition field already captures - // the logical structure using column names, so we compare by definition instead. - column_collations: this.column_collations, - operator_classes: this.operator_classes, - column_options: this.column_options, - index_expressions: this.index_expressions, - partial_predicate: this.partial_predicate, - table_relkind: this.table_relkind, - is_owned_by_constraint: this.is_owned_by_constraint, - is_partitioned_index: this.is_partitioned_index, - is_index_partition: this.is_index_partition, - parent_index_name: this.parent_index_name, - definition: this.definition, - comment: this.comment, - owner: this.owner, - }; - } - - override stableSnapshot() { - const normalizeArray = (arr: unknown) => { - if (!Array.isArray(arr)) return arr; - return [...arr].map((v) => normalizeValue(v)); - }; - - const normalizeValue = (v: unknown): unknown => { - if (Array.isArray(v)) return normalizeArray(v); - if (v && typeof v === "object") { - return Object.fromEntries( - Object.entries(v as Record).map(([k, val]) => [ - k, - normalizeValue(val), - ]), - ); - } - return v; - }; - - return { - identity: this.identityFields, - data: { - ...this.dataFields, - statistics_target: normalizeArray(this.statistics_target), - column_options: normalizeArray(this.column_options), - column_collations: normalizeArray(this.column_collations), - operator_classes: normalizeArray(this.operator_classes), - }, - }; - } -} - -export async function extractIndexes( - pool: Pool, - options?: ExtractRetryOptions, -): Promise { - const indexRows = await extractWithDefinitionRetry({ - label: "indexes", - options, - hasNullDefinition: (row) => row.definition === null, - query: async () => { - const result = await pool.query(sql` - with extension_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass - ), - -- align every per-column array by ordinality (1..indnatts) - -- this is used to ensure that key_columns, column_collations, operator_classes, and column_options are aligned - idx_cols as ( - select - i.indexrelid, - i.indrelid, - k.ord, - k.attnum, - -- collation: only for key cols; 0 for none/default - case when k.ord <= i.indnkeyatts then coalesce(coll.oid, 0) else 0 end as coll_oid, - -- opclass: one per column - coalesce(cls.oid, 0) as cls_oid, - -- options: only for key cols; 0 for include cols - case when k.ord <= i.indnkeyatts then coalesce(opt.val, 0) else 0 end::int2 as indopt - from pg_index i - join lateral unnest(i.indkey) with ordinality as k(attnum, ord) on true - left join lateral unnest(i.indcollation) with ordinality as coll(oid, ordc) on ordc = k.ord - left join lateral unnest(i.indclass) with ordinality as cls(oid, ordo) on ordo = k.ord - left join lateral unnest(i.indoption) with ordinality as opt(val, ordi) on ordi = k.ord - ) - select - c.relnamespace::regnamespace::text as schema, - quote_ident(tc.relname) as table_name, - tc.relkind as table_relkind, - quote_ident(c.relname) as name, - coalesce(c.reloptions, array[]::text[]) as storage_params, - am.amname as index_type, - quote_ident(ts.spcname) as tablespace, - i.indisunique as is_unique, - i.indisprimary as is_primary, - i.indisexclusion as is_exclusion, - i.indnullsnotdistinct as nulls_not_distinct, - i.indimmediate as immediate, - i.indisclustered as is_clustered, - i.indisreplident as is_replica_identity, - i.indkey as key_columns, - - -- NEW: partitioned-index / index-partition tagging - (c.relkind = 'I') as is_partitioned_index, - (parent_idx.oid is not null) as is_index_partition, - case - when parent_idx.oid is not null then - quote_ident(parent_idx_ns.nspname) || '.' || quote_ident(parent_idx.relname) - end as parent_index_name, - - -- Foreign keys don’t create/own an index; their conindid points to the referenced PK/UNIQUE index. - -- Mark as is_owned_by_constraint only when the owning constraint is PK/UNIQUE/EXCLUSION. - exists ( - select 1 - from pg_depend d - join pg_constraint pc on pc.oid = d.refobjid - where d.classid = 'pg_class'::regclass - and d.objid = i.indexrelid - and d.refclassid = 'pg_constraint'::regclass - and d.deptype = 'i' - and pc.contype in ('p','u','x') - ) as is_owned_by_constraint, - - -- per-column arrays from one pass over idx_cols - coalesce(agg.column_collations, array[]::text[]) as column_collations, - coalesce(agg.operator_classes, array[]::text[]) as operator_classes, - coalesce(agg.column_options, array[]::int2[]) as column_options, - - -- always an array (possibly empty), ordered by index attnum - coalesce(st.statistics_target, array[]::int4[]) as statistics_target, - - pg_get_expr(i.indexprs, i.indrelid) as index_expressions, - pg_get_expr(i.indpred, i.indrelid) as partial_predicate, - pg_get_indexdef(i.indexrelid, 0, true) as definition, - obj_description(c.oid, 'pg_class') as comment, - c.relowner::regrole::text as owner - - from pg_index i - join pg_class c on c.oid = i.indexrelid - join pg_class tc on tc.oid = i.indrelid - join pg_am am on am.oid = c.relam - left join pg_tablespace ts on ts.oid = c.reltablespace - left join extension_oids e on c.oid = e.objid - left join extension_oids e_table on tc.oid = e_table.objid - - -- NEW: detect whether this index is an attached partition of a partitioned index - left join pg_inherits inh_idx - on inh_idx.inhrelid = c.oid - left join pg_class parent_idx - on parent_idx.oid = inh_idx.inhparent - left join pg_namespace parent_idx_ns - on parent_idx_ns.oid = parent_idx.relnamespace - - -- single lateral aggregate keeps order by ic2.ord - left join lateral ( - select - array_agg( - case - when ic2.coll_oid = 0 then null - when col.collname = 'default' - and col.collnamespace = 'pg_catalog'::regnamespace then null - else quote_ident(ns_coll.nspname) || '.' || quote_ident(col.collname) - end - order by ic2.ord - ) as column_collations, - - -- 'default' when the AM's default opclass applies to the column's base type - array_agg( - case - when oc.oid is null then 'default' - when ic2.attnum = 0 then oc.opcnamespace::regnamespace::text || '.' || quote_ident(oc.opcname) -- expression key: no column type - -- in the case where the opclass is the default for the column's base type - when oc.opcdefault and ( - (case when t.typtype = 'd' then t.typbasetype else a.atttypid end) = oc.opcintype - or exists ( - select 1 - from pg_catalog.pg_cast pc - where pc.castsource = (case when t.typtype = 'd' then t.typbasetype else a.atttypid end) - and pc.casttarget = oc.opcintype - and pc.castcontext = 'i' -- implicit - ) - ) - then 'default' - else oc.opcnamespace::regnamespace::text || '.' || quote_ident(oc.opcname) - end - order by ic2.ord - ) as operator_classes, - - array_agg(coalesce(ic2.indopt, 0)::int2 order by ic2.ord) as column_options - - from idx_cols ic2 - left join pg_collation col on col.oid = ic2.coll_oid - left join pg_namespace ns_coll on ns_coll.oid = col.collnamespace - left join pg_opclass oc on oc.oid = ic2.cls_oid - -- base type for the underlying column (domain -> base); NULL for expressions - left join pg_attribute a on a.attrelid = ic2.indrelid and a.attnum = ic2.attnum - left join pg_type t on t.oid = a.atttypid - where ic2.indexrelid = i.indexrelid - ) as agg on true - - left join lateral ( - select array_agg(coalesce(a2.attstattarget, -1) order by a2.attnum) as statistics_target - from pg_attribute a2 - where a2.attrelid = i.indexrelid - and a2.attnum > 0 - ) as st on true - - where not c.relnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and i.indislive is true - and e.objid is null - and e_table.objid is null - - order by 1, 2 - `); - return result.rows.map((row: unknown) => indexRowSchema.parse(row)); - }, - }); - const validatedRows = indexRows.filter( - (row): row is IndexProps => row.definition !== null, - ); - return validatedRows.map((row: IndexProps) => new Index(row)); -} diff --git a/packages/pg-delta/src/core/objects/language/changes/language.alter.test.ts b/packages/pg-delta/src/core/objects/language/changes/language.alter.test.ts deleted file mode 100644 index 2c4f6f677..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.alter.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Language, type LanguageProps } from "../language.model.ts"; -import { AlterLanguageChangeOwner } from "./language.alter.ts"; - -describe.concurrent("language", () => { - describe("alter", () => { - test("change owner", async () => { - const props: Omit = { - name: "plpgsql", - is_trusted: true, - is_procedural: true, - call_handler: "plpgsql_call_handler", - inline_handler: "plpgsql_inline_handler", - validator: "plpgsql_validator", - comment: null, - privileges: [], - }; - const language = new Language({ - ...props, - owner: "old_owner", - }); - - const change = new AlterLanguageChangeOwner({ - language, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER LANGUAGE plpgsql OWNER TO new_owner", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/language/changes/language.alter.ts b/packages/pg-delta/src/core/objects/language/changes/language.alter.ts deleted file mode 100644 index 5e3b2eb1d..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.alter.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Language } from "../language.model.ts"; -import { AlterLanguageChange } from "./language.base.ts"; - -/** - * Alter a language. - * - * @see https://www.postgresql.org/docs/17/sql-alterlanguage.html - * - * Synopsis - * ```sql - * ALTER [ PROCEDURAL ] LANGUAGE name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ALTER [ PROCEDURAL ] LANGUAGE name RENAME TO new_name - * ``` - */ - -export type AlterLanguage = AlterLanguageChangeOwner; - -/** - * ALTER LANGUAGE ... OWNER TO ... - */ -export class AlterLanguageChangeOwner extends AlterLanguageChange { - public readonly language: Language; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { language: Language; owner: string }) { - super(); - this.language = props.language; - this.owner = props.owner; - } - - get requires() { - return [this.language.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["ALTER"]; - - // Do not print the optional PROCEDURAL keyword. - // It is syntactic noise and the default for procedural languages, - // so we purposely omit it to avoid emitting defaults. - - parts.push("LANGUAGE", this.language.name, "OWNER TO", this.owner); - - return parts.join(" "); - } -} - -/** - * Replace a language. - * This is used when properties that cannot be altered via ALTER LANGUAGE change. - */ -// NOTE: ReplaceLanguage removed. Non-alterable changes are emitted as Drop + Create in language.diff.ts. diff --git a/packages/pg-delta/src/core/objects/language/changes/language.base.ts b/packages/pg-delta/src/core/objects/language/changes/language.base.ts deleted file mode 100644 index e7b5366da..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Language } from "../language.model.ts"; - -abstract class BaseLanguageChange extends BaseChange { - abstract readonly language: Language; - abstract readonly scope: "object" | "comment" | "privilege"; - readonly objectType = "language" as const; -} - -export abstract class CreateLanguageChange extends BaseLanguageChange { - readonly operation = "create" as const; -} - -export abstract class AlterLanguageChange extends BaseLanguageChange { - readonly operation = "alter" as const; -} - -export abstract class DropLanguageChange extends BaseLanguageChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/language/changes/language.comment.ts b/packages/pg-delta/src/core/objects/language/changes/language.comment.ts deleted file mode 100644 index 8617a6257..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.comment.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Language } from "../language.model.ts"; -import { CreateLanguageChange, DropLanguageChange } from "./language.base.ts"; - -export type CommentLanguage = CreateCommentOnLanguage | DropCommentOnLanguage; - -/** - * Create/drop comments on languages. - */ -export class CreateCommentOnLanguage extends CreateLanguageChange { - public readonly language: Language; - public readonly scope = "comment" as const; - - constructor(props: { language: Language }) { - super(); - this.language = props.language; - } - - get creates() { - return [stableId.comment(this.language.stableId)]; - } - - get requires() { - return [this.language.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON LANGUAGE", - this.language.name, - "IS", - quoteLiteral(this.language.comment as string), - ].join(" "); - } -} - -export class DropCommentOnLanguage extends DropLanguageChange { - public readonly language: Language; - public readonly scope = "comment" as const; - - constructor(props: { language: Language }) { - super(); - this.language = props.language; - } - - get drops() { - return [stableId.comment(this.language.stableId)]; - } - - get requires() { - return [stableId.comment(this.language.stableId), this.language.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["COMMENT ON LANGUAGE", this.language.name, "IS NULL"].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/language/changes/language.create.test.ts b/packages/pg-delta/src/core/objects/language/changes/language.create.test.ts deleted file mode 100644 index b1d1d648f..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.create.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Language } from "../language.model.ts"; -import { CreateLanguage } from "./language.create.ts"; - -describe("language", () => { - test("create", async () => { - const language = new Language({ - name: "plpgsql", - is_trusted: true, - is_procedural: true, - call_handler: "plpgsql_call_handler", - inline_handler: "plpgsql_inline_handler", - validator: "plpgsql_validator", - owner: "test", - comment: null, - privileges: [], - }); - - const change = new CreateLanguage({ - language, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE TRUSTED LANGUAGE plpgsql HANDLER plpgsql_call_handler INLINE plpgsql_inline_handler VALIDATOR plpgsql_validator", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/language/changes/language.create.ts b/packages/pg-delta/src/core/objects/language/changes/language.create.ts deleted file mode 100644 index 9bcbf0fee..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.create.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { parseProcedureReference, stableId } from "../../utils.ts"; -import type { Language } from "../language.model.ts"; -import { CreateLanguageChange } from "./language.base.ts"; - -/** - * Create a language. - * - * @see https://www.postgresql.org/docs/17/sql-createlanguage.html - * - * Synopsis - * ```sql - * CREATE [ OR REPLACE ] [ TRUSTED ] [ PROCEDURAL ] LANGUAGE name - * [ HANDLER call_handler [ INLINE inline_handler ] [ VALIDATOR valfunction ] ] - * ``` - */ -export class CreateLanguage extends CreateLanguageChange { - public readonly language: Language; - public readonly orReplace?: boolean; - public readonly scope = "object" as const; - - constructor(props: { language: Language; orReplace?: boolean }) { - super(); - this.language = props.language; - this.orReplace = props.orReplace; - } - - get creates() { - return [this.language.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Owner dependency - dependencies.add(stableId.role(this.language.owner)); - - // Call handler function dependency - if (this.language.call_handler) { - const callHandlerProc = parseProcedureReference( - this.language.call_handler, - ); - if (callHandlerProc) { - dependencies.add( - stableId.procedure(callHandlerProc.schema, callHandlerProc.name), - ); - } - } - - // Inline handler function dependency - if (this.language.inline_handler) { - const inlineHandlerProc = parseProcedureReference( - this.language.inline_handler, - ); - if (inlineHandlerProc) { - dependencies.add( - stableId.procedure(inlineHandlerProc.schema, inlineHandlerProc.name), - ); - } - } - - // Validator function dependency - if (this.language.validator) { - const validatorProc = parseProcedureReference(this.language.validator); - if (validatorProc) { - dependencies.add( - stableId.procedure(validatorProc.schema, validatorProc.name), - ); - } - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = [`CREATE${this.orReplace ? " OR REPLACE" : ""}`]; - - // Only include non-default flags. We never print the optional - // PROCEDURAL keyword or any defaults. - - // TRUSTED keyword (default is untrusted -> omitted unless true) - if (this.language.is_trusted) { - parts.push("TRUSTED"); - } - - parts.push("LANGUAGE", this.language.name); - - // HANDLER (omit when null) - if (this.language.call_handler) { - parts.push("HANDLER", this.language.call_handler); - } - - // INLINE (omit when null) - if (this.language.inline_handler) { - parts.push("INLINE", this.language.inline_handler); - } - - // VALIDATOR (omit when null) - if (this.language.validator) { - parts.push("VALIDATOR", this.language.validator); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/language/changes/language.drop.test.ts b/packages/pg-delta/src/core/objects/language/changes/language.drop.test.ts deleted file mode 100644 index cdd0cf6ca..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.drop.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Language } from "../language.model.ts"; -import { DropLanguage } from "./language.drop.ts"; - -describe("language", () => { - test("drop", async () => { - const language = new Language({ - name: "plpgsql", - is_trusted: true, - is_procedural: true, - call_handler: "plpgsql_call_handler", - inline_handler: "plpgsql_inline_handler", - validator: "plpgsql_validator", - owner: "test", - comment: null, - privileges: [], - }); - - const change = new DropLanguage({ - language, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP LANGUAGE plpgsql"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/language/changes/language.drop.ts b/packages/pg-delta/src/core/objects/language/changes/language.drop.ts deleted file mode 100644 index 18741d656..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.drop.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Language } from "../language.model.ts"; -import { DropLanguageChange } from "./language.base.ts"; - -/** - * Drop a language. - * - * @see https://www.postgresql.org/docs/17/sql-droplanguage.html - * - * Synopsis - * ```sql - * DROP [ PROCEDURAL ] LANGUAGE [ IF EXISTS ] name [ CASCADE | RESTRICT ] - * ``` - */ -export class DropLanguage extends DropLanguageChange { - public readonly language: Language; - public readonly scope = "object" as const; - - constructor(props: { language: Language }) { - super(); - this.language = props.language; - } - - get drops() { - return [this.language.stableId]; - } - - get requires() { - return [this.language.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["DROP"]; - - // Do not print optional keywords (e.g., PROCEDURAL). Keep the statement minimal. - parts.push("LANGUAGE", this.language.name); - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/language/changes/language.privilege.ts b/packages/pg-delta/src/core/objects/language/changes/language.privilege.ts deleted file mode 100644 index 119f1e391..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.privilege.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { Language } from "../language.model.ts"; -import { AlterLanguageChange } from "./language.base.ts"; - -export type LanguagePrivilege = - | GrantLanguagePrivileges - | RevokeLanguagePrivileges - | RevokeGrantOptionLanguagePrivileges; - -/** - * Grant privileges on a language. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { USAGE | ALL [ PRIVILEGES ] } - * ON LANGUAGE language_name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantLanguagePrivileges extends AlterLanguageChange { - public readonly language: Language; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - language: Language; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.language = props.language; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.language.stableId, this.grantee)]; - } - - get requires() { - return [this.language.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantLanguagePrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("LANGUAGE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("LANGUAGE", list, this.version); - return `GRANT ${privSql} ${kindPrefix} ${this.language.name} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a language. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { USAGE | ALL [ PRIVILEGES ] } - * ON LANGUAGE language_name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeLanguagePrivileges extends AlterLanguageChange { - public readonly language: Language; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - language: Language; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.language = props.language; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.language.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.language.stableId, this.grantee), - this.language.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("LANGUAGE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("LANGUAGE", list, this.version); - return `REVOKE ${privSql} ${kindPrefix} ${this.language.name} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a language. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionLanguagePrivileges extends AlterLanguageChange { - public readonly language: Language; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - language: Language; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.language = props.language; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.language.stableId, this.grantee), - this.language.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("LANGUAGE"); - const privSql = formatObjectPrivilegeList( - "LANGUAGE", - this.privilegeNames, - this.version, - ); - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${this.language.name} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/language/changes/language.types.ts b/packages/pg-delta/src/core/objects/language/changes/language.types.ts deleted file mode 100644 index 16a995162..000000000 --- a/packages/pg-delta/src/core/objects/language/changes/language.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { AlterLanguage } from "./language.alter.ts"; -import type { CommentLanguage } from "./language.comment.ts"; -import type { CreateLanguage } from "./language.create.ts"; -import type { DropLanguage } from "./language.drop.ts"; -import type { LanguagePrivilege } from "./language.privilege.ts"; - -/** Union of all language-related change variants (`objectType: "language"`). @category Change Types */ -export type LanguageChange = - | AlterLanguage - | CommentLanguage - | CreateLanguage - | DropLanguage - | LanguagePrivilege; diff --git a/packages/pg-delta/src/core/objects/language/language.diff.test.ts b/packages/pg-delta/src/core/objects/language/language.diff.test.ts deleted file mode 100644 index ab0a4b7d4..000000000 --- a/packages/pg-delta/src/core/objects/language/language.diff.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { AlterLanguageChangeOwner } from "./changes/language.alter.ts"; -import { - CreateCommentOnLanguage, - DropCommentOnLanguage, -} from "./changes/language.comment.ts"; -import { CreateLanguage } from "./changes/language.create.ts"; -import { DropLanguage } from "./changes/language.drop.ts"; -import { - GrantLanguagePrivileges, - RevokeGrantOptionLanguagePrivileges, - RevokeLanguagePrivileges, -} from "./changes/language.privilege.ts"; -import { diffLanguages } from "./language.diff.ts"; -import { Language, type LanguageProps } from "./language.model.ts"; - -const base: LanguageProps = { - name: "plpgsql", - is_trusted: true, - is_procedural: true, - call_handler: null, - inline_handler: null, - validator: null, - owner: "o1", - comment: null, - privileges: [], -}; - -const makeLanguage = (override: Partial = {}) => - new Language({ - ...base, - ...override, - privileges: override.privileges ?? [...base.privileges], - }); - -const ctx: Pick = { - version: 170000, -}; - -describe.concurrent("language.diff", () => { - test("create and drop", () => { - const l = new Language(base); - const created = diffLanguages(ctx, {}, { [l.stableId]: l }); - expect(created[0]).toBeInstanceOf(CreateLanguage); - - const dropped = diffLanguages(ctx, { [l.stableId]: l }, {}); - expect(dropped[0]).toBeInstanceOf(DropLanguage); - }); - - test("alter owner", () => { - const main = new Language(base); - const branch = new Language({ ...base, owner: "o2" }); - const changes = diffLanguages( - ctx, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterLanguageChangeOwner); - }); - - test("drop + create on non-alterable change", () => { - const main = new Language(base); - const branch = new Language({ ...base, call_handler: "handler()" }); - const changes = diffLanguages( - ctx, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropLanguage); - expect(changes[1]).toBeInstanceOf(CreateLanguage); - }); - - test("create with comment emits create comment change", () => { - const l = makeLanguage({ comment: "my language" }); - const changes = diffLanguages(ctx, {}, { [l.stableId]: l }); - expect(changes[0]).toBeInstanceOf(CreateLanguage); - expect(changes.some((c) => c instanceof CreateCommentOnLanguage)).toBe( - true, - ); - }); - - test("comment changes emit create/drop comment statements", () => { - const main = makeLanguage(); - const withComment = makeLanguage({ comment: "lang comment" }); - - const addComment = diffLanguages( - ctx, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment[0]).toBeInstanceOf(CreateCommentOnLanguage); - - const dropComment = diffLanguages( - ctx, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment[0]).toBeInstanceOf(DropCommentOnLanguage); - }); - - test("privilege diffs emit grant, revoke, and revoke grant option statements", () => { - const main = makeLanguage({ - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: false }, - { grantee: "role_with_option", privilege: "USAGE", grantable: true }, - { grantee: "role_removed", privilege: "USAGE", grantable: false }, - ], - }); - const branch = makeLanguage({ - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: true }, - { grantee: "role_with_option", privilege: "USAGE", grantable: false }, - { grantee: "role_new", privilege: "USAGE", grantable: false }, - ], - }); - - const changes = diffLanguages( - ctx, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.some((c) => c instanceof GrantLanguagePrivileges)).toBe( - true, - ); - expect(changes.some((c) => c instanceof RevokeLanguagePrivileges)).toBe( - true, - ); - expect( - changes.some((c) => c instanceof RevokeGrantOptionLanguagePrivileges), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/language/language.diff.ts b/packages/pg-delta/src/core/objects/language/language.diff.ts deleted file mode 100644 index 021c87e5d..000000000 --- a/packages/pg-delta/src/core/objects/language/language.diff.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Change } from "../../change.types.ts"; -import { diffObjects } from "../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { hasNonAlterableChanges } from "../utils.ts"; -import { AlterLanguageChangeOwner } from "./changes/language.alter.ts"; -import { - CreateCommentOnLanguage, - DropCommentOnLanguage, -} from "./changes/language.comment.ts"; -import { CreateLanguage } from "./changes/language.create.ts"; -import { DropLanguage } from "./changes/language.drop.ts"; -import { - GrantLanguagePrivileges, - RevokeGrantOptionLanguagePrivileges, - RevokeLanguagePrivileges, -} from "./changes/language.privilege.ts"; -import type { Language } from "./language.model.ts"; - -/** - * Diff two sets of languages from main and branch catalogs. - * - * @param ctx - Context containing version information. - * @param main - The languages in the main catalog. - * @param branch - The languages in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffLanguages( - ctx: Pick, - main: Record, - branch: Record, -): Change[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: Change[] = []; - - for (const languageId of created) { - const lang = branch[languageId]; - changes.push(new CreateLanguage({ language: lang })); - if (lang.comment !== null) { - changes.push(new CreateCommentOnLanguage({ language: lang })); - } - } - - for (const languageId of dropped) { - changes.push(new DropLanguage({ language: main[languageId] })); - } - - for (const languageId of altered) { - const mainLanguage = main[languageId]; - const branchLanguage = branch[languageId]; - - // Check if non-alterable properties have changed - // These require dropping and recreating the language - const NON_ALTERABLE_FIELDS: Array = [ - "is_trusted", - "is_procedural", - "call_handler", - "inline_handler", - "validator", - ]; - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainLanguage, - branchLanguage, - NON_ALTERABLE_FIELDS, - ); - - if (nonAlterablePropsChanged) { - // Replace the entire language (drop + create) - changes.push( - new DropLanguage({ language: mainLanguage }), - new CreateLanguage({ language: branchLanguage }), - ); - } else { - // Only alterable properties changed - check each one - - // OWNER - if (mainLanguage.owner !== branchLanguage.owner) { - changes.push( - new AlterLanguageChangeOwner({ - language: mainLanguage, - owner: branchLanguage.owner, - }), - ); - } - - // COMMENT - if (mainLanguage.comment !== branchLanguage.comment) { - if (branchLanguage.comment === null) { - changes.push(new DropCommentOnLanguage({ language: mainLanguage })); - } else { - changes.push( - new CreateCommentOnLanguage({ language: branchLanguage }), - ); - } - } - - // Note: Language renaming would also use ALTER LANGUAGE ... RENAME TO ... - // But since our Language model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - - // PRIVILEGES - // Filter out PUBLIC's built-in default USAGE privilege from main catalog - // (PostgreSQL grants it automatically, so we shouldn't compare it) - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "language", - mainLanguage.privileges, - ); - // Filter out PUBLIC's built-in default USAGE privilege from branch catalog - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "language", - branchLanguage.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchLanguage.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchLanguage, - mainLanguage, - "language", - { - Grant: GrantLanguagePrivileges, - Revoke: RevokeLanguagePrivileges, - RevokeGrantOption: RevokeGrantOptionLanguagePrivileges, - }, - ctx.version, - ) as Change[]), - ); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/language/language.model.ts b/packages/pg-delta/src/core/objects/language/language.model.ts deleted file mode 100644 index 7709d313a..000000000 --- a/packages/pg-delta/src/core/objects/language/language.model.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; - -/** - * All properties exposed by CREATE LANGUAGE statement are included in diff output. - * https://www.postgresql.org/docs/current/sql-createlanguage.html - * - * ALTER LANGUAGE statement can only be used to rename the language or change the owner. - * https://www.postgresql.org/docs/current/sql-alterlanguage.html - * - * Other properties require dropping and creating a new language. - */ - -const languagePropsSchema = z.object({ - name: z.string(), - is_trusted: z.boolean(), - is_procedural: z.boolean(), - call_handler: z.string().nullable(), - inline_handler: z.string().nullable(), - validator: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable(), - privileges: z.array(privilegePropsSchema), -}); - -type LanguagePrivilegeProps = PrivilegeProps; -export type LanguageProps = z.infer; - -export class Language extends BasePgModel { - public readonly name: LanguageProps["name"]; - public readonly is_trusted: LanguageProps["is_trusted"]; - public readonly is_procedural: LanguageProps["is_procedural"]; - public readonly call_handler: LanguageProps["call_handler"]; - public readonly inline_handler: LanguageProps["inline_handler"]; - public readonly validator: LanguageProps["validator"]; - public readonly owner: LanguageProps["owner"]; - public readonly comment: LanguageProps["comment"]; - public readonly privileges: LanguagePrivilegeProps[]; - - constructor(props: LanguageProps) { - super(); - - // Identity fields - this.name = props.name; - - // Data fields - this.is_trusted = props.is_trusted; - this.is_procedural = props.is_procedural; - this.call_handler = props.call_handler; - this.inline_handler = props.inline_handler; - this.validator = props.validator; - this.owner = props.owner; - this.comment = props.comment; - this.privileges = props.privileges; - } - - get stableId(): `language:${string}` { - return `language:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - const sortedPrivileges = [...this.privileges].sort((a, b) => { - return ( - a.grantee.localeCompare(b.grantee) || - a.privilege.localeCompare(b.privilege) || - Number(a.grantable) - Number(b.grantable) - ); - }); - - return { - is_trusted: this.is_trusted, - is_procedural: this.is_procedural, - call_handler: this.call_handler, - inline_handler: this.inline_handler, - validator: this.validator, - owner: this.owner, - comment: this.comment, - privileges: sortedPrivileges, - }; - } -} - -async function _extractLanguages(pool: Pool): Promise { - const { rows: languageRows } = await pool.query(sql` - with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_language'::regclass - ) - select - quote_ident(lan.lanname) as name, - lan.lanpltrusted as is_trusted, - lan.lanispl as is_procedural, - lan.lanplcallfoid::regprocedure::text as call_handler, - lan.laninline::regprocedure::text as inline_handler, - lan.lanvalidator::regprocedure::text as validator, - lan.lanowner::regrole::text as owner, - obj_description(lan.oid, 'pg_language') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(lan.lanacl) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges - from - pg_catalog.pg_language lan - left outer join extension_oids e on lan.oid = e.objid - -- - where lan.lanname not in ('internal', 'c') - order by - lan.lanname - `); - - // Process rows to handle "-" as null values - const processedRows = languageRows.map((row) => ({ - ...row, - call_handler: row.call_handler === "-" ? null : row.call_handler, - inline_handler: row.inline_handler === "-" ? null : row.inline_handler, - validator: row.validator === "-" ? null : row.validator, - })); - - // Validate and parse each row using the Zod schema - const validatedRows = processedRows.map((row: unknown) => - languagePropsSchema.parse(row), - ); - return validatedRows.map((row: LanguageProps) => new Language(row)); -} diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.test.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.test.ts deleted file mode 100644 index b705f1387..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { - MaterializedView, - type MaterializedViewProps, -} from "../materialized-view.model.ts"; -import { - AlterMaterializedViewChangeOwner, - AlterMaterializedViewSetStorageParams, -} from "./materialized-view.alter.ts"; - -describe.concurrent("materialized-view", () => { - describe("alter", () => { - test("change owner", async () => { - const props: Omit = { - schema: "public", - name: "test_mv", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - comment: null, - columns: [], - privileges: [], - }; - const materializedView = new MaterializedView({ - ...props, - owner: "old_owner", - }); - - const change = new AlterMaterializedViewChangeOwner({ - materializedView, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER MATERIALIZED VIEW public.test_mv OWNER TO new_owner", - ); - }); - - test("set storage params", async () => { - const props: Omit = { - schema: "public", - name: "test_mv", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }; - const materializedView = new MaterializedView({ - ...props, - options: [], - }); - - const change = new AlterMaterializedViewSetStorageParams({ - materializedView, - paramsToSet: ["fillfactor=90"], - keysToReset: [], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER MATERIALIZED VIEW public.test_mv SET (fillfactor=90)", - ); - }); - - test("reset and set storage params", async () => { - const props: Omit = { - schema: "public", - name: "test_mv", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }; - const materializedView = new MaterializedView({ - ...props, - options: ["fillfactor=70", "autovacuum_enabled=false"], - }); - - const change = new AlterMaterializedViewSetStorageParams({ - materializedView, - paramsToSet: ["fillfactor=90", "user_catalog_table=true"], - keysToReset: ["autovacuum_enabled"], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - [ - "ALTER MATERIALIZED VIEW public.test_mv RESET (autovacuum_enabled)", - "ALTER MATERIALIZED VIEW public.test_mv SET (fillfactor=90, user_catalog_table=true)", - ].join(";\n"), - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts deleted file mode 100644 index 53fd1cd8e..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { MaterializedView } from "../materialized-view.model.ts"; -import { AlterMaterializedViewChange } from "./materialized-view.base.ts"; - -/** - * Alter a materialized view. - * - * @see https://www.postgresql.org/docs/17/sql-altermaterializedview.html - * - * Synopsis - * ```sql - * ALTER MATERIALIZED VIEW [ IF EXISTS ] name - * action [, ... ] - * where action is one of: - * ALTER [ COLUMN ] column_name SET STATISTICS integer - * ALTER [ COLUMN ] column_name SET ( attribute_option = value [, ... ] ) - * ALTER [ COLUMN ] column_name RESET ( attribute_option [, ... ] ) - * ALTER [ COLUMN ] column_name SET STORAGE { PLAIN | EXTERNAL | EXTENDED | MAIN } - * CLUSTER ON index_name - * SET WITHOUT CLUSTER - * SET ( storage_parameter [= value] [, ... ] ) - * RESET ( storage_parameter [, ... ] ) - * OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * RENAME TO new_name - * SET SCHEMA new_schema - * ``` - * - * Notes for diff-based generation: - * - We currently only emit OWNER TO when owner differs. - * - Name/schema changes are treated as identity changes; handled as drop/create by the diff engine. - * - Column attribute changes, CLUSTER are not modeled and thus not emitted. - * - Changes to definition, options, and other non-alterable properties trigger a replace (drop + create). - */ - -export type AlterMaterializedView = - | AlterMaterializedViewChangeOwner - | AlterMaterializedViewSetStorageParams; - -/** - * ALTER MATERIALIZED VIEW ... OWNER TO ... - */ -export class AlterMaterializedViewChangeOwner extends AlterMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { materializedView: MaterializedView; owner: string }) { - super(); - this.materializedView = props.materializedView; - this.owner = props.owner; - } - - get requires() { - return [this.materializedView.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER MATERIALIZED VIEW", - `${this.materializedView.schema}.${this.materializedView.name}`, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * ALTER MATERIALIZED VIEW ... SET/RESET ( storage_parameter ... ) - * Accepts main and branch, computes differences, and emits RESET then SET statements. - */ -export class AlterMaterializedViewSetStorageParams extends AlterMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly paramsToSet: string[]; - public readonly keysToReset: string[]; - public readonly scope = "object" as const; - - constructor(props: { - materializedView: MaterializedView; - paramsToSet: string[]; - keysToReset: string[]; - }) { - super(); - this.materializedView = props.materializedView; - this.paramsToSet = props.paramsToSet; - this.keysToReset = props.keysToReset; - } - - get requires() { - return [this.materializedView.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const head = [ - "ALTER MATERIALIZED VIEW", - `${this.materializedView.schema}.${this.materializedView.name}`, - ].join(" "); - - const statements: string[] = []; - if (this.keysToReset.length > 0) { - statements.push(`${head} RESET (${this.keysToReset.join(", ")})`); - } - if (this.paramsToSet.length > 0) { - statements.push(`${head} SET (${this.paramsToSet.join(", ")})`); - } - - return statements.join(";\n"); - } -} - -/** - * Replace a materialized view by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER MATERIALIZED VIEW change. - */ -// NOTE: ReplaceMaterializedView removed. Non-alterable changes are emitted as Drop + Create in materialized-view.diff.ts. diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.base.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.base.ts deleted file mode 100644 index deaa852ff..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { MaterializedView } from "../materialized-view.model.ts"; - -abstract class BaseMaterializedViewChange extends BaseChange { - abstract readonly materializedView: MaterializedView; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "materialized_view" as const; -} - -export abstract class CreateMaterializedViewChange extends BaseMaterializedViewChange { - readonly operation = "create" as const; -} - -export abstract class AlterMaterializedViewChange extends BaseMaterializedViewChange { - readonly operation = "alter" as const; -} - -export abstract class DropMaterializedViewChange extends BaseMaterializedViewChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.comment.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.comment.ts deleted file mode 100644 index ab8eb1867..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.comment.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import type { ColumnProps } from "../../base.model.ts"; -import { stableId } from "../../utils.ts"; -import type { MaterializedView } from "../materialized-view.model.ts"; -import { - CreateMaterializedViewChange, - DropMaterializedViewChange, -} from "./materialized-view.base.ts"; - -export type CommentMaterializedView = - | CreateCommentOnMaterializedView - | CreateCommentOnMaterializedViewColumn - | DropCommentOnMaterializedView - | DropCommentOnMaterializedViewColumn; - -/** - * Create/drop comments on materialized view columns. - * - * @see https://www.postgresql.org/docs/17/sql-comment.html - */ - -export class CreateCommentOnMaterializedView extends CreateMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly scope = "comment" as const; - - constructor(props: { materializedView: MaterializedView }) { - super(); - this.materializedView = props.materializedView; - } - - get creates() { - return [stableId.comment(this.materializedView.stableId)]; - } - - get requires() { - return [this.materializedView.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON MATERIALIZED VIEW", - `${this.materializedView.schema}.${this.materializedView.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: mv comment is not nullable in this case - quoteLiteral(this.materializedView.comment!), - ].join(" "); - } -} - -export class DropCommentOnMaterializedView extends DropMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly scope = "comment" as const; - - constructor(props: { materializedView: MaterializedView }) { - super(); - this.materializedView = props.materializedView; - } - - get drops() { - return [stableId.comment(this.materializedView.stableId)]; - } - - get requires() { - return [ - stableId.comment(this.materializedView.stableId), - this.materializedView.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON MATERIALIZED VIEW", - `${this.materializedView.schema}.${this.materializedView.name}`, - "IS NULL", - ].join(" "); - } -} - -export class CreateCommentOnMaterializedViewColumn extends CreateMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly column: ColumnProps; - public readonly scope = "comment" as const; - - constructor(props: { - materializedView: MaterializedView; - column: ColumnProps; - }) { - super(); - this.materializedView = props.materializedView; - this.column = props.column; - } - - get creates() { - return [ - stableId.comment( - stableId.column( - this.materializedView.schema, - this.materializedView.name, - this.column.name, - ), - ), - ]; - } - - get requires() { - return [ - stableId.column( - this.materializedView.schema, - this.materializedView.name, - this.column.name, - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON COLUMN", - `${this.materializedView.schema}.${this.materializedView.name}.${this.column.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: column comment is not nullable in this case - quoteLiteral(this.column.comment!), - ].join(" "); - } -} - -export class DropCommentOnMaterializedViewColumn extends DropMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly column: ColumnProps; - public readonly scope = "comment" as const; - - constructor(props: { - materializedView: MaterializedView; - column: ColumnProps; - }) { - super(); - this.materializedView = props.materializedView; - this.column = props.column; - } - - get drops() { - return [ - stableId.comment( - stableId.column( - this.materializedView.schema, - this.materializedView.name, - this.column.name, - ), - ), - ]; - } - - get requires() { - return [ - stableId.comment( - stableId.column( - this.materializedView.schema, - this.materializedView.name, - this.column.name, - ), - ), - stableId.column( - this.materializedView.schema, - this.materializedView.name, - this.column.name, - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON COLUMN", - `${this.materializedView.schema}.${this.materializedView.name}.${this.column.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.create.test.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.create.test.ts deleted file mode 100644 index 63490f24b..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.create.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { MaterializedView } from "../materialized-view.model.ts"; -import { CreateMaterializedView } from "./materialized-view.create.ts"; - -describe("materialized-view", () => { - test("create minimal", async () => { - const mv = new MaterializedView({ - schema: "public", - name: "test_mv", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - // Default is WITH NO DATA -> omitted - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "test", - columns: [], - comment: null, - privileges: [], - }); - - const change = new CreateMaterializedView({ materializedView: mv }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE MATERIALIZED VIEW public.test_mv AS SELECT * FROM test_table WITH NO DATA", - ); - }); - - test("create with all options", async () => { - const mv = new MaterializedView({ - schema: "public", - name: "test_mv", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: ["fillfactor=90", "autovacuum_enabled=false"], - partition_bound: null, - owner: "test", - columns: [], - comment: null, - privileges: [], - }); - - const change = new CreateMaterializedView({ materializedView: mv }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE MATERIALIZED VIEW public.test_mv WITH (fillfactor=90, autovacuum_enabled=false) AS SELECT * FROM test_table WITH DATA", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.create.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.create.ts deleted file mode 100644 index 0af309534..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.create.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { MaterializedView } from "../materialized-view.model.ts"; -import { CreateMaterializedViewChange } from "./materialized-view.base.ts"; - -/** - * Create a materialized view. - * - * @see https://www.postgresql.org/docs/17/sql-creatematerializedview.html - * - * Synopsis - * ```sql - * CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name - * [ (column_name [, ...] ) ] - * [ WITH ( storage_parameter [= value] [, ... ] ) ] - * [ TABLESPACE tablespace_name ] - * AS query - * [ WITH [ NO ] DATA ] - * ``` - * - * Notes for diff-based generation: - * - IF NOT EXISTS is omitted: diffs are deterministic and explicit. - * - (column_name, ...) list is derived from the SELECT query; we don't emit it. - * - TABLESPACE is not currently modeled/extracted and is not emitted. - * - WITH (options) is emitted only when non-empty. - * - WITH NO DATA is always emitted when is_populated is false to ensure correct state. - * - WITH DATA is emitted when is_populated is true. - */ -export class CreateMaterializedView extends CreateMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly scope = "object" as const; - - constructor(props: { materializedView: MaterializedView }) { - super(); - this.materializedView = props.materializedView; - } - - get creates() { - return [ - this.materializedView.stableId, - ...this.materializedView.columns.map((column) => - stableId.column( - this.materializedView.schema, - this.materializedView.name, - column.name, - ), - ), - ]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.materializedView.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.materializedView.owner)); - - // Note: Materialized view definition dependencies are handled via pg_depend - // for existing objects. For new objects, parsing the SQL definition would be complex. - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE MATERIALIZED VIEW"]; - - // Add schema and name - parts.push(`${this.materializedView.schema}.${this.materializedView.name}`); - - // Add storage parameters if specified - if ( - this.materializedView.options && - this.materializedView.options.length > 0 - ) { - parts.push("WITH", `(${this.materializedView.options.join(", ")})`); - } - - // Add AS query (definition is required) - parts.push("AS", this.materializedView.definition.trim()); - - // Add population clause to match the desired state - // PostgreSQL defaults to WITH NO DATA, but we need to be explicit to ensure - // the created view matches the expected is_populated state - if (this.materializedView.is_populated) { - parts.push("WITH DATA"); - } else { - parts.push("WITH NO DATA"); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.drop.test.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.drop.test.ts deleted file mode 100644 index 05fbf0b15..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.drop.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { MaterializedView } from "../materialized-view.model.ts"; -import { DropMaterializedView } from "./materialized-view.drop.ts"; - -describe("materialized-view", () => { - test("drop", async () => { - const materializedView = new MaterializedView({ - schema: "public", - name: "test_mv", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }); - - const change = new DropMaterializedView({ - materializedView, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP MATERIALIZED VIEW public.test_mv"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.drop.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.drop.ts deleted file mode 100644 index 6f3d6764f..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.drop.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { MaterializedView } from "../materialized-view.model.ts"; -import { DropMaterializedViewChange } from "./materialized-view.base.ts"; - -/** - * Drop a materialized view. - * - * @see https://www.postgresql.org/docs/17/sql-dropmaterializedview.html - * - * Synopsis - * ```sql - * DROP MATERIALIZED VIEW [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - * - * Notes for diff-based generation: - * - IF EXISTS is omitted for deterministic diffs; the object must exist in the source. - * - We do not emit CASCADE; dependency ordering ensures safe drops, and RESTRICT is default. - */ -export class DropMaterializedView extends DropMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly scope = "object" as const; - - constructor(props: { materializedView: MaterializedView }) { - super(); - this.materializedView = props.materializedView; - } - - get drops() { - return [ - this.materializedView.stableId, - ...this.materializedView.columns.map((column) => - stableId.column( - this.materializedView.schema, - this.materializedView.name, - column.name, - ), - ), - ]; - } - - get requires() { - return [ - this.materializedView.stableId, - ...this.materializedView.columns.map((column) => - stableId.column( - this.materializedView.schema, - this.materializedView.name, - column.name, - ), - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "DROP MATERIALIZED VIEW", - `${this.materializedView.schema}.${this.materializedView.name}`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.privilege.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.privilege.ts deleted file mode 100644 index 24a47c760..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.privilege.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { MaterializedView } from "../materialized-view.model.ts"; -import { AlterMaterializedViewChange } from "./materialized-view.base.ts"; - -export type MaterializedViewPrivilege = - | GrantMaterializedViewPrivileges - | RevokeMaterializedViewPrivileges - | RevokeGrantOptionMaterializedViewPrivileges; - -/** - * Grant privileges on a materialized view. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER } - * [, ...] | ALL [ PRIVILEGES ] } - * ON { [ TABLE ] table_name [, ...] } - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantMaterializedViewPrivileges extends AlterMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - materializedView: MaterializedView; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - columns?: string[]; - version?: number; - }) { - super(); - this.materializedView = props.materializedView; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.columns = props.columns - ? [...new Set(props.columns)].sort() - : undefined; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.materializedView.stableId, this.grantee)]; - } - - get requires() { - return [this.materializedView.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantMaterializedViewPrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("MATERIALIZED VIEW"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList( - "MATERIALIZED VIEW", - list, - this.version, - ); - const materializedViewName = `${this.materializedView.schema}.${this.materializedView.name}`; - - // Add column list if present - const columnClause = this.columns ? ` (${this.columns.join(", ")})` : ""; - - return `GRANT ${privSql}${columnClause} ${kindPrefix} ${materializedViewName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a materialized view. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER } - * [, ...] | ALL [ PRIVILEGES ] } - * ON { [ TABLE ] table_name [, ...] } - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeMaterializedViewPrivileges extends AlterMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - materializedView: MaterializedView; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - columns?: string[]; - version?: number; - }) { - super(); - this.materializedView = props.materializedView; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.columns = props.columns - ? [...new Set(props.columns)].sort() - : undefined; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.materializedView.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.materializedView.stableId, this.grantee), - this.materializedView.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("MATERIALIZED VIEW"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList( - "MATERIALIZED VIEW", - list, - this.version, - ); - const materializedViewName = `${this.materializedView.schema}.${this.materializedView.name}`; - - // Add column list if present - const columnClause = this.columns ? ` (${this.columns.join(", ")})` : ""; - - return `REVOKE ${privSql}${columnClause} ${kindPrefix} ${materializedViewName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a materialized view. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionMaterializedViewPrivileges extends AlterMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - materializedView: MaterializedView; - grantee: string; - privilegeNames: string[]; - columns?: string[]; - version?: number; - }) { - super(); - this.materializedView = props.materializedView; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.columns = props.columns - ? [...new Set(props.columns)].sort() - : undefined; - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.materializedView.stableId, this.grantee), - this.materializedView.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("MATERIALIZED VIEW"); - const privSql = formatObjectPrivilegeList( - "MATERIALIZED VIEW", - this.privilegeNames, - this.version, - ); - const materializedViewName = `${this.materializedView.schema}.${this.materializedView.name}`; - - // Add column list if present - const columnClause = this.columns ? ` (${this.columns.join(", ")})` : ""; - - return `REVOKE GRANT OPTION FOR ${privSql}${columnClause} ${kindPrefix} ${materializedViewName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.security-label.test.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.security-label.test.ts deleted file mode 100644 index 1acb41b6e..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.security-label.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { - MaterializedView, - type MaterializedViewProps, -} from "../materialized-view.model.ts"; -import { - CreateSecurityLabelOnMaterializedView, - DropSecurityLabelOnMaterializedView, -} from "./materialized-view.security-label.ts"; - -const makeMV = (): MaterializedView => - new MaterializedView({ - schema: "public", - name: "mv", - definition: "SELECT 1", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "postgres", - comment: null, - columns: [], - privileges: [], - } as MaterializedViewProps); - -describe("materialized-view.security-label", () => { - test("create serializes", async () => { - const mv = makeMV(); - const change = new CreateSecurityLabelOnMaterializedView({ - materializedView: mv, - securityLabel: { provider: "dummy", label: "classified" }, - }); - expect(change.scope).toBe("security_label"); - expect(change.creates).toEqual([ - stableId.securityLabel(mv.stableId, "dummy"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON MATERIALIZED VIEW public.mv IS 'classified'", - ); - }); - - test("drop serializes to IS NULL", async () => { - const mv = makeMV(); - const change = new DropSecurityLabelOnMaterializedView({ - materializedView: mv, - securityLabel: { provider: "dummy", label: "x" }, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON MATERIALIZED VIEW public.mv IS NULL", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.security-label.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.security-label.ts deleted file mode 100644 index 84e48a688..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.security-label.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { MaterializedView } from "../materialized-view.model.ts"; -import { - CreateMaterializedViewChange, - DropMaterializedViewChange, -} from "./materialized-view.base.ts"; - -export type SecurityLabelMaterializedView = - | CreateSecurityLabelOnMaterializedView - | DropSecurityLabelOnMaterializedView; - -export class CreateSecurityLabelOnMaterializedView extends CreateMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - materializedView: MaterializedView; - securityLabel: SecurityLabelProps; - }) { - super(); - this.materializedView = props.materializedView; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.materializedView.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.materializedView.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON MATERIALIZED VIEW", - `${this.materializedView.schema}.${this.materializedView.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnMaterializedView extends DropMaterializedViewChange { - public readonly materializedView: MaterializedView; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - materializedView: MaterializedView; - securityLabel: SecurityLabelProps; - }) { - super(); - this.materializedView = props.materializedView; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.materializedView.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.materializedView.stableId, - this.securityLabel.provider, - ), - this.materializedView.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON MATERIALIZED VIEW", - `${this.materializedView.schema}.${this.materializedView.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.types.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.types.ts deleted file mode 100644 index 1af522f4b..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterMaterializedView } from "./materialized-view.alter.ts"; -import type { CommentMaterializedView } from "./materialized-view.comment.ts"; -import type { CreateMaterializedView } from "./materialized-view.create.ts"; -import type { DropMaterializedView } from "./materialized-view.drop.ts"; -import type { MaterializedViewPrivilege } from "./materialized-view.privilege.ts"; -import type { SecurityLabelMaterializedView } from "./materialized-view.security-label.ts"; - -/** Union of all materialized-view-related change variants (`objectType: "materialized_view"`). @category Change Types */ -export type MaterializedViewChange = - | AlterMaterializedView - | CommentMaterializedView - | CreateMaterializedView - | DropMaterializedView - | MaterializedViewPrivilege - | SecurityLabelMaterializedView; diff --git a/packages/pg-delta/src/core/objects/materialized-view/materialized-view.diff.test.ts b/packages/pg-delta/src/core/objects/materialized-view/materialized-view.diff.test.ts deleted file mode 100644 index e4a160ee2..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/materialized-view.diff.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../base.default-privileges.ts"; -import { - AlterMaterializedViewChangeOwner, - AlterMaterializedViewSetStorageParams, -} from "./changes/materialized-view.alter.ts"; -import { - CreateCommentOnMaterializedView, - CreateCommentOnMaterializedViewColumn, - DropCommentOnMaterializedView, - DropCommentOnMaterializedViewColumn, -} from "./changes/materialized-view.comment.ts"; -import { CreateMaterializedView } from "./changes/materialized-view.create.ts"; -import { DropMaterializedView } from "./changes/materialized-view.drop.ts"; -import { - GrantMaterializedViewPrivileges, - RevokeGrantOptionMaterializedViewPrivileges, - RevokeMaterializedViewPrivileges, -} from "./changes/materialized-view.privilege.ts"; -import { diffMaterializedViews } from "./materialized-view.diff.ts"; -import { - MaterializedView, - type MaterializedViewProps, -} from "./materialized-view.model.ts"; - -const base: MaterializedViewProps = { - schema: "public", - name: "mv1", - definition: "select 1", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "o1", - comment: null, - columns: [], - privileges: [], -}; - -const baseColumn = { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, -}; - -const makeMaterializedView = (override: Partial = {}) => - new MaterializedView({ - ...base, - ...override, - privileges: override.privileges ?? [...base.privileges], - columns: override.columns ?? [...base.columns], - }); - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("materialized-view.diff", () => { - test("create and drop", () => { - const mv = new MaterializedView(base); - const created = diffMaterializedViews( - testContext, - {}, - { [mv.stableId]: mv }, - ); - expect(created[0]).toBeInstanceOf(CreateMaterializedView); - const dropped = diffMaterializedViews( - testContext, - { [mv.stableId]: mv }, - {}, - ); - expect(dropped[0]).toBeInstanceOf(DropMaterializedView); - }); - - test("alter owner", () => { - const main = new MaterializedView(base); - const branch = new MaterializedView({ ...base, owner: "o2" }); - const changes = diffMaterializedViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterMaterializedViewChangeOwner); - }); - - test("drop + create with metadata on non-alterable change", () => { - const main = new MaterializedView(base); - const branch = new MaterializedView({ ...base, definition: "select 2" }); - const changes = diffMaterializedViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(3); - expect(changes[0]).toBeInstanceOf(DropMaterializedView); - expect(changes[1]).toBeInstanceOf(CreateMaterializedView); - expect(changes[2]).toBeInstanceOf(AlterMaterializedViewChangeOwner); - }); - - test("alter storage parameters: set and reset", () => { - const main = new MaterializedView({ - ...base, - options: ["fillfactor=90", "autovacuum_enabled=false"], - }); - const branch = new MaterializedView({ - ...base, - options: ["fillfactor=70", "user_catalog_table=true"], - }); - const changes = diffMaterializedViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterMaterializedViewSetStorageParams), - ).toBe(true); - }); - - test("create with comment and column comments", () => { - const mv = makeMaterializedView({ - comment: "my matview", - columns: [ - { ...baseColumn, comment: "id column" }, - { ...baseColumn, name: "val", position: 2, comment: null }, - ], - }); - const changes = diffMaterializedViews( - testContext, - {}, - { [mv.stableId]: mv }, - ); - expect(changes[0]).toBeInstanceOf(CreateMaterializedView); - expect( - changes.some((c) => c instanceof CreateCommentOnMaterializedView), - ).toBe(true); - expect( - changes.some((c) => c instanceof CreateCommentOnMaterializedViewColumn), - ).toBe(true); - }); - - test("create with privileges emits grant changes", () => { - const mv = makeMaterializedView({ - privileges: [ - { grantee: "role_select", privilege: "SELECT", grantable: false }, - ], - }); - const changes = diffMaterializedViews( - testContext, - {}, - { [mv.stableId]: mv }, - ); - expect(changes[0]).toBeInstanceOf(CreateMaterializedView); - expect( - changes.some((c) => c instanceof GrantMaterializedViewPrivileges), - ).toBe(true); - }); - - test("comment changes emit create/drop comment statements", () => { - const main = makeMaterializedView(); - const withComment = makeMaterializedView({ comment: "matview comment" }); - - const addComment = diffMaterializedViews( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment[0]).toBeInstanceOf(CreateCommentOnMaterializedView); - - const dropComment = diffMaterializedViews( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment[0]).toBeInstanceOf(DropCommentOnMaterializedView); - }); - - test("column comment changes emit create/drop column comment statements", () => { - const main = makeMaterializedView({ - columns: [{ ...baseColumn, comment: null }], - }); - const withColComment = makeMaterializedView({ - columns: [{ ...baseColumn, comment: "id column" }], - }); - - const addColComment = diffMaterializedViews( - testContext, - { [main.stableId]: main }, - { [withColComment.stableId]: withColComment }, - ); - expect( - addColComment.some( - (c) => c instanceof CreateCommentOnMaterializedViewColumn, - ), - ).toBe(true); - - const dropColComment = diffMaterializedViews( - testContext, - { [withColComment.stableId]: withColComment }, - { [main.stableId]: main }, - ); - expect( - dropColComment.some( - (c) => c instanceof DropCommentOnMaterializedViewColumn, - ), - ).toBe(true); - }); - - test("privilege diffs emit grant, revoke, and revoke grant option statements", () => { - const main = makeMaterializedView({ - privileges: [ - { grantee: "role_select", privilege: "SELECT", grantable: false }, - { grantee: "role_with_option", privilege: "SELECT", grantable: true }, - { grantee: "role_removed", privilege: "SELECT", grantable: false }, - ], - }); - const branch = makeMaterializedView({ - privileges: [ - { grantee: "role_select", privilege: "SELECT", grantable: true }, - { grantee: "role_with_option", privilege: "SELECT", grantable: false }, - { grantee: "role_new", privilege: "SELECT", grantable: false }, - ], - }); - - const changes = diffMaterializedViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect( - changes.some((c) => c instanceof GrantMaterializedViewPrivileges), - ).toBe(true); - expect( - changes.some((c) => c instanceof RevokeMaterializedViewPrivileges), - ).toBe(true); - expect( - changes.some( - (c) => c instanceof RevokeGrantOptionMaterializedViewPrivileges, - ), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/materialized-view/materialized-view.diff.ts b/packages/pg-delta/src/core/objects/materialized-view/materialized-view.diff.ts deleted file mode 100644 index 92395591f..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/materialized-view.diff.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { - diffPrivileges, - emitColumnPrivilegeChanges, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { deepEqual, hasNonAlterableChanges } from "../utils.ts"; -import { - AlterMaterializedViewChangeOwner, - AlterMaterializedViewSetStorageParams, -} from "./changes/materialized-view.alter.ts"; -import { - CreateCommentOnMaterializedView, - CreateCommentOnMaterializedViewColumn, - DropCommentOnMaterializedView, - DropCommentOnMaterializedViewColumn, -} from "./changes/materialized-view.comment.ts"; -import { CreateMaterializedView } from "./changes/materialized-view.create.ts"; -import { DropMaterializedView } from "./changes/materialized-view.drop.ts"; -import { - GrantMaterializedViewPrivileges, - RevokeGrantOptionMaterializedViewPrivileges, - RevokeMaterializedViewPrivileges, -} from "./changes/materialized-view.privilege.ts"; -import { - CreateSecurityLabelOnMaterializedView, - DropSecurityLabelOnMaterializedView, -} from "./changes/materialized-view.security-label.ts"; -import type { MaterializedViewChange } from "./changes/materialized-view.types.ts"; -import type { MaterializedView } from "./materialized-view.model.ts"; - -export function buildCreateMaterializedViewChanges( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - mv: MaterializedView, -): MaterializedViewChange[] { - const changes: MaterializedViewChange[] = [ - new CreateMaterializedView({ - materializedView: mv, - }), - ]; - - // OWNER: If the materialized view should be owned by someone other than the current user, - // emit ALTER MATERIALIZED VIEW ... OWNER TO after creation - if (mv.owner !== ctx.currentUser) { - changes.push( - new AlterMaterializedViewChangeOwner({ - materializedView: mv, - owner: mv.owner, - }), - ); - } - - // Materialized view comment on creation - if (mv.comment !== null) { - changes.push( - new CreateCommentOnMaterializedView({ - materializedView: mv, - }), - ); - } - // Column comments on creation - for (const col of mv.columns) { - if (col.comment !== null) { - changes.push( - new CreateCommentOnMaterializedViewColumn({ - materializedView: mv, - column: col, - }), - ); - } - } - - // Security labels on the matview itself (columns of matviews are not - // supported targets of SECURITY LABEL, so we only label the relation). - for (const label of mv.security_labels) { - changes.push( - new CreateSecurityLabelOnMaterializedView({ - materializedView: mv, - securityLabel: label, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "materialized_view", - mv.schema ?? "", - ); - const creatorFilteredDefaults = - mv.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - const desiredPrivileges = mv.privileges; - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the materialized view owner as the reference. - const privilegeResults = diffPrivileges( - creatorFilteredDefaults, - desiredPrivileges, - mv.owner, - ); - - changes.push( - ...(emitColumnPrivilegeChanges( - privilegeResults, - mv, - mv, - "materializedView", - { - Grant: GrantMaterializedViewPrivileges, - Revoke: RevokeMaterializedViewPrivileges, - RevokeGrantOption: RevokeGrantOptionMaterializedViewPrivileges, - }, - effectiveDefaults, - ctx.version, - ) as MaterializedViewChange[]), - ); - - return changes; -} - -/** - * Diff two sets of materialized views from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The materialized views in the main catalog. - * @param branch - The materialized views in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffMaterializedViews( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): MaterializedViewChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: MaterializedViewChange[] = []; - - for (const materializedViewId of created) { - changes.push( - ...buildCreateMaterializedViewChanges(ctx, branch[materializedViewId]), - ); - } - - for (const materializedViewId of dropped) { - changes.push( - new DropMaterializedView({ materializedView: main[materializedViewId] }), - ); - } - - for (const materializedViewId of altered) { - const mainMaterializedView = main[materializedViewId]; - const branchMaterializedView = branch[materializedViewId]; - - // Check if non-alterable properties have changed - // These require dropping and recreating the materialized view - const NON_ALTERABLE_FIELDS: Array = [ - "definition", - "row_security", - "force_row_security", - "has_indexes", - "has_rules", - "has_triggers", - "has_subclasses", - "is_populated", - "replica_identity", - "is_partition", - "partition_bound", - ]; - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainMaterializedView, - branchMaterializedView, - NON_ALTERABLE_FIELDS, - { options: deepEqual }, - ); - - if (nonAlterablePropsChanged) { - // Replace the entire materialized view (drop + create) - changes.push( - new DropMaterializedView({ materializedView: mainMaterializedView }), - ...buildCreateMaterializedViewChanges(ctx, branchMaterializedView), - ); - } else { - // Only alterable properties changed - check each one - - // OWNER - if (mainMaterializedView.owner !== branchMaterializedView.owner) { - changes.push( - new AlterMaterializedViewChangeOwner({ - materializedView: mainMaterializedView, - owner: branchMaterializedView.owner, - }), - ); - } - - // STORAGE PARAMETERS (reloptions) - // Emit a combined SET/RESET change similar to indexes - if ( - !deepEqual(mainMaterializedView.options, branchMaterializedView.options) - ) { - const parseOptions = (options: string[] | null | undefined) => { - const map = new Map(); - if (!options) return map; - for (const opt of options) { - const eqIndex = opt.indexOf("="); - const key = opt.slice(0, eqIndex).trim(); - const value = opt.slice(eqIndex + 1).trim(); - map.set(key, value); - } - return map; - }; - const mainMap = parseOptions(mainMaterializedView.options); - const branchMap = parseOptions(branchMaterializedView.options); - const keysToReset: string[] = []; - for (const key of mainMap.keys()) { - if (!branchMap.has(key)) keysToReset.push(key); - } - const paramsToSet: string[] = []; - for (const [key, newValue] of branchMap.entries()) { - const oldValue = mainMap.get(key); - const changed = oldValue !== newValue; - if (changed) { - paramsToSet.push( - newValue === undefined ? key : `${key}=${newValue}`, - ); - } - } - changes.push( - new AlterMaterializedViewSetStorageParams({ - materializedView: mainMaterializedView, - paramsToSet, - keysToReset, - }), - ); - } - - // Note: Materialized view renaming would also use ALTER MATERIALIZED VIEW ... RENAME TO ... - // But since our MaterializedView model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - // MATERIALIZED VIEW COMMENT (create/drop when comment changes) - if (mainMaterializedView.comment !== branchMaterializedView.comment) { - if (branchMaterializedView.comment === null) { - changes.push( - new DropCommentOnMaterializedView({ - materializedView: mainMaterializedView, - }), - ); - } else { - changes.push( - new CreateCommentOnMaterializedView({ - materializedView: branchMaterializedView, - }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - | CreateSecurityLabelOnMaterializedView - | DropSecurityLabelOnMaterializedView - >( - mainMaterializedView.security_labels, - branchMaterializedView.security_labels, - (securityLabel) => - new CreateSecurityLabelOnMaterializedView({ - materializedView: branchMaterializedView, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnMaterializedView({ - materializedView: mainMaterializedView, - securityLabel, - }), - ), - ); - // COMMENT changes on columns - const mainCols = new Map( - mainMaterializedView.columns.map((c) => [c.name, c]), - ); - const branchCols = new Map( - branchMaterializedView.columns.map((c) => [c.name, c]), - ); - for (const [name, branchCol] of branchCols) { - const mainCol = mainCols.get(name); - if (!mainCol) continue; - if (mainCol.comment !== branchCol.comment) { - if (branchCol.comment === null) { - changes.push( - new DropCommentOnMaterializedViewColumn({ - materializedView: mainMaterializedView, - column: mainCol, - }), - ); - } else { - changes.push( - new CreateCommentOnMaterializedViewColumn({ - materializedView: branchMaterializedView, - column: branchCol, - }), - ); - } - } - } - - // PRIVILEGES (unified object and column privileges) - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainMaterializedView.privileges, - branchMaterializedView.privileges, - branchMaterializedView.owner, - ); - - changes.push( - ...(emitColumnPrivilegeChanges( - privilegeResults, - branchMaterializedView, - mainMaterializedView, - "materializedView", - { - Grant: GrantMaterializedViewPrivileges, - Revoke: RevokeMaterializedViewPrivileges, - RevokeGrantOption: RevokeGrantOptionMaterializedViewPrivileges, - }, - mainMaterializedView.privileges, - ctx.version, - ) as MaterializedViewChange[]), - ); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/materialized-view/materialized-view.model.test.ts b/packages/pg-delta/src/core/objects/materialized-view/materialized-view.model.test.ts deleted file mode 100644 index 7ed032151..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/materialized-view.model.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Pool } from "pg"; -import { - extractMaterializedViews, - MaterializedView, -} from "./materialized-view.model.ts"; - -const baseRow = { - schema: "public", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - owner: "postgres", - comment: null, - columns: [], - privileges: [], -}; - -const mockPool = (rows: unknown[]): Pool => - ({ query: async () => ({ rows }) }) as unknown as Pool; - -const mockPoolSequence = (...attempts: unknown[][]): Pool => { - let i = 0; - return { - query: async () => ({ - rows: attempts[Math.min(i++, attempts.length - 1)], - }), - } as unknown as Pool; -}; - -const NO_BACKOFF = { backoffMs: 0 } as const; - -describe("extractMaterializedViews", () => { - test("skips rows where pg_get_viewdef returned NULL after exhausting retries", async () => { - const mvs = await extractMaterializedViews( - mockPool([ - { - ...baseRow, - name: '"good_mv"', - definition: "SELECT 1", - }, - { ...baseRow, name: '"orphan_mv"', definition: null }, - ]), - NO_BACKOFF, - ); - - expect(mvs).toHaveLength(1); - expect(mvs[0]).toBeInstanceOf(MaterializedView); - expect(mvs[0]?.name).toBe('"good_mv"'); - expect(mvs[0]?.definition).toBe("SELECT 1"); - }); - - test("does not throw ZodError when the only row has a null definition", async () => { - await expect( - extractMaterializedViews( - mockPool([{ ...baseRow, name: '"orphan"', definition: null }]), - NO_BACKOFF, - ), - ).resolves.toEqual([]); - }); - - test("returns all materialized views when every row has a valid definition", async () => { - const mvs = await extractMaterializedViews( - mockPool([ - { ...baseRow, name: '"a"', definition: "SELECT 1" }, - { ...baseRow, name: '"b"', definition: "SELECT 2" }, - ]), - NO_BACKOFF, - ); - expect(mvs.map((m) => m.name)).toEqual(['"a"', '"b"']); - }); - - test("recovers when pg_get_viewdef is NULL on first attempt but resolved on retry", async () => { - const mvs = await extractMaterializedViews( - mockPoolSequence( - [{ ...baseRow, name: '"racy_mv"', definition: null }], - [{ ...baseRow, name: '"racy_mv"', definition: "SELECT 42" }], - ), - { retries: 2, backoffMs: 0 }, - ); - expect(mvs).toHaveLength(1); - expect(mvs[0]?.name).toBe('"racy_mv"'); - expect(mvs[0]?.definition).toBe("SELECT 42"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/materialized-view/materialized-view.model.ts b/packages/pg-delta/src/core/objects/materialized-view/materialized-view.model.ts deleted file mode 100644 index ce2b670f9..000000000 --- a/packages/pg-delta/src/core/objects/materialized-view/materialized-view.model.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { - BasePgModel, - columnPropsSchema, - type TableLikeObject, -} from "../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; -import { - type ExtractRetryOptions, - extractWithDefinitionRetry, -} from "../extract-with-retry.ts"; -import { - normalizeSecurityLabels, - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; -import { ReplicaIdentitySchema } from "../table/table.model.ts"; - -const materializedViewPropsSchema = z.object({ - schema: z.string(), - name: z.string(), - definition: z.string(), - row_security: z.boolean(), - force_row_security: z.boolean(), - has_indexes: z.boolean(), - has_rules: z.boolean(), - has_triggers: z.boolean(), - has_subclasses: z.boolean(), - is_populated: z.boolean(), - replica_identity: ReplicaIdentitySchema, - is_partition: z.boolean(), - options: z.array(z.string()).nullable(), - partition_bound: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable(), - columns: z.array(columnPropsSchema), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -// pg_get_viewdef(oid) can return NULL when the underlying matview (or its -// pg_rewrite row) is dropped between catalog scan and resolution, or under -// transient catalog state during recovery. An unreadable matview cannot be -// diffed, so we accept NULL here and filter the row out at extraction time -// rather than crashing the whole catalog parse with a ZodError. -const materializedViewRowSchema = materializedViewPropsSchema.extend({ - definition: z.string().nullable(), -}); - -type MaterializedViewPrivilegeProps = PrivilegeProps; -export type MaterializedViewProps = z.infer; - -export class MaterializedView extends BasePgModel implements TableLikeObject { - public readonly schema: MaterializedViewProps["schema"]; - public readonly name: MaterializedViewProps["name"]; - public readonly definition: MaterializedViewProps["definition"]; - public readonly row_security: MaterializedViewProps["row_security"]; - public readonly force_row_security: MaterializedViewProps["force_row_security"]; - public readonly has_indexes: MaterializedViewProps["has_indexes"]; - public readonly has_rules: MaterializedViewProps["has_rules"]; - public readonly has_triggers: MaterializedViewProps["has_triggers"]; - public readonly has_subclasses: MaterializedViewProps["has_subclasses"]; - public readonly is_populated: MaterializedViewProps["is_populated"]; - public readonly replica_identity: MaterializedViewProps["replica_identity"]; - public readonly is_partition: MaterializedViewProps["is_partition"]; - public readonly options: MaterializedViewProps["options"]; - public readonly partition_bound: MaterializedViewProps["partition_bound"]; - public readonly owner: MaterializedViewProps["owner"]; - public readonly comment: MaterializedViewProps["comment"]; - public readonly columns: MaterializedViewProps["columns"]; - public readonly privileges: MaterializedViewPrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: MaterializedViewProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.definition = props.definition; - this.row_security = props.row_security; - this.force_row_security = props.force_row_security; - this.has_indexes = props.has_indexes; - this.has_rules = props.has_rules; - this.has_triggers = props.has_triggers; - this.has_subclasses = props.has_subclasses; - this.is_populated = props.is_populated; - this.replica_identity = props.replica_identity; - this.is_partition = props.is_partition; - this.options = props.options; - this.partition_bound = props.partition_bound; - this.owner = props.owner; - this.comment = props.comment; - this.columns = props.columns; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `materializedView:${string}` { - return `materializedView:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - definition: this.definition, - row_security: this.row_security, - force_row_security: this.force_row_security, - has_indexes: this.has_indexes, - has_rules: this.has_rules, - has_triggers: this.has_triggers, - has_subclasses: this.has_subclasses, - is_populated: this.is_populated, - replica_identity: this.replica_identity, - is_partition: this.is_partition, - options: this.options, - partition_bound: this.partition_bound, - owner: this.owner, - comment: this.comment, - columns: this.columns, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } - - override stableSnapshot() { - const normalizeColumns = () => - [...this.columns] - .map((col) => { - const { position: _pos, ...rest } = col as unknown as Record< - string, - unknown - >; - return rest; - }) - .sort((a, b) => { - const nameA = (a.name as string | undefined) ?? ""; - const nameB = (b.name as string | undefined) ?? ""; - return nameA.localeCompare(nameB); - }); - - return { - identity: this.identityFields, - data: { - ...this.dataFields, - columns: normalizeColumns(), - security_labels: normalizeSecurityLabels(this.security_labels), - }, - }; - } -} - -export async function extractMaterializedViews( - pool: Pool, - options?: ExtractRetryOptions, -): Promise { - const mvRows = await extractWithDefinitionRetry({ - label: "materialized views", - options, - hasNullDefinition: (row) => row.definition === null, - query: async () => { - const result = await pool.query(sql` -with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass -) -select - c.relnamespace::regnamespace::text as schema, - quote_ident(c.relname) as name, - -- remove trailing semicolon from the definition if present - rtrim(pg_get_viewdef(c.oid), ';') as definition, - c.relrowsecurity as row_security, - c.relforcerowsecurity as force_row_security, - c.relhasindex as has_indexes, - c.relhasrules as has_rules, - c.relhastriggers as has_triggers, - c.relhassubclass as has_subclasses, - c.relispopulated as is_populated, - c.relreplident as replica_identity, - c.relispartition as is_partition, - c.reloptions as options, - pg_get_expr(c.relpartbound, c.oid) as partition_bound, - c.relowner::regrole::text as owner, - obj_description(c.oid, 'pg_class') as comment, - coalesce(json_agg( - case when a.attname is not null then - json_build_object( - 'name', quote_ident(a.attname), - 'position', a.attnum, - 'data_type', a.atttypid::regtype::text, - 'data_type_str', format_type(a.atttypid, a.atttypmod), - 'is_custom_type', ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema'), - 'custom_type_type', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typtype else null end, - 'custom_type_category', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typcategory else null end, - 'custom_type_schema', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typnamespace::regnamespace else null end, - 'custom_type_name', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then quote_ident(ty.typname) else null end, - 'not_null', a.attnotnull, - 'is_identity', a.attidentity != '', - 'is_identity_always', a.attidentity = 'a', - 'is_generated', a.attgenerated != '', - 'collation', ( - select quote_ident(c2.collname) - from pg_collation c2, pg_type t2 - where c2.oid = a.attcollation - and t2.oid = a.atttypid - and a.attcollation <> t2.typcollation - ), - 'default', pg_get_expr(ad.adbin, ad.adrelid), - 'comment', col_description(a.attrelid, a.attnum) - ) - end - order by a.attnum - ) filter (where a.attname is not null), '[]') as columns, - coalesce(( - select json_agg( - json_build_object( - 'grantee', case when grp.grantee = 0 then 'PUBLIC' else grp.grantee::regrole::text end, - 'privilege', grp.privilege_type, - 'grantable', grp.is_grantable, - 'columns', case when grp.cols is not null and array_length(grp.cols,1) > 0 - then grp.cols - else null end - ) - order by grp.grantee, grp.privilege_type - ) - from ( - select - x.grantee, - x.privilege_type, - bool_or(x.is_grantable) as is_grantable, - array_agg(quote_ident(src.attname) order by src.attname) - filter (where src.attname is not null) as cols - from ( - -- one row for object ACL + one row per column ACL - select null::name as attname, COALESCE(c.relacl, acldefault('r', c.relowner)) as acl - union all - select a2.attname, a2.attacl - from pg_attribute a2 - where a2.attrelid = c.oid - and a2.attnum > 0 - and not a2.attisdropped - and a2.attacl is not null - ) as src - join lateral aclexplode(src.acl) as x(grantor, grantee, privilege_type, is_grantable) on true - group by x.grantee, x.privilege_type - ) as grp - ), '[]') as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = c.oid - and sl.classoid = 'pg_class'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from - pg_catalog.pg_class c - left outer join extension_oids e on c.oid = e.objid - left join pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped - left join pg_attrdef ad on a.attrelid = ad.adrelid and a.attnum = ad.adnum - left join pg_type ty on ty.oid = a.atttypid -where not c.relnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null - and c.relkind = 'm' -group by - c.oid, c.relnamespace, c.relname, pg_get_viewdef(c.oid), c.relrowsecurity, c.relforcerowsecurity, c.relhasindex, c.relhasrules, c.relhastriggers, c.relhassubclass, c.relispopulated, c.relreplident, c.relispartition, c.reloptions, pg_get_expr(c.relpartbound, c.oid), c.relowner -order by - c.relnamespace::regnamespace, c.relname - `); - return result.rows.map((row: unknown) => - materializedViewRowSchema.parse(row), - ); - }, - }); - const validatedRows = mvRows.filter( - (row): row is MaterializedViewProps => row.definition !== null, - ); - return validatedRows.map((row) => new MaterializedView(row)); -} diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.alter.test.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.alter.test.ts deleted file mode 100644 index 86fe1088c..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.alter.test.ts +++ /dev/null @@ -1,1077 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Procedure, type ProcedureProps } from "../procedure.model.ts"; -import { - AlterProcedureChangeOwner, - AlterProcedureSetConfig, - AlterProcedureSetLeakproof, - AlterProcedureSetParallel, - AlterProcedureSetSecurity, - AlterProcedureSetStrictness, - AlterProcedureSetVolatility, -} from "./procedure.alter.ts"; - -describe.concurrent("procedure", () => { - describe("alter", () => { - test("change owner", async () => { - const props: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ - ...props, - owner: "old_owner", - }); - - const change = new AlterProcedureChangeOwner({ - procedure, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() OWNER TO new_owner", - ); - }); - - test("change owner (function)", async () => { - const props: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql AS $$SELECT 1$$", - config: null, - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ - ...props, - owner: "old_owner", - }); - - const change = new AlterProcedureChangeOwner({ - procedure, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() OWNER TO new_owner", - ); - }); - - test("change owner with argument types (overloaded function)", async () => { - const procedure = new Procedure({ - schema: "public", - name: "my_func", - kind: "f", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 2, - argument_default_count: 0, - argument_names: ["a", "b"], - argument_types: ["integer", "text"], - all_argument_types: ["integer", "text"], - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: null, - definition: "CREATE FUNCTION public.my_func(integer, text) ...", - config: null, - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - owner: "old_owner", - }); - const change = new AlterProcedureChangeOwner({ - procedure, - owner: "postgres", - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.my_func(integer, text) OWNER TO postgres", - ); - }); - - test("set security definer", async () => { - const props: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...props, security_definer: false }); - const change = new AlterProcedureSetSecurity({ - procedure, - securityDefiner: true, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() SECURITY DEFINER", - ); - }); - - test("unset security definer (invoker)", async () => { - const props: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...props, security_definer: true }); - const change = new AlterProcedureSetSecurity({ - procedure, - securityDefiner: false, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() SECURITY INVOKER", - ); - }); - - test("set and reset config", async () => { - const base: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql AS $$SELECT 1$$", - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ - ...base, - config: ["search_path=public"], - }); - const change1 = new AlterProcedureSetConfig({ - procedure, - action: "reset", - key: "search_path", - }); - const change2 = new AlterProcedureSetConfig({ - procedure, - action: "set", - key: "search_path", - value: "pg_temp", - }); - const change3 = new AlterProcedureSetConfig({ - procedure, - action: "set", - key: "work_mem", - value: "64MB", - }); - await assertValidSql(change1.serialize()); - expect(change1.serialize()).toBe( - "ALTER FUNCTION public.test_function() RESET search_path", - ); - await assertValidSql(change2.serialize()); - expect(change2.serialize()).toBe( - "ALTER FUNCTION public.test_function() SET search_path TO pg_temp", - ); - await assertValidSql(change3.serialize()); - expect(change3.serialize()).toBe( - "ALTER FUNCTION public.test_function() SET work_mem TO '64MB'", - ); - }); - - test("set config from null (function)", async () => { - const base: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql AS $$SELECT 1$$", - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, config: null }); - const change = new AlterProcedureSetConfig({ - procedure, - action: "set", - key: "search_path", - value: "public", - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() SET search_path TO public", - ); - }); - - test("set volatility", async () => { - const base: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql IMMUTABLE AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, volatility: "v" }); - const change = new AlterProcedureSetVolatility({ - procedure, - volatility: "i", - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() IMMUTABLE", - ); - }); - - test("set strictness", async () => { - const base: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql IMMUTABLE AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, is_strict: false }); - const change = new AlterProcedureSetStrictness({ - procedure, - isStrict: true, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() STRICT", - ); - }); - - test("unset strictness (called on null input)", async () => { - const base: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql IMMUTABLE AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, is_strict: true }); - const change = new AlterProcedureSetStrictness({ - procedure, - isStrict: false, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() CALLED ON NULL INPUT", - ); - }); - - test("set leakproof", async () => { - const base: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql IMMUTABLE AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, leakproof: false }); - const change = new AlterProcedureSetLeakproof({ - procedure, - leakproof: true, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() LEAKPROOF", - ); - }); - - test("unset leakproof", async () => { - const base: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql IMMUTABLE AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, leakproof: true }); - const change = new AlterProcedureSetLeakproof({ - procedure, - leakproof: false, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() NOT LEAKPROOF", - ); - }); - - test("set parallel safety", async () => { - const base: Omit = { - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE OR REPLACE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql IMMUTABLE AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, parallel_safety: "u" }); - const change = new AlterProcedureSetParallel({ - procedure, - parallelSafety: "r", - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER FUNCTION public.test_function() PARALLEL RESTRICTED", - ); - }); - - // PROCEDURE variants - test("procedure: set security definer", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, security_definer: false }); - const change = new AlterProcedureSetSecurity({ - procedure, - securityDefiner: true, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() SECURITY DEFINER", - ); - }); - - test("procedure: unset security definer (invoker)", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, security_definer: true }); - const change = new AlterProcedureSetSecurity({ - procedure, - securityDefiner: false, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() SECURITY INVOKER", - ); - }); - - test("procedure: set and reset config", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const main = new Procedure({ ...base, config: ["search_path=public"] }); - const change1 = new AlterProcedureSetConfig({ - procedure: main, - action: "reset", - key: "search_path", - }); - const change2 = new AlterProcedureSetConfig({ - procedure: main, - action: "set", - key: "search_path", - value: "pg_temp", - }); - const change3 = new AlterProcedureSetConfig({ - procedure: main, - action: "set", - key: "work_mem", - value: "64MB", - }); - await assertValidSql(change1.serialize()); - expect(change1.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() RESET search_path", - ); - await assertValidSql(change2.serialize()); - expect(change2.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() SET search_path TO pg_temp", - ); - await assertValidSql(change3.serialize()); - expect(change3.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() SET work_mem TO '64MB'", - ); - }); - - test("procedure: reset all config (to null)", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE OR REPLACE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const main = new Procedure({ - ...base, - config: ["search_path=public", "work_mem=64MB"], - }); - const change1 = new AlterProcedureSetConfig({ - procedure: main, - action: "reset", - key: "search_path", - }); - const change2 = new AlterProcedureSetConfig({ - procedure: main, - action: "reset", - key: "work_mem", - }); - await assertValidSql(change1.serialize()); - expect(change1.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() RESET search_path", - ); - await assertValidSql(change2.serialize()); - expect(change2.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() RESET work_mem", - ); - }); - - test("procedure: set volatility", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE OR REPLACE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, volatility: "v" }); - const change = new AlterProcedureSetVolatility({ - procedure, - volatility: "s", - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() STABLE", - ); - }); - - test("procedure: set strictness", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE OR REPLACE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, is_strict: false }); - const change = new AlterProcedureSetStrictness({ - procedure, - isStrict: true, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() STRICT", - ); - }); - - test("procedure: unset strictness (called on null input)", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE OR REPLACE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, is_strict: true }); - const change = new AlterProcedureSetStrictness({ - procedure, - isStrict: false, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() CALLED ON NULL INPUT", - ); - }); - - test("procedure: set leakproof", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE OR REPLACE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, leakproof: false }); - const change = new AlterProcedureSetLeakproof({ - procedure, - leakproof: true, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() LEAKPROOF", - ); - }); - - test("procedure: unset leakproof", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE OR REPLACE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, leakproof: true }); - const change = new AlterProcedureSetLeakproof({ - procedure, - leakproof: false, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() NOT LEAKPROOF", - ); - }); - - test("procedure: set parallel safety", async () => { - const base: Omit = { - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - definition: - "CREATE OR REPLACE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }; - const procedure = new Procedure({ ...base, parallel_safety: "u" }); - const change = new AlterProcedureSetParallel({ - procedure, - parallelSafety: "s", - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PROCEDURE public.test_procedure() PARALLEL SAFE", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.alter.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.alter.ts deleted file mode 100644 index 5368302f5..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.alter.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Procedure } from "../procedure.model.ts"; -import { formatConfigValue } from "../utils.ts"; -import { AlterProcedureChange } from "./procedure.base.ts"; - -/** Build schema.name(args) for ALTER statements so overloaded functions are unambiguous. */ -function procedureSignature(procedure: Procedure): string { - const args = procedure.argument_types?.join(", ") ?? ""; - return `${procedure.schema}.${procedure.name}(${args})`; -} - -/** - * Alter a procedure. - * - * @see https://www.postgresql.org/docs/17/sql-alterfunction.html - * - * Synopsis - * ```sql - * ALTER FUNCTION name ( [ [ argmode ] [ argname ] argtype [, ...] ] ) - * action [, ... ] [ RESTRICT ] - * ALTER PROCEDURE name ( [ [ argmode ] [ argname ] argtype [, ...] ] ) - * action [, ... ] [ RESTRICT ] - * where action is one of: - * [ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY DEFINER - * SET configuration_parameter { TO | = } { value | DEFAULT } - * SET configuration_parameter FROM CURRENT - * RESET configuration_parameter - * RESET ALL - * [ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY DEFINER - * SET configuration_parameter { TO | = } { value | DEFAULT } - * SET configuration_parameter FROM CURRENT - * RESET configuration_parameter - * RESET ALL - * ``` - */ - -export type AlterProcedure = - | AlterProcedureChangeOwner - | AlterProcedureSetConfig - | AlterProcedureSetLeakproof - | AlterProcedureSetParallel - | AlterProcedureSetSecurity - | AlterProcedureSetStrictness - | AlterProcedureSetVolatility; - -/** - * ALTER FUNCTION/PROCEDURE ... OWNER TO ... - */ -export class AlterProcedureChangeOwner extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { procedure: Procedure; owner: string }) { - super(); - this.procedure = props.procedure; - this.owner = props.owner; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const objectType = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - - return [ - "ALTER", - objectType, - procedureSignature(this.procedure), - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * ALTER FUNCTION/PROCEDURE ... SECURITY { INVOKER | DEFINER } - */ -export class AlterProcedureSetSecurity extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly securityDefiner: boolean; - public readonly scope = "object" as const; - - constructor(props: { procedure: Procedure; securityDefiner: boolean }) { - super(); - this.procedure = props.procedure; - this.securityDefiner = props.securityDefiner; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const objectType = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - const security = this.securityDefiner - ? "SECURITY DEFINER" - : "SECURITY INVOKER"; // INVOKER is default; only emitted when changed via diff - - return [ - "ALTER", - objectType, - procedureSignature(this.procedure), - security, - ].join(" "); - } -} - -/** - * ALTER FUNCTION/PROCEDURE ... SET/RESET configuration_parameter - * Emits individual RESET for removed keys and SET for added/changed keys. - */ -export class AlterProcedureSetConfig extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly action: "set" | "reset" | "reset_all"; - public readonly key?: string; - public readonly value?: string; - public readonly scope = "object" as const; - - constructor(props: { - procedure: Procedure; - action: "set"; - key: string; - value: string; - }); - constructor(props: { procedure: Procedure; action: "reset"; key: string }); - constructor(props: { procedure: Procedure; action: "reset_all" }); - constructor(props: { - procedure: Procedure; - action: "set" | "reset" | "reset_all"; - key?: string; - value?: string; - }) { - super(); - this.procedure = props.procedure; - this.action = props.action; - this.key = props.key; - this.value = props.value; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const head = [ - "ALTER", - this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION", - procedureSignature(this.procedure), - ].join(" "); - if (this.action === "reset_all") return `${head} RESET ALL`; - if (this.action === "reset") return `${head} RESET ${this.key}`; - const formatted = formatConfigValue( - this.key as string, - this.value as string, - ); - return `${head} SET ${this.key} TO ${formatted}`; - } -} - -/** - * ALTER FUNCTION/PROCEDURE ... { IMMUTABLE | STABLE | VOLATILE } - */ -export class AlterProcedureSetVolatility extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly volatility: string; - public readonly scope = "object" as const; - - constructor(props: { procedure: Procedure; volatility: string }) { - super(); - this.procedure = props.procedure; - this.volatility = props.volatility; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const objectType = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - const volMap: Record = { - i: "IMMUTABLE", - s: "STABLE", - v: "VOLATILE", - }; - return [ - "ALTER", - objectType, - procedureSignature(this.procedure), - volMap[this.volatility], - ].join(" "); - } -} - -/** - * ALTER FUNCTION/PROCEDURE ... { STRICT | CALLED ON NULL INPUT } - */ -export class AlterProcedureSetStrictness extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly isStrict: boolean; - public readonly scope = "object" as const; - - constructor(props: { procedure: Procedure; isStrict: boolean }) { - super(); - this.procedure = props.procedure; - this.isStrict = props.isStrict; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const objectType = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - const strictness = this.isStrict ? "STRICT" : "CALLED ON NULL INPUT"; - return [ - "ALTER", - objectType, - procedureSignature(this.procedure), - strictness, - ].join(" "); - } -} - -/** - * ALTER FUNCTION/PROCEDURE ... { LEAKPROOF | NOT LEAKPROOF } - */ -export class AlterProcedureSetLeakproof extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly leakproof: boolean; - public readonly scope = "object" as const; - - constructor(props: { procedure: Procedure; leakproof: boolean }) { - super(); - this.procedure = props.procedure; - this.leakproof = props.leakproof; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const objectType = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - const leak = this.leakproof ? "LEAKPROOF" : "NOT LEAKPROOF"; - return ["ALTER", objectType, procedureSignature(this.procedure), leak].join( - " ", - ); - } -} - -/** - * ALTER FUNCTION/PROCEDURE ... PARALLEL { UNSAFE | RESTRICTED | SAFE } - */ -export class AlterProcedureSetParallel extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly parallelSafety: string; - public readonly scope = "object" as const; - - constructor(props: { procedure: Procedure; parallelSafety: string }) { - super(); - this.procedure = props.procedure; - this.parallelSafety = props.parallelSafety; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const objectType = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - const parallelMap: Record = { - u: "PARALLEL UNSAFE", - s: "PARALLEL SAFE", - r: "PARALLEL RESTRICTED", - }; - return [ - "ALTER", - objectType, - procedureSignature(this.procedure), - parallelMap[this.parallelSafety], - ].join(" "); - } -} - -/** - * Replace a procedure by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER FUNCTION/PROCEDURE change. - */ -// NOTE: ReplaceProcedure removed. Non-alterable changes are emitted as Drop + Create in procedure.diff.ts. diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.base.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.base.ts deleted file mode 100644 index 00801b8bb..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Procedure } from "../procedure.model.ts"; - -abstract class BaseProcedureChange extends BaseChange { - abstract readonly procedure: Procedure; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "procedure" as const; -} - -export abstract class CreateProcedureChange extends BaseProcedureChange { - readonly operation = "create" as const; -} - -export abstract class AlterProcedureChange extends BaseProcedureChange { - readonly operation = "alter" as const; -} - -export abstract class DropProcedureChange extends BaseProcedureChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.comment.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.comment.ts deleted file mode 100644 index 870fe29a1..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.comment.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Procedure } from "../procedure.model.ts"; -import { - CreateProcedureChange, - DropProcedureChange, -} from "./procedure.base.ts"; - -export type CommentProcedure = - | CreateCommentOnProcedure - | DropCommentOnProcedure; - -/** - * Create/drop comments on procedures/functions. - */ -export class CreateCommentOnProcedure extends CreateProcedureChange { - public readonly procedure: Procedure; - public readonly scope = "comment" as const; - - constructor(props: { procedure: Procedure }) { - super(); - this.procedure = props.procedure; - } - - get creates() { - return [stableId.comment(this.procedure.stableId)]; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON", - this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION", - `${this.procedure.schema}.${this.procedure.name}(${(this.procedure.argument_types ?? []).join(",")})`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: procedure comment is not nullable in this case - quoteLiteral(this.procedure.comment!), - ].join(" "); - } -} - -export class DropCommentOnProcedure extends DropProcedureChange { - public readonly procedure: Procedure; - public readonly scope = "comment" as const; - - constructor(props: { procedure: Procedure }) { - super(); - this.procedure = props.procedure; - } - - get drops() { - return [stableId.comment(this.procedure.stableId)]; - } - - get requires() { - return [stableId.comment(this.procedure.stableId), this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON", - this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION", - `${this.procedure.schema}.${this.procedure.name}(${(this.procedure.argument_types ?? []).join(",")})`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.create.test.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.create.test.ts deleted file mode 100644 index c4ceefc56..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.create.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Procedure } from "../procedure.model.ts"; -import { CreateProcedure } from "./procedure.create.ts"; - -describe("procedure", () => { - test("create", async () => { - const procedure = new Procedure({ - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - definition: - "CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - binary_path: null, - sql_body: null, - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }); - - const change = new CreateProcedure({ - procedure, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.create.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.create.ts deleted file mode 100644 index ddf131fdd..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.create.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { parseTypeString, stableId } from "../../utils.ts"; -import type { Procedure } from "../procedure.model.ts"; -import { CreateProcedureChange } from "./procedure.base.ts"; - -/** - * Create a procedure. - * - * @see https://www.postgresql.org/docs/17/sql-createfunction.html - * - * Synopsis - * ```sql - * CREATE [ OR REPLACE ] FUNCTION - * name ( [ [ argmode ] [ argname ] argtype [ { DEFAULT | = } default_expr ] [, ...] ] ) - * [ RETURNS rettype - * | RETURNS TABLE ( column_name column_type [, ...] ) ] - * { LANGUAGE lang_name - * | TRANSFORM { FOR TYPE type_name } [, ... ] - * | WINDOW - * | IMMUTABLE | STABLE | VOLATILE | [ NOT ] LEAKPROOF - * | CALLED ON NULL INPUT | RETURNS NULL ON NULL INPUT | STRICT - * | [ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY DEFINER - * | PARALLEL { UNSAFE | RESTRICTED | SAFE } - * | COST execution_cost - * | ROWS result_rows - * | SUPPORT support_function - * | SET configuration_parameter { TO value | = value | FROM CURRENT } - * | AS 'definition' - * | AS 'obj_file', 'link_symbol' - * | sql_body - * } ... - * ``` - */ -export class CreateProcedure extends CreateProcedureChange { - public readonly procedure: Procedure; - public readonly orReplace: boolean; - public readonly scope = "object" as const; - - constructor(props: { procedure: Procedure; orReplace?: boolean }) { - super(); - this.procedure = props.procedure; - this.orReplace = props.orReplace ?? false; - } - - get creates() { - return [this.procedure.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.procedure.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.procedure.owner)); - - // Language dependency (if user-defined) - // Note: Most languages are built-in (plpgsql, sql, etc.), but custom languages - // can be created. We can't reliably determine if a language is user-defined - // from just the name, so we rely on pg_depend for language dependencies. - - // Return type dependency (if user-defined) - const returnType = parseTypeString(this.procedure.return_type); - if (returnType) { - dependencies.add(stableId.type(returnType.schema, returnType.name)); - } - - // Argument type dependencies (if user-defined) - if (this.procedure.argument_types) { - for (const argType of this.procedure.argument_types) { - const parsedType = parseTypeString(argType); - if (parsedType) { - dependencies.add(stableId.type(parsedType.schema, parsedType.name)); - } - } - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - // Use the server-generated CREATE statement for functions/procedures - // Normalize trailing semicolon and OR REPLACE clause according to flag - let definition = this.procedure.definition.trim(); - definition = definition.replace(/;\s*$/, ""); - definition = definition.replace( - /^CREATE\s+(?:OR\s+REPLACE\s+)?/i, - `CREATE ${this.orReplace ? "OR REPLACE " : ""}`, - ); - return definition; - } -} diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.drop.test.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.drop.test.ts deleted file mode 100644 index 8ddc7cfb2..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.drop.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Procedure } from "../procedure.model.ts"; -import { DropProcedure } from "./procedure.drop.ts"; - -describe("procedure", () => { - test("drop", async () => { - const procedure = new Procedure({ - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - definition: - "CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$BEGIN RETURN; END;$$", - binary_path: null, - sql_body: null, - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }); - - const change = new DropProcedure({ - procedure, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP PROCEDURE public.test_procedure()"); - }); - - test("drop function", async () => { - const fn = new Procedure({ - schema: "public", - name: "test_function", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: "SELECT 1", - definition: - "CREATE FUNCTION public.test_function() RETURNS int4 LANGUAGE sql AS $$SELECT 1$$", - config: null, - owner: "test", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], - }); - - const change = new DropProcedure({ procedure: fn }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP FUNCTION public.test_function()"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.drop.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.drop.ts deleted file mode 100644 index 566f4ecff..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.drop.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Procedure } from "../procedure.model.ts"; -import { formatFunctionArguments } from "../utils.ts"; -import { DropProcedureChange } from "./procedure.base.ts"; - -/** - * Drop a procedure. - * - * @see https://www.postgresql.org/docs/17/sql-dropfunction.html - * - * Synopsis - * ```sql - * DROP FUNCTION [ IF EXISTS ] name ( [ [ argmode ] [ argname ] argtype [, ...] ] ) [, ...] [ CASCADE | RESTRICT ] - * DROP PROCEDURE [ IF EXISTS ] name ( [ [ argmode ] [ argname ] argtype [, ...] ] ) [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropProcedure extends DropProcedureChange { - public readonly procedure: Procedure; - public readonly scope = "object" as const; - - constructor(props: { procedure: Procedure }) { - super(); - this.procedure = props.procedure; - } - - get drops() { - return [this.procedure.stableId]; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const objectType = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - - // Build argument list - const args = formatFunctionArguments( - this.procedure.argument_names, - this.procedure.argument_types, - this.procedure.argument_modes, - ); - - return [ - "DROP", - objectType, - `${this.procedure.schema}.${this.procedure.name}(${args})`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.privilege.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.privilege.ts deleted file mode 100644 index 679f15097..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.privilege.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { Procedure } from "../procedure.model.ts"; -import { AlterProcedureChange } from "./procedure.base.ts"; - -export type ProcedurePrivilege = - | GrantProcedurePrivileges - | RevokeProcedurePrivileges - | RevokeGrantOptionProcedurePrivileges; - -/** - * Grant privileges on a procedure. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { EXECUTE | ALL [ PRIVILEGES ] } - * ON { { FUNCTION | PROCEDURE | ROUTINE } routine_name [ ( [ [ argmode ] [ arg_name ] arg_type [, ...] ] ) ] [, ...] - * | ALL { FUNCTIONS | PROCEDURES | ROUTINES } IN SCHEMA schema_name [, ...] } - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantProcedurePrivileges extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - procedure: Procedure; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.procedure = props.procedure; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.procedure.stableId, this.grantee)]; - } - - get requires() { - return [this.procedure.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantProcedurePrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const objectKind = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - const kindPrefix = getObjectKindPrefix(objectKind); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList(objectKind, list, this.version); - const procedureName = `${this.procedure.schema}.${this.procedure.name}`; - const args = this.procedure.argument_types?.join(", ") ?? ""; - // Always include parentheses for privilege statements, even for zero-argument procedures/functions - const signature = `${procedureName}(${args})`; - return `GRANT ${privSql} ${kindPrefix} ${signature} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a procedure. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { { EXECUTE | ALL [ PRIVILEGES ] } } - * ON { FUNCTION | PROCEDURE | ROUTINE } routine_name [ ( [ [ argmode ] [ argname ] argtype [, ...] ] ) ] [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeProcedurePrivileges extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - procedure: Procedure; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.procedure = props.procedure; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.procedure.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.procedure.stableId, this.grantee), - this.procedure.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const objectKind = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - const kindPrefix = getObjectKindPrefix(objectKind); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList(objectKind, list, this.version); - const procedureName = `${this.procedure.schema}.${this.procedure.name}`; - const args = this.procedure.argument_types?.join(", ") ?? ""; - // Always include parentheses for privilege statements, even for zero-argument procedures/functions - const signature = `${procedureName}(${args})`; - return `REVOKE ${privSql} ${kindPrefix} ${signature} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a procedure. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionProcedurePrivileges extends AlterProcedureChange { - public readonly procedure: Procedure; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - procedure: Procedure; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.procedure = props.procedure; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.procedure.stableId, this.grantee), - this.procedure.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const objectKind = this.procedure.kind === "p" ? "PROCEDURE" : "FUNCTION"; - const kindPrefix = getObjectKindPrefix(objectKind); - const privSql = formatObjectPrivilegeList( - objectKind, - this.privilegeNames, - this.version, - ); - const procedureName = `${this.procedure.schema}.${this.procedure.name}`; - const args = this.procedure.argument_types?.join(", ") ?? ""; - // Always include parentheses for privilege statements, even for zero-argument procedures/functions - const signature = `${procedureName}(${args})`; - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${signature} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.security-label.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.security-label.ts deleted file mode 100644 index f2d1a4919..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.security-label.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Procedure } from "../procedure.model.ts"; -import { - CreateProcedureChange, - DropProcedureChange, -} from "./procedure.base.ts"; - -export type SecurityLabelProcedure = - | CreateSecurityLabelOnProcedure - | DropSecurityLabelOnProcedure; - -function targetKeyword(p: Procedure): "FUNCTION" | "PROCEDURE" { - return p.kind === "p" ? "PROCEDURE" : "FUNCTION"; -} - -function procedureIdentity(p: Procedure): string { - return `${p.schema}.${p.name}(${(p.argument_types ?? []).join(",")})`; -} - -export class CreateSecurityLabelOnProcedure extends CreateProcedureChange { - public readonly procedure: Procedure; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - procedure: Procedure; - securityLabel: SecurityLabelProps; - }) { - super(); - this.procedure = props.procedure; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.procedure.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.procedure.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON", - targetKeyword(this.procedure), - procedureIdentity(this.procedure), - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnProcedure extends DropProcedureChange { - public readonly procedure: Procedure; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - procedure: Procedure; - securityLabel: SecurityLabelProps; - }) { - super(); - this.procedure = props.procedure; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.procedure.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.procedure.stableId, - this.securityLabel.provider, - ), - this.procedure.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON", - targetKeyword(this.procedure), - procedureIdentity(this.procedure), - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/procedure/changes/procedure.types.ts b/packages/pg-delta/src/core/objects/procedure/changes/procedure.types.ts deleted file mode 100644 index cc4fcd697..000000000 --- a/packages/pg-delta/src/core/objects/procedure/changes/procedure.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterProcedure } from "./procedure.alter.ts"; -import type { CommentProcedure } from "./procedure.comment.ts"; -import type { CreateProcedure } from "./procedure.create.ts"; -import type { DropProcedure } from "./procedure.drop.ts"; -import type { ProcedurePrivilege } from "./procedure.privilege.ts"; -import type { SecurityLabelProcedure } from "./procedure.security-label.ts"; - -/** Union of all procedure-related change variants (`objectType: "procedure"`). @category Change Types */ -export type ProcedureChange = - | AlterProcedure - | CommentProcedure - | CreateProcedure - | DropProcedure - | ProcedurePrivilege - | SecurityLabelProcedure; diff --git a/packages/pg-delta/src/core/objects/procedure/procedure.diff.test.ts b/packages/pg-delta/src/core/objects/procedure/procedure.diff.test.ts deleted file mode 100644 index f085b0707..000000000 --- a/packages/pg-delta/src/core/objects/procedure/procedure.diff.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../base.default-privileges.ts"; -import { - AlterProcedureChangeOwner, - AlterProcedureSetConfig, - AlterProcedureSetLeakproof, - AlterProcedureSetParallel, - AlterProcedureSetSecurity, - AlterProcedureSetStrictness, - AlterProcedureSetVolatility, -} from "./changes/procedure.alter.ts"; -import { CreateCommentOnProcedure } from "./changes/procedure.comment.ts"; -import { CreateProcedure } from "./changes/procedure.create.ts"; -import { DropProcedure } from "./changes/procedure.drop.ts"; -import { diffProcedures } from "./procedure.diff.ts"; -import { Procedure, type ProcedureProps } from "./procedure.model.ts"; - -const base: ProcedureProps = { - schema: "public", - name: "fn1", - kind: "f", - return_type: "int4", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v", - parallel_safety: "s", - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: null, - binary_path: null, - sql_body: null, - definition: - "CREATE FUNCTION public.fn1() RETURNS int4 LANGUAGE sql AS $$SELECT NULL::int4$$", - config: null, - owner: "o1", - execution_cost: 0, - result_rows: 0, - comment: null, - privileges: [], -}; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("procedure.diff", () => { - test("create and drop", () => { - const p = new Procedure(base); - const created = diffProcedures(testContext, {}, { [p.stableId]: p }); - expect(created[0]).toBeInstanceOf(CreateProcedure); - const dropped = diffProcedures(testContext, { [p.stableId]: p }, {}); - expect(dropped[0]).toBeInstanceOf(DropProcedure); - }); - - test("alter owner", () => { - const main = new Procedure(base); - const branch = new Procedure({ ...base, owner: "o2" }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterProcedureChangeOwner); - }); - - test("diff emits alter security when security_definer changes", () => { - const main = new Procedure(base); - const branch = new Procedure({ ...base, security_definer: true }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterProcedureSetSecurity); - }); - - test("diff emits config set/reset when config changes", () => { - const main = new Procedure({ ...base, config: ["search_path=public"] }); - const branch = new Procedure({ - ...base, - config: ["search_path=pg_temp", "work_mem=64MB"], - }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterProcedureSetConfig); - }); - - test("diff emits volatility change", () => { - const main = new Procedure(base); - const branch = new Procedure({ ...base, volatility: "i" }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterProcedureSetVolatility); - }); - - test("diff emits strictness change", () => { - const main = new Procedure(base); - const branch = new Procedure({ ...base, is_strict: true }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterProcedureSetStrictness); - }); - - test("diff emits leakproof change", () => { - const main = new Procedure(base); - const branch = new Procedure({ ...base, leakproof: true }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterProcedureSetLeakproof); - }); - - test("diff emits parallel safety change", () => { - const main = new Procedure(base); - const branch = new Procedure({ ...base, parallel_safety: "r" }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterProcedureSetParallel); - }); - - test("create or replace when body-only non-alterable property changes", () => { - // Changing only the language (or source) is OR-REPLACE-safe: no DROP needed. - const main = new Procedure(base); - const branch = new Procedure({ - ...base, - language: "plpgsql", - source_code: "BEGIN RETURN 1; END", - }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(CreateProcedure); - expect((changes[0] as CreateProcedure).orReplace).toBe(true); - }); - - test("drop + create when return type changes", () => { - // `CREATE OR REPLACE FUNCTION` cannot change the return type. - const main = new Procedure(base); - const branch = new Procedure({ ...base, return_type: "text" }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(DropProcedure); - expect(changes[1]).toBeInstanceOf(CreateProcedure); - expect((changes[1] as CreateProcedure).orReplace).toBe(false); - }); - - test("drop + create when parameter name changes but type is identical", () => { - // Same argument_types means same stableId (altered path), but - // `CREATE OR REPLACE` rejects changing an IN parameter's name. - const main = new Procedure({ - ...base, - argument_count: 1, - argument_names: ["p"], - argument_types: ["int4"], - all_argument_types: ["int4"], - argument_modes: ["i"], - }); - const branch = new Procedure({ - ...base, - argument_count: 1, - argument_names: ["renamed"], - argument_types: ["int4"], - all_argument_types: ["int4"], - argument_modes: ["i"], - }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(DropProcedure); - expect(changes[1]).toBeInstanceOf(CreateProcedure); - expect((changes[1] as CreateProcedure).orReplace).toBe(false); - }); - - test("drop + create when a parameter default is removed", () => { - // `CREATE OR REPLACE` rejects removing parameter defaults. - const main = new Procedure({ - ...base, - argument_count: 1, - argument_default_count: 1, - argument_names: ["p"], - argument_types: ["int4"], - all_argument_types: ["int4"], - argument_modes: ["i"], - argument_defaults: "0", - }); - const branch = new Procedure({ - ...base, - argument_count: 1, - argument_default_count: 0, - argument_names: ["p"], - argument_types: ["int4"], - all_argument_types: ["int4"], - argument_modes: ["i"], - argument_defaults: null, - }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(DropProcedure); - expect(changes[1]).toBeInstanceOf(CreateProcedure); - expect((changes[1] as CreateProcedure).orReplace).toBe(false); - }); - - test("create or replace also emits a procedure comment when the comment changes", () => { - const main = new Procedure(base); - const branch = new Procedure({ - ...base, - definition: - "CREATE FUNCTION public.fn1() RETURNS int4 LANGUAGE sql AS $$SELECT 42::int4$$", - source_code: "SELECT 42::int4", - comment: "updated comment", - }); - - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.some((change) => change instanceof CreateProcedure)).toBe( - true, - ); - expect( - changes.some((change) => change instanceof CreateCommentOnProcedure), - ).toBe(true); - }); - - test("signature change re-emits comment even when comment itself is unchanged", () => { - // DROP destroys the old comment, so the new CREATE path must re-emit it. - const main = new Procedure({ ...base, comment: "hello" }); - const branch = new Procedure({ - ...base, - return_type: "text", - comment: "hello", - }); - const changes = diffProcedures( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((change) => change instanceof DropProcedure)).toBe( - true, - ); - expect( - changes.some((change) => change instanceof CreateCommentOnProcedure), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/procedure/procedure.diff.ts b/packages/pg-delta/src/core/objects/procedure/procedure.diff.ts deleted file mode 100644 index ed91ee335..000000000 --- a/packages/pg-delta/src/core/objects/procedure/procedure.diff.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { deepEqual, hasNonAlterableChanges } from "../utils.ts"; -import { - AlterProcedureChangeOwner, - AlterProcedureSetConfig, - AlterProcedureSetLeakproof, - AlterProcedureSetParallel, - AlterProcedureSetSecurity, - AlterProcedureSetStrictness, - AlterProcedureSetVolatility, -} from "./changes/procedure.alter.ts"; -import { - CreateCommentOnProcedure, - DropCommentOnProcedure, -} from "./changes/procedure.comment.ts"; -import { CreateProcedure } from "./changes/procedure.create.ts"; -import { DropProcedure } from "./changes/procedure.drop.ts"; -import { - GrantProcedurePrivileges, - RevokeGrantOptionProcedurePrivileges, - RevokeProcedurePrivileges, -} from "./changes/procedure.privilege.ts"; -import { - CreateSecurityLabelOnProcedure, - DropSecurityLabelOnProcedure, -} from "./changes/procedure.security-label.ts"; -import type { ProcedureChange } from "./changes/procedure.types.ts"; -import type { Procedure } from "./procedure.model.ts"; - -/** - * Diff two sets of procedures from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The procedures in the main catalog. - * @param branch - The procedures in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffProcedures( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): ProcedureChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: ProcedureChange[] = []; - - const appendCreateProcedureChanges = (proc: Procedure) => { - changes.push(new CreateProcedure({ procedure: proc })); - - // OWNER: If the procedure should be owned by someone other than the current user, - // emit ALTER FUNCTION/PROCEDURE ... OWNER TO after creation - if (proc.owner !== ctx.currentUser) { - changes.push( - new AlterProcedureChangeOwner({ - procedure: proc, - owner: proc.owner, - }), - ); - } - - if (proc.comment !== null) { - changes.push(new CreateCommentOnProcedure({ procedure: proc })); - } - for (const label of proc.security_labels) { - changes.push( - new CreateSecurityLabelOnProcedure({ - procedure: proc, - securityLabel: label, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "procedure", - proc.schema ?? "", - ); - const creatorFilteredDefaults = - proc.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - // Filter out PUBLIC's built-in default EXECUTE privilege (PostgreSQL grants it automatically) - // Reference: https://www.postgresql.org/docs/17/ddl-priv.html Table 5.2 - // This prevents generating unnecessary "GRANT EXECUTE TO PUBLIC" statements - const desiredPrivileges = filterPublicBuiltInDefaults( - "procedure", - proc.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Note: we use the final owner (proc.owner), not the - // current user, because ownership change happens before privilege diffing. - const privilegeResults = diffPrivileges( - filterPublicBuiltInDefaults("procedure", creatorFilteredDefaults), - desiredPrivileges, - proc.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - proc, - proc, - "procedure", - { - Grant: GrantProcedurePrivileges, - Revoke: RevokeProcedurePrivileges, - RevokeGrantOption: RevokeGrantOptionProcedurePrivileges, - }, - ctx.version, - ) as ProcedureChange[]), - ); - }; - - for (const procedureId of created) { - appendCreateProcedureChanges(branch[procedureId]); - } - - for (const procedureId of dropped) { - changes.push(new DropProcedure({ procedure: main[procedureId] })); - } - - for (const procedureId of altered) { - const mainProcedure = main[procedureId]; - const branchProcedure = branch[procedureId]; - - // Fields that are part of the function's identity/signature. PostgreSQL - // rejects `CREATE OR REPLACE FUNCTION` for any of these changes with - // errors such as: - // - cannot change return type of existing function - // - cannot change name of input parameter "..." - // - cannot change whether a procedure has output parameters - // - cannot remove parameter defaults from existing function - // These require `DROP FUNCTION` followed by `CREATE FUNCTION`. - const SIGNATURE_BREAKING_FIELDS: Array = [ - "kind", - "return_type", - "return_type_schema", - "returns_set", - "argument_count", - "argument_default_count", - "argument_names", - "argument_types", - "all_argument_types", - "argument_modes", - "argument_defaults", - ]; - // Fields where `CREATE OR REPLACE` is sufficient - body replacement only. - // Other fields (security_definer, volatility, parallel_safety, is_strict, - // leakproof, config) are alterable via dedicated ALTER actions below. - const OR_REPLACEABLE_NON_ALTERABLE_FIELDS: Array = [ - "language", - "source_code", - "binary_path", - "sql_body", - ]; - const signatureChanged = hasNonAlterableChanges( - mainProcedure, - branchProcedure, - SIGNATURE_BREAKING_FIELDS, - { - argument_names: deepEqual, - argument_types: deepEqual, - all_argument_types: deepEqual, - argument_modes: deepEqual, - }, - ); - const nonAlterablePropsChanged = - signatureChanged || - hasNonAlterableChanges( - mainProcedure, - branchProcedure, - OR_REPLACEABLE_NON_ALTERABLE_FIELDS, - ); - - if (signatureChanged) { - // PostgreSQL cannot change an existing function's signature via - // `CREATE OR REPLACE`. Drop the old signature, then recreate. - // `expandReplaceDependencies` will cascade the replacement to dependent - // objects (views, triggers, column defaults) via pg_depend edges. - changes.push(new DropProcedure({ procedure: mainProcedure })); - appendCreateProcedureChanges(branchProcedure); - } else if (nonAlterablePropsChanged) { - // Body-only non-alterable change - `CREATE OR REPLACE` preserves the - // function OID and keeps dependent objects attached. - changes.push( - new CreateProcedure({ procedure: branchProcedure, orReplace: true }), - ); - - if (mainProcedure.comment !== branchProcedure.comment) { - if (branchProcedure.comment === null) { - changes.push( - new DropCommentOnProcedure({ procedure: mainProcedure }), - ); - } else { - changes.push( - new CreateCommentOnProcedure({ procedure: branchProcedure }), - ); - } - } - } else { - // Only alterable properties changed - check each one - - // OWNER - if (mainProcedure.owner !== branchProcedure.owner) { - changes.push( - new AlterProcedureChangeOwner({ - procedure: mainProcedure, - owner: branchProcedure.owner, - }), - ); - } - - // COMMENT - if (mainProcedure.comment !== branchProcedure.comment) { - if (branchProcedure.comment === null) { - changes.push( - new DropCommentOnProcedure({ procedure: mainProcedure }), - ); - } else { - changes.push( - new CreateCommentOnProcedure({ procedure: branchProcedure }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnProcedure | DropSecurityLabelOnProcedure - >( - mainProcedure.security_labels, - branchProcedure.security_labels, - (securityLabel) => - new CreateSecurityLabelOnProcedure({ - procedure: branchProcedure, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnProcedure({ - procedure: mainProcedure, - securityLabel, - }), - ), - ); - - // SECURITY DEFINER/INVOKER - if (mainProcedure.security_definer !== branchProcedure.security_definer) { - changes.push( - new AlterProcedureSetSecurity({ - procedure: mainProcedure, - securityDefiner: branchProcedure.security_definer, - }), - ); - } - - // CONFIG SET/RESET - const toMap = (opts?: string[] | null) => { - const map = new Map(); - for (const opt of opts ?? []) { - const eq = opt.indexOf("="); - const key = opt.slice(0, eq).trim(); - const value = opt.slice(eq + 1).trim(); - map.set(key, value); - } - return map; - }; - const mainCfg = toMap(mainProcedure.config); - const branchCfg = toMap(branchProcedure.config); - if (branchCfg.size === 0 && mainCfg.size > 0) { - // Branch has no config at all -> prefer a single RESET ALL - changes.push( - new AlterProcedureSetConfig({ - procedure: mainProcedure, - action: "reset_all", - }), - ); - } else { - for (const [key, oldValue] of mainCfg.entries()) { - const hasInBranch = branchCfg.has(key); - const newValue = branchCfg.get(key); - const changed = hasInBranch ? oldValue !== newValue : true; - if (changed) { - changes.push( - new AlterProcedureSetConfig({ - procedure: mainProcedure, - action: "reset", - key, - }), - ); - } - } - for (const [key, newValue] of branchCfg.entries()) { - const oldValue = mainCfg.get(key); - if (oldValue !== newValue) { - changes.push( - new AlterProcedureSetConfig({ - procedure: mainProcedure, - action: "set", - key, - value: newValue, - }), - ); - } - } - } - - // VOLATILITY - if (mainProcedure.volatility !== branchProcedure.volatility) { - changes.push( - new AlterProcedureSetVolatility({ - procedure: mainProcedure, - volatility: branchProcedure.volatility, - }), - ); - } - - // STRICTNESS - if (mainProcedure.is_strict !== branchProcedure.is_strict) { - changes.push( - new AlterProcedureSetStrictness({ - procedure: mainProcedure, - isStrict: branchProcedure.is_strict, - }), - ); - } - - // LEAKPROOF - if (mainProcedure.leakproof !== branchProcedure.leakproof) { - changes.push( - new AlterProcedureSetLeakproof({ - procedure: mainProcedure, - leakproof: branchProcedure.leakproof, - }), - ); - } - - // PARALLEL - if (mainProcedure.parallel_safety !== branchProcedure.parallel_safety) { - changes.push( - new AlterProcedureSetParallel({ - procedure: mainProcedure, - parallelSafety: branchProcedure.parallel_safety, - }), - ); - } - - // Note: Procedure renaming would also use ALTER FUNCTION/PROCEDURE ... RENAME TO ... - // But since our Procedure model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - - // PRIVILEGES - // Filter out PUBLIC's built-in default EXECUTE privilege from main catalog - // (PostgreSQL grants it automatically, so we shouldn't compare it) - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "procedure", - mainProcedure.privileges, - ); - // Filter out PUBLIC's built-in default EXECUTE privilege from branch catalog - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "procedure", - branchProcedure.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchProcedure.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchProcedure, - mainProcedure, - "procedure", - { - Grant: GrantProcedurePrivileges, - Revoke: RevokeProcedurePrivileges, - RevokeGrantOption: RevokeGrantOptionProcedurePrivileges, - }, - ctx.version, - ) as ProcedureChange[]), - ); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/procedure/procedure.model.test.ts b/packages/pg-delta/src/core/objects/procedure/procedure.model.test.ts deleted file mode 100644 index 2aa2cdf32..000000000 --- a/packages/pg-delta/src/core/objects/procedure/procedure.model.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Pool } from "pg"; -import { extractProcedures, Procedure } from "./procedure.model.ts"; - -const baseRow = { - schema: "public", - kind: "f" as const, - return_type: "integer", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "v" as const, - parallel_safety: "u" as const, - execution_cost: 100, - result_rows: 0, - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "select 1", - binary_path: null, - sql_body: null, - config: null, - owner: "postgres", - comment: null, - privileges: [], -}; - -const mockPool = (rows: unknown[]): Pool => - ({ query: async () => ({ rows }) }) as unknown as Pool; - -const mockPoolSequence = (...attempts: unknown[][]): Pool => { - let i = 0; - return { - query: async () => ({ - rows: attempts[Math.min(i++, attempts.length - 1)], - }), - } as unknown as Pool; -}; - -const NO_BACKOFF = { backoffMs: 0 } as const; - -describe("extractProcedures", () => { - test("skips rows where pg_get_functiondef returned NULL after exhausting retries", async () => { - const procs = await extractProcedures( - mockPool([ - { - ...baseRow, - name: '"good_fn"', - definition: - "CREATE OR REPLACE FUNCTION good_fn() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;", - }, - { ...baseRow, name: '"orphan_fn"', definition: null }, - ]), - NO_BACKOFF, - ); - - expect(procs).toHaveLength(1); - expect(procs[0]).toBeInstanceOf(Procedure); - expect(procs[0]?.name).toBe('"good_fn"'); - }); - - test("does not throw ZodError when the only row has a null definition", async () => { - await expect( - extractProcedures( - mockPool([{ ...baseRow, name: '"orphan"', definition: null }]), - NO_BACKOFF, - ), - ).resolves.toEqual([]); - }); - - test("returns all procedures when every row has a valid definition", async () => { - const procs = await extractProcedures( - mockPool([ - { - ...baseRow, - name: '"a"', - definition: - "CREATE OR REPLACE FUNCTION a() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;", - }, - { - ...baseRow, - name: '"b"', - definition: - "CREATE OR REPLACE FUNCTION b() RETURNS integer AS $$ select 2 $$ LANGUAGE sql;", - }, - ]), - NO_BACKOFF, - ); - expect(procs.map((p) => p.name)).toEqual(['"a"', '"b"']); - }); - - test("recovers when pg_get_functiondef is NULL on first attempt but resolved on retry", async () => { - const procs = await extractProcedures( - mockPoolSequence( - [{ ...baseRow, name: '"racy_fn"', definition: null }], - [ - { - ...baseRow, - name: '"racy_fn"', - definition: - "CREATE OR REPLACE FUNCTION racy_fn() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;", - }, - ], - ), - { retries: 2, backoffMs: 0 }, - ); - expect(procs).toHaveLength(1); - expect(procs[0]?.name).toBe('"racy_fn"'); - }); -}); diff --git a/packages/pg-delta/src/core/objects/procedure/procedure.model.ts b/packages/pg-delta/src/core/objects/procedure/procedure.model.ts deleted file mode 100644 index 9e3b3f404..000000000 --- a/packages/pg-delta/src/core/objects/procedure/procedure.model.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; -import { - type ExtractRetryOptions, - extractWithDefinitionRetry, -} from "../extract-with-retry.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -const FunctionKindSchema = z.enum([ - "f", // function - "p", // procedure - "a", // aggregate function - "w", // window function -]); - -const FunctionVolatilitySchema = z.enum([ - "i", // IMMUTABLE - "s", // STABLE - "v", // VOLATILE -]); - -const FunctionParallelSafetySchema = z.enum([ - "u", // UNSAFE (cannot run in parallel) - "s", // SAFE (can run in parallel) - "r", // RESTRICTED (can run in parallel with restrictions) -]); - -const FunctionArgumentModeSchema = z.enum([ - "i", // IN parameter - "o", // OUT parameter - "b", // INOUT parameter - "v", // VARIADIC parameter - "t", // TABLE parameter -]); - -const procedurePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - kind: FunctionKindSchema, - return_type: z.string(), - return_type_schema: z.string(), - language: z.string(), - security_definer: z.boolean(), - volatility: FunctionVolatilitySchema, - parallel_safety: FunctionParallelSafetySchema, - execution_cost: z.number(), - result_rows: z.number(), - is_strict: z.boolean(), - leakproof: z.boolean(), - returns_set: z.boolean(), - argument_count: z.number(), - argument_default_count: z.number(), - argument_names: z.array(z.string()).nullable(), - argument_types: z.array(z.string()).nullable(), - all_argument_types: z.array(z.string()).nullable(), - argument_modes: z.array(FunctionArgumentModeSchema).nullable(), - argument_defaults: z.string().nullable(), - source_code: z.string().nullable(), - binary_path: z.string().nullable(), - sql_body: z.string().nullable(), - definition: z.string(), - config: z.array(z.string()).nullable(), - owner: z.string(), - comment: z.string().nullable(), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -// pg_get_functiondef(oid) can return NULL when the function (its pg_proc -// row) is dropped between catalog scan and resolution, or under transient -// catalog state. An unreadable function cannot be diffed, so we accept NULL -// here and filter the row out at extraction time rather than crashing the -// whole catalog parse with a ZodError. -const procedureRowSchema = procedurePropsSchema.extend({ - definition: z.string().nullable(), -}); - -type ProcedurePrivilegeProps = PrivilegeProps; -export type ProcedureProps = z.infer; - -export class Procedure extends BasePgModel { - public readonly schema: ProcedureProps["schema"]; - public readonly name: ProcedureProps["name"]; - public readonly kind: ProcedureProps["kind"]; - public readonly return_type: ProcedureProps["return_type"]; - public readonly return_type_schema: ProcedureProps["return_type_schema"]; - public readonly language: ProcedureProps["language"]; - public readonly security_definer: ProcedureProps["security_definer"]; - public readonly volatility: ProcedureProps["volatility"]; - public readonly parallel_safety: ProcedureProps["parallel_safety"]; - public readonly execution_cost: ProcedureProps["execution_cost"]; - public readonly result_rows: ProcedureProps["result_rows"]; - public readonly is_strict: ProcedureProps["is_strict"]; - public readonly leakproof: ProcedureProps["leakproof"]; - public readonly returns_set: ProcedureProps["returns_set"]; - public readonly argument_count: ProcedureProps["argument_count"]; - public readonly argument_default_count: ProcedureProps["argument_default_count"]; - public readonly argument_names: ProcedureProps["argument_names"]; - public readonly argument_types: ProcedureProps["argument_types"]; - public readonly all_argument_types: ProcedureProps["all_argument_types"]; - public readonly argument_modes: ProcedureProps["argument_modes"]; - public readonly argument_defaults: ProcedureProps["argument_defaults"]; - public readonly source_code: ProcedureProps["source_code"]; - public readonly binary_path: ProcedureProps["binary_path"]; - public readonly sql_body: ProcedureProps["sql_body"]; - public readonly definition: ProcedureProps["definition"]; - public readonly config: ProcedureProps["config"]; - public readonly owner: ProcedureProps["owner"]; - public readonly comment: ProcedureProps["comment"]; - public readonly privileges: ProcedurePrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: ProcedureProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.kind = props.kind; - this.return_type = props.return_type; - this.return_type_schema = props.return_type_schema; - this.language = props.language; - this.security_definer = props.security_definer; - this.volatility = props.volatility; - this.parallel_safety = props.parallel_safety; - this.execution_cost = props.execution_cost; - this.result_rows = props.result_rows; - this.is_strict = props.is_strict; - this.leakproof = props.leakproof; - this.returns_set = props.returns_set; - this.argument_count = props.argument_count; - this.argument_default_count = props.argument_default_count; - this.argument_names = props.argument_names; - this.argument_types = props.argument_types; - this.all_argument_types = props.all_argument_types; - this.argument_modes = props.argument_modes; - this.argument_defaults = props.argument_defaults; - this.source_code = props.source_code; - this.binary_path = props.binary_path; - this.sql_body = props.sql_body; - this.definition = props.definition; - this.config = props.config; - this.owner = props.owner; - this.comment = props.comment; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `procedure:${string}` { - const args = this.argument_types?.join(",") ?? ""; - return `procedure:${this.schema}.${this.name}(${args})`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - kind: this.kind, - return_type: this.return_type, - return_type_schema: this.return_type_schema, - language: this.language, - security_definer: this.security_definer, - volatility: this.volatility, - parallel_safety: this.parallel_safety, - // execution_cost and result_rows are planner hints. We intentionally - // exclude them from dataFields to avoid generating diffs solely due to - // changes in estimates. They are still used for CREATE serialization. - is_strict: this.is_strict, - leakproof: this.leakproof, - returns_set: this.returns_set, - argument_count: this.argument_count, - argument_default_count: this.argument_default_count, - argument_names: this.argument_names, - argument_types: this.argument_types, - all_argument_types: this.all_argument_types, - argument_modes: this.argument_modes, - argument_defaults: this.argument_defaults, - source_code: this.source_code, - binary_path: this.binary_path, - sql_body: this.sql_body, - definition: this.definition, - config: this.config, - owner: this.owner, - comment: this.comment, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } -} - -export async function extractProcedures( - pool: Pool, - options?: ExtractRetryOptions, -): Promise { - const procedureRows = await extractWithDefinitionRetry({ - label: "procedures", - options, - hasNullDefinition: (row) => row.definition === null, - query: async () => { - const result = await pool.query(sql` -with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_proc'::regclass -) -select - p.pronamespace::regnamespace::text as schema, - quote_ident(p.proname) as name, - p.prokind as kind, - format_type(p.prorettype, null) as return_type, - rt.typnamespace::regnamespace::text as return_type_schema, - l.lanname as language, - p.prosecdef as security_definer, - p.provolatile as volatility, - p.proparallel as parallel_safety, - p.procost as execution_cost, - p.prorows as result_rows, - p.proisstrict as is_strict, - p.proleakproof as leakproof, - p.proretset as returns_set, - p.pronargs as argument_count, - p.pronargdefaults as argument_default_count, - -- quote argument names server-side for safe serialization - case when p.proargnames is null then null - else array(select quote_ident(n) from unnest(p.proargnames) as n) - end as argument_names, - array( - select format_type(oid, null) - from unnest(p.proargtypes) as oid - ) as argument_types, - array( - select format_type(oid, null) - from unnest(p.proallargtypes) as oid - ) as all_argument_types, - p.proargmodes as argument_modes, - pg_get_expr(p.proargdefaults, 0) as argument_defaults, - p.prosrc as source_code, - p.probin as binary_path, - pg_get_function_sqlbody(p.oid) as sql_body, - pg_get_functiondef(p.oid) as definition, - p.proconfig as config, - p.proowner::regrole::text as owner, - obj_description(p.oid, 'pg_proc') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = p.oid - and sl.classoid = 'pg_proc'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from - pg_catalog.pg_proc p - inner join pg_catalog.pg_language l on l.oid = p.prolang - left join pg_catalog.pg_type rt on rt.oid = p.prorettype - left outer join extension_oids e on p.oid = e.objid - where not p.pronamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null - and l.lanname not in ('c', 'internal') -order by - 1, 2 - `); - return result.rows.map((row: unknown) => procedureRowSchema.parse(row)); - }, - }); - const validatedRows = procedureRows.filter( - (row): row is ProcedureProps => row.definition !== null, - ); - return validatedRows.map((row) => new Procedure(row)); -} diff --git a/packages/pg-delta/src/core/objects/procedure/utils.ts b/packages/pg-delta/src/core/objects/procedure/utils.ts deleted file mode 100644 index 34d284e9f..000000000 --- a/packages/pg-delta/src/core/objects/procedure/utils.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Format function arguments for CREATE/DROP FUNCTION statements. - * - * @param argNames - Array of argument names (can be null) - * @param argTypes - Array of argument types (required) - * @param argModes - Array of argument modes (can be null) - * @returns Formatted argument string - */ -export function formatFunctionArguments( - argNames: string[] | null, - argTypes: string[] | null, - argModes: string[] | null, -): string { - const names = argNames ?? []; - const types = argTypes ?? []; - const modes = argModes ?? []; - - if (types.length === 0) { - return ""; - } - - const modeMap: Record = { - i: "IN", - o: "OUT", - b: "INOUT", - v: "VARIADIC", - t: "TABLE", - }; - - return types - .map((type, i) => { - const name = names[i] ?? ""; // already quoted in model, if present - const mode = modes[i] ? modeMap[modes[i]] : ""; - - const parts: string[] = []; - if (mode) parts.push(mode); - if (name) parts.push(name); - parts.push(type); - - return parts.join(" "); - }) - .join(", "); -} - -/** - * Format a GUC value for SET ... TO ... in function/procedure definitions. - * Applies quoting rules consistent with PostgreSQL docs and psql style. - */ -export function formatConfigValue(key: string, rawValue: string): string { - const value = rawValue.trim(); - if (value.length === 0) return value; - const lowerKey = key.toLowerCase(); - if (value.startsWith("'") && value.endsWith("'")) return value; - if (/^(true|false|on|off)$/i.test(value)) return value.toLowerCase(); - if (/^-?\d+(?:\.\d+)?$/.test(value)) return value; - if (lowerKey === "search_path" || value.includes(",")) return value; - return `'${value.replace(/'/g, "''")}'`; -} diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.test.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.alter.test.ts deleted file mode 100644 index 144563ef7..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import type { PublicationTableProps } from "../publication.model.ts"; -import { Publication } from "../publication.model.ts"; -import { - AlterPublicationAddSchemas, - AlterPublicationAddTables, - AlterPublicationDropSchemas, - AlterPublicationDropTables, - AlterPublicationSetList, - AlterPublicationSetOptions, - AlterPublicationSetOwner, -} from "./publication.alter.ts"; - -type PublicationProps = ConstructorParameters[0]; - -const base: PublicationProps = { - name: "pub_base", - owner: "owner1", - comment: null, - all_tables: true, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [], - schemas: [], -}; - -const cloneTables = (tables: PublicationProps["tables"]) => - tables.map((table) => ({ - ...table, - columns: table.columns ? [...table.columns] : null, - })); - -const makePublication = (override: Partial = {}) => - new Publication({ - ...base, - ...override, - tables: override.tables - ? cloneTables(override.tables) - : cloneTables(base.tables), - schemas: override.schemas ? [...override.schemas] : [...base.schemas], - }); - -describe("publication.alter", () => { - test("set options serializes assignments and requires publication", async () => { - const publication = makePublication({ - name: "pub_options", - publish_delete: false, - publish_truncate: false, - publish_via_partition_root: true, - }); - const change = new AlterPublicationSetOptions({ - publication, - setPublish: true, - setPublishViaPartitionRoot: true, - }); - - expect(change.requires).toEqual([publication.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PUBLICATION pub_options SET (publish = 'insert, update', publish_via_partition_root = true)", - ); - }); - - test("set list serializes object selection and tracks dependencies", async () => { - const publication = makePublication({ - name: "pub_set_list", - all_tables: false, - tables: [ - { - schema: "public", - name: "authors", - columns: ["name", "id"], - row_filter: null, - }, - { - schema: "public", - name: "articles", - columns: null, - row_filter: " published = true ", - }, - ], - schemas: ["analytics"], - }); - const change = new AlterPublicationSetList({ publication }); - - expect(change.requires).toEqual([ - publication.stableId, - stableId.table("public", "articles"), - stableId.table("public", "authors"), - stableId.column("public", "authors", "id"), - stableId.column("public", "authors", "name"), - stableId.schema("analytics"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PUBLICATION pub_set_list SET TABLE public.articles WHERE (published = true), TABLE public.authors (id, name), TABLES IN SCHEMA analytics", - ); - }); - - test("add tables serializes new tables and tracks dependencies", async () => { - const publication = makePublication({ name: "pub_add_tables" }); - const tables: PublicationTableProps[] = [ - { - schema: "public", - name: "logs", - columns: null, - row_filter: null, - }, - { - schema: "audit", - name: "events", - columns: ["created_at", "id"], - row_filter: null, - }, - ]; - const change = new AlterPublicationAddTables({ publication, tables }); - - expect(change.requires).toEqual([ - publication.stableId, - stableId.table("public", "logs"), - stableId.table("audit", "events"), - stableId.column("audit", "events", "created_at"), - stableId.column("audit", "events", "id"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PUBLICATION pub_add_tables ADD TABLE public.logs, TABLE audit.events (created_at, id)", - ); - }); - - test("drop tables serializes target list and tracks dependencies", async () => { - const publication = makePublication({ name: "pub_drop_tables" }); - const tables: PublicationTableProps[] = [ - { - schema: "public", - name: "logs", - columns: null, - row_filter: null, - }, - { - schema: "audit", - name: "events", - columns: ["id"], - row_filter: null, - }, - ]; - const change = new AlterPublicationDropTables({ publication, tables }); - - expect(change.requires).toEqual([ - publication.stableId, - stableId.table("public", "logs"), - stableId.table("audit", "events"), - ]); - expect(change.drops).toEqual([ - stableId.table("public", "logs"), - stableId.table("audit", "events"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PUBLICATION pub_drop_tables DROP TABLE public.logs, audit.events", - ); - }); - - test("add schemas serializes and tracks dependencies", async () => { - const publication = makePublication({ name: "pub_add_schemas" }); - const change = new AlterPublicationAddSchemas({ - publication, - schemas: ["analytics", "sales"], - }); - - expect(change.requires).toEqual([ - publication.stableId, - stableId.schema("analytics"), - stableId.schema("sales"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PUBLICATION pub_add_schemas ADD TABLES IN SCHEMA analytics, TABLES IN SCHEMA sales", - ); - }); - - test("drop schemas serializes and tracks dependencies", async () => { - const publication = makePublication({ name: "pub_drop_schemas" }); - const change = new AlterPublicationDropSchemas({ - publication, - schemas: ["analytics", "sales"], - }); - - expect(change.requires).toEqual([ - publication.stableId, - stableId.schema("analytics"), - stableId.schema("sales"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PUBLICATION pub_drop_schemas DROP TABLES IN SCHEMA analytics, TABLES IN SCHEMA sales", - ); - }); - - test("set owner serializes and tracks dependencies", async () => { - const publication = makePublication({ name: "pub_owner" }); - const change = new AlterPublicationSetOwner({ - publication, - owner: "owner2", - }); - - expect(change.requires).toEqual([ - publication.stableId, - stableId.role("owner2"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER PUBLICATION pub_owner OWNER TO owner2", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts deleted file mode 100644 index a5649d138..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { - Publication, - PublicationTableProps, -} from "../publication.model.ts"; -import { - formatPublicationObjects, - formatPublicationTable, - getPublicationOperations, -} from "../utils.ts"; -import { AlterPublicationChange } from "./publication.base.ts"; - -export class AlterPublicationSetOptions extends AlterPublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - private readonly setPublish: boolean; - private readonly setPublishViaPartitionRoot: boolean; - - constructor(props: { - publication: Publication; - setPublish: boolean; - setPublishViaPartitionRoot: boolean; - }) { - super(); - this.publication = props.publication; - this.setPublish = props.setPublish; - this.setPublishViaPartitionRoot = props.setPublishViaPartitionRoot; - } - - get requires() { - return [this.publication.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const assignments: string[] = []; - - if (this.setPublish) { - const operations = getPublicationOperations(this.publication); - assignments.push(`publish = '${operations.join(", ")}'`); - } - - if (this.setPublishViaPartitionRoot) { - assignments.push( - `publish_via_partition_root = ${this.publication.publish_via_partition_root ? "true" : "false"}`, - ); - } - - return `ALTER PUBLICATION ${this.publication.name} SET (${assignments.join(", ")})`; - } -} - -export class AlterPublicationSetList extends AlterPublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - - constructor(props: { publication: Publication }) { - super(); - this.publication = props.publication; - } - - get requires() { - const dependencies = new Set(); - - dependencies.add(this.publication.stableId); - - for (const table of this.publication.tables) { - dependencies.add(stableId.table(table.schema, table.name)); - if (table.columns) { - for (const column of table.columns) { - dependencies.add(stableId.column(table.schema, table.name, column)); - } - } - } - - for (const schema of this.publication.schemas) { - dependencies.add(stableId.schema(schema)); - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const clauses = formatPublicationObjects( - this.publication.tables, - this.publication.schemas, - ); - return `ALTER PUBLICATION ${this.publication.name} SET ${clauses.join(", ")}`; - } -} - -export class AlterPublicationAddTables extends AlterPublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - private readonly tables: PublicationTableProps[]; - - constructor(props: { - publication: Publication; - tables: PublicationTableProps[]; - }) { - super(); - this.publication = props.publication; - this.tables = props.tables; - } - - get requires() { - const dependencies = new Set(); - - dependencies.add(this.publication.stableId); - - for (const table of this.tables) { - dependencies.add(stableId.table(table.schema, table.name)); - if (table.columns) { - for (const column of table.columns) { - dependencies.add(stableId.column(table.schema, table.name, column)); - } - } - } - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const clauses = this.tables.map((table) => formatPublicationTable(table)); - return `ALTER PUBLICATION ${this.publication.name} ADD ${clauses.join(", ")}`; - } -} - -export class AlterPublicationDropTables extends AlterPublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - public readonly tables: PublicationTableProps[]; - - constructor(props: { - publication: Publication; - tables: PublicationTableProps[]; - }) { - super(); - this.publication = props.publication; - this.tables = props.tables; - } - - get requires() { - const dependencies = new Set(); - - dependencies.add(this.publication.stableId); - - for (const table of this.tables) { - dependencies.add(stableId.table(table.schema, table.name)); - } - - return Array.from(dependencies); - } - - get drops() { - // Treat ALTER PUBLICATION ... DROP TABLE as a destructive change so it runs - // in the drop phase before DROP TABLE removes the referenced relation. - return this.tables.map((table) => stableId.table(table.schema, table.name)); - } - - serialize(_options?: SerializeOptions): string { - const targets = this.tables.map((table) => `${table.schema}.${table.name}`); - return `ALTER PUBLICATION ${this.publication.name} DROP TABLE ${targets.join(", ")}`; - } -} - -export class AlterPublicationAddSchemas extends AlterPublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - private readonly schemas: string[]; - - constructor(props: { publication: Publication; schemas: string[] }) { - super(); - this.publication = props.publication; - this.schemas = props.schemas; - } - - get requires() { - return [ - this.publication.stableId, - ...this.schemas.map((schema) => stableId.schema(schema)), - ]; - } - - serialize(_options?: SerializeOptions): string { - const clauses = this.schemas.map((schema) => `TABLES IN SCHEMA ${schema}`); - return `ALTER PUBLICATION ${this.publication.name} ADD ${clauses.join(", ")}`; - } -} - -export class AlterPublicationDropSchemas extends AlterPublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - private readonly schemas: string[]; - - constructor(props: { publication: Publication; schemas: string[] }) { - super(); - this.publication = props.publication; - this.schemas = props.schemas; - } - - get requires() { - return [ - this.publication.stableId, - ...this.schemas.map((schema) => stableId.schema(schema)), - ]; - } - - serialize(_options?: SerializeOptions): string { - const clauses = this.schemas.map((schema) => `TABLES IN SCHEMA ${schema}`); - return `ALTER PUBLICATION ${this.publication.name} DROP ${clauses.join(", ")}`; - } -} - -export class AlterPublicationSetOwner extends AlterPublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - private readonly owner: string; - - constructor(props: { publication: Publication; owner: string }) { - super(); - this.publication = props.publication; - this.owner = props.owner; - } - - get requires() { - return [this.publication.stableId, stableId.role(this.owner)]; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER PUBLICATION ${this.publication.name} OWNER TO ${this.owner}`; - } -} diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.base.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.base.ts deleted file mode 100644 index bb92b45a0..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Publication } from "../publication.model.ts"; - -abstract class BasePublicationChange extends BaseChange { - abstract readonly publication: Publication; - abstract readonly scope: "object" | "comment" | "security_label"; - readonly objectType = "publication" as const; -} - -export abstract class CreatePublicationChange extends BasePublicationChange { - readonly operation = "create" as const; -} - -export abstract class AlterPublicationChange extends BasePublicationChange { - readonly operation = "alter" as const; -} - -export abstract class DropPublicationChange extends BasePublicationChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.comment.test.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.comment.test.ts deleted file mode 100644 index 1f4a11590..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.comment.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Publication } from "../publication.model.ts"; -import { - CreateCommentOnPublication, - DropCommentOnPublication, -} from "./publication.comment.ts"; - -type PublicationProps = ConstructorParameters[0]; - -const base: PublicationProps = { - name: "pub_comment", - owner: "owner1", - comment: null, - all_tables: true, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [], - schemas: [], -}; - -const cloneTables = (tables: PublicationProps["tables"]) => - tables.map((table) => ({ - ...table, - columns: table.columns ? [...table.columns] : null, - })); - -const makePublication = (override: Partial = {}) => - new Publication({ - ...base, - ...override, - tables: override.tables - ? cloneTables(override.tables) - : cloneTables(base.tables), - schemas: override.schemas ? [...override.schemas] : [...base.schemas], - }); - -describe("publication.comment", () => { - test("create comment serializes and tracks dependencies", async () => { - const publication = makePublication({ - comment: "publication's overview", - }); - const change = new CreateCommentOnPublication({ publication }); - - expect(change.creates).toEqual([stableId.comment(publication.stableId)]); - expect(change.requires).toEqual([publication.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "COMMENT ON PUBLICATION pub_comment IS 'publication''s overview'", - ); - }); - - test("drop comment serializes and tracks dependencies", async () => { - const publication = makePublication({ - comment: "some comment", - }); - const change = new DropCommentOnPublication({ publication }); - - expect(change.drops).toEqual([stableId.comment(publication.stableId)]); - expect(change.requires).toEqual([ - stableId.comment(publication.stableId), - publication.stableId, - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "COMMENT ON PUBLICATION pub_comment IS NULL", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.comment.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.comment.ts deleted file mode 100644 index dce697e42..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.comment.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Publication } from "../publication.model.ts"; -import { - CreatePublicationChange, - DropPublicationChange, -} from "./publication.base.ts"; - -export type CommentPublication = - | CreateCommentOnPublication - | DropCommentOnPublication; - -export class CreateCommentOnPublication extends CreatePublicationChange { - public readonly publication: Publication; - public readonly scope = "comment" as const; - - constructor(props: { publication: Publication }) { - super(); - this.publication = props.publication; - } - - get creates() { - return [stableId.comment(this.publication.stableId)]; - } - - get requires() { - return [this.publication.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON PUBLICATION", - this.publication.name, - "IS", - // biome-ignore lint/style/noNonNullAssertion: comment ensured non-null by caller - quoteLiteral(this.publication.comment!), - ].join(" "); - } -} - -export class DropCommentOnPublication extends DropPublicationChange { - public readonly publication: Publication; - public readonly scope = "comment" as const; - - constructor(props: { publication: Publication }) { - super(); - this.publication = props.publication; - } - - get drops() { - return [stableId.comment(this.publication.stableId)]; - } - - get requires() { - return [ - stableId.comment(this.publication.stableId), - this.publication.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return `COMMENT ON PUBLICATION ${this.publication.name} IS NULL`; - } -} diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.create.test.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.create.test.ts deleted file mode 100644 index 6fbffaac2..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.create.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Publication } from "../publication.model.ts"; -import { CreatePublication } from "./publication.create.ts"; - -type PublicationProps = ConstructorParameters[0]; - -const base: PublicationProps = { - name: "pub_all_tables", - owner: "owner1", - comment: null, - all_tables: true, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [], - schemas: [], -}; - -const cloneTables = (tables: PublicationProps["tables"]) => - tables.map((table) => ({ - ...table, - columns: table.columns ? [...table.columns] : null, - })); - -const makePublication = (override: Partial = {}) => - new Publication({ - ...base, - ...override, - tables: override.tables - ? cloneTables(override.tables) - : cloneTables(base.tables), - schemas: override.schemas ? [...override.schemas] : [...base.schemas], - }); - -describe("publication.create", () => { - test("serialize publication for all tables", async () => { - const publication = makePublication(); - const change = new CreatePublication({ publication }); - - expect(change.creates).toEqual([publication.stableId]); - expect(change.requires).toEqual([stableId.role(publication.owner)]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "CREATE PUBLICATION pub_all_tables FOR ALL TABLES", - ); - }); - - test("serialize publication with explicit objects and options", async () => { - const publication = makePublication({ - name: "pub_custom", - all_tables: false, - publish_delete: false, - publish_truncate: false, - publish_via_partition_root: true, - tables: [ - { - schema: "public", - name: "articles", - columns: null, - row_filter: "id > 1", - }, - { - schema: "public", - name: "authors", - columns: ["name", "id"], - row_filter: null, - }, - ], - schemas: ["analytics"], - }); - const change = new CreatePublication({ publication }); - - expect(change.requires).toEqual([ - stableId.role(publication.owner), - stableId.table("public", "articles"), - stableId.table("public", "authors"), - stableId.column("public", "authors", "id"), - stableId.column("public", "authors", "name"), - stableId.schema("analytics"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "CREATE PUBLICATION pub_custom FOR TABLE public.articles WHERE (id > 1), TABLE public.authors (id, name), TABLES IN SCHEMA analytics WITH (publish = 'insert, update', publish_via_partition_root = true)", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.create.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.create.ts deleted file mode 100644 index d8ded7528..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.create.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Publication } from "../publication.model.ts"; -import { - formatPublicationObjects, - getPublicationOperations, - isDefaultPublicationOperations, -} from "../utils.ts"; -import { CreatePublicationChange } from "./publication.base.ts"; - -/** - * Create a logical replication publication. - * - * @see https://www.postgresql.org/docs/17/sql-createpublication.html - */ -export class CreatePublication extends CreatePublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - - constructor(props: { publication: Publication }) { - super(); - this.publication = props.publication; - } - - get creates() { - return [this.publication.stableId]; - } - - get requires() { - const dependencies = new Set(); - - dependencies.add(stableId.role(this.publication.owner)); - - if (!this.publication.all_tables) { - for (const table of this.publication.tables) { - dependencies.add(stableId.table(table.schema, table.name)); - if (table.columns) { - for (const column of table.columns) { - dependencies.add(stableId.column(table.schema, table.name, column)); - } - } - } - - for (const schema of this.publication.schemas) { - dependencies.add(stableId.schema(schema)); - } - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE PUBLICATION", this.publication.name]; - - if (this.publication.all_tables) { - parts.push("FOR ALL TABLES"); - } else { - const objectClauses = formatPublicationObjects( - this.publication.tables, - this.publication.schemas, - ); - if (objectClauses.length > 0) { - parts.push("FOR", objectClauses.join(", ")); - } - } - - const options: string[] = []; - if (!isDefaultPublicationOperations(this.publication)) { - const operations = getPublicationOperations(this.publication); - options.push(`publish = '${operations.join(", ")}'`); - } - - if (this.publication.publish_via_partition_root) { - options.push("publish_via_partition_root = true"); - } - - if (options.length > 0) { - parts.push("WITH", `(${options.join(", ")})`); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.drop.test.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.drop.test.ts deleted file mode 100644 index 441acc05c..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.drop.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Publication } from "../publication.model.ts"; -import { DropPublication } from "./publication.drop.ts"; - -type PublicationProps = ConstructorParameters[0]; - -const base: PublicationProps = { - name: "pub_drop_me", - owner: "owner1", - comment: null, - all_tables: true, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [], - schemas: [], -}; - -const cloneTables = (tables: PublicationProps["tables"]) => - tables.map((table) => ({ - ...table, - columns: table.columns ? [...table.columns] : null, - })); - -const makePublication = (override: Partial = {}) => - new Publication({ - ...base, - ...override, - tables: override.tables - ? cloneTables(override.tables) - : cloneTables(base.tables), - schemas: override.schemas ? [...override.schemas] : [...base.schemas], - }); - -describe("publication.drop", () => { - test("serialize drop statement and track dependencies", async () => { - const publication = makePublication(); - const change = new DropPublication({ publication }); - - expect(change.drops).toEqual([publication.stableId]); - expect(change.requires).toEqual([publication.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("DROP PUBLICATION pub_drop_me"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.drop.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.drop.ts deleted file mode 100644 index fef8de0f7..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.drop.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Publication } from "../publication.model.ts"; -import { DropPublicationChange } from "./publication.base.ts"; - -/** - * Drop a logical replication publication. - * - * @see https://www.postgresql.org/docs/17/sql-droppublication.html - */ -export class DropPublication extends DropPublicationChange { - public readonly publication: Publication; - public readonly scope = "object" as const; - - constructor(props: { publication: Publication }) { - super(); - this.publication = props.publication; - } - - get drops() { - return [this.publication.stableId]; - } - - get requires() { - return [this.publication.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return `DROP PUBLICATION ${this.publication.name}`; - } -} diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.security-label.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.security-label.ts deleted file mode 100644 index 62a21efeb..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.security-label.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Publication } from "../publication.model.ts"; -import { - CreatePublicationChange, - DropPublicationChange, -} from "./publication.base.ts"; - -export type SecurityLabelPublication = - | CreateSecurityLabelOnPublication - | DropSecurityLabelOnPublication; - -export class CreateSecurityLabelOnPublication extends CreatePublicationChange { - public readonly publication: Publication; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - publication: Publication; - securityLabel: SecurityLabelProps; - }) { - super(); - this.publication = props.publication; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.publication.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.publication.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON PUBLICATION", - this.publication.name, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnPublication extends DropPublicationChange { - public readonly publication: Publication; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - publication: Publication; - securityLabel: SecurityLabelProps; - }) { - super(); - this.publication = props.publication; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.publication.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.publication.stableId, - this.securityLabel.provider, - ), - this.publication.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON PUBLICATION", - this.publication.name, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.types.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.types.ts deleted file mode 100644 index d4d45fa37..000000000 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.types.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { - AlterPublicationAddSchemas, - AlterPublicationAddTables, - AlterPublicationDropSchemas, - AlterPublicationDropTables, - AlterPublicationSetList, - AlterPublicationSetOptions, - AlterPublicationSetOwner, -} from "./publication.alter.ts"; -import type { CommentPublication } from "./publication.comment.ts"; -import type { CreatePublication } from "./publication.create.ts"; -import type { DropPublication } from "./publication.drop.ts"; -import type { SecurityLabelPublication } from "./publication.security-label.ts"; - -/** Union of all publication-related change variants (`objectType: "publication"`). @category Change Types */ -export type PublicationChange = - | AlterPublicationAddSchemas - | AlterPublicationAddTables - | AlterPublicationDropSchemas - | AlterPublicationDropTables - | AlterPublicationSetList - | AlterPublicationSetOptions - | AlterPublicationSetOwner - | CommentPublication - | CreatePublication - | DropPublication - | SecurityLabelPublication; diff --git a/packages/pg-delta/src/core/objects/publication/publication.diff.test.ts b/packages/pg-delta/src/core/objects/publication/publication.diff.test.ts deleted file mode 100644 index 9f581739e..000000000 --- a/packages/pg-delta/src/core/objects/publication/publication.diff.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { - AlterPublicationAddSchemas, - AlterPublicationAddTables, - AlterPublicationDropSchemas, - AlterPublicationDropTables, - AlterPublicationSetOptions, - AlterPublicationSetOwner, -} from "./changes/publication.alter.ts"; -import { - CreateCommentOnPublication, - DropCommentOnPublication, -} from "./changes/publication.comment.ts"; -import { CreatePublication } from "./changes/publication.create.ts"; -import { DropPublication } from "./changes/publication.drop.ts"; -import { diffPublications } from "./publication.diff.ts"; -import { Publication, type PublicationProps } from "./publication.model.ts"; - -const ctx: Pick = { - currentUser: "postgres", -}; - -const base: PublicationProps = { - name: "mypub", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [], - schemas: [], -}; - -describe.concurrent("publication.diff", () => { - test("create and drop publication", () => { - const publication = new Publication(base); - const created = diffPublications( - ctx, - {}, - { [publication.stableId]: publication }, - ); - expect(created.some((change) => change instanceof CreatePublication)).toBe( - true, - ); - - const dropped = diffPublications( - ctx, - { [publication.stableId]: publication }, - {}, - ); - expect(dropped.some((change) => change instanceof DropPublication)).toBe( - true, - ); - }); - - test("create publication requires referenced objects", () => { - const publication = new Publication({ - ...base, - tables: [ - { - schema: "public", - name: "accounts", - columns: ["id", "amount"], - row_filter: null, - }, - ], - schemas: ["analytics"], - }); - - const changes = diffPublications( - ctx, - {}, - { [publication.stableId]: publication }, - ); - - const createChange = changes.find( - (change) => change instanceof CreatePublication, - ); - expect(createChange).toBeDefined(); - expect(createChange?.requires).toEqual( - expect.arrayContaining([ - "role:postgres", - "table:public.accounts", - "column:public.accounts.id", - "column:public.accounts.amount", - "schema:analytics", - ]), - ); - }); - - test("detect publish option changes", () => { - const mainPublication = new Publication(base); - const branchPublication = new Publication({ - ...base, - publish_delete: false, - }); - const changes = diffPublications( - ctx, - { [mainPublication.stableId]: mainPublication }, - { [branchPublication.stableId]: branchPublication }, - ); - expect( - changes.some((change) => change instanceof AlterPublicationSetOptions), - ).toBe(true); - }); - - test("switch to FOR ALL TABLES", () => { - const mainPublication = new Publication(base); - const branchPublication = new Publication({ - ...base, - all_tables: true, - }); - const changes = diffPublications( - ctx, - { [mainPublication.stableId]: mainPublication }, - { [branchPublication.stableId]: branchPublication }, - ); - expect(changes.some((change) => change instanceof DropPublication)).toBe( - true, - ); - expect(changes.some((change) => change instanceof CreatePublication)).toBe( - true, - ); - }); - - test("switch from FOR ALL TABLES to explicit list", () => { - const mainPublication = new Publication({ - ...base, - all_tables: true, - }); - const branchPublication = new Publication({ - ...base, - tables: [ - { - schema: "public", - name: "mytable", - columns: null, - row_filter: null, - }, - ], - }); - const changes = diffPublications( - ctx, - { [mainPublication.stableId]: mainPublication }, - { [branchPublication.stableId]: branchPublication }, - ); - expect(changes.some((change) => change instanceof DropPublication)).toBe( - true, - ); - expect(changes.some((change) => change instanceof CreatePublication)).toBe( - true, - ); - }); - - test("add and drop tables", () => { - const mainPublication = new Publication(base); - const branchPublication = new Publication({ - ...base, - tables: [ - { - schema: "public", - name: "t", - columns: ["id"], - row_filter: null, - }, - ], - }); - const addChanges = diffPublications( - ctx, - { [mainPublication.stableId]: mainPublication }, - { [branchPublication.stableId]: branchPublication }, - ); - expect( - addChanges.some((change) => change instanceof AlterPublicationAddTables), - ).toBe(true); - - const addTablesChange = addChanges.find( - (change) => change instanceof AlterPublicationAddTables, - ); - expect(addTablesChange?.requires).toEqual( - expect.arrayContaining(["table:public.t", "column:public.t.id"]), - ); - - const dropChanges = diffPublications( - ctx, - { [branchPublication.stableId]: branchPublication }, - { [mainPublication.stableId]: mainPublication }, - ); - expect( - dropChanges.some( - (change) => change instanceof AlterPublicationDropTables, - ), - ).toBe(true); - }); - - test("detect row filter change as drop and add", () => { - const mainPublication = new Publication({ - ...base, - tables: [ - { - schema: "public", - name: "t", - columns: null, - row_filter: null, - }, - ], - }); - const branchPublication = new Publication({ - ...base, - tables: [ - { - schema: "public", - name: "t", - columns: null, - row_filter: "(id > 0)", - }, - ], - }); - const changes = diffPublications( - ctx, - { [mainPublication.stableId]: mainPublication }, - { [branchPublication.stableId]: branchPublication }, - ); - expect( - changes.some((change) => change instanceof AlterPublicationDropTables), - ).toBe(true); - expect( - changes.some((change) => change instanceof AlterPublicationAddTables), - ).toBe(true); - }); - - test("add and drop schemas", () => { - const mainPublication = new Publication(base); - const branchPublication = new Publication({ - ...base, - schemas: ["public"], - }); - const addChanges = diffPublications( - ctx, - { [mainPublication.stableId]: mainPublication }, - { [branchPublication.stableId]: branchPublication }, - ); - expect( - addChanges.some((change) => change instanceof AlterPublicationAddSchemas), - ).toBe(true); - - const dropChanges = diffPublications( - ctx, - { [branchPublication.stableId]: branchPublication }, - { [mainPublication.stableId]: mainPublication }, - ); - expect( - dropChanges.some( - (change) => change instanceof AlterPublicationDropSchemas, - ), - ).toBe(true); - }); - - test("owner and comment changes", () => { - const mainPublication = new Publication(base); - const branchPublication = new Publication({ - ...base, - owner: "other_user", - comment: "replication publication", - }); - const changes = diffPublications( - ctx, - { [mainPublication.stableId]: mainPublication }, - { [branchPublication.stableId]: branchPublication }, - ); - expect( - changes.some((change) => change instanceof AlterPublicationSetOwner), - ).toBe(true); - expect( - changes.some((change) => change instanceof CreateCommentOnPublication), - ).toBe(true); - - const removeCommentPublication = new Publication({ - ...base, - comment: null, - }); - const dropCommentChanges = diffPublications( - ctx, - { [branchPublication.stableId]: branchPublication }, - { [removeCommentPublication.stableId]: removeCommentPublication }, - ); - expect( - dropCommentChanges.some( - (change) => change instanceof DropCommentOnPublication, - ), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/publication/publication.diff.ts b/packages/pg-delta/src/core/objects/publication/publication.diff.ts deleted file mode 100644 index 35da8f2a2..000000000 --- a/packages/pg-delta/src/core/objects/publication/publication.diff.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { deepEqual } from "../utils.ts"; -import { - AlterPublicationAddSchemas, - AlterPublicationAddTables, - AlterPublicationDropSchemas, - AlterPublicationDropTables, - AlterPublicationSetOptions, - AlterPublicationSetOwner, -} from "./changes/publication.alter.ts"; -import { - CreateCommentOnPublication, - DropCommentOnPublication, -} from "./changes/publication.comment.ts"; -import { CreatePublication } from "./changes/publication.create.ts"; -import { DropPublication } from "./changes/publication.drop.ts"; -import { - CreateSecurityLabelOnPublication, - DropSecurityLabelOnPublication, -} from "./changes/publication.security-label.ts"; -import type { PublicationChange } from "./changes/publication.types.ts"; -import type { - Publication, - PublicationTableProps, -} from "./publication.model.ts"; - -export function diffPublications( - ctx: Pick, - main: Record, - branch: Record, -): PublicationChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - const changes: PublicationChange[] = []; - - for (const id of created) { - const publication = branch[id]; - changes.push(new CreatePublication({ publication })); - - // OWNER: If the publication should be owned by someone other than the current user, - // emit ALTER PUBLICATION ... OWNER TO after creation - if (publication.owner !== ctx.currentUser) { - changes.push( - new AlterPublicationSetOwner({ - publication, - owner: publication.owner, - }), - ); - } - - if (publication.comment !== null) { - changes.push(new CreateCommentOnPublication({ publication })); - } - for (const label of publication.security_labels) { - changes.push( - new CreateSecurityLabelOnPublication({ - publication, - securityLabel: label, - }), - ); - } - } - - for (const id of dropped) { - changes.push(new DropPublication({ publication: main[id] })); - } - - for (const id of altered) { - const mainPublication = main[id]; - const branchPublication = branch[id]; - - if (mainPublication.all_tables && !branchPublication.all_tables) { - changes.push(new DropPublication({ publication: mainPublication })); - changes.push(new CreatePublication({ publication: branchPublication })); - if (branchPublication.comment !== null) { - changes.push( - new CreateCommentOnPublication({ publication: branchPublication }), - ); - } - continue; - } - - const operationsChanged = - mainPublication.publish_insert !== branchPublication.publish_insert || - mainPublication.publish_update !== branchPublication.publish_update || - mainPublication.publish_delete !== branchPublication.publish_delete || - mainPublication.publish_truncate !== branchPublication.publish_truncate; - - const publishViaPartitionRootChanged = - mainPublication.publish_via_partition_root !== - branchPublication.publish_via_partition_root; - - if (operationsChanged || publishViaPartitionRootChanged) { - changes.push( - new AlterPublicationSetOptions({ - publication: branchPublication, - setPublish: operationsChanged, - setPublishViaPartitionRoot: publishViaPartitionRootChanged, - }), - ); - } - - let handledObjectLists = false; - - if (mainPublication.all_tables !== branchPublication.all_tables) { - handledObjectLists = true; - // Changing the all_tables mode requires DROP + CREATE because - // ALTER PUBLICATION does not support SET ALL TABLES. - changes.push(new DropPublication({ publication: mainPublication })); - changes.push(new CreatePublication({ publication: branchPublication })); - if (branchPublication.comment !== null) { - changes.push( - new CreateCommentOnPublication({ - publication: branchPublication, - }), - ); - } - continue; - } - - if (!handledObjectLists && !branchPublication.all_tables) { - const tableDiff = diffPublicationTables( - mainPublication.tables, - branchPublication.tables, - ); - if (tableDiff.tablesToDrop.length > 0) { - changes.push( - new AlterPublicationDropTables({ - publication: mainPublication, - tables: tableDiff.tablesToDrop, - }), - ); - } - if (tableDiff.tablesToAdd.length > 0) { - changes.push( - new AlterPublicationAddTables({ - publication: branchPublication, - tables: tableDiff.tablesToAdd, - }), - ); - } - - const schemaDiff = diffPublicationSchemas( - mainPublication.schemas, - branchPublication.schemas, - ); - if (schemaDiff.schemasToDrop.length > 0) { - changes.push( - new AlterPublicationDropSchemas({ - publication: mainPublication, - schemas: schemaDiff.schemasToDrop, - }), - ); - } - if (schemaDiff.schemasToAdd.length > 0) { - changes.push( - new AlterPublicationAddSchemas({ - publication: branchPublication, - schemas: schemaDiff.schemasToAdd, - }), - ); - } - } - - if (mainPublication.owner !== branchPublication.owner) { - changes.push( - new AlterPublicationSetOwner({ - publication: branchPublication, - owner: branchPublication.owner, - }), - ); - } - - if (mainPublication.comment !== branchPublication.comment) { - if (branchPublication.comment === null) { - if (mainPublication.comment !== null) { - changes.push( - new DropCommentOnPublication({ publication: mainPublication }), - ); - } - } else { - changes.push( - new CreateCommentOnPublication({ publication: branchPublication }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnPublication | DropSecurityLabelOnPublication - >( - mainPublication.security_labels, - branchPublication.security_labels, - (securityLabel) => - new CreateSecurityLabelOnPublication({ - publication: branchPublication, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnPublication({ - publication: mainPublication, - securityLabel, - }), - ), - ); - } - - return changes; -} - -function diffPublicationTables( - mainTables: PublicationTableProps[], - branchTables: PublicationTableProps[], -) { - const mainMap = new Map( - mainTables.map((table) => [`${table.schema}.${table.name}`, table]), - ); - const branchMap = new Map( - branchTables.map((table) => [`${table.schema}.${table.name}`, table]), - ); - - const tablesToDropMap = new Map(); - const tablesToAddMap = new Map(); - - for (const [key, mainTable] of mainMap) { - if (!branchMap.has(key)) { - tablesToDropMap.set(key, mainTable); - } - } - - for (const [key, branchTable] of branchMap) { - const mainTable = mainMap.get(key); - if (!mainTable) { - tablesToAddMap.set(key, branchTable); - continue; - } - - const mainComparable = { - columns: mainTable.columns, - row_filter: mainTable.row_filter, - }; - const branchComparable = { - columns: branchTable.columns, - row_filter: branchTable.row_filter, - }; - - if (!deepEqual(mainComparable, branchComparable)) { - tablesToDropMap.set(key, mainTable); - tablesToAddMap.set(key, branchTable); - } - } - - const tablesToDrop = Array.from(tablesToDropMap.values()).sort((a, b) => { - return a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name); - }); - const tablesToAdd = Array.from(tablesToAddMap.values()).sort((a, b) => { - return a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name); - }); - - return { tablesToDrop, tablesToAdd }; -} - -function diffPublicationSchemas( - mainSchemas: string[], - branchSchemas: string[], -) { - const mainSet = new Set(mainSchemas); - const branchSet = new Set(branchSchemas); - - const schemasToDrop = [...mainSet.difference(branchSet)].sort((a, b) => - a.localeCompare(b), - ); - const schemasToAdd = [...branchSet.difference(mainSet)].sort((a, b) => - a.localeCompare(b), - ); - - return { schemasToDrop, schemasToAdd }; -} diff --git a/packages/pg-delta/src/core/objects/publication/publication.model.ts b/packages/pg-delta/src/core/objects/publication/publication.model.ts deleted file mode 100644 index 0979b5b82..000000000 --- a/packages/pg-delta/src/core/objects/publication/publication.model.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - normalizeSecurityLabels, - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -const publicationTablePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - columns: z.array(z.string()).nullable(), - row_filter: z.string().nullable(), -}); - -const publicationPropsSchema = z.object({ - name: z.string(), - owner: z.string(), - comment: z.string().nullable(), - all_tables: z.boolean(), - publish_insert: z.boolean(), - publish_update: z.boolean(), - publish_delete: z.boolean(), - publish_truncate: z.boolean(), - publish_via_partition_root: z.boolean(), - tables: z.array(publicationTablePropsSchema), - schemas: z.array(z.string()), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -export type PublicationTableProps = z.infer; -export type PublicationProps = z.infer; - -/** - * Logical replication publication definition extracted from pg_publication. - * - * @see https://www.postgresql.org/docs/17/sql-createpublication.html - */ -export class Publication extends BasePgModel { - public readonly name: PublicationProps["name"]; - public readonly owner: PublicationProps["owner"]; - public readonly comment: PublicationProps["comment"]; - public readonly all_tables: PublicationProps["all_tables"]; - public readonly publish_insert: PublicationProps["publish_insert"]; - public readonly publish_update: PublicationProps["publish_update"]; - public readonly publish_delete: PublicationProps["publish_delete"]; - public readonly publish_truncate: PublicationProps["publish_truncate"]; - public readonly publish_via_partition_root: PublicationProps["publish_via_partition_root"]; - public readonly tables: PublicationTableProps[]; - public readonly schemas: PublicationProps["schemas"]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: PublicationProps) { - super(); - - this.name = props.name; - this.owner = props.owner; - this.comment = props.comment; - this.all_tables = props.all_tables; - this.publish_insert = props.publish_insert; - this.publish_update = props.publish_update; - this.publish_delete = props.publish_delete; - this.publish_truncate = props.publish_truncate; - this.publish_via_partition_root = props.publish_via_partition_root; - - const normalizedTables = props.tables.map((table) => ({ - schema: table.schema, - name: table.name, - columns: table.columns - ? [...table.columns].sort((a, b) => a.localeCompare(b)) - : null, - row_filter: table.row_filter, - })); - - this.tables = normalizedTables.sort((a, b) => { - return a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name); - }); - - this.schemas = [...props.schemas].sort((a, b) => a.localeCompare(b)); - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `publication:${string}` { - return `publication:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - return { - owner: this.owner, - comment: this.comment, - all_tables: this.all_tables, - publish_insert: this.publish_insert, - publish_update: this.publish_update, - publish_delete: this.publish_delete, - publish_truncate: this.publish_truncate, - publish_via_partition_root: this.publish_via_partition_root, - tables: this.tables, - schemas: this.schemas, - security_labels: this.security_labels, - }; - } - - override stableSnapshot() { - const normalizedTables = this.tables.map((table) => ({ - schema: table.schema, - name: table.name, - columns: table.columns - ? [...table.columns].sort((a, b) => a.localeCompare(b)) - : null, - row_filter: table.row_filter, - })); - - return { - identity: this.identityFields, - data: { - ...this.dataFields, - tables: normalizedTables.sort( - (a, b) => - a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name), - ), - schemas: [...this.schemas].sort((a, b) => a.localeCompare(b)), - security_labels: normalizeSecurityLabels(this.security_labels), - }, - }; - } -} - -/** - * Extract all logical replication publications from the database. - */ -export async function extractPublications(pool: Pool): Promise { - const { rows } = await pool.query(sql` - with extension_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_publication'::regclass - ), - publication_tables as ( - select - pr.prpubid, - quote_ident(ns.nspname) as schema, - quote_ident(cls.relname) as name, - case - when pr.prattrs is null then null - else ( - select json_agg(quote_ident(att.attname) order by cols.ord) - from unnest(pr.prattrs) with ordinality as cols(attnum, ord) - join pg_attribute att - on att.attrelid = pr.prrelid - and att.attnum = cols.attnum - ) - end as columns, - pg_get_expr(pr.prqual, pr.prrelid) as row_filter - from pg_publication_rel pr - join pg_class cls on cls.oid = pr.prrelid - join pg_namespace ns on ns.oid = cls.relnamespace - ), - publication_schemas as ( - select - pn.pnpubid, - quote_ident(ns.nspname) as schema - from pg_publication_namespace pn - join pg_namespace ns on ns.oid = pn.pnnspid - ) - select - quote_ident(p.pubname) as name, - p.pubowner::regrole::text as owner, - obj_description(p.oid, 'pg_publication') as comment, - p.puballtables as all_tables, - p.pubinsert as publish_insert, - p.pubupdate as publish_update, - p.pubdelete as publish_delete, - p.pubtruncate as publish_truncate, - p.pubviaroot as publish_via_partition_root, - coalesce( - ( - select json_agg( - json_build_object( - 'schema', t.schema, - 'name', t.name, - 'columns', t.columns, - 'row_filter', t.row_filter - ) - order by t.schema, t.name - ) - from publication_tables t - where t.prpubid = p.oid - ), - '[]'::json - ) as tables, - coalesce( - ( - select json_agg(s.schema order by s.schema) - from publication_schemas s - where s.pnpubid = p.oid - ), - '[]'::json - ) as schemas, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = p.oid - and sl.classoid = 'pg_publication'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels - from pg_publication p - left join extension_oids e on e.objid = p.oid - where e.objid is null - order by 1 - `); - - const validated = rows.map((row) => publicationPropsSchema.parse(row)); - return validated.map((row) => new Publication(row)); -} diff --git a/packages/pg-delta/src/core/objects/publication/utils.ts b/packages/pg-delta/src/core/objects/publication/utils.ts deleted file mode 100644 index cc32ddf97..000000000 --- a/packages/pg-delta/src/core/objects/publication/utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { - Publication, - PublicationTableProps, -} from "./publication.model.ts"; - -function wrapRowFilter(expression: string): string { - const trimmed = expression.trim(); - if (trimmed.startsWith("(") && trimmed.endsWith(")")) { - return trimmed; - } - return `(${trimmed})`; -} - -export function formatPublicationTable(table: PublicationTableProps): string { - let clause = `TABLE ${table.schema}.${table.name}`; - if (table.columns && table.columns.length > 0) { - clause += ` (${table.columns.join(", ")})`; - } - if (table.row_filter) { - clause += ` WHERE ${wrapRowFilter(table.row_filter)}`; - } - return clause; -} - -export function formatPublicationObjects( - tables: PublicationTableProps[], - schemas: string[], -): string[] { - const clauses: string[] = []; - for (const table of tables) { - clauses.push(formatPublicationTable(table)); - } - for (const schema of schemas) { - clauses.push(`TABLES IN SCHEMA ${schema}`); - } - return clauses; -} - -export function getPublicationOperations(publication: Publication): string[] { - const operations: string[] = []; - if (publication.publish_insert) operations.push("insert"); - if (publication.publish_update) operations.push("update"); - if (publication.publish_delete) operations.push("delete"); - if (publication.publish_truncate) operations.push("truncate"); - return operations; -} - -export function isDefaultPublicationOperations(publication: Publication) { - return ( - publication.publish_insert && - publication.publish_update && - publication.publish_delete && - publication.publish_truncate - ); -} diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.alter.test.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.alter.test.ts deleted file mode 100644 index 1a874879d..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.alter.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { diffRlsPolicies } from "../rls-policy.diff.ts"; -import { RlsPolicy, type RlsPolicyProps } from "../rls-policy.model.ts"; -import { - AlterRlsPolicySetRoles, - AlterRlsPolicySetUsingExpression, - AlterRlsPolicySetWithCheckExpression, -} from "./rls-policy.alter.ts"; -import { CreateRlsPolicy } from "./rls-policy.create.ts"; -import { DropRlsPolicy } from "./rls-policy.drop.ts"; - -describe.concurrent("rls-policy", () => { - describe("alter", () => { - test("change roles", async () => { - const props: Omit = { - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - permissive: true, - using_expression: "user_id = current_user_id()", - with_check_expression: null, - owner: "owner", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const policy = new RlsPolicy({ - ...props, - roles: ["public"], - }); - - const change = new AlterRlsPolicySetRoles({ - policy, - roles: ["role1", "role2"], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER POLICY test_policy ON public.test_table TO role1, role2", - ); - }); - - test("change roles to PUBLIC (default)", async () => { - const props: Omit = { - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - permissive: true, - using_expression: "expr", - with_check_expression: null, - owner: "owner", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const policy = new RlsPolicy({ - ...props, - roles: ["role1"], - }); - - const change = new AlterRlsPolicySetRoles({ - policy, - roles: ["public"], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER POLICY test_policy ON public.test_table TO PUBLIC", - ); - }); - - test("drop + create rls policy when command changes", async () => { - const props: Omit = { - schema: "public", - name: "test_policy", - table_name: "test_table", - permissive: true, - roles: ["public"], - using_expression: "user_id = current_user_id()", - with_check_expression: null, - owner: "owner", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const main = new RlsPolicy({ - ...props, - command: "r", // SELECT - }); - const branch = new RlsPolicy({ - ...props, - command: "w", // UPDATE - }); - - const changes = diffRlsPolicies( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropRlsPolicy); - expect(changes[1]).toBeInstanceOf(CreateRlsPolicy); - await assertValidSql(changes[0].serialize()); - expect(changes[0].serialize()).toBe( - "DROP POLICY test_policy ON public.test_table", - ); - await assertValidSql(changes[1].serialize()); - expect(changes[1].serialize()).toBe( - "CREATE POLICY test_policy ON public.test_table FOR UPDATE USING (user_id = current_user_id())", - ); - }); - - test("drop + create rls policy when permissive changes", async () => { - const props: Omit = { - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - roles: ["public"], - using_expression: "user_id = current_user_id()", - with_check_expression: null, - owner: "owner", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const main = new RlsPolicy({ - ...props, - permissive: true, - }); - const branch = new RlsPolicy({ - ...props, - permissive: false, - }); - - const changes = diffRlsPolicies( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropRlsPolicy); - expect(changes[1]).toBeInstanceOf(CreateRlsPolicy); - await assertValidSql(changes[0].serialize()); - expect(changes[0].serialize()).toBe( - "DROP POLICY test_policy ON public.test_table", - ); - await assertValidSql(changes[1].serialize()); - expect(changes[1].serialize()).toBe( - "CREATE POLICY test_policy ON public.test_table AS RESTRICTIVE FOR SELECT USING (user_id = current_user_id())", - ); - }); - - test("alter using expression", async () => { - const props: Omit = { - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - permissive: true, - roles: ["public"], - with_check_expression: null, - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const policy = new RlsPolicy({ - ...props, - using_expression: "old_expr", - }); - - const change = new AlterRlsPolicySetUsingExpression({ - policy, - usingExpression: "new_expr", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER POLICY test_policy ON public.test_table USING (new_expr)", - ); - }); - - test("clear using expression -> USING (true)", async () => { - const props: Omit = { - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - permissive: true, - roles: ["public"], - with_check_expression: null, - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const policy = new RlsPolicy({ - ...props, - using_expression: "old_expr", - }); - - const change = new AlterRlsPolicySetUsingExpression({ - policy, - usingExpression: null, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER POLICY test_policy ON public.test_table USING (true)", - ); - }); - - test("alter with check expression", async () => { - const props: Omit = { - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - permissive: true, - roles: ["public"], - using_expression: "expr", - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const policy = new RlsPolicy({ - ...props, - with_check_expression: "old_check", - }); - - const change = new AlterRlsPolicySetWithCheckExpression({ - policy, - withCheckExpression: "new_check", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER POLICY test_policy ON public.test_table WITH CHECK (new_check)", - ); - }); - - test("clear with check expression -> WITH CHECK (true)", async () => { - const props: Omit = { - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - permissive: true, - roles: ["public"], - using_expression: "expr", - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const policy = new RlsPolicy({ - ...props, - with_check_expression: "old_check", - }); - - const change = new AlterRlsPolicySetWithCheckExpression({ - policy, - withCheckExpression: null, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER POLICY test_policy ON public.test_table WITH CHECK (true)", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.alter.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.alter.ts deleted file mode 100644 index dc73f498d..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.alter.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { RlsPolicy } from "../rls-policy.model.ts"; -import { AlterRlsPolicyChange } from "./rls-policy.base.ts"; - -/** - * Alter an RLS policy. - * - * @see https://www.postgresql.org/docs/17/sql-alterpolicy.html - * - * Synopsis - * ```sql - * ALTER POLICY name ON table_name - * [ TO { role_name | PUBLIC | CURRENT_ROLE | CURRENT_USER | SESSION_USER } [, ...] ] - * [ USING ( using_expression ) ] - * [ WITH CHECK ( with_check_expression ) ] - * ``` - */ - -export type AlterRlsPolicy = - | AlterRlsPolicySetRoles - | AlterRlsPolicySetUsingExpression - | AlterRlsPolicySetWithCheckExpression; - -/** - * ALTER POLICY ... TO roles ... - */ -export class AlterRlsPolicySetRoles extends AlterRlsPolicyChange { - public readonly policy: RlsPolicy; - public readonly roles: string[]; - public readonly scope = "object" as const; - - constructor(props: { policy: RlsPolicy; roles: string[] }) { - super(); - this.policy = props.policy; - this.roles = props.roles; - } - - get requires() { - return [this.policy.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const targetRoles = this.roles; - const toPublic = - targetRoles.length === 0 || - (targetRoles.length === 1 && targetRoles[0].toLowerCase() === "public"); - const rolesSql = toPublic ? "PUBLIC" : targetRoles.join(", "); - - return [ - "ALTER POLICY", - this.policy.name, - "ON", - `${this.policy.schema}.${this.policy.table_name}`, - "TO", - rolesSql, - ].join(" "); - } -} - -/** - * ALTER POLICY ... USING (...) - */ -export class AlterRlsPolicySetUsingExpression extends AlterRlsPolicyChange { - public readonly policy: RlsPolicy; - public readonly usingExpression: string | null; - public readonly scope = "object" as const; - - constructor(props: { policy: RlsPolicy; usingExpression: string | null }) { - super(); - this.policy = props.policy; - this.usingExpression = props.usingExpression; - } - - get requires() { - return [this.policy.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const expr = this.usingExpression ?? "true"; - return [ - "ALTER POLICY", - this.policy.name, - "ON", - `${this.policy.schema}.${this.policy.table_name}`, - "USING", - `(${expr})`, - ].join(" "); - } -} - -/** - * ALTER POLICY ... WITH CHECK (...) - */ -export class AlterRlsPolicySetWithCheckExpression extends AlterRlsPolicyChange { - public readonly policy: RlsPolicy; - public readonly withCheckExpression: string | null; - public readonly scope = "object" as const; - - constructor(props: { - policy: RlsPolicy; - withCheckExpression: string | null; - }) { - super(); - this.policy = props.policy; - this.withCheckExpression = props.withCheckExpression; - } - - get requires() { - return [this.policy.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const expr = this.withCheckExpression ?? "true"; - return [ - "ALTER POLICY", - this.policy.name, - "ON", - `${this.policy.schema}.${this.policy.table_name}`, - "WITH CHECK", - `(${expr})`, - ].join(" "); - } -} - -/** - * Replace an RLS policy by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER POLICY change. - */ -// NOTE: ReplaceRlsPolicy removed. Non-alterable changes are emitted as Drop + Create in rls-policy.diff.ts. diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.base.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.base.ts deleted file mode 100644 index 72c313381..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { RlsPolicy } from "../rls-policy.model.ts"; - -abstract class BaseRlsPolicyChange extends BaseChange { - abstract readonly policy: RlsPolicy; - abstract readonly scope: "object" | "comment"; - readonly objectType = "rls_policy" as const; -} - -export abstract class CreateRlsPolicyChange extends BaseRlsPolicyChange { - readonly operation = "create" as const; -} - -export abstract class AlterRlsPolicyChange extends BaseRlsPolicyChange { - readonly operation = "alter" as const; -} - -export abstract class DropRlsPolicyChange extends BaseRlsPolicyChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.comment.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.comment.ts deleted file mode 100644 index bf30b9608..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.comment.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { RlsPolicy } from "../rls-policy.model.ts"; -import { - CreateRlsPolicyChange, - DropRlsPolicyChange, -} from "./rls-policy.base.ts"; - -export type CommentRlsPolicy = - | CreateCommentOnRlsPolicy - | DropCommentOnRlsPolicy; - -export class CreateCommentOnRlsPolicy extends CreateRlsPolicyChange { - public readonly policy: RlsPolicy; - public readonly scope = "comment" as const; - - constructor(props: { policy: RlsPolicy }) { - super(); - this.policy = props.policy; - } - - get creates() { - return [stableId.comment(this.policy.stableId)]; - } - - get requires() { - return [this.policy.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON POLICY", - this.policy.name, - "ON", - `${this.policy.schema}.${this.policy.table_name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: rls policy comment is not nullable in this case - quoteLiteral(this.policy.comment!), - ].join(" "); - } -} - -export class DropCommentOnRlsPolicy extends DropRlsPolicyChange { - public readonly policy: RlsPolicy; - public readonly scope = "comment" as const; - - constructor(props: { policy: RlsPolicy }) { - super(); - this.policy = props.policy; - } - - get drops() { - return [stableId.comment(this.policy.stableId)]; - } - - get requires() { - return [stableId.comment(this.policy.stableId), this.policy.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON POLICY", - this.policy.name, - "ON", - `${this.policy.schema}.${this.policy.table_name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.create.test.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.create.test.ts deleted file mode 100644 index 2f2662880..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.create.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { RlsPolicy } from "../rls-policy.model.ts"; -import { CreateRlsPolicy } from "./rls-policy.create.ts"; - -describe("rls-policy", () => { - test("create minimal", async () => { - const policy = new RlsPolicy({ - schema: "public", - name: "test_policy_min", - table_name: "test_table", - command: "*", - permissive: true, - roles: [], - using_expression: null, - with_check_expression: null, - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }); - - const change = new CreateRlsPolicy({ - policy, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE POLICY test_policy_min ON public.test_table", - ); - }); - - test("create", async () => { - const policy = new RlsPolicy({ - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - permissive: true, - roles: ["public"], - using_expression: "user_id = current_user_id()", - with_check_expression: null, - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }); - - const change = new CreateRlsPolicy({ - policy, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE POLICY test_policy ON public.test_table FOR SELECT USING (user_id = current_user_id())", - ); - }); - - test("create with all options", async () => { - const policy = new RlsPolicy({ - schema: "public", - name: "test_policy_all", - table_name: "test_table", - command: "w", - permissive: false, - roles: ["role1", "role2"], - using_expression: "expr1", - with_check_expression: "expr2", - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }); - - const change = new CreateRlsPolicy({ - policy, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE POLICY test_policy_all ON public.test_table AS RESTRICTIVE FOR UPDATE TO role1, role2 USING (expr1) WITH CHECK (expr2)", - ); - }); - - test("requires referenced relations reported by pg_depend", () => { - const policy = new RlsPolicy({ - schema: "app", - name: "cross_relation_policy", - table_name: "accounts", - command: "r", - permissive: true, - roles: ["public"], - using_expression: - "(EXISTS (SELECT 1 FROM app.users) AND EXISTS (SELECT 1 FROM app.active_accounts))", - with_check_expression: - "(id IN (SELECT account_id FROM app.memberships WHERE active))", - owner: "test", - comment: null, - referenced_relations: [ - { kind: "table", schema: "app", name: "users" }, - { kind: "table", schema: "app", name: "memberships" }, - { kind: "view", schema: "app", name: "active_accounts" }, - { kind: "materialized_view", schema: "app", name: "account_stats" }, - { kind: "foreign_table", schema: "app", name: "remote_profiles" }, - ], - referenced_procedures: [], - }); - - const change = new CreateRlsPolicy({ policy }); - - expect(change.requires).toContain(stableId.table("app", "users")); - expect(change.requires).toContain(stableId.table("app", "memberships")); - expect(change.requires).toContain(stableId.view("app", "active_accounts")); - expect(change.requires).toContain( - stableId.materializedView("app", "account_stats"), - ); - expect(change.requires).toContain( - stableId.foreignTable("app", "remote_profiles"), - ); - }); - - test("requires referenced procedures reported by pg_depend", () => { - const policy = new RlsPolicy({ - schema: "app", - name: "function_guarded_policy", - table_name: "accounts", - command: "r", - permissive: true, - roles: ["public"], - using_expression: "public.is_admin()", - with_check_expression: null, - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [ - { schema: "public", name: "is_admin", argument_types: [] }, - { - schema: "public", - name: "has_role", - argument_types: ["text", "integer"], - }, - ], - }); - - const change = new CreateRlsPolicy({ policy }); - - expect(change.requires).toContain(stableId.procedure("public", "is_admin")); - expect(change.requires).toContain( - stableId.procedure("public", "has_role", "text,integer"), - ); - }); - - test("does not require additional objects when referenced lists are empty", () => { - const policy = new RlsPolicy({ - schema: "app", - name: "simple_policy", - table_name: "accounts", - command: "*", - permissive: true, - roles: [], - using_expression: null, - with_check_expression: null, - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }); - - const change = new CreateRlsPolicy({ policy }); - - expect(change.requires).toEqual([ - stableId.schema("app"), - stableId.table("app", "accounts"), - stableId.role("test"), - ]); - }); - - // Sequences referenced via nextval() are a known gap. pg_depend only - // records the sequence edge when the argument is written as a regclass - // literal (e.g. `nextval('app.seq'::regclass)`); bare string literals - // produce no pg_depend row. Tracked in - // https://github.com/supabase/pg-toolbelt/issues/220. - test.skip("requires referenced sequences (follow-up)", () => { - const policy = new RlsPolicy({ - schema: "app", - name: "sequence_policy", - table_name: "accounts", - command: "r", - permissive: true, - roles: ["public"], - using_expression: "id < nextval('app.next_id'::regclass)", - with_check_expression: null, - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }); - - const change = new CreateRlsPolicy({ policy }); - - // Expected once the gap is closed: - // expect(change.requires).toContain(stableId.sequence("app", "next_id")); - expect(change.requires.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.create.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.create.ts deleted file mode 100644 index 42f71b285..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.create.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { RlsPolicy } from "../rls-policy.model.ts"; -import { CreateRlsPolicyChange } from "./rls-policy.base.ts"; - -/** - * Create an RLS policy. - * - * @see https://www.postgresql.org/docs/17/sql-createpolicy.html - * - * Synopsis - * ```sql - * CREATE POLICY name ON table_name - * [ AS { PERMISSIVE | RESTRICTIVE } ] - * [ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ] - * [ TO { role_name | PUBLIC | CURRENT_ROLE | CURRENT_USER | SESSION_USER } [, ...] ] - * [ USING ( using_expression ) ] - * [ WITH CHECK ( with_check_expression ) ] - * ``` - */ -export class CreateRlsPolicy extends CreateRlsPolicyChange { - public readonly policy: RlsPolicy; - public readonly scope = "object" as const; - - constructor(props: { policy: RlsPolicy }) { - super(); - this.policy = props.policy; - } - - get creates() { - return [this.policy.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.policy.schema)); - - // Table dependency - dependencies.add( - stableId.table(this.policy.schema, this.policy.table_name), - ); - - // Owner dependency - dependencies.add(stableId.role(this.policy.owner)); - - // Relations and functions referenced inside USING / WITH CHECK - // expressions must exist before the policy is created. These come from - // pg_depend (populated by PostgreSQL's recordDependencyOnExpr at policy - // creation), not from re-parsing the expression text. - for (const ref of this.policy.referenced_relations) { - switch (ref.kind) { - case "table": - dependencies.add(stableId.table(ref.schema, ref.name)); - break; - case "view": - dependencies.add(stableId.view(ref.schema, ref.name)); - break; - case "materialized_view": - dependencies.add(stableId.materializedView(ref.schema, ref.name)); - break; - case "foreign_table": - dependencies.add(stableId.foreignTable(ref.schema, ref.name)); - break; - } - } - - for (const ref of this.policy.referenced_procedures) { - dependencies.add( - stableId.procedure(ref.schema, ref.name, ref.argument_types.join(",")), - ); - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE POLICY"]; - - // Add policy name - parts.push(this.policy.name); - - // Add ON table - parts.push("ON", `${this.policy.schema}.${this.policy.table_name}`); - - // Add AS RESTRICTIVE only if false (default is PERMISSIVE) - if (!this.policy.permissive) { - parts.push("AS RESTRICTIVE"); - } - - // Add FOR command - const commandMap: Record = { - r: "FOR SELECT", - a: "FOR INSERT", - w: "FOR UPDATE", - d: "FOR DELETE", - "*": "FOR ALL", - }; - // Default is FOR ALL; only print when not default - if (this.policy.command && this.policy.command !== "*") { - parts.push(commandMap[this.policy.command]); - } - - // Add TO roles - // Default is TO PUBLIC; avoid printing explicit PUBLIC in CREATE - if (this.policy.roles && this.policy.roles.length > 0) { - const onlyPublic = - this.policy.roles.length === 1 && - this.policy.roles[0].toLowerCase() === "public"; - if (!onlyPublic) { - parts.push("TO", this.policy.roles.join(", ")); - } - } - - // Add USING expression - if (this.policy.using_expression) { - parts.push("USING", `(${this.policy.using_expression})`); - } - - // Add WITH CHECK expression - if (this.policy.with_check_expression) { - parts.push("WITH CHECK", `(${this.policy.with_check_expression})`); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.drop.test.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.drop.test.ts deleted file mode 100644 index 09a726085..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.drop.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { RlsPolicy } from "../rls-policy.model.ts"; -import { DropRlsPolicy } from "./rls-policy.drop.ts"; - -describe("rls-policy", () => { - test("drop", async () => { - const policy = new RlsPolicy({ - schema: "public", - name: "test_policy", - table_name: "test_table", - command: "r", - permissive: true, - roles: ["public"], - using_expression: "user_id = current_user_id()", - with_check_expression: null, - owner: "test", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }); - - const change = new DropRlsPolicy({ - policy, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "DROP POLICY test_policy ON public.test_table", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.drop.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.drop.ts deleted file mode 100644 index d1a8eb860..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.drop.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { RlsPolicy } from "../rls-policy.model.ts"; -import { DropRlsPolicyChange } from "./rls-policy.base.ts"; - -/** - * Drop an RLS policy. - * - * @see https://www.postgresql.org/docs/17/sql-droppolicy.html - * - * Synopsis - * ```sql - * DROP POLICY [ IF EXISTS ] name ON table_name [ CASCADE | RESTRICT ] - * ``` - */ -export class DropRlsPolicy extends DropRlsPolicyChange { - public readonly policy: RlsPolicy; - public readonly scope = "object" as const; - - constructor(props: { policy: RlsPolicy }) { - super(); - this.policy = props.policy; - } - - get drops() { - return [this.policy.stableId]; - } - - get requires() { - return [this.policy.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "DROP POLICY", - this.policy.name, - "ON", - `${this.policy.schema}.${this.policy.table_name}`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.types.ts b/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.types.ts deleted file mode 100644 index f3abf93f1..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/changes/rls-policy.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AlterRlsPolicy } from "./rls-policy.alter.ts"; -import type { CommentRlsPolicy } from "./rls-policy.comment.ts"; -import type { CreateRlsPolicy } from "./rls-policy.create.ts"; -import type { DropRlsPolicy } from "./rls-policy.drop.ts"; - -/** Union of all RLS policy-related change variants (`objectType: "rls_policy"`). @category Change Types */ -export type RlsPolicyChange = - | AlterRlsPolicy - | CommentRlsPolicy - | CreateRlsPolicy - | DropRlsPolicy; diff --git a/packages/pg-delta/src/core/objects/rls-policy/rls-policy.diff.test.ts b/packages/pg-delta/src/core/objects/rls-policy/rls-policy.diff.test.ts deleted file mode 100644 index 017bc27ab..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/rls-policy.diff.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - AlterRlsPolicySetRoles, - AlterRlsPolicySetUsingExpression, - AlterRlsPolicySetWithCheckExpression, -} from "./changes/rls-policy.alter.ts"; -import { CreateRlsPolicy } from "./changes/rls-policy.create.ts"; -import { DropRlsPolicy } from "./changes/rls-policy.drop.ts"; -import { diffRlsPolicies } from "./rls-policy.diff.ts"; -import { RlsPolicy, type RlsPolicyProps } from "./rls-policy.model.ts"; - -const base: RlsPolicyProps = { - schema: "public", - name: "p1", - table_name: "t", - command: "r", - permissive: true, - roles: ["role1"], - using_expression: null, - with_check_expression: null, - owner: "o1", - comment: null, - referenced_relations: [], - referenced_procedures: [], -}; - -describe.concurrent("rls-policy.diff", () => { - test("create and drop", () => { - const p = new RlsPolicy(base); - const created = diffRlsPolicies({}, { [p.stableId]: p }); - expect(created[0]).toBeInstanceOf(CreateRlsPolicy); - const dropped = diffRlsPolicies({ [p.stableId]: p }, {}); - expect(dropped[0]).toBeInstanceOf(DropRlsPolicy); - }); - - test("alter roles", () => { - const main = new RlsPolicy(base); - const branch = new RlsPolicy({ ...base, roles: ["r1", "r2"] }); - const changes = diffRlsPolicies( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterRlsPolicySetRoles); - }); - - test("alter USING expression", () => { - const main = new RlsPolicy({ ...base, using_expression: "old_expr" }); - const branch = new RlsPolicy({ ...base, using_expression: "new_expr" }); - const changes = diffRlsPolicies( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterRlsPolicySetUsingExpression); - }); - - test("alter WITH CHECK expression", () => { - const main = new RlsPolicy({ ...base, with_check_expression: "old" }); - const branch = new RlsPolicy({ ...base, with_check_expression: "new" }); - const changes = diffRlsPolicies( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterRlsPolicySetWithCheckExpression); - }); - - test("drop and create when non-alterable property changes", () => { - const main = new RlsPolicy(base); - const branch = new RlsPolicy({ - ...base, - command: "w", - permissive: false, - }); - const changes = diffRlsPolicies( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropRlsPolicy); - expect(changes[1]).toBeInstanceOf(CreateRlsPolicy); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rls-policy/rls-policy.diff.ts b/packages/pg-delta/src/core/objects/rls-policy/rls-policy.diff.ts deleted file mode 100644 index ed8c659b4..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/rls-policy.diff.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { deepEqual, hasNonAlterableChanges } from "../utils.ts"; -import { - AlterRlsPolicySetRoles, - AlterRlsPolicySetUsingExpression, - AlterRlsPolicySetWithCheckExpression, -} from "./changes/rls-policy.alter.ts"; -import { - CreateCommentOnRlsPolicy, - DropCommentOnRlsPolicy, -} from "./changes/rls-policy.comment.ts"; -import { CreateRlsPolicy } from "./changes/rls-policy.create.ts"; -import { DropRlsPolicy } from "./changes/rls-policy.drop.ts"; -import type { RlsPolicyChange } from "./changes/rls-policy.types.ts"; -import type { RlsPolicy } from "./rls-policy.model.ts"; - -/** - * Diff two sets of RLS policies from main and branch catalogs. - * - * @param main - The RLS policies in the main catalog. - * @param branch - The RLS policies in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffRlsPolicies( - main: Record, - branch: Record, -): RlsPolicyChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: RlsPolicyChange[] = []; - - for (const rlsPolicyId of created) { - const policy = branch[rlsPolicyId]; - changes.push(new CreateRlsPolicy({ policy: policy })); - if (policy.comment !== null) { - changes.push(new CreateCommentOnRlsPolicy({ policy })); - } - } - - for (const rlsPolicyId of dropped) { - changes.push(new DropRlsPolicy({ policy: main[rlsPolicyId] })); - } - - for (const rlsPolicyId of altered) { - const mainRlsPolicy = main[rlsPolicyId]; - const branchRlsPolicy = branch[rlsPolicyId]; - - // Check if non-alterable properties have changed - // These require dropping and recreating the RLS policy - // These attributes require drop+create (Postgres has no ALTER for them) - const NON_ALTERABLE_FIELDS: Array = [ - "command", - "permissive", - ]; - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainRlsPolicy, - branchRlsPolicy, - NON_ALTERABLE_FIELDS, - {}, - ); - - // The set of relations and procedures that the policy's USING / WITH - // CHECK expressions reference is recorded by PostgreSQL in pg_depend - // (recordDependencyOnExpr at policy creation). When that set changes - // it is unsafe to ALTER POLICY in place: the old reference target may - // be dropped in the same plan, and the new reference target may only - // exist after the create phase. Drop+create lets the sort phase order - // the policy's drop before the referenced object's drop and the - // policy's recreate after the referenced object's create. - const referencedDependenciesChanged = hasNonAlterableChanges( - mainRlsPolicy, - branchRlsPolicy, - ["referenced_procedures", "referenced_relations"] as const, - { - referenced_procedures: deepEqual, - referenced_relations: deepEqual, - }, - ); - - if (nonAlterablePropsChanged || referencedDependenciesChanged) { - // Replace the entire RLS policy (drop + create) - changes.push( - new DropRlsPolicy({ policy: mainRlsPolicy }), - new CreateRlsPolicy({ policy: branchRlsPolicy }), - ); - } else { - // Only alterable properties changed - check each one - - // ROLES (TO ...) - const rolesEqual = deepEqual(mainRlsPolicy.roles, branchRlsPolicy.roles); - if (!rolesEqual) { - changes.push( - new AlterRlsPolicySetRoles({ - policy: mainRlsPolicy, - roles: branchRlsPolicy.roles, - }), - ); - } - - // USING expression - if (mainRlsPolicy.using_expression !== branchRlsPolicy.using_expression) { - changes.push( - new AlterRlsPolicySetUsingExpression({ - policy: mainRlsPolicy, - usingExpression: branchRlsPolicy.using_expression, - }), - ); - } - - // WITH CHECK expression - if ( - mainRlsPolicy.with_check_expression !== - branchRlsPolicy.with_check_expression - ) { - changes.push( - new AlterRlsPolicySetWithCheckExpression({ - policy: mainRlsPolicy, - withCheckExpression: branchRlsPolicy.with_check_expression, - }), - ); - } - - // COMMENT - if (mainRlsPolicy.comment !== branchRlsPolicy.comment) { - if (branchRlsPolicy.comment === null) { - changes.push(new DropCommentOnRlsPolicy({ policy: mainRlsPolicy })); - } else { - changes.push( - new CreateCommentOnRlsPolicy({ policy: branchRlsPolicy }), - ); - } - } - - // Note: RLS policy renaming would require drop+create due to identity fields - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/rls-policy/rls-policy.model.ts b/packages/pg-delta/src/core/objects/rls-policy/rls-policy.model.ts deleted file mode 100644 index dfc0f6efa..000000000 --- a/packages/pg-delta/src/core/objects/rls-policy/rls-policy.model.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; - -const RlsPolicyCommandSchema = z.enum([ - "r", // SELECT command - "a", // INSERT command (add) - "w", // UPDATE command (write) - "d", // DELETE command - "*", // ALL commands -]); - -const RlsPolicyReferencedRelationKindSchema = z.enum([ - "table", - "view", - "materialized_view", - "foreign_table", -]); - -const rlsPolicyReferencedRelationSchema = z.object({ - kind: RlsPolicyReferencedRelationKindSchema, - schema: z.string(), - name: z.string(), -}); - -export type RlsPolicyReferencedRelation = z.infer< - typeof rlsPolicyReferencedRelationSchema ->; - -const rlsPolicyReferencedProcedureSchema = z.object({ - schema: z.string(), - name: z.string(), - argument_types: z.array(z.string()), -}); - -export type RlsPolicyReferencedProcedure = z.infer< - typeof rlsPolicyReferencedProcedureSchema ->; - -const rlsPolicyPropsSchema = z.object({ - schema: z.string(), - name: z.string(), - table_name: z.string(), - command: RlsPolicyCommandSchema, - permissive: z.boolean(), - roles: z.array(z.string()), - using_expression: z.string().nullable(), - with_check_expression: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable(), - referenced_relations: z.array(rlsPolicyReferencedRelationSchema), - referenced_procedures: z.array(rlsPolicyReferencedProcedureSchema), -}); - -export type RlsPolicyProps = z.infer; - -export class RlsPolicy extends BasePgModel { - public readonly schema: RlsPolicyProps["schema"]; - public readonly name: RlsPolicyProps["name"]; - public readonly table_name: RlsPolicyProps["table_name"]; - public readonly command: RlsPolicyProps["command"]; - public readonly permissive: RlsPolicyProps["permissive"]; - public readonly roles: RlsPolicyProps["roles"]; - public readonly using_expression: RlsPolicyProps["using_expression"]; - public readonly with_check_expression: RlsPolicyProps["with_check_expression"]; - public readonly owner: RlsPolicyProps["owner"]; - public readonly comment: RlsPolicyProps["comment"]; - /** - * Tables / views / materialized views / foreign tables that - * `using_expression` / `with_check_expression` reference, sourced from - * `pg_depend` (`recordDependencyOnExpr` at policy creation). Drives - * ordering dependencies in `CreateRlsPolicy.requires`. Intentionally - * excluded from `dataFields` — it's derived from the expression text - * and changes lockstep with it. - */ - public readonly referenced_relations: RlsPolicyProps["referenced_relations"]; - /** - * Functions / procedures that `using_expression` / `with_check_expression` - * reference, sourced from `pg_depend` (refclassid = `pg_proc`). The - * argument-type signature comes straight from `pg_proc.proargtypes` via - * `format_type`, so it matches the signature the procedure extractor - * embeds in `stableId.procedure(...)`. Not part of `dataFields`. - */ - public readonly referenced_procedures: RlsPolicyProps["referenced_procedures"]; - - constructor(props: RlsPolicyProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - this.table_name = props.table_name; - - // Data fields - this.command = props.command; - this.permissive = props.permissive; - this.roles = props.roles; - this.using_expression = props.using_expression; - this.with_check_expression = props.with_check_expression; - this.owner = props.owner; - this.comment = props.comment; - - // Derived metadata (not part of equality) - this.referenced_relations = props.referenced_relations; - this.referenced_procedures = props.referenced_procedures; - } - - get stableId(): `rlsPolicy:${string}` { - return `rlsPolicy:${this.schema}.${this.table_name}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - table_name: this.table_name, - name: this.name, - }; - } - - get dataFields() { - return { - command: this.command, - permissive: this.permissive, - roles: this.roles, - using_expression: this.using_expression, - with_check_expression: this.with_check_expression, - owner: this.owner, - comment: this.comment, - }; - } -} - -export async function extractRlsPolicies(pool: Pool): Promise { - const { rows: policyRows } = await pool.query(sql` -with extension_policy_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_policy'::regclass -), -extension_table_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass - and d.deptype = 'e' -), -policy_relation_deps as ( - -- Relations referenced inside polqual / polwithcheck. PostgreSQL records - -- these via recordDependencyOnExpr(..., DEPENDENCY_NORMAL = 'n') at - -- CREATE POLICY time, so pg_depend is authoritative and we don't need to - -- re-parse the expression text. Covers regular tables, partitioned - -- tables, views, materialized views, and foreign tables — any relation - -- kind the policy can reference in a subquery. - select distinct - d.objid as policy_oid, - case ref_c.relkind - when 'r' then 'table' - when 'p' then 'table' - when 'v' then 'view' - when 'm' then 'materialized_view' - when 'f' then 'foreign_table' - end as ref_kind, - ref_ns.nspname as ref_schema, - ref_c.relname as ref_name - from - pg_depend d - join pg_policy p on p.oid = d.objid - join pg_class ref_c on ref_c.oid = d.refobjid - join pg_namespace ref_ns on ref_ns.oid = ref_c.relnamespace - where - d.classid = 'pg_policy'::regclass - and d.refclassid = 'pg_class'::regclass - and d.deptype = 'n' - and ref_c.relkind in ('r', 'p', 'v', 'm', 'f') - and d.refobjid <> p.polrelid -), -policy_procedure_deps as ( - -- Functions / procedures referenced inside polqual / polwithcheck. Same - -- pg_depend mechanism as above, just refclassid = pg_proc. proargtypes - -- formatted via format_type(oid, null) matches the signature produced by - -- the procedure extractor (see procedure.model.ts), so stableId.procedure - -- on both sides of the diff lines up exactly. - select distinct - d.objid as policy_oid, - ref_ns.nspname as ref_schema, - ref_p.proname as ref_name, - array( - select format_type(oid, null) - from unnest(ref_p.proargtypes) as oid - ) as ref_argument_types - from - pg_depend d - join pg_proc ref_p on ref_p.oid = d.refobjid - join pg_namespace ref_ns on ref_ns.oid = ref_p.pronamespace - where - d.classid = 'pg_policy'::regclass - and d.refclassid = 'pg_proc'::regclass - and d.deptype = 'n' -) -select - tc.relnamespace::regnamespace::text as schema, - quote_ident(p.polname) as name, - quote_ident(tc.relname) as table_name, - p.polcmd as command, - p.polpermissive as permissive, - case - when p.polroles = '{0}' then array['public']::text[] - else array( - select quote_ident(rolname) - from pg_catalog.pg_roles - where oid = any(p.polroles) - order by rolname - ) - end as roles, - pg_get_expr(p.polqual, p.polrelid) as using_expression, - pg_get_expr(p.polwithcheck, p.polrelid) as with_check_expression, - tc.relowner::regrole::text as owner, - obj_description(p.oid, 'pg_policy') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'kind', prd.ref_kind, - 'schema', prd.ref_schema, - 'name', prd.ref_name - ) - order by prd.ref_schema, prd.ref_name - ) - from policy_relation_deps prd - where prd.policy_oid = p.oid - ), - '[]' - ) as referenced_relations, - coalesce( - ( - select json_agg( - json_build_object( - 'schema', ppd.ref_schema, - 'name', ppd.ref_name, - 'argument_types', ppd.ref_argument_types - ) - order by ppd.ref_schema, ppd.ref_name, ppd.ref_argument_types - ) - from policy_procedure_deps ppd - where ppd.policy_oid = p.oid - ), - '[]' - ) as referenced_procedures -from - pg_catalog.pg_policy p - inner join pg_catalog.pg_class tc on tc.oid = p.polrelid - left outer join extension_policy_oids e_policy on p.oid = e_policy.objid - left outer join extension_table_oids e_table on tc.oid = e_table.objid - where not tc.relnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e_policy.objid is null - and e_table.objid is null -order by - 1, 2 - `); - // Validate and parse each row using the Zod schema - const validatedRows = policyRows.map((row: unknown) => - rlsPolicyPropsSchema.parse(row), - ); - return validatedRows.map((row: RlsPolicyProps) => new RlsPolicy(row)); -} diff --git a/packages/pg-delta/src/core/objects/role/changes/role.alter.test.ts b/packages/pg-delta/src/core/objects/role/changes/role.alter.test.ts deleted file mode 100644 index 19ba70e6c..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.alter.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Role, type RoleProps } from "../role.model.ts"; -import { AlterRoleSetOptions } from "./role.alter.ts"; - -describe.concurrent("role", () => { - describe("alter", () => { - test("alter SUPERUSER", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ role, options: ["SUPERUSER"] }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH SUPERUSER"); - }); - - test("alter NOSUPERUSER", async () => { - const role = new Role({ - name: "r", - is_superuser: true, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ - role, - options: ["NOSUPERUSER"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH NOSUPERUSER"); - }); - - test("alter NOCREATEDB", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: true, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ role, options: ["NOCREATEDB"] }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH NOCREATEDB"); - }); - - test("alter NOCREATEROLE", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: true, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ - role, - options: ["NOCREATEROLE"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH NOCREATEROLE"); - }); - - test("alter INHERIT", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: false, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ role, options: ["INHERIT"] }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH INHERIT"); - }); - - test("alter LOGIN", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ role, options: ["LOGIN"] }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH LOGIN"); - }); - - test("alter NOREPLICATION", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: true, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ - role, - options: ["NOREPLICATION"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH NOREPLICATION"); - }); - - test("alter NOBYPASSRLS", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: true, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ - role, - options: ["NOBYPASSRLS"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH NOBYPASSRLS"); - }); - - test("alter CREATEROLE", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ role, options: ["CREATEROLE"] }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH CREATEROLE"); - }); - - test("alter NOINHERIT", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ role, options: ["NOINHERIT"] }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH NOINHERIT"); - }); - - test("alter NOLOGIN", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: true, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ role, options: ["NOLOGIN"] }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH NOLOGIN"); - }); - - test("alter REPLICATION", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ - role, - options: ["REPLICATION"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH REPLICATION"); - }); - - test("alter BYPASSRLS", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ role, options: ["BYPASSRLS"] }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER ROLE r WITH BYPASSRLS"); - }); - - test("alter multiple options ordering", async () => { - const role = new Role({ - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: false, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - const change = new AlterRoleSetOptions({ - role, - options: [ - "SUPERUSER", - "CREATEDB", - "CREATEROLE", - "NOINHERIT", - "LOGIN", - "REPLICATION", - "BYPASSRLS", - "CONNECTION LIMIT 10", - ], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER ROLE r WITH SUPERUSER CREATEDB CREATEROLE NOINHERIT LOGIN REPLICATION BYPASSRLS CONNECTION LIMIT 10", - ); - }); - test("alter flags and connection limit", async () => { - const props: Omit< - RoleProps, - "can_create_databases" | "connection_limit" - > = { - name: "r", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_login: false, - can_replicate: false, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }; - const role = new Role({ - ...props, - can_create_databases: false, - connection_limit: null, - }); - const change = new AlterRoleSetOptions({ - role, - options: ["CREATEDB", "CONNECTION LIMIT 3"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER ROLE r WITH CREATEDB CONNECTION LIMIT 3", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/role/changes/role.alter.ts b/packages/pg-delta/src/core/objects/role/changes/role.alter.ts deleted file mode 100644 index ab9061370..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.alter.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { formatConfigValue } from "../../procedure/utils.ts"; -import type { Role } from "../role.model.ts"; -import { AlterRoleChange } from "./role.base.ts"; - -/** - * Alter a role. - * - * @see https://www.postgresql.org/docs/17/sql-alterrole.html - * - * Synopsis - * ```sql - * ALTER ROLE role_name [ WITH ] option [ ... ] - * where option can be: - * SUPERUSER | NOSUPERUSER - * | CREATEDB | NOCREATEDB - * | CREATEROLE | NOCREATEROLE - * | INHERIT | NOINHERIT - * | LOGIN | NOLOGIN - * | REPLICATION | NOREPLICATION - * | BYPASSRLS | NOBYPASSRLS - * | CONNECTION LIMIT connlimit - * | [ ENCRYPTED ] PASSWORD 'password' | PASSWORD NULL - * | VALID UNTIL 'timestamp' - * | IN ROLE role_name [, ...] - * | IN GROUP role_name [, ...] - * | ROLE role_name [, ...] - * | ADMIN role_name [, ...] - * | USER role_name [, ...] - * | SYSID uid - * - * ALTER ROLE { role_specification | ALL } [ IN DATABASE database_name ] SET configuration_parameter { TO | = } { value | DEFAULT } - * ALTER ROLE { role_specification | ALL } [ IN DATABASE database_name ] SET configuration_parameter FROM CURRENT - * ALTER ROLE { role_specification | ALL } [ IN DATABASE database_name ] RESET configuration_parameter - * ALTER ROLE { role_specification | ALL } [ IN DATABASE database_name ] RESET ALL - * ``` - */ - -export type AlterRole = AlterRoleSetConfig | AlterRoleSetOptions; - -/** - * ALTER ROLE ... WITH option [...] - * Emits only options that differ between main and branch. - */ -export class AlterRoleSetOptions extends AlterRoleChange { - public readonly role: Role; - public readonly options: string[]; - public readonly scope = "object" as const; - - constructor(props: { role: Role; options: string[] }) { - super(); - this.role = props.role; - this.options = props.options; - } - - get requires() { - return [this.role.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["ALTER ROLE", this.role.name]; - return [...parts, "WITH", this.options.join(" ")].join(" "); - } -} - -/** - * ALTER ROLE ... SET/RESET configuration_parameter (single statement) - * Represents one action: SET key TO value, RESET key, or RESET ALL. - */ -export class AlterRoleSetConfig extends AlterRoleChange { - public readonly role: Role; - public readonly action: "set" | "reset" | "reset_all"; - public readonly key?: string; - public readonly value?: string; - public readonly scope = "object" as const; - - constructor(props: { role: Role; action: "set"; key: string; value: string }); - constructor(props: { role: Role; action: "reset"; key: string }); - constructor(props: { role: Role; action: "reset_all" }); - constructor(props: { - role: Role; - action: "set" | "reset" | "reset_all"; - key?: string; - value?: string; - }) { - super(); - this.role = props.role; - this.action = props.action; - this.key = props.key; - this.value = props.value; - } - - get requires() { - return [this.role.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const head = ["ALTER ROLE", this.role.name].join(" "); - if (this.action === "reset_all") { - return `${head} RESET ALL`; - } - if (this.action === "reset") { - return `${head} RESET ${this.key}`; - } - const formatted = formatConfigValue( - this.key as string, - this.value as string, - ); - return `${head} SET ${this.key} TO ${formatted}`; - } -} diff --git a/packages/pg-delta/src/core/objects/role/changes/role.base.ts b/packages/pg-delta/src/core/objects/role/changes/role.base.ts deleted file mode 100644 index aeb33b76c..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.base.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Role } from "../role.model.ts"; - -abstract class BaseRoleChange extends BaseChange { - abstract readonly role: Role; - abstract readonly scope: - | "object" - | "comment" - | "membership" - | "default_privilege" - | "security_label"; - readonly objectType = "role" as const; -} - -export abstract class CreateRoleChange extends BaseRoleChange { - readonly operation = "create" as const; -} - -export abstract class AlterRoleChange extends BaseRoleChange { - readonly operation = "alter" as const; -} - -export abstract class DropRoleChange extends BaseRoleChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/role/changes/role.comment.ts b/packages/pg-delta/src/core/objects/role/changes/role.comment.ts deleted file mode 100644 index 892f9f6cd..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.comment.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Role } from "../role.model.ts"; -import { CreateRoleChange, DropRoleChange } from "./role.base.ts"; - -export type CommentRole = CreateCommentOnRole | DropCommentOnRole; - -export class CreateCommentOnRole extends CreateRoleChange { - public readonly role: Role; - public readonly scope = "comment" as const; - - constructor(props: { role: Role }) { - super(); - this.role = props.role; - } - - get creates() { - return [stableId.comment(this.role.stableId)]; - } - - get requires() { - return [this.role.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON ROLE", - this.role.name, - "IS", - quoteLiteral(this.role.comment as string), - ].join(" "); - } -} - -export class DropCommentOnRole extends DropRoleChange { - public readonly role: Role; - public readonly scope = "comment" as const; - - constructor(props: { role: Role }) { - super(); - this.role = props.role; - } - - get drops() { - return [stableId.comment(this.role.stableId)]; - } - - get requires() { - return [stableId.comment(this.role.stableId), this.role.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["COMMENT ON ROLE", this.role.name, "IS NULL"].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/role/changes/role.create.test.ts b/packages/pg-delta/src/core/objects/role/changes/role.create.test.ts deleted file mode 100644 index 175ce16dd..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.create.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Role } from "../role.model.ts"; -import { CreateRole } from "./role.create.ts"; - -describe("role", () => { - test("create minimal (omit defaults)", async () => { - const role = new Role({ - name: "test_role", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: true, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - - const change = new CreateRole({ - role, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("CREATE ROLE test_role WITH LOGIN"); - }); - - test("create with all options (non-defaults only)", async () => { - const role = new Role({ - name: "r_all", - is_superuser: true, - can_inherit: false, - can_create_roles: true, - can_create_databases: true, - can_login: true, - can_replicate: true, - connection_limit: 5, - can_bypass_rls: true, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - - const change = new CreateRole({ role }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "CREATE ROLE r_all WITH SUPERUSER CREATEDB CREATEROLE NOINHERIT LOGIN REPLICATION BYPASSRLS CONNECTION LIMIT 5", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/role/changes/role.create.ts b/packages/pg-delta/src/core/objects/role/changes/role.create.ts deleted file mode 100644 index e2213495c..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.create.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Role } from "../role.model.ts"; -import { CreateRoleChange } from "./role.base.ts"; - -/** - * Create a role. - * - * @see https://www.postgresql.org/docs/17/sql-createrole.html - * - * Synopsis - * ```sql - * CREATE ROLE name [ [ WITH ] option [ ... ] ] - * where option can be: - * SUPERUSER | NOSUPERUSER - * | CREATEDB | NOCREATEDB - * | CREATEROLE | NOCREATEROLE - * | INHERIT | NOINHERIT - * | LOGIN | NOLOGIN - * | REPLICATION | NOREPLICATION - * | BYPASSRLS | NOBYPASSRLS - * | CONNECTION LIMIT connlimit - * | [ ENCRYPTED ] PASSWORD 'password' | PASSWORD NULL - * | VALID UNTIL 'timestamp' - * | IN ROLE role_name [, ...] - * | IN GROUP role_name [, ...] - * | ROLE role_name [, ...] - * | ADMIN role_name [, ...] - * | USER role_name [, ...] - * | SYSID uid - * ``` - */ -export class CreateRole extends CreateRoleChange { - public readonly role: Role; - public readonly scope = "object" as const; - - constructor(props: { role: Role }) { - super(); - this.role = props.role; - } - - get creates() { - return [this.role.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE ROLE"]; - - // Add role name - parts.push(this.role.name); - - // Add options (only non-default values) - const options: string[] = []; - - // SUPERUSER (default is NOSUPERUSER) - if (this.role.is_superuser) { - options.push("SUPERUSER"); - } - - // CREATEDB (default is NOCREATEDB) - if (this.role.can_create_databases) { - options.push("CREATEDB"); - } - - // CREATEROLE (default is NOCREATEROLE) - if (this.role.can_create_roles) { - options.push("CREATEROLE"); - } - - // INHERIT (default is INHERIT, so only print if false) - if (!this.role.can_inherit) { - options.push("NOINHERIT"); - } - - // LOGIN (default is NOLOGIN) - if (this.role.can_login) { - options.push("LOGIN"); - } - - // REPLICATION (default is NOREPLICATION) - if (this.role.can_replicate) { - options.push("REPLICATION"); - } - - // BYPASSRLS (default is NOBYPASSRLS) - if (this.role.can_bypass_rls) { - options.push("BYPASSRLS"); - } - - // CONNECTION LIMIT - if ( - this.role.connection_limit !== null && - this.role.connection_limit !== -1 - ) { - options.push(`CONNECTION LIMIT ${this.role.connection_limit}`); - } - - if (options.length > 0) { - parts.push("WITH", options.join(" ")); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/role/changes/role.drop.test.ts b/packages/pg-delta/src/core/objects/role/changes/role.drop.test.ts deleted file mode 100644 index 2f6f8f967..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.drop.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Role } from "../role.model.ts"; -import { DropRole } from "./role.drop.ts"; - -describe("role", () => { - test("drop", async () => { - const role = new Role({ - name: "test_role", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: true, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], - }); - - const change = new DropRole({ - role, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP ROLE test_role"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/role/changes/role.drop.ts b/packages/pg-delta/src/core/objects/role/changes/role.drop.ts deleted file mode 100644 index fba7895a5..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Role } from "../role.model.ts"; -import { DropRoleChange } from "./role.base.ts"; - -/** - * Drop a role. - * - * @see https://www.postgresql.org/docs/17/sql-droprole.html - * - * Synopsis - * ```sql - * DROP ROLE [ IF EXISTS ] name [, ...] - * ``` - */ -export class DropRole extends DropRoleChange { - public readonly role: Role; - public readonly scope = "object" as const; - - constructor(props: { role: Role }) { - super(); - this.role = props.role; - } - - get drops() { - return [this.role.stableId]; - } - - get requires() { - return [this.role.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP ROLE", this.role.name].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/role/changes/role.privilege.ts b/packages/pg-delta/src/core/objects/role/changes/role.privilege.ts deleted file mode 100644 index 49df942c4..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.privilege.ts +++ /dev/null @@ -1,377 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Role } from "../role.model.ts"; -import { CreateRoleChange, DropRoleChange } from "./role.base.ts"; - -export type RolePrivilege = - | GrantRoleMembership - | RevokeRoleMembership - | RevokeRoleMembershipOptions - | GrantRoleDefaultPrivileges - | RevokeRoleDefaultPrivileges; - -/** - * Grant role membership. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT role_name [, ...] TO role_specification [, ...] - * [ WITH ADMIN OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantRoleMembership extends CreateRoleChange { - public readonly role: Role; - public readonly member: string; - public readonly options: { - admin: boolean; - inherit?: boolean | null; - set?: boolean | null; - }; - public readonly scope = "membership" as const; - - constructor(props: { - role: Role; - member: string; - options: { admin: boolean; inherit?: boolean | null; set?: boolean | null }; - }) { - super(); - this.role = props.role; - this.member = props.member; - this.options = props.options; - } - - get creates() { - return [stableId.membership(this.role.name, this.member)]; - } - - get requires() { - return [this.role.stableId, stableId.role(this.member)]; - } - - serialize(_options?: SerializeOptions): string { - // On creation, only emit ADMIN OPTION; leave INHERIT/SET to defaults - const opts: string[] = []; - if (this.options.admin) opts.push("ADMIN OPTION"); - const withClause = opts.length > 0 ? ` WITH ${opts.join(" ")}` : ""; - return `GRANT ${this.role.name} TO ${this.member}${withClause}`; - } -} - -/** - * Revoke role membership. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ ADMIN OPTION FOR ] role_name [, ...] FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeRoleMembership extends DropRoleChange { - public readonly role: Role; - public readonly member: string; - public readonly scope = "membership" as const; - - constructor(props: { role: Role; member: string }) { - super(); - this.role = props.role; - this.member = props.member; - } - - get drops() { - return [stableId.membership(this.role.name, this.member)]; - } - - get requires() { - return [ - stableId.membership(this.role.name, this.member), - stableId.role(this.member), - this.role.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return `REVOKE ${this.role.name} FROM ${this.member}`; - } -} - -/** - * Revoke membership options for a role. - * - * This removes specific options (ADMIN, INHERIT, SET) from a role membership, - * but keeps the membership itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeRoleMembershipOptions extends DropRoleChange { - public readonly role: Role; - public readonly member: string; - public readonly admin?: boolean; - public readonly inherit?: boolean; - public readonly set?: boolean; - public readonly scope = "membership" as const; - - constructor(props: { - role: Role; - member: string; - admin?: boolean; - inherit?: boolean; - set?: boolean; - }) { - super(); - this.role = props.role; - this.member = props.member; - this.admin = props.admin; - this.inherit = props.inherit; - this.set = props.set; - } - - get requires() { - return [ - stableId.membership(this.role.name, this.member), - stableId.role(this.member), - this.role.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = []; - if (this.admin) parts.push("ADMIN OPTION"); - if (this.inherit) parts.push("INHERIT OPTION"); - if (this.set) parts.push("SET OPTION"); - return `REVOKE ${parts.join(" ")} FOR ${this.role.name} FROM ${this.member}`; - } -} - -/** - * Grant default privileges for a role. - * - * @see https://www.postgresql.org/docs/17/sql-alterdefaultprivileges.html - */ -export class GrantRoleDefaultPrivileges extends CreateRoleChange { - public readonly role: Role; - public readonly inSchema: string | null; - public readonly objtype: string; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number; - public readonly scope = "default_privilege" as const; - - constructor(props: { - role: Role; - inSchema: string | null; - objtype: string; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version: number; - }) { - super(); - this.role = props.role; - this.inSchema = props.inSchema; - this.objtype = props.objtype; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [ - stableId.defacl( - this.role.name, - this.objtype, - this.inSchema, - this.grantee, - ), - ]; - } - - get requires() { - return [ - this.role.stableId, - stableId.role(this.grantee), - ...(this.inSchema ? [stableId.schema(this.inSchema)] : []), - ]; - } - - serialize(_options?: SerializeOptions): string { - const scope = this.inSchema ? ` IN SCHEMA ${this.inSchema}` : ""; - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantRoleDefaultPrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const privSql = formatPrivilegeList( - this.objtype, - this.privileges.map((p) => p.privilege), - this.version, - ); - return `ALTER DEFAULT PRIVILEGES FOR ROLE ${this.role.name}${scope} GRANT ${privSql} ON ${objtypeToKeyword(this.objtype)} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke default privileges for a role. - * - * @see https://www.postgresql.org/docs/17/sql-alterdefaultprivileges.html - */ -export class RevokeRoleDefaultPrivileges extends DropRoleChange { - public readonly role: Role; - public readonly inSchema: string | null; - public readonly objtype: string; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number; - public readonly scope = "default_privilege" as const; - - constructor(props: { - role: Role; - inSchema: string | null; - objtype: string; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version: number; - }) { - super(); - this.role = props.role; - this.inSchema = props.inSchema; - this.objtype = props.objtype; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - return [ - stableId.defacl( - this.role.name, - this.objtype, - this.inSchema, - this.grantee, - ), - ]; - } - - get requires() { - return [ - stableId.defacl( - this.role.name, - this.objtype, - this.inSchema, - this.grantee, - ), - this.role.stableId, - stableId.role(this.grantee), - ...(this.inSchema ? [stableId.schema(this.inSchema)] : []), - ]; - } - - serialize(_options?: SerializeOptions): string { - const scope = this.inSchema ? ` IN SCHEMA ${this.inSchema}` : ""; - const grantOptionPrivs = this.privileges - .filter((p) => p.grantable) - .map((p) => p.privilege); - const basePrivs = this.privileges - .filter((p) => !p.grantable) - .map((p) => p.privilege); - const hasGrantOption = grantOptionPrivs.length > 0; - const hasBase = basePrivs.length > 0; - if (hasGrantOption && hasBase) { - throw new Error( - "AlterDefaultPrivilegesRevoke expects privileges from a single revoke kind", - ); - } - if (hasGrantOption) { - const privSql = formatPrivilegeList( - this.objtype, - grantOptionPrivs, - this.version, - ); - return `ALTER DEFAULT PRIVILEGES FOR ROLE ${this.role.name}${scope} REVOKE GRANT OPTION FOR ${privSql} ON ${objtypeToKeyword(this.objtype)} FROM ${this.grantee}`; - } - const privSql = formatPrivilegeList(this.objtype, basePrivs, this.version); - return `ALTER DEFAULT PRIVILEGES FOR ROLE ${this.role.name}${scope} REVOKE ${privSql} ON ${objtypeToKeyword(this.objtype)} FROM ${this.grantee}`; - } -} - -// Helper functions for default privileges -function objtypeToKeyword(objtype: string): string { - switch (objtype) { - case "r": - return "TABLES"; - case "S": - return "SEQUENCES"; - case "f": - return "ROUTINES"; - case "T": - return "TYPES"; - case "n": - return "SCHEMAS"; - default: - return objtype; - } -} - -function defaultPrivilegeUniverse(objtype: string, version: number): string[] { - // Full privilege sets per object kind for ALTER DEFAULT PRIVILEGES - // Keep names aligned with pg_catalog privilege_type values - switch (objtype) { - case "r": { - // TABLES - // MAINTAIN exists on PostgreSQL >= 17 - const includesMaintain = version >= 170000; - return [ - "DELETE", - "INSERT", - ...(includesMaintain ? (["MAINTAIN"] as const) : []), - "REFERENCES", - "SELECT", - "TRIGGER", - "TRUNCATE", - "UPDATE", - ]; - } - case "S": // SEQUENCES - return ["SELECT", "UPDATE", "USAGE"].sort(); - case "f": // ROUTINES (FUNCTIONS) - return ["EXECUTE"]; - case "T": // TYPES - return ["USAGE"]; - case "n": // SCHEMAS - return ["CREATE", "USAGE"].sort(); - default: - return []; - } -} - -function isFullPrivilegeSet( - objtype: string, - list: string[], - version: number, -): boolean { - const uniqSorted = [...new Set(list)].sort(); - const fullSorted = [...defaultPrivilegeUniverse(objtype, version)].sort(); - if (uniqSorted.length !== fullSorted.length) return false; - for (let i = 0; i < uniqSorted.length; i++) { - if (uniqSorted[i] !== fullSorted[i]) return false; - } - return true; -} - -function formatPrivilegeList( - objtype: string, - list: string[], - version: number, -): string { - const uniqSorted = [...new Set(list)].sort(); - return isFullPrivilegeSet(objtype, uniqSorted, version) - ? "ALL" - : uniqSorted.join(", "); -} diff --git a/packages/pg-delta/src/core/objects/role/changes/role.security-label.ts b/packages/pg-delta/src/core/objects/role/changes/role.security-label.ts deleted file mode 100644 index f05b32491..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.security-label.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Role } from "../role.model.ts"; -import { CreateRoleChange, DropRoleChange } from "./role.base.ts"; - -export type SecurityLabelRole = - | CreateSecurityLabelOnRole - | DropSecurityLabelOnRole; - -export class CreateSecurityLabelOnRole extends CreateRoleChange { - public readonly role: Role; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { role: Role; securityLabel: SecurityLabelProps }) { - super(); - this.role = props.role; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel(this.role.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [this.role.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON ROLE", - this.role.name, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnRole extends DropRoleChange { - public readonly role: Role; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { role: Role; securityLabel: SecurityLabelProps }) { - super(); - this.role = props.role; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel(this.role.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [ - stableId.securityLabel(this.role.stableId, this.securityLabel.provider), - this.role.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON ROLE", - this.role.name, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/role/changes/role.types.ts b/packages/pg-delta/src/core/objects/role/changes/role.types.ts deleted file mode 100644 index d1be83abe..000000000 --- a/packages/pg-delta/src/core/objects/role/changes/role.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterRole } from "./role.alter.ts"; -import type { CommentRole } from "./role.comment.ts"; -import type { CreateRole } from "./role.create.ts"; -import type { DropRole } from "./role.drop.ts"; -import type { RolePrivilege } from "./role.privilege.ts"; -import type { SecurityLabelRole } from "./role.security-label.ts"; - -/** Union of all role-related change variants (`objectType: "role"`). @category Change Types */ -export type RoleChange = - | AlterRole - | CommentRole - | CreateRole - | DropRole - | RolePrivilege - | SecurityLabelRole; diff --git a/packages/pg-delta/src/core/objects/role/role.diff.test.ts b/packages/pg-delta/src/core/objects/role/role.diff.test.ts deleted file mode 100644 index 18a64dc6b..000000000 --- a/packages/pg-delta/src/core/objects/role/role.diff.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { AlterRoleSetOptions } from "./changes/role.alter.ts"; -import { CreateRole } from "./changes/role.create.ts"; -import { DropRole } from "./changes/role.drop.ts"; -import { GrantRoleMembership } from "./changes/role.privilege.ts"; -import { diffRoles } from "./role.diff.ts"; -import { Role, type RoleProps } from "./role.model.ts"; - -const base: RoleProps = { - name: "r1", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: true, - can_replicate: false, - connection_limit: null, - can_bypass_rls: false, - config: null, - comment: null, - members: [], - default_privileges: [], -}; - -describe.concurrent("role.diff", () => { - test("create and drop", () => { - const r = new Role(base); - const created = diffRoles({ version: 170000 }, {}, { [r.stableId]: r }); - expect(created[0]).toBeInstanceOf(CreateRole); - - const dropped = diffRoles({ version: 170000 }, { [r.stableId]: r }, {}); - expect(dropped[0]).toBeInstanceOf(DropRole); - }); - - test("alter on flag change", () => { - const main = new Role(base); - const branch = new Role({ ...base, can_login: false }); - const changes = diffRoles( - { version: 170000 }, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterRoleSetOptions); - }); - - test("no duplicate membership grants when members have multiple grantors", () => { - // In PG 16+, pg_auth_members can have multiple rows for the same - // (roleid, member) pair with different grantors. The Role constructor - // should deduplicate these so diffRoles doesn't emit duplicate changes. - const parentRole = new Role({ - ...base, - name: "postgres", - members: [ - { - member: "cli_login_postgres", - grantor: "postgres", - admin_option: false, - inherit_option: false, - set_option: false, - }, - { - member: "cli_login_postgres", - grantor: "supabase_admin", - admin_option: false, - inherit_option: false, - set_option: false, - }, - ], - }); - - // After deduplication, should have only one member entry - expect(parentRole.members).toHaveLength(1); - expect(parentRole.members[0].member).toBe("cli_login_postgres"); - - // When diffing, the created role should emit only one GRANT - const changes = diffRoles( - { version: 170000 }, - {}, - { [parentRole.stableId]: parentRole }, - ); - const grantChanges = changes.filter( - (c) => c instanceof GrantRoleMembership, - ); - expect(grantChanges).toHaveLength(1); - }); - - test("duplicate members are merged with most permissive options", () => { - const role = new Role({ - ...base, - name: "test_role", - members: [ - { - member: "member1", - grantor: "grantor_a", - admin_option: false, - inherit_option: false, - set_option: true, - }, - { - member: "member1", - grantor: "grantor_b", - admin_option: true, - inherit_option: false, - set_option: false, - }, - ], - }); - - expect(role.members).toHaveLength(1); - expect(role.members[0].admin_option).toBe(true); - expect(role.members[0].set_option).toBe(true); - }); - - test("no false alter when both sides have duplicate members from different grantors", () => { - // Both main and branch have the same membership but from different - // grantors. After deduplication the roles should be equal, producing - // no changes. - const main = new Role({ - ...base, - name: "parent", - members: [ - { - member: "child", - grantor: "postgres", - admin_option: false, - inherit_option: false, - set_option: false, - }, - { - member: "child", - grantor: "supabase_admin", - admin_option: false, - inherit_option: false, - set_option: false, - }, - ], - }); - const branch = new Role({ - ...base, - name: "parent", - members: [ - { - member: "child", - grantor: "another_admin", - admin_option: false, - inherit_option: false, - set_option: false, - }, - ], - }); - - const changes = diffRoles( - { version: 170000 }, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(0); - }); - - test("create role skips self-granted membership (member === grantor)", () => { - // Simulates the auto-created membership when postgres creates a role: - // PostgreSQL automatically makes the creator a member with grantor=self. - const role = new Role({ - ...base, - name: "developer", - members: [ - { - member: "postgres", - grantor: "postgres", - admin_option: true, - inherit_option: true, - set_option: true, - }, - ], - }); - const changes = diffRoles( - { version: 170000 }, - {}, - { [role.stableId]: role }, - ); - // Should only have CreateRole, no GrantRoleMembership for postgres - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(CreateRole); - }); - - test("create role keeps membership when member differs from grantor", () => { - const role = new Role({ - ...base, - name: "developer", - members: [ - { - member: "app_user", - grantor: "postgres", - admin_option: true, - inherit_option: true, - set_option: true, - }, - ], - }); - const changes = diffRoles( - { version: 170000 }, - {}, - { [role.stableId]: role }, - ); - // Should have CreateRole + GrantRoleMembership - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(CreateRole); - expect(changes[1]).toBeInstanceOf(GrantRoleMembership); - }); - - test("create role keeps mixed-grantor membership where not all grantors equal member", () => { - // Model dedup should prefer the non-self grantor, so diff keeps the membership - const role = new Role({ - ...base, - name: "developer", - members: [ - { - member: "postgres", - grantor: "postgres", - admin_option: false, - inherit_option: true, - set_option: true, - }, - { - member: "postgres", - grantor: "supabase_admin", - admin_option: true, - inherit_option: true, - set_option: true, - }, - ], - }); - const changes = diffRoles( - { version: 170000 }, - {}, - { [role.stableId]: role }, - ); - // One grantor is different from member, dedup prefers it → membership kept - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(CreateRole); - expect(changes[1]).toBeInstanceOf(GrantRoleMembership); - }); - - test("alter role skips granting admin to self-granted membership", () => { - const mainRole = new Role({ - ...base, - name: "developer", - members: [ - { - member: "postgres", - grantor: "postgres", - admin_option: false, - inherit_option: true, - set_option: true, - }, - ], - }); - const branchRole = new Role({ - ...base, - name: "developer", - members: [ - { - member: "postgres", - grantor: "postgres", - admin_option: true, - inherit_option: true, - set_option: true, - }, - ], - }); - const changes = diffRoles( - { version: 170000 }, - { [mainRole.stableId]: mainRole }, - { [branchRole.stableId]: branchRole }, - ); - // Should produce no changes — granting admin back to self would fail - expect(changes).toHaveLength(0); - }); -}); diff --git a/packages/pg-delta/src/core/objects/role/role.diff.ts b/packages/pg-delta/src/core/objects/role/role.diff.ts deleted file mode 100644 index b7ff4273d..000000000 --- a/packages/pg-delta/src/core/objects/role/role.diff.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { - AlterRoleSetConfig, - AlterRoleSetOptions, -} from "./changes/role.alter.ts"; -import { - CreateCommentOnRole, - DropCommentOnRole, -} from "./changes/role.comment.ts"; -import { CreateRole } from "./changes/role.create.ts"; -import { DropRole } from "./changes/role.drop.ts"; -import { - GrantRoleDefaultPrivileges, - GrantRoleMembership, - RevokeRoleDefaultPrivileges, - RevokeRoleMembership, - RevokeRoleMembershipOptions, -} from "./changes/role.privilege.ts"; -import { - CreateSecurityLabelOnRole, - DropSecurityLabelOnRole, -} from "./changes/role.security-label.ts"; -import type { RoleChange } from "./changes/role.types.ts"; -import type { Role } from "./role.model.ts"; - -/** - * Diff two sets of roles from main and branch catalogs. - * - * @param ctx - Context containing version information. - * @param main - The roles in the main catalog. - * @param branch - The roles in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffRoles( - ctx: { version: number }, - main: Record, - branch: Record, -): RoleChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: RoleChange[] = []; - - for (const roleId of created) { - const role = branch[roleId]; - changes.push(new CreateRole({ role })); - // Initialize config after creation: one SET per key - const cfg = role.config ?? []; - for (const opt of cfg) { - const eqIndex = opt.indexOf("="); - if (eqIndex === -1) continue; - const key = opt.slice(0, eqIndex).trim(); - const value = opt.slice(eqIndex + 1).trim(); - changes.push(new AlterRoleSetConfig({ role, action: "set", key, value })); - } - if (role.comment !== null) { - changes.push(new CreateCommentOnRole({ role })); - } - for (const label of role.security_labels) { - changes.push( - new CreateSecurityLabelOnRole({ - role, - securityLabel: label, - }), - ); - } - // MEMBERSHIPS: Grant memberships immediately after role creation. - // Members are already deduplicated by the Role model constructor. - for (const membership of role.members) { - // Skip memberships where the member is the grantor (auto-created by - // CREATE ROLE — re-granting them, especially WITH ADMIN OPTION, fails - // with "ADMIN option cannot be granted back to your own grantor"). - if (membership.grantor === membership.member) { - continue; - } - changes.push( - new GrantRoleMembership({ - role, - member: membership.member, - options: { - admin: membership.admin_option, - inherit: membership.inherit_option ?? null, - set: membership.set_option ?? null, - }, - }), - ); - } - // DEFAULT PRIVILEGES: Grant default privileges immediately after role creation - for (const defaultPriv of role.default_privileges) { - if (defaultPriv.is_implicit) continue; - if (defaultPriv.privileges.length === 0) continue; - const grantGroups = new Map< - boolean, - { privilege: string; grantable: boolean }[] - >(); - for (const p of defaultPriv.privileges) { - const arr = grantGroups.get(p.grantable) ?? []; - arr.push(p); - grantGroups.set(p.grantable, arr); - } - for (const [grantable, list] of grantGroups) { - void grantable; - changes.push( - new GrantRoleDefaultPrivileges({ - role, - inSchema: defaultPriv.in_schema, - objtype: defaultPriv.objtype, - grantee: defaultPriv.grantee, - privileges: list, - version: ctx.version, - }), - ); - } - } - } - - for (const roleId of dropped) { - changes.push(new DropRole({ role: main[roleId] })); - } - - for (const roleId of altered) { - const mainRole = main[roleId]; - const branchRole = branch[roleId]; - - // Use ALTER for flag and connection limit changes, only if any option changed - const optionsChanged = - mainRole.is_superuser !== branchRole.is_superuser || - mainRole.can_create_databases !== branchRole.can_create_databases || - mainRole.can_create_roles !== branchRole.can_create_roles || - mainRole.can_inherit !== branchRole.can_inherit || - mainRole.can_login !== branchRole.can_login || - mainRole.can_replicate !== branchRole.can_replicate || - mainRole.can_bypass_rls !== branchRole.can_bypass_rls || - mainRole.connection_limit !== branchRole.connection_limit; - - if (optionsChanged) { - const options: string[] = []; - if (mainRole.is_superuser !== branchRole.is_superuser) { - options.push(branchRole.is_superuser ? "SUPERUSER" : "NOSUPERUSER"); - } - if (mainRole.can_create_databases !== branchRole.can_create_databases) { - options.push( - branchRole.can_create_databases ? "CREATEDB" : "NOCREATEDB", - ); - } - if (mainRole.can_create_roles !== branchRole.can_create_roles) { - options.push( - branchRole.can_create_roles ? "CREATEROLE" : "NOCREATEROLE", - ); - } - if (mainRole.can_inherit !== branchRole.can_inherit) { - options.push(branchRole.can_inherit ? "INHERIT" : "NOINHERIT"); - } - if (mainRole.can_login !== branchRole.can_login) { - options.push(branchRole.can_login ? "LOGIN" : "NOLOGIN"); - } - if (mainRole.can_replicate !== branchRole.can_replicate) { - options.push( - branchRole.can_replicate ? "REPLICATION" : "NOREPLICATION", - ); - } - if (mainRole.can_bypass_rls !== branchRole.can_bypass_rls) { - options.push(branchRole.can_bypass_rls ? "BYPASSRLS" : "NOBYPASSRLS"); - } - if (mainRole.connection_limit !== branchRole.connection_limit) { - options.push(`CONNECTION LIMIT ${branchRole.connection_limit}`); - } - changes.push(new AlterRoleSetOptions({ role: mainRole, options })); - } - - // CONFIG SET/RESET (emit single-statement changes) - const parseOptions = (options: string[] | null | undefined) => { - const map = new Map(); - if (!options) return map; - for (const opt of options) { - const eqIndex = opt.indexOf("="); - if (eqIndex === -1) continue; - const key = opt.slice(0, eqIndex).trim(); - const value = opt.slice(eqIndex + 1).trim(); - map.set(key, value); - } - return map; - }; - - const mainMap = parseOptions(mainRole.config); - const branchMap = parseOptions(branchRole.config); - - if (mainMap.size > 0 && branchMap.size === 0) { - // All settings removed -> prefer RESET ALL - changes.push( - new AlterRoleSetConfig({ role: mainRole, action: "reset_all" }), - ); - } else { - // Removed or changed keys -> RESET key - for (const [key, oldValue] of mainMap.entries()) { - const hasInBranch = branchMap.has(key); - const newValue = branchMap.get(key); - const changed = hasInBranch ? oldValue !== newValue : true; - if (changed) { - changes.push( - new AlterRoleSetConfig({ role: mainRole, action: "reset", key }), - ); - } - } - - // Added or changed keys -> SET key TO value - for (const [key, newValue] of branchMap.entries()) { - const oldValue = mainMap.get(key); - if (oldValue !== newValue) { - changes.push( - new AlterRoleSetConfig({ - role: mainRole, - action: "set", - key, - value: newValue, - }), - ); - } - } - } - - // COMMENT - if (mainRole.comment !== branchRole.comment) { - if (branchRole.comment === null) { - changes.push(new DropCommentOnRole({ role: mainRole })); - } else { - changes.push(new CreateCommentOnRole({ role: branchRole })); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnRole | DropSecurityLabelOnRole - >( - mainRole.security_labels, - branchRole.security_labels, - (securityLabel) => - new CreateSecurityLabelOnRole({ - role: branchRole, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnRole({ - role: mainRole, - securityLabel, - }), - ), - ); - - // MEMBERSHIPS - // Members are already deduplicated by the Role model constructor. - const mainMembers = new Map(mainRole.members.map((m) => [m.member, m])); - const branchMembers = new Map(branchRole.members.map((m) => [m.member, m])); - - // Find new members to grant - for (const [member, membership] of branchMembers) { - if (!mainMembers.has(member)) { - // Skip memberships where the member is the grantor (auto-created by - // CREATE ROLE — re-granting them fails with "ADMIN option cannot be - // granted back to your own grantor"). - if (membership.grantor === membership.member) { - continue; - } - changes.push( - new GrantRoleMembership({ - role: branchRole, - member: membership.member, - options: { - admin: membership.admin_option, - inherit: membership.inherit_option ?? null, - set: membership.set_option ?? null, - }, - }), - ); - } - } - - // Find members to revoke - for (const [member, membership] of mainMembers) { - if (!branchMembers.has(member)) { - changes.push( - new RevokeRoleMembership({ - role: mainRole, - member: membership.member, - }), - ); - } - } - - // Find membership option changes - for (const [member, branchMembership] of branchMembers) { - const mainMembership = mainMembers.get(member); - if (mainMembership) { - const toRevoke: { admin?: boolean; inherit?: boolean; set?: boolean } = - {}; - const toGrant: { admin?: boolean; inherit?: boolean; set?: boolean } = - {}; - - if (mainMembership.admin_option !== branchMembership.admin_option) { - if (branchMembership.admin_option) toGrant.admin = true; - else toRevoke.admin = true; - } - if ( - (mainMembership.inherit_option ?? null) !== - (branchMembership.inherit_option ?? null) - ) { - if (branchMembership.inherit_option) toGrant.inherit = true; - else toRevoke.inherit = true; - } - if ( - (mainMembership.set_option ?? null) !== - (branchMembership.set_option ?? null) - ) { - if (branchMembership.set_option) toGrant.set = true; - else toRevoke.set = true; - } - - if (toRevoke.admin || toRevoke.inherit || toRevoke.set) { - changes.push( - new RevokeRoleMembershipOptions({ - role: mainRole, - member: mainMembership.member, - admin: toRevoke.admin, - inherit: toRevoke.inherit, - set: toRevoke.set, - }), - ); - } - if (toGrant.admin || toGrant.inherit || toGrant.set) { - // Skip granting options back to the grantor (same restriction as - // for newly created roles). - if (branchMembership.grantor === branchMembership.member) { - continue; - } - changes.push( - new GrantRoleMembership({ - role: branchRole, - member: branchMembership.member, - options: { - admin: !!toGrant.admin, - inherit: toGrant.inherit ?? null, - set: toGrant.set ?? null, - }, - }), - ); - } - } - } - - // DEFAULT PRIVILEGES - const mainDefaultPrivs = new Map( - mainRole.default_privileges.map((dp) => [ - `${dp.in_schema ?? ""}:${dp.objtype}:${dp.grantee}`, - dp, - ]), - ); - const branchDefaultPrivs = new Map( - branchRole.default_privileges.map((dp) => [ - `${dp.in_schema ?? ""}:${dp.objtype}:${dp.grantee}`, - dp, - ]), - ); - - // Find new default privileges to grant - for (const [key, defaultPriv] of branchDefaultPrivs) { - if (!mainDefaultPrivs.has(key)) { - if (defaultPriv.privileges.length === 0) continue; - const grantGroups = new Map< - boolean, - { privilege: string; grantable: boolean }[] - >(); - for (const p of defaultPriv.privileges) { - const arr = grantGroups.get(p.grantable) ?? []; - arr.push(p); - grantGroups.set(p.grantable, arr); - } - for (const [grantable, list] of grantGroups) { - void grantable; - changes.push( - new GrantRoleDefaultPrivileges({ - role: branchRole, - inSchema: defaultPriv.in_schema, - objtype: defaultPriv.objtype, - grantee: defaultPriv.grantee, - privileges: list, - version: ctx.version, - }), - ); - } - } - } - - // Find default privileges to revoke - for (const [key, defaultPriv] of mainDefaultPrivs) { - if (!branchDefaultPrivs.has(key)) { - if (defaultPriv.privileges.length === 0) continue; - const revokeGroups = new Map< - boolean, - { privilege: string; grantable: boolean }[] - >(); - for (const p of defaultPriv.privileges) { - const arr = revokeGroups.get(p.grantable) ?? []; - arr.push(p); - revokeGroups.set(p.grantable, arr); - } - for (const [grantable, list] of revokeGroups) { - void grantable; - changes.push( - new RevokeRoleDefaultPrivileges({ - role: mainRole, - inSchema: defaultPriv.in_schema, - objtype: defaultPriv.objtype, - grantee: defaultPriv.grantee, - privileges: list, - version: ctx.version, - }), - ); - } - } - } - - // Find default privilege changes - for (const [key, branchDefaultPriv] of branchDefaultPrivs) { - const mainDefaultPriv = mainDefaultPrivs.get(key); - if (mainDefaultPriv) { - const toKey = (p: { privilege: string; grantable: boolean }) => - `${p.privilege}:${p.grantable}`; - const mainSet = new Set(mainDefaultPriv.privileges.map(toKey)); - const branchSet = new Set(branchDefaultPriv.privileges.map(toKey)); - - const grants: { privilege: string; grantable: boolean }[] = []; - const revokes: { privilege: string; grantable: boolean }[] = []; - const revokeGrantOption: string[] = []; - - for (const key of branchSet) { - if (!mainSet.has(key)) { - const [privilege, grantableStr] = key.split(":"); - grants.push({ privilege, grantable: grantableStr === "true" }); - } - } - for (const key of mainSet) { - if (!branchSet.has(key)) { - const [privilege, grantableStr] = key.split(":"); - const wasGrantable = grantableStr === "true"; - const stillHasBase = branchDefaultPriv.privileges.some( - (p) => p.privilege === privilege, - ); - const upgraded = - !wasGrantable && branchSet.has(`${privilege}:true`); - if (upgraded) { - // base -> with grant option; do not revoke base - continue; - } - if (wasGrantable && stillHasBase) { - revokeGrantOption.push(privilege); - } else { - revokes.push({ privilege, grantable: wasGrantable }); - } - } - } - - if (grants.length > 0) { - const grantGroups = new Map< - boolean, - { privilege: string; grantable: boolean }[] - >(); - for (const p of grants) { - const arr = grantGroups.get(p.grantable) ?? []; - arr.push(p); - grantGroups.set(p.grantable, arr); - } - for (const [grantable, list] of grantGroups) { - void grantable; - changes.push( - new GrantRoleDefaultPrivileges({ - role: branchRole, - inSchema: branchDefaultPriv.in_schema, - objtype: branchDefaultPriv.objtype, - grantee: branchDefaultPriv.grantee, - privileges: list, - version: ctx.version, - }), - ); - } - } - if (revokes.length > 0) { - const revokeGroups = new Map< - boolean, - { privilege: string; grantable: boolean }[] - >(); - for (const p of revokes) { - const arr = revokeGroups.get(p.grantable) ?? []; - arr.push(p); - revokeGroups.set(p.grantable, arr); - } - for (const [grantable, list] of revokeGroups) { - void grantable; - changes.push( - new RevokeRoleDefaultPrivileges({ - role: mainRole, - inSchema: mainDefaultPriv.in_schema, - objtype: mainDefaultPriv.objtype, - grantee: mainDefaultPriv.grantee, - privileges: list, - version: ctx.version, - }), - ); - } - } - if (revokeGrantOption.length > 0) { - // Encode as GRANT OPTION revocation by marking grantable true - changes.push( - new RevokeRoleDefaultPrivileges({ - role: mainRole, - inSchema: mainDefaultPriv.in_schema, - objtype: mainDefaultPriv.objtype, - grantee: mainDefaultPriv.grantee, - privileges: revokeGrantOption.map((p) => ({ - privilege: p, - grantable: true, - })), - version: ctx.version, - }), - ); - } - } - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/role/role.model.ts b/packages/pg-delta/src/core/objects/role/role.model.ts deleted file mode 100644 index a9bff21bb..000000000 --- a/packages/pg-delta/src/core/objects/role/role.model.ts +++ /dev/null @@ -1,484 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -const membershipInfoSchema = z.object({ - member: z.string(), - grantor: z.string(), - admin_option: z.boolean(), - inherit_option: z.boolean().nullish(), - set_option: z.boolean().nullish(), -}); - -const defaultPrivilegeSchema = z.object({ - in_schema: z.string().nullable(), - objtype: z.enum(["r", "S", "f", "T", "n"]), - grantee: z.string(), - privileges: z.array( - z.object({ privilege: z.string(), grantable: z.boolean() }), - ), - is_implicit: z.boolean(), -}); - -const rolePropsSchema = z.object({ - name: z.string(), - is_superuser: z.boolean(), - can_inherit: z.boolean(), - can_create_roles: z.boolean(), - can_create_databases: z.boolean(), - can_login: z.boolean(), - can_replicate: z.boolean(), - connection_limit: z.number().nullable(), - can_bypass_rls: z.boolean(), - config: z.array(z.string()).nullable(), - comment: z.string().nullable(), - members: z.array(membershipInfoSchema), - default_privileges: z.array(defaultPrivilegeSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -export type RoleProps = z.infer; - -export class Role extends BasePgModel { - public readonly name: RoleProps["name"]; - public readonly is_superuser: RoleProps["is_superuser"]; - public readonly can_inherit: RoleProps["can_inherit"]; - public readonly can_create_roles: RoleProps["can_create_roles"]; - public readonly can_create_databases: RoleProps["can_create_databases"]; - public readonly can_login: RoleProps["can_login"]; - public readonly can_replicate: RoleProps["can_replicate"]; - public readonly connection_limit: RoleProps["connection_limit"]; - public readonly can_bypass_rls: RoleProps["can_bypass_rls"]; - public readonly config: RoleProps["config"]; - public readonly comment: RoleProps["comment"]; - public readonly members: RoleProps["members"]; - public readonly default_privileges: RoleProps["default_privileges"]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: RoleProps) { - super(); - - // Identity fields - this.name = props.name; - - // Data fields - this.is_superuser = props.is_superuser; - this.can_inherit = props.can_inherit; - this.can_create_roles = props.can_create_roles; - this.can_create_databases = props.can_create_databases; - this.can_login = props.can_login; - this.can_replicate = props.can_replicate; - this.connection_limit = props.connection_limit; - this.can_bypass_rls = props.can_bypass_rls; - this.config = props.config; - this.comment = props.comment; - this.members = deduplicateMembers(props.members); - this.default_privileges = props.default_privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `role:${string}` { - return `role:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - const sortedMembers = [...this.members].sort((a, b) => { - return ( - a.member.localeCompare(b.member) || - a.grantor.localeCompare(b.grantor) || - Number(a.admin_option) - Number(b.admin_option) || - Number(a.inherit_option ?? false) - Number(b.inherit_option ?? false) || - Number(a.set_option ?? false) - Number(b.set_option ?? false) - ); - }); - - const sortedDefaultPrivs = [...this.default_privileges].map((dp) => { - const { is_implicit: _, ...rest } = dp; - return { - ...rest, - privileges: [...dp.privileges].sort((a, b) => { - return ( - a.privilege.localeCompare(b.privilege) || - Number(a.grantable) - Number(b.grantable) - ); - }), - }; - }); - sortedDefaultPrivs.sort((a, b) => { - return ( - (a.in_schema ?? "").localeCompare(b.in_schema ?? "") || - a.objtype.localeCompare(b.objtype) || - a.grantee.localeCompare(b.grantee) - ); - }); - - return { - is_superuser: this.is_superuser, - can_inherit: this.can_inherit, - can_create_roles: this.can_create_roles, - can_create_databases: this.can_create_databases, - can_login: this.can_login, - can_replicate: this.can_replicate, - connection_limit: this.connection_limit, - can_bypass_rls: this.can_bypass_rls, - config: this.config, - comment: this.comment, - members: sortedMembers, - default_privileges: sortedDefaultPrivs, - security_labels: this.security_labels, - }; - } -} - -/** - * Deduplicate members by member name. - * - * In PostgreSQL 16+, `pg_auth_members` can have multiple rows for the same - * (roleid, member) pair with different grantors. Merge them into a single - * entry per member, combining options with OR so the most permissive wins. - * - * When merging, prefer a non-self grantor (grantor !== member) so that - * downstream code can detect true self-grants (auto-created by CREATE ROLE) - * by checking `grantor === member`. - */ -function deduplicateMembers( - members: RoleProps["members"], -): RoleProps["members"] { - const map = new Map(); - for (const m of members) { - const existing = map.get(m.member); - if (existing) { - // admin_option is always boolean (non-nullable in schema) - existing.admin_option = existing.admin_option || m.admin_option; - // inherit_option and set_option are nullish (only available in PG 16+) - if (m.inherit_option != null) { - existing.inherit_option = - (existing.inherit_option ?? false) || m.inherit_option; - } - if (m.set_option != null) { - existing.set_option = (existing.set_option ?? false) || m.set_option; - } - // Prefer a non-self grantor so diff can detect true self-grants. - // Once a non-self grantor is chosen the value is kept (the specific - // non-self grantor doesn't matter — only the self vs non-self - // distinction is used downstream). - if (existing.grantor === existing.member && m.grantor !== m.member) { - existing.grantor = m.grantor; - } - } else { - map.set(m.member, { ...m }); - } - } - return [...map.values()]; -} - -export async function extractRoles(pool: Pool): Promise { - // Check PostgreSQL version capabilities for membership options - const { rows: capabilitiesRows } = await pool.query<{ - has_inherit: boolean; - has_set: boolean; - }>(sql` - select - exists ( - select 1 - from pg_attribute - where attrelid = 'pg_auth_members'::regclass - and attname = 'inherit_option' - ) as has_inherit, - exists ( - select 1 - from pg_attribute - where attrelid = 'pg_auth_members'::regclass - and attname = 'set_option' - ) as has_set - `); - - const capabilities = capabilitiesRows[0]; - - let roleRows: RoleProps[]; - - if (capabilities?.has_inherit && capabilities?.has_set) { - const result = await pool.query(sql` - WITH role_memberships AS ( - SELECT - r.rolname AS role_name, - json_agg( - json_build_object( - 'member', m.rolname, - 'grantor', g.rolname, - 'admin_option', am.admin_option, - 'inherit_option', am.inherit_option, - 'set_option', am.set_option - ) - ) FILTER (WHERE m.rolname IS NOT NULL) AS members - FROM pg_catalog.pg_roles r - LEFT JOIN pg_auth_members am ON am.roleid = r.oid - LEFT JOIN pg_roles m ON m.oid = am.member - LEFT JOIN pg_roles g ON g.oid = am.grantor - GROUP BY r.rolname - ) - SELECT - quote_ident(r.rolname) AS name, - r.rolsuper AS is_superuser, - r.rolinherit AS can_inherit, - r.rolcreaterole AS can_create_roles, - r.rolcreatedb AS can_create_databases, - r.rolcanlogin AS can_login, - r.rolreplication AS can_replicate, - r.rolconnlimit AS connection_limit, - r.rolbypassrls AS can_bypass_rls, - r.rolconfig AS config, - obj_description(r.oid, 'pg_authid') AS comment, - COALESCE( - ( - SELECT json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - ORDER BY sl.provider - ) - FROM pg_catalog.pg_shseclabel sl - WHERE sl.objoid = r.oid - AND sl.classoid = 'pg_authid'::regclass - ), - '[]'::json - ) AS security_labels, - COALESCE(rm.members, '[]') AS members, - COALESCE( - ( - SELECT json_agg( - json_build_object( - 'in_schema', - CASE WHEN s.defaclnamespace = 0 - THEN NULL - ELSE s.defaclnamespace::regnamespace::text - END, - 'objtype', s.defaclobjtype, - 'grantee', - CASE WHEN s.grantee = 0 - THEN 'PUBLIC' - ELSE s.grantee::regrole::text - END, - 'privileges', s.privileges, - 'is_implicit', s.is_implicit - ) - ORDER BY s.defaclnamespace NULLS FIRST, - s.defaclobjtype, - s.grantee - ) - FROM ( - -- Explicit entries from pg_default_acl - SELECT - d.defaclnamespace, - d.defaclobjtype, - x.grantee, - json_agg( - json_build_object( - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - ORDER BY x.privilege_type, x.is_grantable - ) AS privileges, - false AS is_implicit - FROM pg_default_acl d - CROSS JOIN LATERAL aclexplode(COALESCE(d.defaclacl, ARRAY[]::aclitem[])) - AS x(grantor, grantee, privilege_type, is_grantable) - WHERE d.defaclrole = r.oid - GROUP BY d.defaclnamespace, d.defaclobjtype, x.grantee - UNION ALL - -- Implicit defaults from acldefault() for objtypes without a - -- global pg_default_acl entry. PostgreSQL applies these implicit - -- defaults (e.g. PUBLIC gets EXECUTE on functions) when no - -- explicit ALTER DEFAULT PRIVILEGES has been issued. Including - -- them lets the diff detect REVOKEs of implicit grants. - SELECT - 0 AS defaclnamespace, - v.t::"char" AS defaclobjtype, - x.grantee, - json_agg( - json_build_object( - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - ORDER BY x.privilege_type, x.is_grantable - ) AS privileges, - true AS is_implicit - FROM (VALUES ('r'), ('S'), ('f'), ('T'), ('n')) AS v(t) - CROSS JOIN LATERAL aclexplode(acldefault(v.t::"char", r.oid)) - AS x(grantor, grantee, privilege_type, is_grantable) - WHERE NOT EXISTS ( - SELECT 1 FROM pg_default_acl d2 - WHERE d2.defaclrole = r.oid - AND d2.defaclobjtype = v.t::"char" - AND d2.defaclnamespace = 0 - ) - GROUP BY v.t, x.grantee - ) AS s - ), - '[]' - ) AS default_privileges - FROM pg_catalog.pg_roles r - LEFT JOIN role_memberships rm ON rm.role_name = r.rolname - WHERE - r.rolname !~ '^pg_' - AND NOT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_shdepend d - WHERE d.classid = 'pg_authid'::regclass - AND d.objid = r.oid - AND d.refclassid = 'pg_extension'::regclass - AND d.deptype IN ('e','x') - ) - ORDER BY 1 - `); - roleRows = result.rows; - } else { - const result = await pool.query(sql` - WITH role_memberships AS ( - SELECT - r.rolname AS role_name, - json_agg( - json_build_object( - 'member', m.rolname, - 'grantor', g.rolname, - 'admin_option', am.admin_option, - 'inherit_option', NULL, - 'set_option', NULL - ) - ) FILTER (WHERE m.rolname IS NOT NULL) AS members - FROM pg_catalog.pg_roles r - LEFT JOIN pg_auth_members am ON am.roleid = r.oid - LEFT JOIN pg_roles m ON m.oid = am.member - LEFT JOIN pg_roles g ON g.oid = am.grantor - GROUP BY r.rolname - ) - SELECT - quote_ident(r.rolname) AS name, - r.rolsuper AS is_superuser, - r.rolinherit AS can_inherit, - r.rolcreaterole AS can_create_roles, - r.rolcreatedb AS can_create_databases, - r.rolcanlogin AS can_login, - r.rolreplication AS can_replicate, - r.rolconnlimit AS connection_limit, - r.rolbypassrls AS can_bypass_rls, - r.rolconfig AS config, - obj_description(r.oid, 'pg_authid') AS comment, - COALESCE( - ( - SELECT json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - ORDER BY sl.provider - ) - FROM pg_catalog.pg_shseclabel sl - WHERE sl.objoid = r.oid - AND sl.classoid = 'pg_authid'::regclass - ), - '[]'::json - ) AS security_labels, - COALESCE(rm.members, '[]') AS members, - COALESCE( - ( - SELECT json_agg( - json_build_object( - 'in_schema', - CASE WHEN s.defaclnamespace = 0 - THEN NULL - ELSE s.defaclnamespace::regnamespace::text - END, - 'objtype', s.defaclobjtype, - 'grantee', - CASE WHEN s.grantee = 0 - THEN 'PUBLIC' - ELSE s.grantee::regrole::text - END, - 'privileges', s.privileges, - 'is_implicit', s.is_implicit - ) - ORDER BY s.defaclnamespace NULLS FIRST, - s.defaclobjtype, - s.grantee - ) - FROM ( - -- Explicit entries from pg_default_acl - SELECT - d.defaclnamespace, - d.defaclobjtype, - x.grantee, - json_agg( - json_build_object( - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - ORDER BY x.privilege_type, x.is_grantable - ) AS privileges, - false AS is_implicit - FROM pg_default_acl d - CROSS JOIN LATERAL aclexplode(COALESCE(d.defaclacl, ARRAY[]::aclitem[])) - AS x(grantor, grantee, privilege_type, is_grantable) - WHERE d.defaclrole = r.oid - GROUP BY d.defaclnamespace, d.defaclobjtype, x.grantee - UNION ALL - -- Implicit defaults from acldefault() for objtypes without a - -- global pg_default_acl entry. PostgreSQL applies these implicit - -- defaults (e.g. PUBLIC gets EXECUTE on functions) when no - -- explicit ALTER DEFAULT PRIVILEGES has been issued. Including - -- them lets the diff detect REVOKEs of implicit grants. - SELECT - 0 AS defaclnamespace, - v.t::"char" AS defaclobjtype, - x.grantee, - json_agg( - json_build_object( - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - ORDER BY x.privilege_type, x.is_grantable - ) AS privileges, - true AS is_implicit - FROM (VALUES ('r'), ('S'), ('f'), ('T'), ('n')) AS v(t) - CROSS JOIN LATERAL aclexplode(acldefault(v.t::"char", r.oid)) - AS x(grantor, grantee, privilege_type, is_grantable) - WHERE NOT EXISTS ( - SELECT 1 FROM pg_default_acl d2 - WHERE d2.defaclrole = r.oid - AND d2.defaclobjtype = v.t::"char" - AND d2.defaclnamespace = 0 - ) - GROUP BY v.t, x.grantee - ) AS s - ), - '[]' - ) AS default_privileges - FROM pg_catalog.pg_roles r - LEFT JOIN role_memberships rm ON rm.role_name = r.rolname - WHERE - r.rolname !~ '^pg_' - AND NOT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_shdepend d - WHERE d.classid = 'pg_authid'::regclass - AND d.objid = r.oid - AND d.refclassid = 'pg_extension'::regclass - AND d.deptype IN ('e','x') - ) - ORDER BY 1 - `); - roleRows = result.rows; - } - - // Validate and parse each row using the Zod schema - const validatedRows = roleRows.map((row: unknown) => - rolePropsSchema.parse(row), - ); - return validatedRows.map((row: RoleProps) => new Role(row)); -} diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.alter.test.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.alter.test.ts deleted file mode 100644 index e1548e4cb..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.alter.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Rule } from "../rule.model.ts"; -import { ReplaceRule, SetRuleEnabledState } from "./rule.alter.ts"; - -type RuleProps = ConstructorParameters[0]; - -const base: RuleProps = { - schema: "public", - name: '"my_rule"', - table_name: '"my_table"', - relation_kind: "r", - event: "INSERT", - enabled: "O", - is_instead: true, - owner: "owner1", - definition: - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING', - comment: null, - columns: ["id"], -}; - -const makeRule = (override: Partial = {}) => - new Rule({ - ...base, - ...override, - columns: override.columns ? [...override.columns] : [...base.columns], - }); - -describe("rule.alter", () => { - test("replace rule serializes using create or replace and tracks dependencies", async () => { - const rule = makeRule({ columns: ["id", "amount"] }); - const change = new ReplaceRule({ rule }); - - expect(change.requires).toEqual([ - rule.stableId, - rule.relationStableId, - ...rule.columns.map((column) => - stableId.column(rule.schema, rule.table_name, column), - ), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'CREATE OR REPLACE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING', - ); - }); - - test("set rule enabled state serializes appropriate clause", async () => { - const rule = makeRule({ columns: ["id", "amount"] }); - const change = new SetRuleEnabledState({ rule, enabled: "D" }); - - expect(change.requires).toEqual([ - rule.stableId, - rule.relationStableId, - ...rule.columns.map((column) => - stableId.column(rule.schema, rule.table_name, column), - ), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'ALTER TABLE public."my_table" DISABLE RULE "my_rule"', - ); - }); - - test("set rule enabled state defaults to rule value and supports views", async () => { - const rule = makeRule({ - table_name: '"my_view"', - relation_kind: "v", - enabled: "R", - columns: [], - }); - - const change = new SetRuleEnabledState({ rule }); - - expect(change.requires).toEqual([rule.stableId, rule.relationStableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'ALTER TABLE public."my_view" ENABLE REPLICA RULE "my_rule"', - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.alter.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.alter.ts deleted file mode 100644 index 15f587687..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.alter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Rule, RuleEnabledState } from "../rule.model.ts"; -import { AlterRuleChange } from "./rule.base.ts"; -import { CreateRule } from "./rule.create.ts"; - -export class ReplaceRule extends AlterRuleChange { - public readonly rule: Rule; - public readonly scope = "object" as const; - - constructor(props: { rule: Rule }) { - super(); - this.rule = props.rule; - } - - get requires() { - return [ - this.rule.stableId, - this.rule.relationStableId, - ...this.rule.columns.map((column) => - stableId.column(this.rule.schema, this.rule.table_name, column), - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - return new CreateRule({ rule: this.rule, orReplace: true }).serialize(); - } -} - -export class SetRuleEnabledState extends AlterRuleChange { - public readonly rule: Rule; - public readonly scope = "object" as const; - public readonly enabled: RuleEnabledState; - - constructor(props: { rule: Rule; enabled?: RuleEnabledState }) { - super(); - this.rule = props.rule; - this.enabled = props.enabled ?? props.rule.enabled; - } - - get requires() { - return [ - this.rule.stableId, - this.rule.relationStableId, - ...this.rule.columns.map((column) => - stableId.column(this.rule.schema, this.rule.table_name, column), - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - const clause = clauseForState(this.enabled); - return `ALTER TABLE ${this.rule.schema}.${this.rule.table_name} ${clause} ${this.rule.name}`; - } -} - -function clauseForState(state: RuleEnabledState) { - switch (state) { - case "O": - return "ENABLE RULE"; - case "D": - return "DISABLE RULE"; - case "R": - return "ENABLE REPLICA RULE"; - case "A": - return "ENABLE ALWAYS RULE"; - default: { - const _exhaustive: never = state; - return _exhaustive; - } - } -} diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.base.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.base.ts deleted file mode 100644 index 9df53da96..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Rule } from "../rule.model.ts"; - -abstract class BaseRuleChange extends BaseChange { - abstract readonly rule: Rule; - abstract readonly scope: "object" | "comment"; - readonly objectType = "rule" as const; -} - -export abstract class CreateRuleChange extends BaseRuleChange { - readonly operation = "create" as const; -} - -export abstract class AlterRuleChange extends BaseRuleChange { - readonly operation = "alter" as const; -} - -export abstract class DropRuleChange extends BaseRuleChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.comment.test.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.comment.test.ts deleted file mode 100644 index fb84188f2..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.comment.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Rule } from "../rule.model.ts"; -import { CreateCommentOnRule, DropCommentOnRule } from "./rule.comment.ts"; - -type RuleProps = ConstructorParameters[0]; - -const base: RuleProps = { - schema: "public", - name: '"my_rule"', - table_name: '"my_table"', - relation_kind: "r", - event: "INSERT", - enabled: "O", - is_instead: true, - owner: "owner1", - definition: - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING', - comment: null, - columns: ["id"], -}; - -const makeRule = (override: Partial = {}) => - new Rule({ - ...base, - ...override, - columns: override.columns ? [...override.columns] : [...base.columns], - }); - -describe("rule.comment", () => { - test("create comment serializes and tracks dependencies", async () => { - const rule = makeRule({ comment: "rule's description" }); - const change = new CreateCommentOnRule({ rule }); - - expect(change.creates).toEqual([stableId.comment(rule.stableId)]); - expect(change.requires).toEqual([rule.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "COMMENT ON RULE \"my_rule\" ON public.\"my_table\" IS 'rule''s description'", - ); - }); - - test("drop comment serializes and tracks dependencies", async () => { - const rule = makeRule({ comment: "temporary comment" }); - const change = new DropCommentOnRule({ rule }); - - expect(change.drops).toEqual([stableId.comment(rule.stableId)]); - expect(change.requires).toEqual([ - stableId.comment(rule.stableId), - rule.stableId, - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'COMMENT ON RULE "my_rule" ON public."my_table" IS NULL', - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.comment.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.comment.ts deleted file mode 100644 index 3ee39ee28..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.comment.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Rule } from "../rule.model.ts"; -import { CreateRuleChange, DropRuleChange } from "./rule.base.ts"; - -export class CreateCommentOnRule extends CreateRuleChange { - public readonly rule: Rule; - public readonly scope = "comment" as const; - - constructor(props: { rule: Rule }) { - super(); - this.rule = props.rule; - } - - get creates() { - return [stableId.comment(this.rule.stableId)]; - } - - get requires() { - return [this.rule.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON RULE", - this.rule.name, - "ON", - `${this.rule.schema}.${this.rule.table_name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: rule comment is not nullable in this case - quoteLiteral(this.rule.comment!), - ].join(" "); - } -} - -export class DropCommentOnRule extends DropRuleChange { - public readonly rule: Rule; - public readonly scope = "comment" as const; - - constructor(props: { rule: Rule }) { - super(); - this.rule = props.rule; - } - - get drops() { - return [stableId.comment(this.rule.stableId)]; - } - - get requires() { - return [stableId.comment(this.rule.stableId), this.rule.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON RULE", - this.rule.name, - "ON", - `${this.rule.schema}.${this.rule.table_name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.create.test.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.create.test.ts deleted file mode 100644 index d0762d6ab..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.create.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Rule } from "../rule.model.ts"; -import { CreateRule } from "./rule.create.ts"; - -type RuleProps = ConstructorParameters[0]; - -const base: RuleProps = { - schema: "public", - name: '"my_rule"', - table_name: '"my_table"', - relation_kind: "r", - event: "INSERT", - enabled: "O", - is_instead: true, - owner: "owner1", - definition: - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING', - comment: null, - columns: ["id"], -}; - -const makeRule = (override: Partial = {}) => - new Rule({ - ...base, - ...override, - columns: override.columns ? [...override.columns] : [...base.columns], - }); - -describe("rule.create", () => { - test("serialize rule definition and track dependencies", async () => { - const rule = makeRule(); - const change = new CreateRule({ rule }); - - expect(change.creates).toEqual([rule.stableId]); - expect(change.requires).toEqual([ - rule.relationStableId, - ...rule.columns.map((column) => - stableId.column(rule.schema, rule.table_name, column), - ), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING', - ); - }); - - test("serialize rule definition with or replace override", async () => { - const rule = makeRule({ - definition: - ' CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING ', - }); - - const change = new CreateRule({ rule, orReplace: true }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - 'CREATE OR REPLACE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING', - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.create.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.create.ts deleted file mode 100644 index f7f1d5983..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.create.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Rule } from "../rule.model.ts"; -import { CreateRuleChange } from "./rule.base.ts"; - -export class CreateRule extends CreateRuleChange { - public readonly rule: Rule; - public readonly scope = "object" as const; - public readonly orReplace?: boolean; - - constructor(props: { rule: Rule; orReplace?: boolean }) { - super(); - this.rule = props.rule; - this.orReplace = props.orReplace; - } - - get creates() { - return [this.rule.stableId]; - } - - get requires() { - return [ - this.rule.relationStableId, - ...this.rule.columns.map((column) => - stableId.column(this.rule.schema, this.rule.table_name, column), - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - let definition = this.rule.definition.trim(); - - definition = definition.replace( - /^CREATE\s+(?:OR\s+REPLACE\s+)?/i, - `CREATE ${this.orReplace ? "OR REPLACE " : ""}`, - ); - - // Remove trailing semicolons (pg_get_ruledef includes them, but we add our own) - definition = definition.replace(/;+\s*$/, ""); - - return definition; - } -} diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.drop.test.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.drop.test.ts deleted file mode 100644 index 6723a4f26..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.drop.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Rule } from "../rule.model.ts"; -import { DropRule } from "./rule.drop.ts"; - -type RuleProps = ConstructorParameters[0]; - -const base: RuleProps = { - schema: "public", - name: '"my_rule"', - table_name: '"my_table"', - relation_kind: "r", - event: "INSERT", - enabled: "O", - is_instead: true, - owner: "owner1", - definition: - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING', - comment: null, - columns: ["id"], -}; - -const makeRule = (override: Partial = {}) => - new Rule({ - ...base, - ...override, - columns: override.columns ? [...override.columns] : [...base.columns], - }); - -describe("rule.drop", () => { - test("serialize rule drop and track dependencies", async () => { - const rule = makeRule(); - const change = new DropRule({ rule }); - - expect(change.drops).toEqual([rule.stableId]); - expect(change.requires).toEqual([rule.stableId, rule.relationStableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe('DROP RULE "my_rule" ON public."my_table"'); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.drop.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.drop.ts deleted file mode 100644 index 9033b789c..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.drop.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Rule } from "../rule.model.ts"; -import { DropRuleChange } from "./rule.base.ts"; - -export class DropRule extends DropRuleChange { - public readonly rule: Rule; - public readonly scope = "object" as const; - - constructor(props: { rule: Rule }) { - super(); - this.rule = props.rule; - } - - get drops() { - return [this.rule.stableId]; - } - - get requires() { - return [this.rule.stableId, this.rule.relationStableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "DROP RULE", - this.rule.name, - "ON", - `${this.rule.schema}.${this.rule.table_name}`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/rule/changes/rule.types.ts b/packages/pg-delta/src/core/objects/rule/changes/rule.types.ts deleted file mode 100644 index 7c63e6715..000000000 --- a/packages/pg-delta/src/core/objects/rule/changes/rule.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ReplaceRule, SetRuleEnabledState } from "./rule.alter.ts"; -import type { CreateCommentOnRule, DropCommentOnRule } from "./rule.comment.ts"; -import type { CreateRule } from "./rule.create.ts"; -import type { DropRule } from "./rule.drop.ts"; - -/** Union of all rule-related change variants (`objectType: "rule"`). @category Change Types */ -export type RuleChange = - | CreateRule - | DropRule - | ReplaceRule - | SetRuleEnabledState - | CreateCommentOnRule - | DropCommentOnRule; diff --git a/packages/pg-delta/src/core/objects/rule/rule.diff.test.ts b/packages/pg-delta/src/core/objects/rule/rule.diff.test.ts deleted file mode 100644 index 8d17fa33a..000000000 --- a/packages/pg-delta/src/core/objects/rule/rule.diff.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { ReplaceRule, SetRuleEnabledState } from "./changes/rule.alter.ts"; -import { - CreateCommentOnRule, - DropCommentOnRule, -} from "./changes/rule.comment.ts"; -import { CreateRule } from "./changes/rule.create.ts"; -import { DropRule } from "./changes/rule.drop.ts"; -import { diffRules } from "./rule.diff.ts"; -import { Rule, type RuleProps } from "./rule.model.ts"; - -const baseRule: RuleProps = { - schema: "public", - name: '"my_rule"', - table_name: '"my_table"', - relation_kind: "r", - event: "INSERT", - enabled: "O", - is_instead: true, - definition: - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO INSTEAD NOTHING', - comment: null, - columns: ["id"], - owner: "o1", -}; - -describe.concurrent("rule.diff", () => { - test("create rule", () => { - const rule = new Rule(baseRule); - const changes = diffRules({}, { [rule.stableId]: rule }); - expect(changes[0]).toBeInstanceOf(CreateRule); - }); - - test("drop rule", () => { - const rule = new Rule(baseRule); - const changes = diffRules({ [rule.stableId]: rule }, {}); - expect(changes[0]).toBeInstanceOf(DropRule); - }); - - test("replace when definition changes", () => { - const main = new Rule(baseRule); - const branch = new Rule({ - ...baseRule, - definition: - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO ALSO NOTHING', - is_instead: false, - columns: ["id", "amount"], - }); - const changes = diffRules( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((change) => change instanceof ReplaceRule)).toBe(true); - }); - - test("handle comment changes", () => { - const main = new Rule({ ...baseRule, comment: "old comment" }); - const branch = new Rule({ ...baseRule, comment: "new comment" }); - const changes = diffRules( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((change) => change instanceof CreateCommentOnRule), - ).toBe(true); - expect(changes.some((change) => change instanceof DropCommentOnRule)).toBe( - false, - ); - }); - - test("handle comment removal", () => { - const main = new Rule({ ...baseRule, comment: "old comment" }); - const branch = new Rule(baseRule); - const changes = diffRules( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(DropCommentOnRule); - }); - - test("handle enabled state changes", () => { - const main = new Rule(baseRule); - const branch = new Rule({ ...baseRule, enabled: "D" }); - const changes = diffRules( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((change) => change instanceof SetRuleEnabledState), - ).toBe(true); - }); - - test("reapply comment when replacing rule", () => { - const main = new Rule({ ...baseRule, comment: "my comment" }); - const branch = new Rule({ - ...baseRule, - definition: - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO ALSO NOTHING', - is_instead: false, - comment: "my comment", - columns: ["id", "balance"], - }); - const changes = diffRules( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((change) => change instanceof ReplaceRule)).toBe(true); - expect( - changes.some((change) => change instanceof CreateCommentOnRule), - ).toBe(true); - }); - - test("reapply enabled state when replacing rule", () => { - const main = new Rule({ ...baseRule, enabled: "D" }); - const branch = new Rule({ - ...baseRule, - definition: - 'CREATE RULE "my_rule" AS ON INSERT TO public."my_table" DO ALSO NOTHING', - is_instead: false, - enabled: "D", - columns: ["id", "balance"], - }); - const changes = diffRules( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((change) => change instanceof ReplaceRule)).toBe(true); - expect( - changes.some((change) => change instanceof SetRuleEnabledState), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rule/rule.diff.ts b/packages/pg-delta/src/core/objects/rule/rule.diff.ts deleted file mode 100644 index 674b17879..000000000 --- a/packages/pg-delta/src/core/objects/rule/rule.diff.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { deepEqual, hasNonAlterableChanges } from "../utils.ts"; -import { ReplaceRule, SetRuleEnabledState } from "./changes/rule.alter.ts"; -import { - CreateCommentOnRule, - DropCommentOnRule, -} from "./changes/rule.comment.ts"; -import { CreateRule } from "./changes/rule.create.ts"; -import { DropRule } from "./changes/rule.drop.ts"; -import type { RuleChange } from "./changes/rule.types.ts"; -import type { Rule } from "./rule.model.ts"; - -export function diffRules( - main: Record, - branch: Record, -): RuleChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - const changes: RuleChange[] = []; - - for (const id of created) { - const rule = branch[id]; - changes.push(new CreateRule({ rule })); - - if (rule.comment !== null) { - changes.push(new CreateCommentOnRule({ rule })); - } - - if (rule.enabled !== "O") { - changes.push(new SetRuleEnabledState({ rule })); - } - } - - for (const id of dropped) { - changes.push(new DropRule({ rule: main[id] })); - } - - for (const id of altered) { - const mainRule = main[id]; - const branchRule = branch[id]; - - const NON_ALTERABLE_FIELDS: Array = [ - "definition", - "event", - "is_instead", - ]; - - const shouldReplace = hasNonAlterableChanges( - mainRule, - branchRule, - NON_ALTERABLE_FIELDS, - { columns: deepEqual }, - ); - - const replaced = shouldReplace; - - if (shouldReplace) { - changes.push(new ReplaceRule({ rule: branchRule })); - } - - if (mainRule.comment !== branchRule.comment) { - if (branchRule.comment === null) { - changes.push(new DropCommentOnRule({ rule: mainRule })); - } else { - changes.push(new CreateCommentOnRule({ rule: branchRule })); - } - } else if (replaced && branchRule.comment !== null) { - changes.push(new CreateCommentOnRule({ rule: branchRule })); - } - - if ( - mainRule.enabled !== branchRule.enabled || - (replaced && branchRule.enabled !== "O") - ) { - changes.push(new SetRuleEnabledState({ rule: branchRule })); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/rule/rule.model.test.ts b/packages/pg-delta/src/core/objects/rule/rule.model.test.ts deleted file mode 100644 index 906276552..000000000 --- a/packages/pg-delta/src/core/objects/rule/rule.model.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Pool } from "pg"; -import { extractRules, Rule } from "./rule.model.ts"; - -const baseRow = { - schema: "public", - table_name: '"events"', - relation_kind: "r" as const, - event: "INSERT" as const, - enabled: "O" as const, - is_instead: false, - owner: "postgres", - comment: null, - columns: [] as string[], -}; - -const mockPool = (rows: unknown[]): Pool => - ({ query: async () => ({ rows }) }) as unknown as Pool; - -const mockPoolSequence = (...attempts: unknown[][]): Pool => { - let i = 0; - return { - query: async () => ({ - rows: attempts[Math.min(i++, attempts.length - 1)], - }), - } as unknown as Pool; -}; - -const NO_BACKOFF = { backoffMs: 0 } as const; - -describe("extractRules", () => { - test("skips rows where pg_get_ruledef returned NULL after exhausting retries", async () => { - const rules = await extractRules( - mockPool([ - { - ...baseRow, - name: '"good_rule"', - definition: - "CREATE RULE good_rule AS ON INSERT TO events DO INSTEAD NOTHING;", - }, - { ...baseRow, name: '"orphan_rule"', definition: null }, - ]), - NO_BACKOFF, - ); - - expect(rules).toHaveLength(1); - expect(rules[0]).toBeInstanceOf(Rule); - expect(rules[0]?.name).toBe('"good_rule"'); - }); - - test("does not throw ZodError when the only row has a null definition", async () => { - await expect( - extractRules( - mockPool([{ ...baseRow, name: '"orphan"', definition: null }]), - NO_BACKOFF, - ), - ).resolves.toEqual([]); - }); - - test("returns all rules when every row has a valid definition", async () => { - const rules = await extractRules( - mockPool([ - { - ...baseRow, - name: '"a"', - definition: - "CREATE RULE a AS ON INSERT TO events DO INSTEAD NOTHING;", - }, - { - ...baseRow, - name: '"b"', - definition: - "CREATE RULE b AS ON UPDATE TO events DO INSTEAD NOTHING;", - }, - ]), - NO_BACKOFF, - ); - expect(rules.map((r) => r.name)).toEqual(['"a"', '"b"']); - }); - - test("recovers when pg_get_ruledef is NULL on first attempt but resolved on retry", async () => { - const rules = await extractRules( - mockPoolSequence( - [{ ...baseRow, name: '"racy_rule"', definition: null }], - [ - { - ...baseRow, - name: '"racy_rule"', - definition: - "CREATE RULE racy_rule AS ON INSERT TO events DO INSTEAD NOTHING;", - }, - ], - ), - { retries: 2, backoffMs: 0 }, - ); - expect(rules).toHaveLength(1); - expect(rules[0]?.name).toBe('"racy_rule"'); - }); -}); diff --git a/packages/pg-delta/src/core/objects/rule/rule.model.ts b/packages/pg-delta/src/core/objects/rule/rule.model.ts deleted file mode 100644 index 79cf803cf..000000000 --- a/packages/pg-delta/src/core/objects/rule/rule.model.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type ExtractRetryOptions, - extractWithDefinitionRetry, -} from "../extract-with-retry.ts"; -import { stableId } from "../utils.ts"; - -const RuleEventSchema = z.enum(["SELECT", "INSERT", "UPDATE", "DELETE"]); -const RuleEnabledStateSchema = z.enum(["O", "D", "R", "A"]); - -const RuleRelationKindSchema = z.enum([ - "r", // ordinary table - "p", // partitioned table - "f", // foreign table - "v", // view - "m", // materialized view -]); - -const rulePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - table_name: z.string(), - relation_kind: RuleRelationKindSchema, - event: RuleEventSchema, - enabled: RuleEnabledStateSchema, - is_instead: z.boolean(), - owner: z.string(), - definition: z.string(), - comment: z.string().nullable(), - columns: z.array(z.string()), -}); - -// pg_get_ruledef(oid, pretty) can return NULL when the rule (its pg_rewrite -// row) is dropped between catalog scan and resolution, or under transient -// catalog state. An unreadable rule cannot be diffed, so we accept NULL here -// and filter the row out at extraction time rather than crashing the whole -// catalog parse with a ZodError. -const ruleRowSchema = rulePropsSchema.extend({ - definition: z.string().nullable(), -}); - -export type RuleEnabledState = z.infer; -export type RuleProps = z.infer; - -export class Rule extends BasePgModel { - public readonly schema: RuleProps["schema"]; - public readonly name: RuleProps["name"]; - public readonly table_name: RuleProps["table_name"]; - public readonly relation_kind: RuleProps["relation_kind"]; - public readonly event: RuleProps["event"]; - public readonly enabled: RuleProps["enabled"]; - public readonly is_instead: RuleProps["is_instead"]; - public readonly owner: RuleProps["owner"]; - public readonly definition: RuleProps["definition"]; - public readonly comment: RuleProps["comment"]; - public readonly columns: RuleProps["columns"]; - - constructor(props: RuleProps) { - super(); - - this.schema = props.schema; - this.name = props.name; - this.table_name = props.table_name; - this.relation_kind = props.relation_kind; - this.event = props.event; - this.enabled = props.enabled; - this.is_instead = props.is_instead; - this.owner = props.owner; - this.definition = props.definition; - this.comment = props.comment; - this.columns = props.columns; - } - - get stableId(): `rule:${string}` { - return `rule:${this.schema}.${this.table_name}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - table_name: this.table_name, - }; - } - - get dataFields() { - return { - event: this.event, - enabled: this.enabled, - is_instead: this.is_instead, - owner: this.owner, - definition: this.definition, - comment: this.comment, - columns: this.columns, - }; - } - - get relationStableId(): string { - switch (this.relation_kind) { - case "v": - return stableId.view(this.schema, this.table_name); - case "m": - return stableId.materializedView(this.schema, this.table_name); - default: - return stableId.table(this.schema, this.table_name); - } - } -} - -export async function extractRules( - pool: Pool, - options?: ExtractRetryOptions, -): Promise { - const ruleRows = await extractWithDefinitionRetry({ - label: "rules", - options, - hasNullDefinition: (row) => row.definition === null, - query: async () => { - const result = await pool.query(sql` - WITH extension_rule_oids AS ( - SELECT - objid - FROM - pg_depend d - WHERE - d.refclassid = 'pg_extension'::regclass - AND d.classid = 'pg_rewrite'::regclass - ), - extension_relation_oids AS ( - SELECT - objid - FROM - pg_depend d - WHERE - d.refclassid = 'pg_extension'::regclass - AND d.classid = 'pg_class'::regclass - AND d.deptype = 'e' - ) - SELECT - c.relnamespace::regnamespace::text AS schema, - quote_ident(r.rulename) AS name, - quote_ident(c.relname) AS table_name, - c.relkind AS relation_kind, - CASE r.ev_type - WHEN '1' THEN 'SELECT' - WHEN '2' THEN 'UPDATE' - WHEN '3' THEN 'INSERT' - WHEN '4' THEN 'DELETE' - ELSE NULL - END AS event, - r.ev_enabled AS enabled, - r.is_instead, - c.relowner::regrole::text AS owner, - pg_get_ruledef(r.oid, true) AS definition, - obj_description(r.oid, 'pg_rewrite') AS comment, - COALESCE( - ( - SELECT json_agg(quote_ident(att.attname) ORDER BY dep.refobjsubid) - FROM pg_depend dep - JOIN pg_attribute att - ON att.attrelid = dep.refobjid - AND att.attnum = dep.refobjsubid - AND att.attnum > 0 - AND NOT att.attisdropped - WHERE dep.classid = 'pg_rewrite'::regclass - AND dep.objid = r.oid - AND dep.refclassid = 'pg_class'::regclass - AND dep.refobjid = c.oid - AND dep.refobjsubid > 0 - ), '[]' - ) AS columns - FROM - pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - LEFT JOIN extension_rule_oids e_rule ON r.oid = e_rule.objid - LEFT JOIN extension_relation_oids e_rel ON c.oid = e_rel.objid - WHERE - NOT c.relnamespace::regnamespace::text LIKE ANY (ARRAY['pg\\_%', 'information\\_schema']) - AND e_rule.objid IS NULL - AND e_rel.objid IS NULL - AND r.rulename <> '_RETURN' - ORDER BY - 1, 3, 2 - `); - return result.rows.map((row: unknown) => ruleRowSchema.parse(row)); - }, - }); - - const validatedRows = ruleRows.filter( - (row): row is RuleProps => row.definition !== null, - ); - - return validatedRows.map((row) => new Rule(row)); -} diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.alter.test.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.alter.test.ts deleted file mode 100644 index 4d532aac8..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.alter.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Schema, type SchemaProps } from "../schema.model.ts"; -import { AlterSchemaChangeOwner } from "./schema.alter.ts"; - -describe.concurrent("schema", () => { - describe("alter", () => { - test("change owner", async () => { - const props: Omit = { - name: "test_schema", - comment: null, - privileges: [], - security_labels: [], - }; - const schemaObj = new Schema({ - ...props, - owner: "old_owner", - }); - - const change = new AlterSchemaChangeOwner({ - schema: schemaObj, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SCHEMA test_schema OWNER TO new_owner", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.alter.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.alter.ts deleted file mode 100644 index d9a0c0ed7..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.alter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Schema } from "../schema.model.ts"; -import { AlterSchemaChange } from "./schema.base.ts"; - -/** - * Alter a schema. - * - * @see https://www.postgresql.org/docs/17/sql-alterschema.html - * - * Synopsis - * ```sql - * ALTER SCHEMA name RENAME TO new_name - * ALTER SCHEMA name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ``` - */ - -export type AlterSchema = AlterSchemaChangeOwner; - -/** - * ALTER SCHEMA ... OWNER TO ... - */ -export class AlterSchemaChangeOwner extends AlterSchemaChange { - public readonly schema: Schema; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { schema: Schema; owner: string }) { - super(); - this.schema = props.schema; - this.owner = props.owner; - } - - get requires() { - return [this.schema.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["ALTER SCHEMA", this.schema.name, "OWNER TO", this.owner].join(" "); - } -} - -/** - * Replace a schema by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER SCHEMA change. - */ -// NOTE: ReplaceSchema removed. Non-alterable changes would be emitted via Drop + Create in schema.diff.ts if needed. diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.base.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.base.ts deleted file mode 100644 index e7661128d..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Schema } from "../schema.model.ts"; - -abstract class BaseSchemaChange extends BaseChange { - abstract readonly schema: Schema; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "schema" as const; -} - -export abstract class CreateSchemaChange extends BaseSchemaChange { - readonly operation = "create" as const; -} - -export abstract class AlterSchemaChange extends BaseSchemaChange { - readonly operation = "alter" as const; -} - -export abstract class DropSchemaChange extends BaseSchemaChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.comment.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.comment.ts deleted file mode 100644 index 555c6e957..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.comment.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Schema } from "../schema.model.ts"; -import { CreateSchemaChange, DropSchemaChange } from "./schema.base.ts"; - -export type CommentSchema = CreateCommentOnSchema | DropCommentOnSchema; - -export class CreateCommentOnSchema extends CreateSchemaChange { - public readonly schema: Schema; - public readonly scope = "comment" as const; - - constructor(props: { schema: Schema }) { - super(); - this.schema = props.schema; - } - - get creates() { - return [stableId.comment(this.schema.stableId)]; - } - - get requires() { - return [this.schema.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON SCHEMA", - this.schema.name, - "IS", - // biome-ignore lint/style/noNonNullAssertion: schema comment is not nullable in this case - quoteLiteral(this.schema.comment!), - ].join(" "); - } -} - -export class DropCommentOnSchema extends DropSchemaChange { - public readonly schema: Schema; - public readonly scope = "comment" as const; - - constructor(props: { schema: Schema }) { - super(); - this.schema = props.schema; - } - - get drops() { - return [stableId.comment(this.schema.stableId)]; - } - - get requires() { - return [stableId.comment(this.schema.stableId), this.schema.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["COMMENT ON SCHEMA", this.schema.name, "IS NULL"].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.create.test.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.create.test.ts deleted file mode 100644 index 30ac92338..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.create.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Schema } from "../schema.model.ts"; -import { CreateSchema } from "./schema.create.ts"; - -describe("schema", () => { - test("create", async () => { - const schema = new Schema({ - name: "test_schema", - owner: "test", - comment: null, - privileges: [], - security_labels: [], - }); - - const change = new CreateSchema({ - schema, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE SCHEMA test_schema AUTHORIZATION test", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.create.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.create.ts deleted file mode 100644 index b7c6c4ec8..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.create.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { SchemaSerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Schema } from "../schema.model.ts"; -import { CreateSchemaChange } from "./schema.base.ts"; -/** - * Create a schema. - * - * @see https://www.postgresql.org/docs/17/sql-createschema.html - * - * Synopsis - * ```sql - * CREATE SCHEMA [ IF NOT EXISTS ] schema_name [ AUTHORIZATION role_specification ] [ schema_element [ ... ] ] - * CREATE SCHEMA [ IF NOT EXISTS ] AUTHORIZATION role_specification [ schema_element [ ... ] ] - * CREATE SCHEMA [ IF NOT EXISTS ] schema_name AUTHORIZATION role_specification [ schema_element [ ... ] ] - * ``` - */ -export class CreateSchema extends CreateSchemaChange { - public readonly schema: Schema; - public readonly scope = "object" as const; - - constructor(props: { schema: Schema } & SchemaSerializeOptions) { - super(); - this.schema = props.schema; - } - - get creates() { - return [this.schema.stableId]; - } - - get requires() { - return [stableId.role(this.schema.owner)]; - } - - serialize(options?: SchemaSerializeOptions): string { - const parts: string[] = ["CREATE SCHEMA"]; - - // Add schema name - parts.push(this.schema.name); - - // Add AUTHORIZATION - if (!options?.skipAuthorization) { - parts.push("AUTHORIZATION", this.schema.owner); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.drop.test.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.drop.test.ts deleted file mode 100644 index 02ec66325..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.drop.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Schema } from "../schema.model.ts"; -import { DropSchema } from "./schema.drop.ts"; - -describe("schema", () => { - test("drop", async () => { - const schema = new Schema({ - name: "test_schema", - owner: "test", - comment: null, - privileges: [], - security_labels: [], - }); - - const change = new DropSchema({ - schema, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP SCHEMA test_schema"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.drop.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.drop.ts deleted file mode 100644 index 2c0456a84..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Schema } from "../schema.model.ts"; -import { DropSchemaChange } from "./schema.base.ts"; - -/** - * Drop a schema. - * - * @see https://www.postgresql.org/docs/17/sql-dropschema.html - * - * Synopsis - * ```sql - * DROP SCHEMA [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropSchema extends DropSchemaChange { - public readonly schema: Schema; - public readonly scope = "object" as const; - - constructor(props: { schema: Schema }) { - super(); - this.schema = props.schema; - } - - get drops() { - return [this.schema.stableId]; - } - - get requires() { - return [this.schema.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP SCHEMA", this.schema.name].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.privilege.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.privilege.ts deleted file mode 100644 index bf8033922..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.privilege.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { Schema } from "../schema.model.ts"; -import { AlterSchemaChange } from "./schema.base.ts"; - -export type SchemaPrivilege = - | GrantSchemaPrivileges - | RevokeSchemaPrivileges - | RevokeGrantOptionSchemaPrivileges; - -/** - * Grant privileges on a schema. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { { CREATE | USAGE } [, ...] | ALL [ PRIVILEGES ] } - * ON SCHEMA schema_name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantSchemaPrivileges extends AlterSchemaChange { - public readonly schema: Schema; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - schema: Schema; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.schema = props.schema; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.schema.stableId, this.grantee)]; - } - - get requires() { - return [this.schema.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantSchemaPrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("SCHEMA"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("SCHEMA", list, this.version); - const schemaName = this.schema.name; - return `GRANT ${privSql} ${kindPrefix} ${schemaName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a schema. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { { CREATE | USAGE } [, ...] | ALL [ PRIVILEGES ] } - * ON SCHEMA schema_name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeSchemaPrivileges extends AlterSchemaChange { - public readonly schema: Schema; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - schema: Schema; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.schema = props.schema; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.schema.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.schema.stableId, this.grantee), - this.schema.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("SCHEMA"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("SCHEMA", list, this.version); - const schemaName = this.schema.name; - return `REVOKE ${privSql} ${kindPrefix} ${schemaName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a schema. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionSchemaPrivileges extends AlterSchemaChange { - public readonly schema: Schema; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - schema: Schema; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.schema = props.schema; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.schema.stableId, this.grantee), - this.schema.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("SCHEMA"); - const privSql = formatObjectPrivilegeList( - "SCHEMA", - this.privilegeNames, - this.version, - ); - const schemaName = this.schema.name; - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${schemaName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.security-label.test.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.security-label.test.ts deleted file mode 100644 index 885deb555..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.security-label.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Schema } from "../schema.model.ts"; -import { - CreateSecurityLabelOnSchema, - DropSecurityLabelOnSchema, -} from "./schema.security-label.ts"; - -const makeSchema = () => - new Schema({ - name: "app", - owner: "postgres", - comment: null, - privileges: [], - security_labels: [], - }); - -describe("schema.security-label", () => { - test("create serializes and tracks dependencies", async () => { - const schema = makeSchema(); - const change = new CreateSecurityLabelOnSchema({ - schema, - securityLabel: { - provider: "pg_graphql", - label: '{"inflect_names":true}', - }, - }); - - expect(change.scope).toBe("security_label"); - expect(change.operation).toBe("create"); - expect(change.objectType).toBe("schema"); - expect(change.creates).toEqual([ - stableId.securityLabel(schema.stableId, "pg_graphql"), - ]); - expect(change.requires).toEqual([schema.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - `SECURITY LABEL FOR pg_graphql ON SCHEMA app IS '{"inflect_names":true}'`, - ); - }); - - test("drop serializes to IS NULL and tracks dependencies", async () => { - const schema = makeSchema(); - const change = new DropSecurityLabelOnSchema({ - schema, - securityLabel: { provider: "pg_graphql", label: "old" }, - }); - - expect(change.scope).toBe("security_label"); - expect(change.operation).toBe("drop"); - expect(change.drops).toEqual([ - stableId.securityLabel(schema.stableId, "pg_graphql"), - ]); - expect(change.requires).toEqual([ - stableId.securityLabel(schema.stableId, "pg_graphql"), - schema.stableId, - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR pg_graphql ON SCHEMA app IS NULL", - ); - }); - - test("create escapes single quotes in label", async () => { - const schema = makeSchema(); - const change = new CreateSecurityLabelOnSchema({ - schema, - securityLabel: { provider: "p", label: "it's a test" }, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR p ON SCHEMA app IS 'it''s a test'", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.security-label.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.security-label.ts deleted file mode 100644 index 36c2489b7..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.security-label.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Schema } from "../schema.model.ts"; -import { CreateSchemaChange, DropSchemaChange } from "./schema.base.ts"; - -export type SecurityLabelSchema = - | CreateSecurityLabelOnSchema - | DropSecurityLabelOnSchema; - -export class CreateSecurityLabelOnSchema extends CreateSchemaChange { - public readonly schema: Schema; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { schema: Schema; securityLabel: SecurityLabelProps }) { - super(); - this.schema = props.schema; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel(this.schema.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [this.schema.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON SCHEMA", - this.schema.name, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnSchema extends DropSchemaChange { - public readonly schema: Schema; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { schema: Schema; securityLabel: SecurityLabelProps }) { - super(); - this.schema = props.schema; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel(this.schema.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [ - stableId.securityLabel(this.schema.stableId, this.securityLabel.provider), - this.schema.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON SCHEMA", - this.schema.name, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/schema/changes/schema.types.ts b/packages/pg-delta/src/core/objects/schema/changes/schema.types.ts deleted file mode 100644 index 727a31708..000000000 --- a/packages/pg-delta/src/core/objects/schema/changes/schema.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterSchema } from "./schema.alter.ts"; -import type { CommentSchema } from "./schema.comment.ts"; -import type { CreateSchema } from "./schema.create.ts"; -import type { DropSchema } from "./schema.drop.ts"; -import type { SchemaPrivilege } from "./schema.privilege.ts"; -import type { SecurityLabelSchema } from "./schema.security-label.ts"; - -/** Union of all schema-related change variants (`objectType: "schema"`). @category Change Types */ -export type SchemaChange = - | AlterSchema - | CommentSchema - | CreateSchema - | DropSchema - | SchemaPrivilege - | SecurityLabelSchema; diff --git a/packages/pg-delta/src/core/objects/schema/schema.diff.test.ts b/packages/pg-delta/src/core/objects/schema/schema.diff.test.ts deleted file mode 100644 index cd514a80f..000000000 --- a/packages/pg-delta/src/core/objects/schema/schema.diff.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../base.default-privileges.ts"; -import { AlterSchemaChangeOwner } from "./changes/schema.alter.ts"; -import { CreateSchema } from "./changes/schema.create.ts"; -import { DropSchema } from "./changes/schema.drop.ts"; -import { diffSchemas } from "./schema.diff.ts"; -import { Schema, type SchemaProps } from "./schema.model.ts"; - -const base: SchemaProps = { - name: "utils", - owner: "o1", - comment: null, - privileges: [], - security_labels: [], -}; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("schema.diff", () => { - test("create and drop", () => { - const s = new Schema(base); - const created = diffSchemas(testContext, {}, { [s.stableId]: s }); - expect(created[0]).toBeInstanceOf(CreateSchema); - const dropped = diffSchemas(testContext, { [s.stableId]: s }, {}); - expect(dropped[0]).toBeInstanceOf(DropSchema); - }); - - test("alter owner", () => { - const main = new Schema(base); - const branch = new Schema({ ...base, owner: "o2" }); - const changes = diffSchemas( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterSchemaChangeOwner); - }); -}); diff --git a/packages/pg-delta/src/core/objects/schema/schema.diff.ts b/packages/pg-delta/src/core/objects/schema/schema.diff.ts deleted file mode 100644 index a02b4327d..000000000 --- a/packages/pg-delta/src/core/objects/schema/schema.diff.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { AlterSchemaChangeOwner } from "./changes/schema.alter.ts"; -import { - CreateCommentOnSchema, - DropCommentOnSchema, -} from "./changes/schema.comment.ts"; -import { CreateSchema } from "./changes/schema.create.ts"; -import { DropSchema } from "./changes/schema.drop.ts"; -import { - GrantSchemaPrivileges, - RevokeGrantOptionSchemaPrivileges, - RevokeSchemaPrivileges, -} from "./changes/schema.privilege.ts"; -import { - CreateSecurityLabelOnSchema, - DropSecurityLabelOnSchema, -} from "./changes/schema.security-label.ts"; -import type { SchemaChange } from "./changes/schema.types.ts"; -import type { Schema } from "./schema.model.ts"; - -/** - * Diff two sets of schemas from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The schemas in the main catalog. - * @param branch - The schemas in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffSchemas( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): SchemaChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: SchemaChange[] = []; - - for (const schemaId of created) { - const sc = branch[schemaId]; - changes.push(new CreateSchema({ schema: sc })); - if (sc.comment !== null) { - changes.push(new CreateCommentOnSchema({ schema: sc })); - } - for (const label of sc.security_labels) { - changes.push( - new CreateSecurityLabelOnSchema({ - schema: sc, - securityLabel: label, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - // Note: Schemas don't have a schema property, so we pass empty string - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "schema", - "", - ); - const creatorFilteredDefaults = - sc.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - const desiredPrivileges = sc.privileges; - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the schema owner as the reference. - const privilegeResults = diffPrivileges( - creatorFilteredDefaults, - desiredPrivileges, - sc.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - sc, - sc, - "schema", - { - Grant: GrantSchemaPrivileges, - Revoke: RevokeSchemaPrivileges, - RevokeGrantOption: RevokeGrantOptionSchemaPrivileges, - }, - ctx.version, - ) as SchemaChange[]), - ); - } - - for (const schemaId of dropped) { - const mainSchema = main[schemaId]; - for (const label of mainSchema.security_labels) { - changes.push( - new DropSecurityLabelOnSchema({ - schema: mainSchema, - securityLabel: label, - }), - ); - } - changes.push(new DropSchema({ schema: mainSchema })); - } - - for (const schemaId of altered) { - const mainSchema = main[schemaId]; - const branchSchema = branch[schemaId]; - - // OWNER - if (mainSchema.owner !== branchSchema.owner) { - changes.push( - new AlterSchemaChangeOwner({ - schema: mainSchema, - owner: branchSchema.owner, - }), - ); - } - - // COMMENT - if (mainSchema.comment !== branchSchema.comment) { - if (branchSchema.comment === null) { - changes.push(new DropCommentOnSchema({ schema: mainSchema })); - } else { - changes.push(new CreateCommentOnSchema({ schema: branchSchema })); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnSchema | DropSecurityLabelOnSchema - >( - mainSchema.security_labels, - branchSchema.security_labels, - (securityLabel) => - new CreateSecurityLabelOnSchema({ - schema: branchSchema, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnSchema({ - schema: mainSchema, - securityLabel, - }), - ), - ); - - // PRIVILEGES - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainSchema.privileges, - branchSchema.privileges, - branchSchema.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchSchema, - mainSchema, - "schema", - { - Grant: GrantSchemaPrivileges, - Revoke: RevokeSchemaPrivileges, - RevokeGrantOption: RevokeGrantOptionSchemaPrivileges, - }, - ctx.version, - ) as SchemaChange[]), - ); - - // Note: Schema renaming would also use ALTER SCHEMA ... RENAME TO ... - // But since our Schema model uses 'schema' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/schema/schema.model.ts b/packages/pg-delta/src/core/objects/schema/schema.model.ts deleted file mode 100644 index d01105efc..000000000 --- a/packages/pg-delta/src/core/objects/schema/schema.model.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -/** - * All properties exposed by CREATE SCHEMA statement are included in diff output. - * https://www.postgresql.org/docs/current/sql-createschema.html - * - * ALTER SCHEMA statement can be generated for all properties. - * https://www.postgresql.org/docs/current/sql-alterschema.html - */ -const schemaPropsSchema = z.object({ - name: z.string(), - owner: z.string(), - comment: z.string().nullable(), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -type SchemaPrivilegeProps = PrivilegeProps; -export type SchemaProps = z.infer; - -export class Schema extends BasePgModel { - public readonly name: SchemaProps["name"]; - public readonly owner: SchemaProps["owner"]; - public readonly comment: SchemaProps["comment"]; - public readonly privileges: SchemaPrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: SchemaProps) { - super(); - - // Identity fields - this.name = props.name; - - // Data fields - this.owner = props.owner; - this.comment = props.comment; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `schema:${string}` { - return `schema:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - return { - owner: this.owner, - comment: this.comment, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } -} - -export async function extractSchemas(pool: Pool): Promise { - const { rows: schemaRows } = await pool.query(sql` - with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_namespace'::regclass - ) - select - quote_ident(nspname) as name, - nspowner::regrole::text as owner, - obj_description(oid, 'pg_namespace') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(COALESCE(nspacl, acldefault('n', nspowner))) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = pg_namespace.oid - and sl.classoid = 'pg_namespace'::regclass - and sl.objsubid = 0 - ), '[]' - ) as security_labels - from - pg_catalog.pg_namespace - left outer join extension_oids e on e.objid = oid - -- - where not nspname like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null - -- - order by - 1 - `); - // Validate and parse each row using the Zod schema - const validatedRows = schemaRows.map((row: unknown) => - schemaPropsSchema.parse(row), - ); - return validatedRows.map((row: SchemaProps) => new Schema(row)); -} diff --git a/packages/pg-delta/src/core/objects/security-label.types.test.ts b/packages/pg-delta/src/core/objects/security-label.types.test.ts deleted file mode 100644 index ee5dfc1f2..000000000 --- a/packages/pg-delta/src/core/objects/security-label.types.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - diffSecurityLabels, - type SecurityLabelProps, - securityLabelPropsSchema, -} from "./security-label.types.ts"; - -describe("securityLabelPropsSchema", () => { - test("parses valid props", () => { - const parsed = securityLabelPropsSchema.parse({ - provider: "p", - label: "l", - }); - expect(parsed).toEqual({ provider: "p", label: "l" }); - }); - - test("rejects null provider", () => { - expect(() => - securityLabelPropsSchema.parse({ provider: null, label: "l" }), - ).toThrow(); - }); - - test("rejects null label", () => { - expect(() => - securityLabelPropsSchema.parse({ provider: "p", label: null }), - ).toThrow(); - }); -}); - -describe("diffSecurityLabels", () => { - type Change = { kind: "create" | "drop" } & SecurityLabelProps; - const makeCreate = (p: SecurityLabelProps): Change => ({ - kind: "create", - ...p, - }); - const makeDrop = (p: SecurityLabelProps): Change => ({ - kind: "drop", - ...p, - }); - - test("both empty → no changes", () => { - expect(diffSecurityLabels([], [], makeCreate, makeDrop)).toEqual([]); - }); - - test("added providers emit create", () => { - expect( - diffSecurityLabels( - [], - [{ provider: "a", label: "x" }], - makeCreate, - makeDrop, - ), - ).toEqual([{ kind: "create", provider: "a", label: "x" }]); - }); - - test("removed providers emit drop", () => { - expect( - diffSecurityLabels( - [{ provider: "a", label: "x" }], - [], - makeCreate, - makeDrop, - ), - ).toEqual([{ kind: "drop", provider: "a", label: "x" }]); - }); - - test("changed label emits create (overwrite semantics)", () => { - expect( - diffSecurityLabels( - [{ provider: "a", label: "old" }], - [{ provider: "a", label: "new" }], - makeCreate, - makeDrop, - ), - ).toEqual([{ kind: "create", provider: "a", label: "new" }]); - }); - - test("unchanged label emits nothing", () => { - expect( - diffSecurityLabels( - [{ provider: "a", label: "x" }], - [{ provider: "a", label: "x" }], - makeCreate, - makeDrop, - ), - ).toEqual([]); - }); - - test("mixed add/remove/change/unchanged across providers, sorted by provider", () => { - const main = [ - { provider: "a", label: "stay" }, - { provider: "b", label: "old" }, - { provider: "c", label: "remove" }, - ]; - const branch = [ - { provider: "a", label: "stay" }, - { provider: "b", label: "new" }, - { provider: "d", label: "add" }, - ]; - expect(diffSecurityLabels(main, branch, makeCreate, makeDrop)).toEqual([ - { kind: "create", provider: "b", label: "new" }, - { kind: "drop", provider: "c", label: "remove" }, - { kind: "create", provider: "d", label: "add" }, - ]); - }); -}); diff --git a/packages/pg-delta/src/core/objects/security-label.types.ts b/packages/pg-delta/src/core/objects/security-label.types.ts deleted file mode 100644 index c43fcd24f..000000000 --- a/packages/pg-delta/src/core/objects/security-label.types.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { z } from "zod"; - -export const securityLabelPropsSchema = z.object({ - provider: z.string(), - label: z.string(), -}); - -export type SecurityLabelProps = z.infer; - -export function normalizeSecurityLabels( - labels: readonly SecurityLabelProps[], -): SecurityLabelProps[] { - return [...labels].sort((a, b) => a.provider.localeCompare(b.provider)); -} - -/** - * Pure helper: compares two arrays of security labels keyed by provider and - * returns a deterministic list of create/drop changes. - * - * - Labels present only on `branch` → emit create (via makeCreate). - * - Labels present only on `main` → emit drop (via makeDrop). - * - Labels with differing `label` under the same provider → emit create - * (PostgreSQL's SECURITY LABEL … IS '…' overwrites, so no separate alter). - * - Unchanged labels → nothing. - * - * Output order: by provider ascending. - */ -export function diffSecurityLabels( - main: readonly SecurityLabelProps[], - branch: readonly SecurityLabelProps[], - makeCreate: (props: SecurityLabelProps) => C, - makeDrop: (props: SecurityLabelProps) => C, -): C[] { - const mainByProvider = new Map(main.map((l) => [l.provider, l.label])); - const branchByProvider = new Map(branch.map((l) => [l.provider, l.label])); - - const providers = new Set([ - ...mainByProvider.keys(), - ...branchByProvider.keys(), - ]); - const sortedProviders = [...providers].sort(); - - const out: C[] = []; - for (const provider of sortedProviders) { - const mainLabel = mainByProvider.get(provider); - const branchLabel = branchByProvider.get(provider); - - if (mainLabel === undefined && branchLabel !== undefined) { - out.push(makeCreate({ provider, label: branchLabel })); - } else if (mainLabel !== undefined && branchLabel === undefined) { - out.push(makeDrop({ provider, label: mainLabel })); - } else if ( - mainLabel !== undefined && - branchLabel !== undefined && - mainLabel !== branchLabel - ) { - out.push(makeCreate({ provider, label: branchLabel })); - } - } - return out; -} diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.test.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.test.ts deleted file mode 100644 index 1a47cf78f..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Sequence, type SequenceProps } from "../sequence.model.ts"; -import { - AlterSequenceSetOptions, - AlterSequenceSetOwnedBy, -} from "./sequence.alter.ts"; - -describe.concurrent("sequence", () => { - describe("alter", () => { - test("set owned by table column", async () => { - const props: Omit< - SequenceProps, - "owned_by_schema" | "owned_by_table" | "owned_by_column" - > = { - schema: "public", - name: "test_sequence", - data_type: "integer", - start_value: 1, - minimum_value: 1n, - maximum_value: 2147483647n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - comment: null, - privileges: [], - owner: "test", - }; - const sequence = new Sequence({ - ...props, - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - }); - - const change = new AlterSequenceSetOwnedBy({ - sequence, - ownedBy: { schema: "public", table: "t", column: "id" }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SEQUENCE public.test_sequence OWNED BY public.t.id", - ); - }); - - test("owned by none", async () => { - const sequence = new Sequence({ - schema: "public", - name: "s", - data_type: "bigint", - start_value: 1, - minimum_value: 1n, - maximum_value: 9223372036854775807n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: "public", - owned_by_table: "t", - owned_by_column: "id", - comment: null, - privileges: [], - owner: "test", - }); - const change = new AlterSequenceSetOwnedBy({ sequence, ownedBy: null }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER SEQUENCE public.s OWNED BY NONE"); - }); - - test("drop + create sequence (handled in diff)", async () => { - expect(1).toBe(1); - }); - - test("alter options: increment, min/max, start, cache, cycle", async () => { - const sequence = new Sequence({ - schema: "public", - name: "s", - data_type: "bigint", - start_value: 1, - minimum_value: 1n, - maximum_value: 9223372036854775807n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "test", - }); - const change = new AlterSequenceSetOptions({ - sequence, - options: [ - "INCREMENT BY", - "2", - "MINVALUE", - "5", - "MAXVALUE", - "100", - "START WITH", - "10", - "CACHE", - "3", - "CYCLE", - ], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER SEQUENCE public.s INCREMENT BY 2 MINVALUE 5 MAXVALUE 100 START WITH 10 CACHE 3 CYCLE", - ); - }); - - test("alter options: reset to defaults uses NO MINVALUE/NO MAXVALUE", async () => { - const sequence = new Sequence({ - schema: "public", - name: "s", - data_type: "integer", - start_value: 5, - minimum_value: 3n, - maximum_value: 100n, - increment: 2, - cycle_option: true, - cache_size: 2, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "test", - }); - const change = new AlterSequenceSetOptions({ - sequence, - options: [ - "INCREMENT BY", - "1", - "NO MINVALUE", - "NO MAXVALUE", - "START WITH", - "1", - "CACHE", - "1", - "NO CYCLE", - ], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER SEQUENCE public.s INCREMENT BY 1 NO MINVALUE NO MAXVALUE START WITH 1 CACHE 1 NO CYCLE", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts deleted file mode 100644 index b18c2e5a1..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Sequence } from "../sequence.model.ts"; -import { AlterSequenceChange } from "./sequence.base.ts"; - -/** - * Alter a sequence. - * - * @see https://www.postgresql.org/docs/17/sql-altersequence.html - * - * Synopsis - * ```sql - * ALTER SEQUENCE [ IF EXISTS ] name [ INCREMENT [ BY ] increment ] - * [ MINVALUE minvalue | NO MINVALUE ] [ MAXVALUE maxvalue | NO MAXVALUE ] - * [ START [ WITH ] start ] [ RESTART [ [ WITH ] restart ] ] - * [ CACHE cache ] [ [ NO ] CYCLE ] [ OWNED BY { table_name.column_name | NONE } ] - * ``` - */ - -export type AlterSequence = AlterSequenceSetOptions | AlterSequenceSetOwnedBy; - -/** - * ALTER SEQUENCE ... OWNED BY ... | OWNED BY NONE - */ -export class AlterSequenceSetOwnedBy extends AlterSequenceChange { - public readonly sequence: Sequence; - public readonly ownedBy: { - schema: string; - table: string; - column: string; - } | null; - public readonly scope = "object" as const; - - constructor(props: { - sequence: Sequence; - ownedBy: { schema: string; table: string; column: string } | null; - }) { - super(); - this.sequence = props.sequence; - this.ownedBy = props.ownedBy; - } - - get creates() { - return []; - } - - get requires() { - return [ - this.sequence.stableId, - ...(this.ownedBy - ? [ - stableId.column( - this.ownedBy.schema, - this.ownedBy.table, - this.ownedBy.column, - ), - ] - : []), - ]; - } - - serialize(_options?: SerializeOptions): string { - const head = [ - "ALTER SEQUENCE", - `${this.sequence.schema}.${this.sequence.name}`, - ]; - if (this.ownedBy) { - return [ - ...head, - "OWNED BY", - `${this.ownedBy.schema}.${this.ownedBy.table}.${this.ownedBy.column}`, - ].join(" "); - } - return [...head, "OWNED BY NONE"].join(" "); - } -} - -/** - * ALTER SEQUENCE ... set options ... - * Emits only changed options, in a stable order. - */ -export class AlterSequenceSetOptions extends AlterSequenceChange { - public readonly sequence: Sequence; - public readonly options: string[]; - public readonly scope = "object" as const; - - constructor(props: { sequence: Sequence; options: string[] }) { - super(); - this.sequence = props.sequence; - this.options = props.options; - } - - get creates() { - return []; - } - - get requires() { - return [this.sequence.stableId]; - } - - // Note: default max computation moved to diff when building options - - serialize(_options?: SerializeOptions): string { - const parts: string[] = [ - "ALTER SEQUENCE", - `${this.sequence.schema}.${this.sequence.name}`, - ]; - return [...parts, ...this.options].join(" "); - } -} - -/** - * Replace a sequence by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER SEQUENCE change. - */ -// NOTE: ReplaceSequence removed. Non-alterable changes are emitted as Drop + Create in sequence.diff.ts. diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.base.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.base.ts deleted file mode 100644 index 398662221..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Sequence } from "../sequence.model.ts"; - -abstract class BaseSequenceChange extends BaseChange { - abstract readonly sequence: Sequence; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "sequence" as const; -} - -export abstract class CreateSequenceChange extends BaseSequenceChange { - readonly operation = "create" as const; -} - -export abstract class AlterSequenceChange extends BaseSequenceChange { - readonly operation = "alter" as const; -} - -export abstract class DropSequenceChange extends BaseSequenceChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.comment.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.comment.ts deleted file mode 100644 index 4b221a160..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.comment.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Sequence } from "../sequence.model.ts"; -import { CreateSequenceChange, DropSequenceChange } from "./sequence.base.ts"; - -export type CommentSequence = CreateCommentOnSequence | DropCommentOnSequence; - -export class CreateCommentOnSequence extends CreateSequenceChange { - public readonly sequence: Sequence; - public readonly scope = "comment" as const; - - constructor(props: { sequence: Sequence }) { - super(); - this.sequence = props.sequence; - } - - get creates() { - return [stableId.comment(this.sequence.stableId)]; - } - - get requires() { - return [this.sequence.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON SEQUENCE", - `${this.sequence.schema}.${this.sequence.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: sequence comment is not nullable in this case - quoteLiteral(this.sequence.comment!), - ].join(" "); - } -} - -export class DropCommentOnSequence extends DropSequenceChange { - public readonly sequence: Sequence; - public readonly scope = "comment" as const; - - constructor(props: { sequence: Sequence }) { - super(); - this.sequence = props.sequence; - } - - get drops() { - return [stableId.comment(this.sequence.stableId)]; - } - - get requires() { - return [stableId.comment(this.sequence.stableId), this.sequence.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON SEQUENCE", - `${this.sequence.schema}.${this.sequence.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.create.test.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.create.test.ts deleted file mode 100644 index 19d6df6ee..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.create.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Sequence } from "../sequence.model.ts"; -import { CreateSequence } from "./sequence.create.ts"; - -describe("sequence", () => { - test("create minimal (all defaults elided)", async () => { - const sequence = new Sequence({ - schema: "public", - name: "s_min", - data_type: "bigint", - start_value: 1, - minimum_value: 1n, - maximum_value: 9223372036854775807n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "test", - }); - - const change = new CreateSequence({ sequence }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("CREATE SEQUENCE public.s_min"); - }); - - test("create", async () => { - const sequence = new Sequence({ - schema: "public", - name: "test_sequence", - data_type: "integer", - start_value: 1, - minimum_value: 1n, - maximum_value: 2147483647n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "test", - }); - - const change = new CreateSequence({ - sequence, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE SEQUENCE public.test_sequence AS integer", - ); - }); - - test("create with all options", async () => { - const sequence = new Sequence({ - schema: "public", - name: "s_all", - data_type: "integer", - start_value: 10, - minimum_value: 5n, - maximum_value: 100n, - increment: 2, - cycle_option: true, - cache_size: 3, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "test", - }); - - const change = new CreateSequence({ sequence }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "CREATE SEQUENCE public.s_all AS integer INCREMENT BY 2 MINVALUE 5 MAXVALUE 100 START WITH 10 CACHE 3 CYCLE", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.create.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.create.ts deleted file mode 100644 index b0a0a546f..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.create.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Sequence } from "../sequence.model.ts"; -import { CreateSequenceChange } from "./sequence.base.ts"; - -/** - * Create a sequence. - * - * @see https://www.postgresql.org/docs/17/sql-createsequence.html - * - * Synopsis - * ```sql - * CREATE [ TEMPORARY | TEMP ] SEQUENCE [ IF NOT EXISTS ] name [ INCREMENT [ BY ] increment ] - * [ MINVALUE minvalue | NO MINVALUE ] [ MAXVALUE maxvalue | NO MAXVALUE ] - * [ START [ WITH ] start ] [ CACHE cache ] [ [ NO ] CYCLE ] - * [ OWNED BY { table_name.column_name | NONE } ] - * ``` - */ -export class CreateSequence extends CreateSequenceChange { - public readonly sequence: Sequence; - public readonly scope = "object" as const; - - constructor(props: { sequence: Sequence }) { - super(); - this.sequence = props.sequence; - } - - get creates() { - return [this.sequence.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.sequence.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.sequence.owner)); - - // Owned by table/column dependency (if set) - if ( - this.sequence.owned_by_schema && - this.sequence.owned_by_table && - this.sequence.owned_by_column - ) { - dependencies.add( - stableId.table( - this.sequence.owned_by_schema, - this.sequence.owned_by_table, - ), - ); - dependencies.add( - stableId.column( - this.sequence.owned_by_schema, - this.sequence.owned_by_table, - this.sequence.owned_by_column, - ), - ); - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE SEQUENCE"]; - - // Add schema and name - parts.push(`${this.sequence.schema}.${this.sequence.name}`); - - // Add data type if not default - if (this.sequence.data_type && this.sequence.data_type !== "bigint") { - parts.push("AS", this.sequence.data_type); - } - - // Add INCREMENT - if (this.sequence.increment !== 1) { - parts.push("INCREMENT BY", this.sequence.increment.toString()); - } - - // Add MINVALUE if not default (1) - if (this.sequence.minimum_value !== BigInt(1)) { - parts.push("MINVALUE", this.sequence.minimum_value.toString()); - } - - // Add MAXVALUE if not default (depends on data type) - const defaultMaxValue = - this.sequence.data_type === "integer" - ? BigInt("2147483647") - : BigInt("9223372036854775807"); - if (this.sequence.maximum_value !== defaultMaxValue) { - parts.push("MAXVALUE", this.sequence.maximum_value.toString()); - } - - // Add START - if (this.sequence.start_value !== 1) { - parts.push("START WITH", this.sequence.start_value.toString()); - } - - // Add CACHE - if (this.sequence.cache_size !== 1) { - parts.push("CACHE", this.sequence.cache_size.toString()); - } - - // Add CYCLE only if true (default is NO CYCLE) - if (this.sequence.cycle_option) { - parts.push("CYCLE"); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.drop.test.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.drop.test.ts deleted file mode 100644 index a9129eaa1..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.drop.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Sequence } from "../sequence.model.ts"; -import { DropSequence } from "./sequence.drop.ts"; - -describe("sequence", () => { - test("drop", async () => { - const sequence = new Sequence({ - schema: "public", - name: "test_sequence", - data_type: "integer", - start_value: 1, - minimum_value: 1n, - maximum_value: 2147483647n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "test", - }); - - const change = new DropSequence({ - sequence, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP SEQUENCE public.test_sequence"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.drop.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.drop.ts deleted file mode 100644 index 82cbb8db3..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.drop.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Sequence } from "../sequence.model.ts"; -import { DropSequenceChange } from "./sequence.base.ts"; - -/** - * Drop a sequence. - * - * @see https://www.postgresql.org/docs/17/sql-dropsequence.html - * - * Synopsis - * ```sql - * DROP SEQUENCE [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropSequence extends DropSequenceChange { - public readonly sequence: Sequence; - public readonly scope = "object" as const; - - constructor(props: { sequence: Sequence }) { - super(); - this.sequence = props.sequence; - } - - get drops() { - return [this.sequence.stableId]; - } - - get requires() { - return [this.sequence.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const parts = [ - "DROP SEQUENCE", - `${this.sequence.schema}.${this.sequence.name}`, - ]; - - // Owned sequences still need CASCADE here because DROP runs in the earlier - // phase and later ALTER TABLE statements will re-establish the desired default. - if ( - this.sequence.owned_by_schema && - this.sequence.owned_by_table && - this.sequence.owned_by_column - ) { - parts.push("CASCADE"); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.privilege.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.privilege.ts deleted file mode 100644 index 299893ace..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.privilege.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { Sequence } from "../sequence.model.ts"; -import { AlterSequenceChange } from "./sequence.base.ts"; - -export type SequencePrivilege = - | GrantSequencePrivileges - | RevokeSequencePrivileges - | RevokeGrantOptionSequencePrivileges; - -/** - * Grant privileges on a sequence. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { { USAGE | SELECT | UPDATE } - * [, ...] | ALL [ PRIVILEGES ] } - * ON { SEQUENCE sequence_name [, ...] - * | ALL SEQUENCES IN SCHEMA schema_name [, ...] } - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantSequencePrivileges extends AlterSequenceChange { - public readonly sequence: Sequence; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - sequence: Sequence; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.sequence = props.sequence; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.sequence.stableId, this.grantee)]; - } - - get requires() { - return [this.sequence.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantSequencePrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("SEQUENCE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("SEQUENCE", list, this.version); - const sequenceName = `${this.sequence.schema}.${this.sequence.name}`; - return `GRANT ${privSql} ${kindPrefix} ${sequenceName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a sequence. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { { USAGE | SELECT | UPDATE } - * [, ...] | ALL [ PRIVILEGES ] } - * ON { SEQUENCE sequence_name [, ...] - * | ALL SEQUENCES IN SCHEMA schema_name [, ...] } - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeSequencePrivileges extends AlterSequenceChange { - public readonly sequence: Sequence; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - sequence: Sequence; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.sequence = props.sequence; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.sequence.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.sequence.stableId, this.grantee), - this.sequence.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("SEQUENCE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("SEQUENCE", list, this.version); - const sequenceName = `${this.sequence.schema}.${this.sequence.name}`; - return `REVOKE ${privSql} ${kindPrefix} ${sequenceName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a sequence. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionSequencePrivileges extends AlterSequenceChange { - public readonly sequence: Sequence; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - sequence: Sequence; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.sequence = props.sequence; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.sequence.stableId, this.grantee), - this.sequence.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("SEQUENCE"); - const privSql = formatObjectPrivilegeList( - "SEQUENCE", - this.privilegeNames, - this.version, - ); - const sequenceName = `${this.sequence.schema}.${this.sequence.name}`; - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${sequenceName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.security-label.test.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.security-label.test.ts deleted file mode 100644 index 49ffeafe0..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.security-label.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Sequence, type SequenceProps } from "../sequence.model.ts"; -import { - CreateSecurityLabelOnSequence, - DropSecurityLabelOnSequence, -} from "./sequence.security-label.ts"; - -const makeSequence = (): Sequence => - new Sequence({ - schema: "public", - name: "s1", - data_type: "bigint", - start_value: 1, - minimum_value: BigInt(1), - maximum_value: BigInt("9223372036854775807"), - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "postgres", - } as SequenceProps); - -describe("sequence.security-label", () => { - test("create serializes", async () => { - const sequence = makeSequence(); - const change = new CreateSecurityLabelOnSequence({ - sequence, - securityLabel: { provider: "dummy", label: "classified" }, - }); - expect(change.scope).toBe("security_label"); - expect(change.creates).toEqual([ - stableId.securityLabel(sequence.stableId, "dummy"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON SEQUENCE public.s1 IS 'classified'", - ); - }); - - test("drop serializes to IS NULL", async () => { - const sequence = makeSequence(); - const change = new DropSecurityLabelOnSequence({ - sequence, - securityLabel: { provider: "dummy", label: "x" }, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON SEQUENCE public.s1 IS NULL", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.security-label.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.security-label.ts deleted file mode 100644 index 789f4acdd..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.security-label.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Sequence } from "../sequence.model.ts"; -import { CreateSequenceChange, DropSequenceChange } from "./sequence.base.ts"; - -export type SecurityLabelSequence = - | CreateSecurityLabelOnSequence - | DropSecurityLabelOnSequence; - -export class CreateSecurityLabelOnSequence extends CreateSequenceChange { - public readonly sequence: Sequence; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - sequence: Sequence; - securityLabel: SecurityLabelProps; - }) { - super(); - this.sequence = props.sequence; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.sequence.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.sequence.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON SEQUENCE", - `${this.sequence.schema}.${this.sequence.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnSequence extends DropSequenceChange { - public readonly sequence: Sequence; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - sequence: Sequence; - securityLabel: SecurityLabelProps; - }) { - super(); - this.sequence = props.sequence; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.sequence.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.sequence.stableId, - this.securityLabel.provider, - ), - this.sequence.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON SEQUENCE", - `${this.sequence.schema}.${this.sequence.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.types.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.types.ts deleted file mode 100644 index 6ae80e0fa..000000000 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterSequence } from "./sequence.alter.ts"; -import type { CommentSequence } from "./sequence.comment.ts"; -import type { CreateSequence } from "./sequence.create.ts"; -import type { DropSequence } from "./sequence.drop.ts"; -import type { SequencePrivilege } from "./sequence.privilege.ts"; -import type { SecurityLabelSequence } from "./sequence.security-label.ts"; - -/** Union of all sequence-related change variants (`objectType: "sequence"`). @category Change Types */ -export type SequenceChange = - | AlterSequence - | CommentSequence - | CreateSequence - | DropSequence - | SequencePrivilege - | SecurityLabelSequence; diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts deleted file mode 100644 index 071067b86..000000000 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../base.default-privileges.ts"; -import { AlterTableAlterColumnSetDefault } from "../table/changes/table.alter.ts"; -import { Table } from "../table/table.model.ts"; -import { - AlterSequenceSetOptions, - AlterSequenceSetOwnedBy, -} from "./changes/sequence.alter.ts"; -import { - CreateCommentOnSequence, - DropCommentOnSequence, -} from "./changes/sequence.comment.ts"; -import { CreateSequence } from "./changes/sequence.create.ts"; -import { DropSequence } from "./changes/sequence.drop.ts"; -import { - GrantSequencePrivileges, - RevokeGrantOptionSequencePrivileges, - RevokeSequencePrivileges, -} from "./changes/sequence.privilege.ts"; -import { diffSequences } from "./sequence.diff.ts"; -import { Sequence, type SequenceProps } from "./sequence.model.ts"; - -const base: SequenceProps = { - schema: "public", - name: "seq1", - data_type: "bigint", - start_value: 1, - minimum_value: 1n, - maximum_value: 1000n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: null, - privileges: [], - owner: "test", -}; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("sequence.diff", () => { - test("create and drop", () => { - const s = new Sequence(base); - const created = diffSequences(testContext, {}, { [s.stableId]: s }); - expect(created[0]).toBeInstanceOf(CreateSequence); - const dropped = diffSequences(testContext, { [s.stableId]: s }, {}); - expect(dropped[0]).toBeInstanceOf(DropSequence); - }); - - test("alter owned by", () => { - const main = new Sequence(base); - const branch = new Sequence({ - ...base, - owned_by_schema: "public", - owned_by_table: "t", - owned_by_column: "id", - }); - const changes = diffSequences( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterSequenceSetOwnedBy); - }); - - test("alter options via diff", () => { - const main = new Sequence(base); - const branch = new Sequence({ - ...base, - increment: 2, - minimum_value: 5n, - maximum_value: 500n, - start_value: 10, - cache_size: 3, - cycle_option: true, - }); - const changes = diffSequences( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterSequenceSetOptions)).toBe( - true, - ); - }); - - test("drop and create when non-alterable property changes", () => { - const main = new Sequence(base); - const branch = new Sequence({ - ...base, - data_type: "integer", - persistence: "u", - }); - const changes = diffSequences( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(DropSequence); - expect(changes[1]).toBeInstanceOf(CreateSequence); - }); - - test("replacing an owned sequence re-emits the owning column default", () => { - // Use `persistence` (UNLOGGED → LOGGED) to trigger the - // non-alterable replace path: it's the only field still in - // NON_ALTERABLE_FIELDS. `data_type` was previously in that list - // but is now alterable in place via ALTER SEQUENCE ... AS . - const main = new Sequence({ - ...base, - persistence: "u", - owned_by_schema: "public", - owned_by_table: "users", - owned_by_column: "id", - }); - const branch = new Sequence({ - ...base, - persistence: "p", - owned_by_schema: "public", - owned_by_table: "users", - owned_by_column: "id", - }); - const branchTable = new Table({ - schema: "public", - name: "users", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "test", - comment: null, - parent_schema: null, - parent_name: null, - columns: [ - { - name: "id", - position: 1, - data_type: "bigint", - data_type_str: "bigint", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "nextval('public.seq1'::regclass)", - comment: null, - }, - ], - privileges: [], - }); - - const changes = diffSequences( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - { [branchTable.stableId]: branchTable }, - ); - - expect(changes).toHaveLength(4); - expect(changes[0]).toBeInstanceOf(DropSequence); - expect(changes[1]).toBeInstanceOf(CreateSequence); - expect(changes[2]).toBeInstanceOf(AlterSequenceSetOwnedBy); - expect(changes[3]).toBeInstanceOf(AlterTableAlterColumnSetDefault); - }); - - test("skip DROP SEQUENCE when owned by table being dropped", () => { - const ownedSequence = new Sequence({ - ...base, - owned_by_schema: "public", - owned_by_table: "users", - owned_by_column: "id", - }); - // When the owning table is not in branch catalog (being dropped), - // DROP SEQUENCE should not be generated (PostgreSQL auto-drops it) - const changes = diffSequences( - testContext, - { [ownedSequence.stableId]: ownedSequence }, - {}, // branch has no sequences (sequence was auto-dropped) - {}, // branch has no tables (table is being dropped) - ); - // Should not generate DROP SEQUENCE since table is being dropped - expect(changes).toHaveLength(0); - }); - - test("generate DROP SEQUENCE when owned by table/column that still exists", () => { - const ownedSequence = new Sequence({ - ...base, - owned_by_schema: "public", - owned_by_table: "users", - owned_by_column: "id", - }); - const branchTable = new Table({ - schema: "public", - name: "users", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "test", - comment: null, - parent_schema: null, - parent_name: null, - columns: [ - { - name: "id", - position: 1, - data_type: "bigint", - data_type_str: "bigint", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - // When the owning table AND the owning column still exist in branch catalog, - // DROP SEQUENCE should be generated (sequence is orphaned and needs explicit drop). - const changes = diffSequences( - testContext, - { [ownedSequence.stableId]: ownedSequence }, - {}, - { [branchTable.stableId]: branchTable }, - ); - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(DropSequence); - }); - - test("skip DROP SEQUENCE when owning column is dropped but table survives", () => { - // Reproduction guard for the DropSequence ↔ AlterTableDropColumn cycle: - // dropping a SERIAL column on a surviving table leaves PG to cascade-drop - // the owned sequence. Emitting DROP SEQUENCE here would both fail at apply - // time AND form an unbreakable drop-phase cycle with AlterTableDropColumn. - const ownedSequence = new Sequence({ - ...base, - owned_by_schema: "public", - owned_by_table: "widgets", - owned_by_column: "id", - }); - const branchTable = new Table({ - schema: "public", - name: "widgets", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "test", - comment: null, - parent_schema: null, - parent_name: null, - // `id` column has been dropped in branch; only `label` remains. - columns: [ - { - name: "label", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const changes = diffSequences( - testContext, - { [ownedSequence.stableId]: ownedSequence }, - {}, - { [branchTable.stableId]: branchTable }, - ); - expect(changes).toHaveLength(0); - }); - - test("recreate same-name sequence when owning table is renamed away", () => { - // Reproduces issue #228 case 1: a SERIAL column's table is renamed - // (`old_table` → `new_table`). The sequence keeps the same name - // (`old_table_id_seq`) but its OWNED BY now points at `new_table.id`. - // PostgreSQL cascade-drops the sequence with the old table, so a later - // CREATE TABLE that references `old_table_id_seq` fails. The diff must - // emit CreateSequence (and skip the explicit DropSequence to avoid an - // unbreakable cycle with the DropTable). - const tableColumn = { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "nextval('public.old_table_id_seq'::regclass)", - comment: null, - }; - const tableBaseProps = { - schema: "public", - persistence: "p" as const, - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "test", - comment: null, - parent_schema: null, - parent_name: null, - privileges: [], - }; - const oldTable = new Table({ - ...tableBaseProps, - name: "old_table", - columns: [tableColumn], - }); - const newTable = new Table({ - ...tableBaseProps, - name: "new_table", - columns: [tableColumn], - }); - const mainSequence = new Sequence({ - ...base, - name: "old_table_id_seq", - owned_by_schema: "public", - owned_by_table: "old_table", - owned_by_column: "id", - }); - const branchSequence = new Sequence({ - ...base, - name: "old_table_id_seq", - owned_by_schema: "public", - owned_by_table: "new_table", - owned_by_column: "id", - }); - - const changes = diffSequences( - testContext, - { [mainSequence.stableId]: mainSequence }, - { [branchSequence.stableId]: branchSequence }, - { [newTable.stableId]: newTable }, - { [oldTable.stableId]: oldTable }, - ); - - expect(changes.some((c) => c instanceof DropSequence)).toBe(false); - expect(changes.some((c) => c instanceof CreateSequence)).toBe(true); - expect(changes.some((c) => c instanceof AlterSequenceSetOwnedBy)).toBe( - true, - ); - }); - - test("create with comment emits CreateCommentOnSequence", () => { - const s = new Sequence({ ...base, comment: "my seq" }); - const changes = diffSequences(testContext, {}, { [s.stableId]: s }); - expect(changes[0]).toBeInstanceOf(CreateSequence); - expect(changes.some((c) => c instanceof CreateCommentOnSequence)).toBe( - true, - ); - }); - - test("create with owned-by emits AlterSequenceSetOwnedBy", () => { - const s = new Sequence({ - ...base, - owned_by_schema: "public", - owned_by_table: "t", - owned_by_column: "id", - }); - const changes = diffSequences(testContext, {}, { [s.stableId]: s }); - expect(changes[0]).toBeInstanceOf(CreateSequence); - expect(changes.some((c) => c instanceof AlterSequenceSetOwnedBy)).toBe( - true, - ); - }); - - test("create with privileges emits grant, revoke, and revoke grant option", () => { - const dpState = new DefaultPrivilegeState({}); - dpState.applyGrant("postgres", "S", null, "role_revoke_me", [ - { privilege: "USAGE", grantable: false }, - ]); - dpState.applyGrant("postgres", "S", null, "role_downgrade", [ - { privilege: "USAGE", grantable: true }, - ]); - const ctx = { ...testContext, defaultPrivilegeState: dpState }; - const s = new Sequence({ - ...base, - privileges: [ - { grantee: "role_grant_me", privilege: "USAGE", grantable: false }, - { grantee: "role_downgrade", privilege: "USAGE", grantable: false }, - ], - }); - const changes = diffSequences(ctx, {}, { [s.stableId]: s }); - expect(changes[0]).toBeInstanceOf(CreateSequence); - expect(changes.some((c) => c instanceof GrantSequencePrivileges)).toBe( - true, - ); - expect(changes.some((c) => c instanceof RevokeSequencePrivileges)).toBe( - true, - ); - expect( - changes.some((c) => c instanceof RevokeGrantOptionSequencePrivileges), - ).toBe(true); - }); - - test("alter comment emits create and drop comment", () => { - const main = new Sequence(base); - const withComment = new Sequence({ ...base, comment: "my seq" }); - - const addComment = diffSequences( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment[0]).toBeInstanceOf(CreateCommentOnSequence); - - const dropComment = diffSequences( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment[0]).toBeInstanceOf(DropCommentOnSequence); - }); - - test("alter privileges emits grant, revoke, and revoke grant option", () => { - const main = new Sequence({ - ...base, - privileges: [ - { grantee: "role_a", privilege: "USAGE", grantable: false }, - { grantee: "role_b", privilege: "USAGE", grantable: true }, - { grantee: "role_removed", privilege: "USAGE", grantable: false }, - ], - }); - const branch = new Sequence({ - ...base, - privileges: [ - { grantee: "role_a", privilege: "USAGE", grantable: true }, - { grantee: "role_b", privilege: "USAGE", grantable: false }, - { grantee: "role_new", privilege: "USAGE", grantable: false }, - ], - }); - - const changes = diffSequences( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof GrantSequencePrivileges)).toBe( - true, - ); - expect(changes.some((c) => c instanceof RevokeSequencePrivileges)).toBe( - true, - ); - expect( - changes.some((c) => c instanceof RevokeGrantOptionSequencePrivileges), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts deleted file mode 100644 index f8f6b4d97..000000000 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { AlterTableAlterColumnSetDefault } from "../table/changes/table.alter.ts"; -import type { Table } from "../table/table.model.ts"; -import { hasNonAlterableChanges } from "../utils.ts"; -import { - AlterSequenceSetOptions, - AlterSequenceSetOwnedBy, -} from "./changes/sequence.alter.ts"; -import { - CreateCommentOnSequence, - DropCommentOnSequence, -} from "./changes/sequence.comment.ts"; -import { CreateSequence } from "./changes/sequence.create.ts"; -import { DropSequence } from "./changes/sequence.drop.ts"; -import { - GrantSequencePrivileges, - RevokeGrantOptionSequencePrivileges, - RevokeSequencePrivileges, -} from "./changes/sequence.privilege.ts"; -import { - CreateSecurityLabelOnSequence, - DropSecurityLabelOnSequence, -} from "./changes/sequence.security-label.ts"; -import type { SequenceChange } from "./changes/sequence.types.ts"; -import type { Sequence } from "./sequence.model.ts"; - -type SequenceOrColumnSetDefaultChange = - | AlterTableAlterColumnSetDefault - | SequenceChange; - -/** - * Diff two sets of sequences from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The sequences in the main catalog. - * @param branch - The sequences in the branch catalog. - * @param branchTables - The tables in the branch catalog (used to check if owning tables are being dropped). - * @param mainTables - The tables in the main catalog (used to detect when a same-name sequence will be cascade-dropped because its main-side owning table is going away). - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffSequences( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, - branchTables: Record = {}, - mainTables: Record = {}, -): SequenceOrColumnSetDefaultChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: SequenceOrColumnSetDefaultChange[] = []; - - for (const sequenceId of created) { - const createdSeq = branch[sequenceId]; - changes.push(new CreateSequence({ sequence: createdSeq })); - if (createdSeq.comment !== null) { - changes.push(new CreateCommentOnSequence({ sequence: createdSeq })); - } - for (const label of createdSeq.security_labels) { - changes.push( - new CreateSecurityLabelOnSequence({ - sequence: createdSeq, - securityLabel: label, - }), - ); - } - // If the created sequence is OWNED BY a column, emit an ALTER to set it - if ( - createdSeq.owned_by_schema !== null && - createdSeq.owned_by_table !== null && - createdSeq.owned_by_column !== null - ) { - changes.push( - new AlterSequenceSetOwnedBy({ - sequence: createdSeq, - ownedBy: { - schema: createdSeq.owned_by_schema, - table: createdSeq.owned_by_table, - column: createdSeq.owned_by_column, - } as { schema: string; table: string; column: string }, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "sequence", - createdSeq.schema ?? "", - ); - const creatorFilteredDefaults = - createdSeq.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - const desiredPrivileges = createdSeq.privileges; - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the sequence owner as the reference. - const privilegeResults = diffPrivileges( - creatorFilteredDefaults, - desiredPrivileges, - createdSeq.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - createdSeq, - createdSeq, - "sequence", - { - Grant: GrantSequencePrivileges, - Revoke: RevokeSequencePrivileges, - RevokeGrantOption: RevokeGrantOptionSequencePrivileges, - }, - ctx.version, - ) as SequenceChange[]), - ); - } - - for (const sequenceId of dropped) { - const sequence = main[sequenceId]; - // Skip generating DROP SEQUENCE if the sequence is owned by a table/column that's being dropped. - // PostgreSQL automatically cascades owned sequences when the owning table OR the owning - // column is dropped (via OWNED BY). Emitting DROP SEQUENCE in those cases would either - // fail at apply time (sequence already gone) or — in the column-drop case — create an - // unbreakable DropSequence ↔ AlterTableDropColumn cycle in the drop-phase sort graph. - if ( - sequence.owned_by_schema && - sequence.owned_by_table && - sequence.owned_by_column - ) { - const ownedByTableId = `table:${sequence.owned_by_schema}.${sequence.owned_by_table}`; - const ownedByTable = branchTables[ownedByTableId]; - // Owning table is dropped → PG auto-drops the owned sequence. - if (!ownedByTable) { - continue; - } - // Owning column is dropped (table survives) → PG still auto-drops the owned - // sequence as part of the column drop, so we must not emit DROP SEQUENCE. - const ownedByColumnExists = ownedByTable.columns?.some( - (col) => col.name === sequence.owned_by_column, - ); - if (!ownedByColumnExists) { - continue; - } - } - changes.push(new DropSequence({ sequence })); - } - - for (const sequenceId of altered) { - const mainSequence = main[sequenceId]; - const branchSequence = branch[sequenceId]; - - // Check if non-alterable properties have changed - // These require dropping and recreating the sequence - const NON_ALTERABLE_FIELDS: Array = ["persistence"]; - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainSequence, - branchSequence, - NON_ALTERABLE_FIELDS, - ); - - // A sequence kept the same name (so it's "altered" in catalog terms), - // but its main-side owning table is going away from the plan (renamed - // away or simply dropped). PostgreSQL will cascade-drop the sequence - // alongside the table, leaving any later CREATE TABLE / column-default - // that depends on the sequence name pointing at nothing. Treat this - // like a non-alterable change so we recreate the sequence after the - // owning table is dropped. - const mainOwnedByTableId = - mainSequence.owned_by_schema && mainSequence.owned_by_table - ? `table:${mainSequence.owned_by_schema}.${mainSequence.owned_by_table}` - : null; - const cascadeOrphanedByOwningTable = - mainOwnedByTableId !== null && - mainTables[mainOwnedByTableId] !== undefined && - branchTables[mainOwnedByTableId] === undefined; - - if (nonAlterablePropsChanged || cascadeOrphanedByOwningTable) { - // When the owning table is going away in this plan, PostgreSQL will - // cascade-drop the sequence as part of the DROP TABLE. Emitting an - // explicit DROP SEQUENCE here would (a) introduce an unbreakable - // DropSequence ↔ DropTable cycle on the catalog edges between the - // sequence and the dropped column, and (b) be redundant with the - // cascade. The CreateSequence below restores the sequence under its - // original name so any same-name reference in a later CREATE TABLE - // resolves correctly. - if (!cascadeOrphanedByOwningTable) { - changes.push(new DropSequence({ sequence: mainSequence })); - } - changes.push(new CreateSequence({ sequence: branchSequence })); - // Re-apply OWNED BY if present on branch - if ( - branchSequence.owned_by_schema !== null && - branchSequence.owned_by_table !== null && - branchSequence.owned_by_column !== null - ) { - const ownedByTableId = `table:${branchSequence.owned_by_schema}.${branchSequence.owned_by_table}`; - const ownedByTable = branchTables[ownedByTableId]; - const ownedByColumn = ownedByTable?.columns?.find( - (column) => column.name === branchSequence.owned_by_column, - ); - - changes.push( - new AlterSequenceSetOwnedBy({ - sequence: branchSequence, - ownedBy: { - schema: branchSequence.owned_by_schema, - table: branchSequence.owned_by_table, - column: branchSequence.owned_by_column, - } as { schema: string; table: string; column: string }, - }), - ); - - // Replacing an owned sequence with DROP ... CASCADE removes the column's - // existing nextval(...) default, so restore it after ownership is reattached. - if (ownedByTable && ownedByColumn && ownedByColumn.default !== null) { - changes.push( - new AlterTableAlterColumnSetDefault({ - table: ownedByTable, - column: ownedByColumn, - }), - ); - } - } else if ( - mainSequence.owned_by_schema !== null || - mainSequence.owned_by_table !== null || - mainSequence.owned_by_column !== null - ) { - // If main had ownership but branch removed it, emit OWNED BY NONE - changes.push( - new AlterSequenceSetOwnedBy({ - sequence: mainSequence, - ownedBy: null, - }), - ); - } - } else { - // Only alterable properties changed - emit ALTER for options/owner - const optionsChanged = - mainSequence.data_type !== branchSequence.data_type || - mainSequence.increment !== branchSequence.increment || - mainSequence.minimum_value !== branchSequence.minimum_value || - mainSequence.maximum_value !== branchSequence.maximum_value || - mainSequence.start_value !== branchSequence.start_value || - mainSequence.cache_size !== branchSequence.cache_size || - mainSequence.cycle_option !== branchSequence.cycle_option; - - if (optionsChanged) { - const options: string[] = []; - // `AS ` must come before any MIN/MAX/RESTART clauses per the - // PG ALTER SEQUENCE grammar. Valid types are smallint, integer, - // bigint — the same set CREATE SEQUENCE accepts — so the universe - // of legal transitions is closed. PG enforces last_value range at - // apply time when shrinking; that's the desired behavior because - // the previous Drop+Create path silently reset last_value to 1 - // (data-loss bug, see Sentry SUPABASE-API-7RS). - if (mainSequence.data_type !== branchSequence.data_type) { - options.push("AS", branchSequence.data_type); - } - if (mainSequence.increment !== branchSequence.increment) { - options.push("INCREMENT BY", String(branchSequence.increment)); - } - if (mainSequence.minimum_value !== branchSequence.minimum_value) { - const defaultMin = BigInt(1); - if (branchSequence.minimum_value === defaultMin) { - options.push("NO MINVALUE"); - } else { - options.push("MINVALUE", branchSequence.minimum_value.toString()); - } - } - if (mainSequence.maximum_value !== branchSequence.maximum_value) { - const defaultMax = - branchSequence.data_type === "integer" - ? BigInt("2147483647") - : BigInt("9223372036854775807"); - if (branchSequence.maximum_value === defaultMax) { - options.push("NO MAXVALUE"); - } else { - options.push("MAXVALUE", branchSequence.maximum_value.toString()); - } - } - if (mainSequence.start_value !== branchSequence.start_value) { - options.push("START WITH", String(branchSequence.start_value)); - } - if (mainSequence.cache_size !== branchSequence.cache_size) { - options.push("CACHE", String(branchSequence.cache_size)); - } - if (mainSequence.cycle_option !== branchSequence.cycle_option) { - options.push(branchSequence.cycle_option ? "CYCLE" : "NO CYCLE"); - } - changes.push( - new AlterSequenceSetOptions({ sequence: mainSequence, options }), - ); - } - - const ownedByChanged = - mainSequence.owned_by_schema !== branchSequence.owned_by_schema || - mainSequence.owned_by_table !== branchSequence.owned_by_table || - mainSequence.owned_by_column !== branchSequence.owned_by_column; - - if (ownedByChanged) { - const ownedBy = - branchSequence.owned_by_schema && - branchSequence.owned_by_table && - branchSequence.owned_by_column - ? { - schema: branchSequence.owned_by_schema, - table: branchSequence.owned_by_table, - column: branchSequence.owned_by_column, - } - : null; - changes.push( - new AlterSequenceSetOwnedBy({ sequence: mainSequence, ownedBy }), - ); - } - - // COMMENT - if (mainSequence.comment !== branchSequence.comment) { - if (branchSequence.comment === null) { - changes.push(new DropCommentOnSequence({ sequence: mainSequence })); - } else { - changes.push( - new CreateCommentOnSequence({ sequence: branchSequence }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnSequence | DropSecurityLabelOnSequence - >( - mainSequence.security_labels, - branchSequence.security_labels, - (securityLabel) => - new CreateSecurityLabelOnSequence({ - sequence: branchSequence, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnSequence({ - sequence: mainSequence, - securityLabel, - }), - ), - ); - - // PRIVILEGES - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainSequence.privileges, - branchSequence.privileges, - branchSequence.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchSequence, - mainSequence, - "sequence", - { - Grant: GrantSequencePrivileges, - Revoke: RevokeSequencePrivileges, - RevokeGrantOption: RevokeGrantOptionSequencePrivileges, - }, - ctx.version, - ) as SequenceChange[]), - ); - - // Note: Sequence renaming would also use ALTER SEQUENCE ... RENAME TO ... - // But since our Sequence model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.model.ts b/packages/pg-delta/src/core/objects/sequence/sequence.model.ts deleted file mode 100644 index c199c9735..000000000 --- a/packages/pg-delta/src/core/objects/sequence/sequence.model.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -const sequencePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - data_type: z.string(), - start_value: z.number(), - minimum_value: z.bigint(), - maximum_value: z.bigint(), - increment: z.number(), - cycle_option: z.boolean(), - cache_size: z.number(), - persistence: z.string(), - owned_by_schema: z.string().nullable(), - owned_by_table: z.string().nullable(), - owned_by_column: z.string().nullable(), - comment: z.string().nullable(), - privileges: z.array(privilegePropsSchema), - owner: z.string(), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -type SequencePrivilegeProps = PrivilegeProps; -export type SequenceProps = z.infer; - -export class Sequence extends BasePgModel { - public readonly schema: SequenceProps["schema"]; - public readonly name: SequenceProps["name"]; - public readonly data_type: SequenceProps["data_type"]; - public readonly start_value: SequenceProps["start_value"]; - public readonly minimum_value: SequenceProps["minimum_value"]; - public readonly maximum_value: SequenceProps["maximum_value"]; - public readonly increment: SequenceProps["increment"]; - public readonly cycle_option: SequenceProps["cycle_option"]; - public readonly cache_size: SequenceProps["cache_size"]; - public readonly persistence: SequenceProps["persistence"]; - public readonly owned_by_schema: SequenceProps["owned_by_schema"]; - public readonly owned_by_table: SequenceProps["owned_by_table"]; - public readonly owned_by_column: SequenceProps["owned_by_column"]; - public readonly comment: SequenceProps["comment"]; - public readonly privileges: SequencePrivilegeProps[]; - public readonly owner: SequenceProps["owner"]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: SequenceProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.data_type = props.data_type; - this.start_value = props.start_value; - this.minimum_value = props.minimum_value; - this.maximum_value = props.maximum_value; - this.increment = props.increment; - this.cycle_option = props.cycle_option; - this.cache_size = props.cache_size; - this.persistence = props.persistence; - this.owned_by_schema = props.owned_by_schema; - this.owned_by_table = props.owned_by_table; - this.owned_by_column = props.owned_by_column; - this.comment = props.comment; - this.privileges = props.privileges; - this.owner = props.owner; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `sequence:${string}` { - return `sequence:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - data_type: this.data_type, - start_value: this.start_value, - minimum_value: this.minimum_value, - maximum_value: this.maximum_value, - increment: this.increment, - cycle_option: this.cycle_option, - cache_size: this.cache_size, - persistence: this.persistence, - owned_by_schema: this.owned_by_schema, - owned_by_table: this.owned_by_table, - owned_by_column: this.owned_by_column, - comment: this.comment, - privileges: this.privileges, - owner: this.owner, - security_labels: this.security_labels, - }; - } -} - -export async function extractSequences(pool: Pool): Promise { - const { rows: sequenceRows } = await pool.query(sql` -with extension_sequence_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass -), -extension_table_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass - and d.deptype = 'e' -) -select - c.relnamespace::regnamespace::text as schema, - quote_ident(c.relname) as name, - format_type(s.seqtypid, null) as data_type, - s.seqstart::int as start_value, - s.seqmin as minimum_value, - s.seqmax as maximum_value, - s.seqincrement::int as increment, - s.seqcycle as cycle_option, - s.seqcache::int as cache_size, - c.relpersistence as persistence, - quote_ident(t_ns.nspname) as owned_by_schema, - case when t.relname is not null then quote_ident(t.relname) else null end as owned_by_table, - case when att.attname is not null then quote_ident(att.attname) else null end as owned_by_column, - obj_description(c.oid, 'pg_class') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(COALESCE(c.relacl, acldefault('S', c.relowner))) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges, - c.relowner::regrole::text as owner, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = c.oid - and sl.classoid = 'pg_class'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from - pg_catalog.pg_class c - inner join pg_catalog.pg_sequence s on s.seqrelid = c.oid - left join pg_depend d on d.classid = 'pg_class'::regclass and d.objid = c.oid and d.refclassid = 'pg_class'::regclass and d.deptype = 'a' - left join pg_class t on t.oid = d.refobjid - left join pg_namespace t_ns on t.relnamespace = t_ns.oid - left join pg_attribute att on att.attrelid = t.oid and att.attnum = d.refobjsubid and d.refobjsubid > 0 - left outer join extension_sequence_oids e_seq on c.oid = e_seq.objid - left outer join extension_table_oids e_table on t.oid = e_table.objid - where not c.relnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e_seq.objid is null - and (t.oid is null or e_table.objid is null) - and c.relkind = 'S' - -- exclude sequences that are tied to an IDENTITY column - and not exists ( - select 1 - from pg_depend di - where di.classid = 'pg_class'::regclass - and di.objid = c.oid - and di.refclassid = 'pg_class'::regclass - and di.deptype = 'i' - ) -order by - 1, 2 - `); - // Validate and parse each row using the Zod schema - const validatedRows = sequenceRows.map((row: unknown) => - sequencePropsSchema.parse(row), - ); - return validatedRows.map((row: SequenceProps) => new Sequence(row)); -} diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.alter.test.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.alter.test.ts deleted file mode 100644 index 7fc43cf6e..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.alter.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Subscription } from "../subscription.model.ts"; -import { - AlterSubscriptionDisable, - AlterSubscriptionEnable, - AlterSubscriptionSetConnection, - AlterSubscriptionSetOptions, - AlterSubscriptionSetOwner, - AlterSubscriptionSetPublication, -} from "./subscription.alter.ts"; - -type SubscriptionProps = ConstructorParameters[0]; - -const base: SubscriptionProps = { - name: "sub_base", - raw_name: "sub_base", - owner: "owner1", - comment: null, - enabled: true, - binary: false, - streaming: "off", - two_phase: false, - disable_on_error: false, - password_required: true, - run_as_owner: false, - failover: false, - conninfo: "host=example dbname=postgres", - slot_name: null, - slot_is_none: false, - replication_slot_created: true, - synchronous_commit: "off", - publications: ["pub_base"], - origin: "any", -}; - -const makeSubscription = (override: Partial = {}) => - new Subscription({ - ...base, - ...override, - publications: override.publications - ? [...override.publications] - : [...base.publications], - }); - -describe("subscription.alter", () => { - test("set connection serializes conninfo literal", async () => { - const subscription = makeSubscription({ - conninfo: "dbname=postgres host=replica", - }); - const change = new AlterSubscriptionSetConnection({ subscription }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SUBSCRIPTION sub_base CONNECTION 'dbname=postgres host=replica'", - ); - }); - - test("set publication preserves ordering and refresh hint when disabled", async () => { - const enabledSubscription = makeSubscription({ - publications: ["pub_a", "pub_b"], - enabled: true, - }); - const enabledChange = new AlterSubscriptionSetPublication({ - subscription: enabledSubscription, - }); - - await assertValidSql(enabledChange.serialize()); - - expect(enabledChange.serialize()).toBe( - "ALTER SUBSCRIPTION sub_base SET PUBLICATION pub_a, pub_b", - ); - - const disabledSubscription = makeSubscription({ - publications: ["pub_b", "pub_a"], - enabled: false, - }); - const disabledChange = new AlterSubscriptionSetPublication({ - subscription: disabledSubscription, - }); - - await assertValidSql(disabledChange.serialize()); - - expect(disabledChange.serialize()).toBe( - "ALTER SUBSCRIPTION sub_base SET PUBLICATION pub_a, pub_b WITH (refresh = false)", - ); - }); - - test("toggle enablement serializes ENABLE and DISABLE statements", async () => { - const subscription = makeSubscription(); - - expect(new AlterSubscriptionEnable({ subscription }).serialize()).toBe( - "ALTER SUBSCRIPTION sub_base ENABLE", - ); - expect(new AlterSubscriptionDisable({ subscription }).serialize()).toBe( - "ALTER SUBSCRIPTION sub_base DISABLE", - ); - }); - - test("set options delegates to option formatter", async () => { - const subscription = makeSubscription({ - slot_name: "custom_slot", - slot_is_none: false, - disable_on_error: true, - origin: "none", - }); - const change = new AlterSubscriptionSetOptions({ - subscription, - options: ["slot_name", "disable_on_error", "origin"], - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER SUBSCRIPTION sub_base SET (slot_name = 'custom_slot', disable_on_error = true, origin = 'none')", - ); - }); - - test("set owner tracks role dependency", async () => { - const subscription = makeSubscription(); - const change = new AlterSubscriptionSetOwner({ - subscription, - owner: "new_owner", - }); - - expect(change.requires).toEqual([stableId.role("new_owner")]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER SUBSCRIPTION sub_base OWNER TO new_owner", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.alter.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.alter.ts deleted file mode 100644 index 9df55762c..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.alter.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Subscription } from "../subscription.model.ts"; -import { - formatSubscriptionOption, - type SubscriptionSettableOption, -} from "../utils.ts"; -import { AlterSubscriptionChange } from "./subscription.base.ts"; - -export class AlterSubscriptionSetConnection extends AlterSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "object" as const; - - constructor(props: { subscription: Subscription }) { - super(); - this.subscription = props.subscription; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER SUBSCRIPTION ${this.subscription.name} CONNECTION ${quoteLiteral(this.subscription.conninfo)}`; - } -} - -export class AlterSubscriptionSetPublication extends AlterSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "object" as const; - - constructor(props: { subscription: Subscription }) { - super(); - this.subscription = props.subscription; - } - - // When the subscription is enabled, serialize() keeps the refresh = true - // default, which PostgreSQL rejects inside a transaction block. - override get nonTransactional() { - return this.subscription.enabled; - } - - serialize(_options?: SerializeOptions): string { - const base = `ALTER SUBSCRIPTION ${this.subscription.name} SET PUBLICATION ${this.subscription.publications.join(", ")}`; - if (!this.subscription.enabled) { - return `${base} WITH (refresh = false)`; - } - return base; - } -} - -export class AlterSubscriptionEnable extends AlterSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "object" as const; - - constructor(props: { subscription: Subscription }) { - super(); - this.subscription = props.subscription; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER SUBSCRIPTION ${this.subscription.name} ENABLE`; - } -} - -export class AlterSubscriptionDisable extends AlterSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "object" as const; - - constructor(props: { subscription: Subscription }) { - super(); - this.subscription = props.subscription; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER SUBSCRIPTION ${this.subscription.name} DISABLE`; - } -} - -export class AlterSubscriptionSetOptions extends AlterSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "object" as const; - private readonly options: SubscriptionSettableOption[]; - - constructor(props: { - subscription: Subscription; - options: SubscriptionSettableOption[]; - }) { - super(); - this.subscription = props.subscription; - this.options = props.options; - } - - serialize(_options?: SerializeOptions): string { - const assignments = this.options.map((option) => - formatSubscriptionOption(this.subscription, option), - ); - return `ALTER SUBSCRIPTION ${this.subscription.name} SET (${assignments.join(", ")})`; - } -} - -export class AlterSubscriptionSetOwner extends AlterSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "object" as const; - public readonly owner: string; - - constructor(props: { subscription: Subscription; owner: string }) { - super(); - this.subscription = props.subscription; - this.owner = props.owner; - } - - get requires() { - return [stableId.role(this.owner)]; - } - - serialize(_options?: SerializeOptions): string { - return `ALTER SUBSCRIPTION ${this.subscription.name} OWNER TO ${this.owner}`; - } -} diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.base.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.base.ts deleted file mode 100644 index d5c998649..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Subscription } from "../subscription.model.ts"; - -abstract class BaseSubscriptionChange extends BaseChange { - abstract readonly subscription: Subscription; - abstract readonly scope: "object" | "comment" | "security_label"; - readonly objectType = "subscription" as const; -} - -export abstract class CreateSubscriptionChange extends BaseSubscriptionChange { - readonly operation = "create" as const; -} - -export abstract class AlterSubscriptionChange extends BaseSubscriptionChange { - readonly operation = "alter" as const; -} - -export abstract class DropSubscriptionChange extends BaseSubscriptionChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.comment.test.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.comment.test.ts deleted file mode 100644 index 78221d434..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.comment.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Subscription } from "../subscription.model.ts"; -import { - CreateCommentOnSubscription, - DropCommentOnSubscription, -} from "./subscription.comment.ts"; - -type SubscriptionProps = ConstructorParameters[0]; - -const base: SubscriptionProps = { - name: "sub_base", - raw_name: "sub_base", - owner: "owner1", - comment: null, - enabled: true, - binary: false, - streaming: "off", - two_phase: false, - disable_on_error: false, - password_required: true, - run_as_owner: false, - failover: false, - conninfo: "host=example dbname=postgres", - slot_name: null, - slot_is_none: false, - replication_slot_created: true, - synchronous_commit: "off", - publications: ["pub_base"], - origin: "any", -}; - -const makeSubscription = (override: Partial = {}) => - new Subscription({ - ...base, - ...override, - publications: override.publications - ? [...override.publications] - : [...base.publications], - }); - -describe("subscription.comment", () => { - test("create comment serializes and declares dependencies", async () => { - const subscription = makeSubscription({ - comment: "subscription's metadata", - }); - const change = new CreateCommentOnSubscription({ subscription }); - - expect(change.creates).toEqual([stableId.comment(subscription.stableId)]); - expect(change.requires).toEqual([subscription.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "COMMENT ON SUBSCRIPTION sub_base IS 'subscription''s metadata'", - ); - }); - - test("drop comment serializes and tracks drops", async () => { - const subscription = makeSubscription({ comment: "not used" }); - const change = new DropCommentOnSubscription({ subscription }); - - expect(change.drops).toEqual([stableId.comment(subscription.stableId)]); - expect(change.requires).toEqual([ - stableId.comment(subscription.stableId), - subscription.stableId, - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("COMMENT ON SUBSCRIPTION sub_base IS NULL"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.comment.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.comment.ts deleted file mode 100644 index ae546f090..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.comment.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Subscription } from "../subscription.model.ts"; -import { - CreateSubscriptionChange, - DropSubscriptionChange, -} from "./subscription.base.ts"; - -export type CommentSubscription = - | CreateCommentOnSubscription - | DropCommentOnSubscription; - -export class CreateCommentOnSubscription extends CreateSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "comment" as const; - - constructor(props: { subscription: Subscription }) { - super(); - this.subscription = props.subscription; - } - - get creates() { - return [stableId.comment(this.subscription.stableId)]; - } - - get requires() { - return [this.subscription.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON SUBSCRIPTION", - this.subscription.name, - "IS", - // biome-ignore lint/style/noNonNullAssertion: ensures comment provided by caller - quoteLiteral(this.subscription.comment!), - ].join(" "); - } -} - -export class DropCommentOnSubscription extends DropSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "comment" as const; - - constructor(props: { subscription: Subscription }) { - super(); - this.subscription = props.subscription; - } - - get drops() { - return [stableId.comment(this.subscription.stableId)]; - } - - get requires() { - return [ - stableId.comment(this.subscription.stableId), - this.subscription.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return `COMMENT ON SUBSCRIPTION ${this.subscription.name} IS NULL`; - } -} diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.create.test.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.create.test.ts deleted file mode 100644 index 6d91c6000..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.create.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { Subscription } from "../subscription.model.ts"; -import { CreateSubscription } from "./subscription.create.ts"; - -type SubscriptionProps = ConstructorParameters[0]; - -const base: SubscriptionProps = { - name: "sub_base", - raw_name: "sub_base", - owner: "owner1", - comment: null, - enabled: true, - binary: false, - streaming: "off", - two_phase: false, - disable_on_error: false, - password_required: true, - run_as_owner: false, - failover: false, - conninfo: "host=example dbname=postgres", - slot_name: null, - slot_is_none: false, - replication_slot_created: true, - synchronous_commit: "off", - publications: ["pub_base"], - origin: "any", -}; - -const makeSubscription = (override: Partial = {}) => - new Subscription({ - ...base, - ...override, - publications: override.publications - ? [...override.publications] - : [...base.publications], - }); - -describe("subscription.create", () => { - test("serialize minimal subscription", async () => { - const subscription = makeSubscription(); - const change = new CreateSubscription({ subscription }); - - expect(change.creates).toEqual([subscription.stableId]); - expect(change.requires).toEqual([stableId.role(subscription.owner)]); - await assertValidSql(change.serialize()); - // The slot already exists on the publisher, so the statement must reuse - // it: create_slot defaults to true and would fail with "replication slot - // already exists" (and 25001 inside a transaction block). - expect(change.serialize()).toBe( - "CREATE SUBSCRIPTION sub_base CONNECTION 'host=example dbname=postgres' PUBLICATION pub_base WITH (create_slot = false)", - ); - }); - - test("serialize subscription with extended options", async () => { - const subscription = makeSubscription({ - enabled: false, - binary: true, - streaming: "parallel", - two_phase: true, - disable_on_error: true, - password_required: false, - run_as_owner: true, - failover: true, - conninfo: "dbname=postgres application_name=sub_base", - slot_name: "custom_slot", - slot_is_none: false, - replication_slot_created: false, - synchronous_commit: "local", - publications: ["pub_b", "pub_a"], - origin: "none", - }); - - const change = new CreateSubscription({ subscription }); - - expect(change.requires).toEqual([stableId.role(subscription.owner)]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "CREATE SUBSCRIPTION sub_base CONNECTION 'dbname=postgres application_name=sub_base' PUBLICATION pub_a, pub_b WITH (enabled = false, slot_name = 'custom_slot', binary = true, streaming = 'parallel', synchronous_commit = 'local', two_phase = true, disable_on_error = true, password_required = false, run_as_owner = true, origin = 'none', failover = true, create_slot = false, connect = false)", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.create.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.create.ts deleted file mode 100644 index 0f8cf50bc..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.create.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -// src/objects/subscription/changes/subscription.create.ts -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Subscription } from "../subscription.model.ts"; -import { collectSubscriptionOptions } from "../utils.ts"; -import { CreateSubscriptionChange } from "./subscription.base.ts"; - -export class CreateSubscription extends CreateSubscriptionChange { - readonly subscription: Subscription; - readonly scope = "object" as const; - - constructor(props: { subscription: Subscription }) { - super(); - this.subscription = props.subscription; - } - - get creates() { - return [this.subscription.stableId]; - } - - get requires() { - return [stableId.role(this.subscription.owner)]; - } - - // No nonTransactional override: PostgreSQL's transaction-block gate for - // CREATE SUBSCRIPTION is on create_slot = true, and serialize() always - // emits create_slot = false (either reusing an existing slot or skipping - // the connect entirely). - - serialize(_options?: SerializeOptions): string { - const parts: string[] = [ - "CREATE SUBSCRIPTION", - this.subscription.name, - "CONNECTION", - quoteLiteral(this.subscription.conninfo), - "PUBLICATION", - this.subscription.publications.join(", "), - ]; - - const optionEntries = collectSubscriptionOptions(this.subscription, { - includeTwoPhase: true, - includeEnabled: true, - }); - const optionsMap = new Map( - optionEntries.map(({ key, value }) => [key, value]), - ); - - if (this.subscription.replication_slot_created) { - // The slot already exists on the publisher: keep the connect = true - // default so it is looked up, but never recreated. - optionsMap.set("create_slot", "false"); - } else { - optionsMap.set("create_slot", "false"); - optionsMap.set("connect", "false"); - - const defaultSlotName = this.subscription.raw_name; - const slotName = this.subscription.slot_name ?? defaultSlotName; - const shouldUseNone = - this.subscription.slot_is_none || slotName === defaultSlotName; - - if (shouldUseNone) { - optionsMap.set("slot_name", "NONE"); - } else { - optionsMap.set("slot_name", quoteLiteral(slotName)); - } - } - - const withOptions = Array.from(optionsMap.entries()).map( - ([key, value]) => `${key} = ${value}`, - ); - - if (withOptions.length > 0) { - parts.push("WITH", `(${withOptions.join(", ")})`); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.drop.test.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.drop.test.ts deleted file mode 100644 index 7399b353b..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.drop.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Subscription } from "../subscription.model.ts"; -import { DropSubscription } from "./subscription.drop.ts"; - -type SubscriptionProps = ConstructorParameters[0]; - -const base: SubscriptionProps = { - name: "sub_base", - raw_name: "sub_base", - owner: "owner1", - comment: null, - enabled: true, - binary: false, - streaming: "off", - two_phase: false, - disable_on_error: false, - password_required: true, - run_as_owner: false, - failover: false, - conninfo: "host=example dbname=postgres", - slot_name: null, - slot_is_none: false, - replication_slot_created: true, - synchronous_commit: "off", - publications: ["pub_base"], - origin: "any", -}; - -const makeSubscription = (override: Partial = {}) => - new Subscription({ - ...base, - ...override, - publications: override.publications - ? [...override.publications] - : [...base.publications], - }); - -describe("subscription.drop", () => { - test("serialize drop subscription", async () => { - const subscription = makeSubscription(); - const change = new DropSubscription({ subscription }); - - expect(change.drops).toEqual([subscription.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("DROP SUBSCRIPTION sub_base"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.drop.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.drop.ts deleted file mode 100644 index 801566f13..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.drop.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Subscription } from "../subscription.model.ts"; -import { DropSubscriptionChange } from "./subscription.base.ts"; - -export class DropSubscription extends DropSubscriptionChange { - public readonly subscription: Subscription; - public readonly scope = "object" as const; - - constructor(props: { subscription: Subscription }) { - super(); - this.subscription = props.subscription; - } - - get drops() { - return [this.subscription.stableId]; - } - - // PostgreSQL forbids DROP SUBSCRIPTION inside a transaction block when a - // replication slot is associated with the subscription. - override get nonTransactional() { - return !this.subscription.slot_is_none; - } - - serialize(_options?: SerializeOptions): string { - return `DROP SUBSCRIPTION ${this.subscription.name}`; - } -} diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.security-label.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.security-label.ts deleted file mode 100644 index 10918f759..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.security-label.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Subscription } from "../subscription.model.ts"; -import { - CreateSubscriptionChange, - DropSubscriptionChange, -} from "./subscription.base.ts"; - -export type SecurityLabelSubscription = - | CreateSecurityLabelOnSubscription - | DropSecurityLabelOnSubscription; - -export class CreateSecurityLabelOnSubscription extends CreateSubscriptionChange { - public readonly subscription: Subscription; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - subscription: Subscription; - securityLabel: SecurityLabelProps; - }) { - super(); - this.subscription = props.subscription; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.subscription.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.subscription.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON SUBSCRIPTION", - this.subscription.name, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnSubscription extends DropSubscriptionChange { - public readonly subscription: Subscription; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - subscription: Subscription; - securityLabel: SecurityLabelProps; - }) { - super(); - this.subscription = props.subscription; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.subscription.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.subscription.stableId, - this.securityLabel.provider, - ), - this.subscription.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON SUBSCRIPTION", - this.subscription.name, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.traits.test.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.traits.test.ts deleted file mode 100644 index ed06ff9d3..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.traits.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Subscription, SubscriptionProps } from "../subscription.model.ts"; -import { Subscription as SubscriptionModel } from "../subscription.model.ts"; -import { AlterSubscriptionSetPublication } from "./subscription.alter.ts"; -import { CreateSubscription } from "./subscription.create.ts"; -import { DropSubscription } from "./subscription.drop.ts"; - -function makeSubscription( - overrides: Partial = {}, -): Subscription { - return new SubscriptionModel({ - name: "sub_orders", - raw_name: "sub_orders", - owner: "postgres", - comment: null, - enabled: false, - binary: false, - streaming: "off", - two_phase: false, - disable_on_error: false, - password_required: true, - run_as_owner: false, - failover: false, - conninfo: "host=publisher dbname=app", - slot_name: "sub_orders", - slot_is_none: false, - replication_slot_created: false, - synchronous_commit: "off", - publications: ["pub_orders"], - origin: "any", - ...overrides, - }); -} - -describe("subscription transaction-block traits", () => { - test("CREATE SUBSCRIPTION is always transactional", () => { - // PostgreSQL's transaction-block gate is on create_slot = true, and - // serialize() always emits create_slot = false: connect stays true when - // the slot already exists (it is reused, never recreated), and connect - // is false otherwise. - const withSlot = new CreateSubscription({ - subscription: makeSubscription({ replication_slot_created: true }), - }); - expect(withSlot.nonTransactional).toBe(false); - expect(withSlot.serialize()).toContain("create_slot = false"); - - const withoutSlot = new CreateSubscription({ - subscription: makeSubscription({ replication_slot_created: false }), - }); - expect(withoutSlot.nonTransactional).toBe(false); - expect(withoutSlot.serialize()).toContain("create_slot = false"); - }); - - test("ALTER SUBSCRIPTION SET PUBLICATION with implicit refresh cannot run in a transaction block", () => { - // serialize() omits WITH (refresh = false) when the subscription is - // enabled, and refresh = true is rejected inside a transaction block. - const change = new AlterSubscriptionSetPublication({ - subscription: makeSubscription({ enabled: true }), - }); - expect(change.nonTransactional).toBe(true); - }); - - test("ALTER SUBSCRIPTION SET PUBLICATION with refresh = false is transactional", () => { - const change = new AlterSubscriptionSetPublication({ - subscription: makeSubscription({ enabled: false }), - }); - expect(change.nonTransactional).toBe(false); - }); - - test("DROP SUBSCRIPTION with an associated slot cannot run in a transaction block", () => { - const change = new DropSubscription({ - subscription: makeSubscription({ slot_is_none: false }), - }); - expect(change.nonTransactional).toBe(true); - }); - - test("DROP SUBSCRIPTION with slot_name = NONE is transactional", () => { - const change = new DropSubscription({ - subscription: makeSubscription({ slot_is_none: true, slot_name: null }), - }); - expect(change.nonTransactional).toBe(false); - }); -}); diff --git a/packages/pg-delta/src/core/objects/subscription/changes/subscription.types.ts b/packages/pg-delta/src/core/objects/subscription/changes/subscription.types.ts deleted file mode 100644 index d346e1552..000000000 --- a/packages/pg-delta/src/core/objects/subscription/changes/subscription.types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - AlterSubscriptionDisable, - AlterSubscriptionEnable, - AlterSubscriptionSetConnection, - AlterSubscriptionSetOptions, - AlterSubscriptionSetOwner, - AlterSubscriptionSetPublication, -} from "./subscription.alter.ts"; -import type { CommentSubscription } from "./subscription.comment.ts"; -import type { CreateSubscription } from "./subscription.create.ts"; -import type { DropSubscription } from "./subscription.drop.ts"; -import type { SecurityLabelSubscription } from "./subscription.security-label.ts"; - -/** Union of all subscription-related change variants (`objectType: "subscription"`). @category Change Types */ -export type SubscriptionChange = - | CreateSubscription - | DropSubscription - | AlterSubscriptionSetConnection - | AlterSubscriptionSetPublication - | AlterSubscriptionEnable - | AlterSubscriptionDisable - | AlterSubscriptionSetOptions - | AlterSubscriptionSetOwner - | CommentSubscription - | SecurityLabelSubscription; diff --git a/packages/pg-delta/src/core/objects/subscription/subscription.diff.test.ts b/packages/pg-delta/src/core/objects/subscription/subscription.diff.test.ts deleted file mode 100644 index b5c12bd94..000000000 --- a/packages/pg-delta/src/core/objects/subscription/subscription.diff.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { - AlterSubscriptionDisable, - AlterSubscriptionEnable, - AlterSubscriptionSetConnection, - AlterSubscriptionSetOptions, - AlterSubscriptionSetOwner, - AlterSubscriptionSetPublication, -} from "./changes/subscription.alter.ts"; -import { - CreateCommentOnSubscription, - DropCommentOnSubscription, -} from "./changes/subscription.comment.ts"; -import { CreateSubscription } from "./changes/subscription.create.ts"; -import { DropSubscription } from "./changes/subscription.drop.ts"; -import { diffSubscriptions } from "./subscription.diff.ts"; -import { Subscription, type SubscriptionProps } from "./subscription.model.ts"; - -const ctx: Pick = { - currentUser: "postgres", -}; - -const baseProps: SubscriptionProps = { - name: "mysub", - raw_name: "mysub", - owner: "postgres", - comment: null, - enabled: true, - binary: false, - streaming: "off", - two_phase: false, - disable_on_error: false, - password_required: true, - run_as_owner: false, - failover: false, - conninfo: "host=localhost port=5432 dbname=postgres", - slot_name: null, - slot_is_none: false, - replication_slot_created: true, - synchronous_commit: "off", - publications: ["pub_a"], - origin: "any", -}; - -describe.concurrent("subscription.diff", () => { - test("create and drop subscription", () => { - const subscription = new Subscription(baseProps); - const created = diffSubscriptions( - ctx, - {}, - { [subscription.stableId]: subscription }, - ); - expect(created.some((change) => change instanceof CreateSubscription)).toBe( - true, - ); - - const dropped = diffSubscriptions( - ctx, - { [subscription.stableId]: subscription }, - {}, - ); - expect(dropped.some((change) => change instanceof DropSubscription)).toBe( - true, - ); - }); - - test("detect connection string change", () => { - // conninfo changes are detected by diff, but filtered by integration filter - const mainSubscription = new Subscription(baseProps); - const branchSubscription = new Subscription({ - ...baseProps, - conninfo: "host=replica port=5433 dbname=postgres", - }); - const changes = diffSubscriptions( - ctx, - { [mainSubscription.stableId]: mainSubscription }, - { [branchSubscription.stableId]: branchSubscription }, - ); - // conninfo changes are detected (filtering happens at integration level) - expect( - changes.some( - (change) => change instanceof AlterSubscriptionSetConnection, - ), - ).toBe(true); - }); - - test("detect publication list change", () => { - const mainSubscription = new Subscription(baseProps); - const branchSubscription = new Subscription({ - ...baseProps, - publications: ["pub_a", "pub_b"], - }); - const changes = diffSubscriptions( - ctx, - { [mainSubscription.stableId]: mainSubscription }, - { [branchSubscription.stableId]: branchSubscription }, - ); - expect( - changes.some( - (change) => change instanceof AlterSubscriptionSetPublication, - ), - ).toBe(true); - }); - - test("detect enabled toggle", () => { - const mainSubscription = new Subscription(baseProps); - const branchDisabled = new Subscription({ - ...baseProps, - enabled: false, - }); - const disableChanges = diffSubscriptions( - ctx, - { [mainSubscription.stableId]: mainSubscription }, - { [branchDisabled.stableId]: branchDisabled }, - ); - expect( - disableChanges.some( - (change) => change instanceof AlterSubscriptionDisable, - ), - ).toBe(true); - - const enableChanges = diffSubscriptions( - ctx, - { [branchDisabled.stableId]: branchDisabled }, - { [mainSubscription.stableId]: mainSubscription }, - ); - expect( - enableChanges.some((change) => change instanceof AlterSubscriptionEnable), - ).toBe(true); - }); - - test("detect option changes including slot name overrides", () => { - const mainSubscription = new Subscription(baseProps); - const branchSubscription = new Subscription({ - ...baseProps, - binary: true, - streaming: "parallel", - synchronous_commit: "local", - disable_on_error: true, - password_required: false, - run_as_owner: true, - origin: "none", - failover: true, - slot_name: "custom_slot", - slot_is_none: false, - }); - const changes = diffSubscriptions( - ctx, - { [mainSubscription.stableId]: mainSubscription }, - { [branchSubscription.stableId]: branchSubscription }, - ); - const setOptionsChange = changes.find( - (change) => change instanceof AlterSubscriptionSetOptions, - ) as AlterSubscriptionSetOptions | undefined; - expect(setOptionsChange).toBeDefined(); - expect(setOptionsChange?.serialize()).toBe( - "ALTER SUBSCRIPTION mysub SET (slot_name = 'custom_slot', binary = true, streaming = 'parallel', synchronous_commit = 'local', disable_on_error = true, password_required = false, run_as_owner = true, origin = 'none', failover = true)", - ); - }); - - test("set slot name to NONE when branch subscription uses slotless configuration", () => { - const mainSubscription = new Subscription(baseProps); - const branchSubscription = new Subscription({ - ...baseProps, - slot_name: null, - slot_is_none: true, - replication_slot_created: false, - }); - const changes = diffSubscriptions( - ctx, - { [mainSubscription.stableId]: mainSubscription }, - { [branchSubscription.stableId]: branchSubscription }, - ); - const setOptionsChange = changes.find( - (change) => change instanceof AlterSubscriptionSetOptions, - ) as AlterSubscriptionSetOptions | undefined; - expect(setOptionsChange).toBeDefined(); - expect(setOptionsChange?.serialize()).toBe( - "ALTER SUBSCRIPTION mysub SET (slot_name = NONE)", - ); - }); - - test("owner and comment changes", () => { - const mainSubscription = new Subscription(baseProps); - const branchSubscription = new Subscription({ - ...baseProps, - owner: "other_role", - comment: "replication subscription", - }); - const changes = diffSubscriptions( - ctx, - { [mainSubscription.stableId]: mainSubscription }, - { [branchSubscription.stableId]: branchSubscription }, - ); - expect( - changes.some((change) => change instanceof AlterSubscriptionSetOwner), - ).toBe(true); - expect( - changes.some((change) => change instanceof CreateCommentOnSubscription), - ).toBe(true); - - const removeCommentSubscription = new Subscription({ - ...baseProps, - comment: null, - }); - const dropCommentChanges = diffSubscriptions( - ctx, - { [branchSubscription.stableId]: branchSubscription }, - { [removeCommentSubscription.stableId]: removeCommentSubscription }, - ); - expect( - dropCommentChanges.some( - (change) => change instanceof DropCommentOnSubscription, - ), - ).toBe(true); - }); - - test("two_phase change triggers drop and recreate", () => { - const mainSubscription = new Subscription(baseProps); - const branchSubscription = new Subscription({ - ...baseProps, - two_phase: true, - }); - const changes = diffSubscriptions( - ctx, - { [mainSubscription.stableId]: mainSubscription }, - { [branchSubscription.stableId]: branchSubscription }, - ); - expect( - changes.filter((change) => change instanceof DropSubscription).length, - ).toBe(1); - expect( - changes.filter((change) => change instanceof CreateSubscription).length, - ).toBe(1); - }); -}); diff --git a/packages/pg-delta/src/core/objects/subscription/subscription.diff.ts b/packages/pg-delta/src/core/objects/subscription/subscription.diff.ts deleted file mode 100644 index 00ee8c5d0..000000000 --- a/packages/pg-delta/src/core/objects/subscription/subscription.diff.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { hasNonAlterableChanges } from "../utils.ts"; -import { - AlterSubscriptionDisable, - AlterSubscriptionEnable, - AlterSubscriptionSetConnection, - AlterSubscriptionSetOptions, - AlterSubscriptionSetOwner, - AlterSubscriptionSetPublication, -} from "./changes/subscription.alter.ts"; -import { - CreateCommentOnSubscription, - DropCommentOnSubscription, -} from "./changes/subscription.comment.ts"; -import { CreateSubscription } from "./changes/subscription.create.ts"; -import { DropSubscription } from "./changes/subscription.drop.ts"; -import { - CreateSecurityLabelOnSubscription, - DropSecurityLabelOnSubscription, -} from "./changes/subscription.security-label.ts"; -import type { SubscriptionChange } from "./changes/subscription.types.ts"; -import type { Subscription } from "./subscription.model.ts"; -import type { SubscriptionSettableOption } from "./utils.ts"; - -const NON_ALTERABLE_FIELDS: Array = [ - "two_phase", -]; - -const SETTABLE_OPTIONS: SubscriptionSettableOption[] = [ - "slot_name", - "binary", - "streaming", - "synchronous_commit", - "disable_on_error", - "password_required", - "run_as_owner", - "origin", - "failover", -]; - -export function diffSubscriptions( - ctx: Pick, - main: Record, - branch: Record, -): SubscriptionChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - const changes: SubscriptionChange[] = []; - - for (const id of created) { - const subscription = branch[id]; - changes.push(new CreateSubscription({ subscription })); - - // OWNER: If the subscription should be owned by someone other than the current user, - // emit ALTER SUBSCRIPTION ... OWNER TO after creation - if (subscription.owner !== ctx.currentUser) { - changes.push( - new AlterSubscriptionSetOwner({ - subscription, - owner: subscription.owner, - }), - ); - } - - if (subscription.comment !== null) { - changes.push(new CreateCommentOnSubscription({ subscription })); - } - for (const label of subscription.security_labels) { - changes.push( - new CreateSecurityLabelOnSubscription({ - subscription, - securityLabel: label, - }), - ); - } - } - - for (const id of dropped) { - changes.push(new DropSubscription({ subscription: main[id] })); - } - - for (const id of altered) { - const mainSubscription = main[id]; - const branchSubscription = branch[id]; - - if ( - hasNonAlterableChanges( - mainSubscription.dataFields, - branchSubscription.dataFields, - NON_ALTERABLE_FIELDS, - ) - ) { - changes.push(new DropSubscription({ subscription: mainSubscription })); - changes.push( - new CreateSubscription({ subscription: branchSubscription }), - ); - if (branchSubscription.comment !== null) { - changes.push( - new CreateCommentOnSubscription({ subscription: branchSubscription }), - ); - } - continue; - } - - if (mainSubscription.conninfo !== branchSubscription.conninfo) { - changes.push( - new AlterSubscriptionSetConnection({ - subscription: branchSubscription, - }), - ); - } - - const publicationsChanged = - mainSubscription.publications.length !== - branchSubscription.publications.length || - mainSubscription.publications.some( - (pub, index) => pub !== branchSubscription.publications[index], - ); - - if (publicationsChanged) { - changes.push( - new AlterSubscriptionSetPublication({ - subscription: branchSubscription, - }), - ); - } - - if (mainSubscription.enabled !== branchSubscription.enabled) { - if (branchSubscription.enabled) { - changes.push( - new AlterSubscriptionEnable({ subscription: branchSubscription }), - ); - } else { - changes.push( - new AlterSubscriptionDisable({ subscription: branchSubscription }), - ); - } - } - - const optionKeys: SubscriptionSettableOption[] = []; - for (const option of SETTABLE_OPTIONS) { - switch (option) { - case "slot_name": { - if ( - mainSubscription.slot_is_none !== branchSubscription.slot_is_none || - mainSubscription.slot_name !== branchSubscription.slot_name - ) { - optionKeys.push(option); - } - break; - } - case "binary": { - if (mainSubscription.binary !== branchSubscription.binary) { - optionKeys.push(option); - } - break; - } - case "streaming": { - if (mainSubscription.streaming !== branchSubscription.streaming) { - optionKeys.push(option); - } - break; - } - case "synchronous_commit": { - if ( - mainSubscription.synchronous_commit !== - branchSubscription.synchronous_commit - ) { - optionKeys.push(option); - } - break; - } - case "disable_on_error": { - if ( - mainSubscription.disable_on_error !== - branchSubscription.disable_on_error - ) { - optionKeys.push(option); - } - break; - } - case "password_required": { - if ( - mainSubscription.password_required !== - branchSubscription.password_required - ) { - optionKeys.push(option); - } - break; - } - case "run_as_owner": { - if ( - mainSubscription.run_as_owner !== branchSubscription.run_as_owner - ) { - optionKeys.push(option); - } - break; - } - case "origin": { - if (mainSubscription.origin !== branchSubscription.origin) { - optionKeys.push(option); - } - break; - } - case "failover": { - if (mainSubscription.failover !== branchSubscription.failover) { - optionKeys.push(option); - } - break; - } - default: { - const _exhaustive: never = option; - void _exhaustive; - } - } - } - - if (optionKeys.length > 0) { - changes.push( - new AlterSubscriptionSetOptions({ - subscription: branchSubscription, - options: optionKeys, - }), - ); - } - - if (mainSubscription.owner !== branchSubscription.owner) { - changes.push( - new AlterSubscriptionSetOwner({ - subscription: branchSubscription, - owner: branchSubscription.owner, - }), - ); - } - - if (mainSubscription.comment !== branchSubscription.comment) { - if (branchSubscription.comment === null) { - if (mainSubscription.comment !== null) { - changes.push( - new DropCommentOnSubscription({ subscription: mainSubscription }), - ); - } - } else { - changes.push( - new CreateCommentOnSubscription({ - subscription: branchSubscription, - }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnSubscription | DropSecurityLabelOnSubscription - >( - mainSubscription.security_labels, - branchSubscription.security_labels, - (securityLabel) => - new CreateSecurityLabelOnSubscription({ - subscription: branchSubscription, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnSubscription({ - subscription: mainSubscription, - securityLabel, - }), - ), - ); - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/subscription/subscription.model.ts b/packages/pg-delta/src/core/objects/subscription/subscription.model.ts deleted file mode 100644 index 74e11da32..000000000 --- a/packages/pg-delta/src/core/objects/subscription/subscription.model.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { Pool } from "pg"; -import z from "zod"; -import { extractVersion } from "../../context.ts"; -import { BasePgModel } from "../base.model.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -/** - * Placeholder used in place of a subscription's real `subconninfo`. - * - * `normalizeCatalog` unconditionally replaces every subscription's conninfo - * with this value, so real connection strings (which carry credentials) never - * reach output. It is also substituted at extraction time when the connected - * role lacks SELECT on the superuser-only `pg_subscription.subconninfo` - * column, so non-superuser extraction does not fail — see supabase/cli#5826. - * - * Lives here (a leaf module) rather than in `catalog.model.ts` because - * `catalog.model.ts` imports from this file; importing the other direction - * would create a cycle. - */ -export const SUBSCRIPTION_CONNINFO_PLACEHOLDER = - "host=__CONN_HOST__ port=__CONN_PORT__ dbname=__CONN_DBNAME__ user=__CONN_USER__ password=__CONN_PASSWORD__"; - -const subscriptionPropsSchema = z.object({ - name: z.string(), - raw_name: z.string(), - owner: z.string(), - comment: z.string().nullable(), - enabled: z.boolean(), - binary: z.boolean(), - streaming: z.enum(["off", "on", "parallel"]), - two_phase: z.boolean(), - disable_on_error: z.boolean(), - password_required: z.boolean(), - run_as_owner: z.boolean(), - failover: z.boolean(), - conninfo: z.string(), - slot_name: z.string().nullable(), - slot_is_none: z.boolean(), - replication_slot_created: z.boolean(), - synchronous_commit: z.string(), - publications: z.array(z.string()), - origin: z.enum(["any", "none"]), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -export type SubscriptionProps = z.infer; - -export class Subscription extends BasePgModel { - public readonly name: SubscriptionProps["name"]; - public readonly raw_name: SubscriptionProps["raw_name"]; - public readonly owner: SubscriptionProps["owner"]; - public readonly comment: SubscriptionProps["comment"]; - public readonly enabled: SubscriptionProps["enabled"]; - public readonly binary: SubscriptionProps["binary"]; - public readonly streaming: SubscriptionProps["streaming"]; - public readonly two_phase: SubscriptionProps["two_phase"]; - public readonly disable_on_error: SubscriptionProps["disable_on_error"]; - public readonly password_required: SubscriptionProps["password_required"]; - public readonly run_as_owner: SubscriptionProps["run_as_owner"]; - public readonly failover: SubscriptionProps["failover"]; - public readonly conninfo: SubscriptionProps["conninfo"]; - public readonly slot_name: SubscriptionProps["slot_name"]; - public readonly slot_is_none: SubscriptionProps["slot_is_none"]; - public readonly replication_slot_created: SubscriptionProps["replication_slot_created"]; - public readonly synchronous_commit: SubscriptionProps["synchronous_commit"]; - public readonly publications: SubscriptionProps["publications"]; - public readonly origin: SubscriptionProps["origin"]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: SubscriptionProps) { - super(); - - this.name = props.name; - this.raw_name = props.raw_name; - this.owner = props.owner; - this.comment = props.comment; - this.enabled = props.enabled; - this.binary = props.binary; - this.streaming = props.streaming; - this.two_phase = props.two_phase; - this.disable_on_error = props.disable_on_error; - this.password_required = props.password_required; - this.run_as_owner = props.run_as_owner; - this.failover = props.failover; - this.conninfo = props.conninfo; - this.slot_name = props.slot_name; - this.slot_is_none = props.slot_is_none; - this.replication_slot_created = props.replication_slot_created; - this.synchronous_commit = props.synchronous_commit; - this.publications = [...props.publications].sort((a, b) => - a.localeCompare(b), - ); - this.origin = props.origin; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `subscription:${string}` { - return `subscription:${this.name}`; - } - - get identityFields() { - return { - name: this.name, - }; - } - - get dataFields() { - return { - raw_name: this.raw_name, - owner: this.owner, - comment: this.comment, - enabled: this.enabled, - binary: this.binary, - streaming: this.streaming, - two_phase: this.two_phase, - disable_on_error: this.disable_on_error, - password_required: this.password_required, - run_as_owner: this.run_as_owner, - failover: this.failover, - conninfo: this.conninfo, - slot_name: this.slot_name, - slot_is_none: this.slot_is_none, - replication_slot_created: this.replication_slot_created, - synchronous_commit: this.synchronous_commit, - publications: this.publications, - origin: this.origin, - security_labels: this.security_labels, - }; - } -} - -export async function extractSubscriptions( - pool: Pool, -): Promise { - const version = await extractVersion(pool); - const isPostgres16OrGreater = version >= 160000; - const isPostgres17OrGreater = version >= 170000; - const isPostgres17_2OrGreater = version >= 170002; // failover added in 17.2 (170002) - const isPostgres17_3OrGreater = version >= 170003; // origin column added in 17.3 - - // Build the query dynamically based on PostgreSQL version - const passwordRequiredExpr = isPostgres16OrGreater - ? "s.subpasswordrequired" - : "true"; - const runAsOwnerExpr = isPostgres17OrGreater ? "s.subrunasowner" : "false"; - const failoverExpr = isPostgres17_2OrGreater ? "s.subfailover" : "false"; - const originExpr = isPostgres17_3OrGreater - ? "case s.suborigin when 'none' then 'none' else 'any' end" - : "'any'"; - - // `subconninfo` has SELECT revoked from PUBLIC on every supported PG version, - // so a bare `select s.*` fails for non-superusers even with zero - // subscriptions. Select only the PUBLIC-granted columns explicitly. - const versionGatedColumns = [ - isPostgres16OrGreater ? "s.subpasswordrequired" : null, - isPostgres17OrGreater ? "s.subrunasowner" : null, - isPostgres17_2OrGreater ? "s.subfailover" : null, - isPostgres17_3OrGreater ? "s.suborigin" : null, - ] - .filter((c): c is string => c !== null) - .map((c) => ` ${c},\n`) - .join(""); - - // Column privileges are checked at plan time for every column a query - // references, even inside a CASE branch that never executes — so we cannot - // guard `subconninfo` inline. Probe the privilege first and only reference - // the column when the reader can see it, otherwise substitute the redacted - // placeholder (which `normalizeCatalog` would apply anyway) so non-superuser - // extraction does not fail — see supabase/cli#5826. - const { rows: privRows } = await pool.query<{ has_conninfo: boolean }>( - "select has_column_privilege('pg_catalog.pg_subscription', 'subconninfo', 'SELECT') as has_conninfo", - ); - const conninfoExpr = privRows[0]?.has_conninfo - ? "s.subconninfo" - : `'${SUBSCRIPTION_CONNINFO_PLACEHOLDER}'`; - - const queryText = ` - with extension_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_subscription'::regclass - ), - scoped_subscriptions as ( - select - s.oid, - s.subdbid, - s.subname, - s.subowner, - s.subenabled, - s.subbinary, - s.substream, - s.subtwophasestate, - s.subdisableonerr, - s.subslotname, - s.subsynccommit, - s.subpublications, -${versionGatedColumns} ${conninfoExpr} as subconninfo - from pg_subscription s - where s.subdbid = (select oid from pg_database where datname = current_database()) - ) - select - quote_ident(s.subname) as name, - s.subname::text as raw_name, - s.subowner::regrole::text as owner, - obj_description(s.oid, 'pg_subscription') as comment, - s.subenabled as enabled, - s.subbinary as binary, - case s.substream::text - when 'f' then 'off' - when 't' then 'on' - when 'p' then 'parallel' - else 'off' - end as streaming, - (s.subtwophasestate <> 'd') as two_phase, - s.subdisableonerr as disable_on_error, - ${passwordRequiredExpr} as password_required, - ${runAsOwnerExpr} as run_as_owner, - ${failoverExpr} as failover, - s.subconninfo as conninfo, - case - when s.subslotname is null then null - when s.subslotname = s.subname then null - else s.subslotname::text - end as slot_name, - s.subslotname is null as slot_is_none, - (r.slot_name is not null) as replication_slot_created, - s.subsynccommit as synchronous_commit, - coalesce( - ( - select json_agg(quote_ident(pub) order by quote_ident(pub)) - from unnest(s.subpublications) as pub - ), - '[]'::json - ) as publications, - ${originExpr} as origin, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = s.oid - and sl.classoid = 'pg_subscription'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels - from scoped_subscriptions s - left join pg_replication_slots r - on r.slot_name = s.subslotname - and r.datoid = s.subdbid - left join extension_oids e on e.objid = s.oid - where e.objid is null - order by s.subname - `; - - const { rows } = await pool.query(queryText); - - const validated = rows.map((row) => subscriptionPropsSchema.parse(row)); - return validated.map((row) => new Subscription(row)); -} diff --git a/packages/pg-delta/src/core/objects/subscription/utils.ts b/packages/pg-delta/src/core/objects/subscription/utils.ts deleted file mode 100644 index ad92a8fe8..000000000 --- a/packages/pg-delta/src/core/objects/subscription/utils.ts +++ /dev/null @@ -1,156 +0,0 @@ -// src/objects/subscription/utils.ts -import { quoteLiteral } from "../base.change.ts"; -import type { Subscription } from "./subscription.model.ts"; - -/** - * Subscription parameters that can be manipulated via `ALTER SUBSCRIPTION ... SET (...)`. - * The list intentionally mirrors the options we surface in diff output. When you add a new - * entry here, make sure the corresponding serialization/version handling exists in - * `getSubscriptionOptionValue` and in the SQL emitter. - */ -export type SubscriptionSettableOption = - | "slot_name" - | "binary" - | "streaming" - | "synchronous_commit" - | "disable_on_error" - | "password_required" - | "run_as_owner" - | "origin" - | "failover"; - -interface CollectOptions { - includeEnabled?: boolean; - includeTwoPhase?: boolean; -} - -/** - * Resolve the textual value we should emit for a given subscription option. - * - * Each branch encodes the quirks we already normalize in the model. For example, - * `slot_name` collapses to `NONE` when the subscription was extracted without an - * associated logical slot, while `streaming` stays free-form because PG 17+ allows - * enumerated values (`on`/`off`/`parallel`). - */ -function getSubscriptionOptionValue( - subscription: Subscription, - option: SubscriptionSettableOption, -): string { - switch (option) { - case "slot_name": { - if (subscription.slot_is_none) return "NONE"; - if (subscription.slot_name) return quoteLiteral(subscription.slot_name); - return quoteLiteral(subscription.raw_name); - } - case "binary": - return subscription.binary ? "true" : "false"; - case "streaming": - return quoteLiteral(subscription.streaming); - case "synchronous_commit": - return quoteLiteral(subscription.synchronous_commit); - case "disable_on_error": - return subscription.disable_on_error ? "true" : "false"; - case "password_required": - return subscription.password_required ? "true" : "false"; - case "run_as_owner": - return subscription.run_as_owner ? "true" : "false"; - case "origin": - return quoteLiteral(subscription.origin); - case "failover": - return subscription.failover ? "true" : "false"; - default: { - const _exhaustive: never = option; - return _exhaustive; - } - } -} - -/** - * Convenience helper used by ALTER change classes. It stitches the option key/value - * into the canonical `"key = value"` form that PostgreSQL expects. - */ -export function formatSubscriptionOption( - subscription: Subscription, - option: SubscriptionSettableOption, -): string { - return `${option} = ${getSubscriptionOptionValue(subscription, option)}`; -} - -/** - * Collect all options that should accompany a CREATE/ALTER statement. - * - * This routine encapsulates the nuanced logic around default slot handling and - * version-dependent fields: - * - When `includeEnabled` is true we emit `enabled = false` for disabled subs so that - * recreating them does not inadvertently enable replication. - * - When we know no replication slot exists we force `slot_name = NONE`, plus - * `connect = false` / `create_slot = false` in the caller. - * - Optional flags (`streaming`, `password_required`, `origin`, etc.) are emitted only - * when their value deviates from the PostgreSQL defaults. - * - * Callers can toggle `includeTwoPhase` / `includeEnabled` to opt-in to those - * adjustments depending on which statement is being generated. - */ -export function collectSubscriptionOptions( - subscription: Subscription, - { includeEnabled = false, includeTwoPhase = false }: CollectOptions = {}, -) { - const entries: { key: string; value: string }[] = []; - - if (includeEnabled && !subscription.enabled) { - entries.push({ key: "enabled", value: "false" }); - } - - if (subscription.slot_is_none) { - entries.push({ key: "slot_name", value: "NONE" }); - } else if (subscription.slot_name) { - entries.push({ - key: "slot_name", - value: quoteLiteral(subscription.slot_name), - }); - } - - if (subscription.binary) { - entries.push({ key: "binary", value: "true" }); - } - - if (subscription.streaming !== "off") { - entries.push({ - key: "streaming", - value: quoteLiteral(subscription.streaming), - }); - } - - if (subscription.synchronous_commit !== "off") { - entries.push({ - key: "synchronous_commit", - value: quoteLiteral(subscription.synchronous_commit), - }); - } - - if (includeTwoPhase && subscription.two_phase) { - entries.push({ key: "two_phase", value: "true" }); - } - - if (subscription.disable_on_error) { - entries.push({ key: "disable_on_error", value: "true" }); - } - - if (!subscription.password_required) { - entries.push({ key: "password_required", value: "false" }); - } - - if (subscription.run_as_owner) { - entries.push({ key: "run_as_owner", value: "true" }); - } - - if (subscription.origin === "none") { - entries.push({ key: "origin", value: quoteLiteral("none") }); - } - - if (subscription.failover) { - entries.push({ key: "failover", value: "true" }); - } - - return entries; -} diff --git a/packages/pg-delta/src/core/objects/table/changes/table.alter.test.ts b/packages/pg-delta/src/core/objects/table/changes/table.alter.test.ts deleted file mode 100644 index 954e43bf2..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.alter.test.ts +++ /dev/null @@ -1,902 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import type { ColumnProps } from "../../base.model.ts"; -import { Table, type TableProps } from "../table.model.ts"; -import { - AlterTableAddColumn, - AlterTableAddConstraint, - AlterTableAlterColumnAddIdentity, - AlterTableAlterColumnDropDefault, - AlterTableAlterColumnDropIdentity, - AlterTableAlterColumnDropNotNull, - AlterTableAlterColumnSetDefault, - AlterTableAlterColumnSetGenerated, - AlterTableAlterColumnSetNotNull, - AlterTableAlterColumnType, - AlterTableAttachPartition, - AlterTableChangeOwner, - AlterTableDetachPartition, - AlterTableDisableRowLevelSecurity, - AlterTableDropColumn, - AlterTableDropConstraint, - AlterTableEnableRowLevelSecurity, - AlterTableForceRowLevelSecurity, - AlterTableNoForceRowLevelSecurity, - AlterTableResetStorageParams, - AlterTableSetLogged, - AlterTableSetReplicaIdentity, - AlterTableSetStorageParams, - AlterTableSetUnlogged, - AlterTableValidateConstraint, -} from "./table.alter.ts"; - -describe.concurrent("table", () => { - describe("alter", () => { - test("change owner", async () => { - const props: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const table = new Table({ - ...props, - owner: "old_owner", - }); - - const change = new AlterTableChangeOwner({ table, owner: "new_owner" }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER TABLE public.test_table OWNER TO new_owner", - ); - }); - - test("set unlogged", async () => { - const props: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const table = new Table({ ...props, owner: "o1", options: null }); - - const change = new AlterTableSetUnlogged({ table }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER TABLE public.test_table SET UNLOGGED", - ); - }); - - test("set logged", async () => { - const props: Omit = { - schema: "public", - name: "test_table", - persistence: "u", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const table = new Table({ ...props, owner: "o1", options: null }); - - const change = new AlterTableSetLogged({ table }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER TABLE public.test_table SET LOGGED", - ); - }); - - test("enable/disable row level security", async () => { - const base: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const enable = new AlterTableEnableRowLevelSecurity({ - table: new Table({ - ...base, - owner: "o1", - options: null, - row_security: false, - }), - }); - await assertValidSql(enable.serialize()); - expect(enable.serialize()).toBe( - "ALTER TABLE public.test_table ENABLE ROW LEVEL SECURITY", - ); - const disable = new AlterTableDisableRowLevelSecurity({ - table: new Table({ - ...base, - owner: "o1", - options: null, - row_security: true, - }), - }); - await assertValidSql(disable.serialize()); - expect(disable.serialize()).toBe( - "ALTER TABLE public.test_table DISABLE ROW LEVEL SECURITY", - ); - }); - - test("force/no force row level security", async () => { - const base: Omit = - { - schema: "public", - name: "test_table", - persistence: "p", - row_security: true, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const force = new AlterTableForceRowLevelSecurity({ - table: new Table({ - ...base, - owner: "o1", - options: null, - force_row_security: false, - }), - }); - await assertValidSql(force.serialize()); - expect(force.serialize()).toBe( - "ALTER TABLE public.test_table FORCE ROW LEVEL SECURITY", - ); - const noforce = new AlterTableNoForceRowLevelSecurity({ - table: new Table({ - ...base, - owner: "o1", - options: null, - force_row_security: true, - }), - }); - await assertValidSql(noforce.serialize()); - expect(noforce.serialize()).toBe( - "ALTER TABLE public.test_table NO FORCE ROW LEVEL SECURITY", - ); - }); - - test("set storage params", async () => { - const base: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const change = new AlterTableSetStorageParams({ - table: new Table({ ...base, owner: "o1", options: null }), - options: ["fillfactor=90"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER TABLE public.test_table SET (fillfactor=90)", - ); - }); - - test("reset storage params", async () => { - const base: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const table = new Table({ - ...base, - owner: "o1", - options: ["fillfactor=90", "autovacuum_enabled=true"], - }); - const change = new AlterTableResetStorageParams({ - table, - params: ["fillfactor", "autovacuum_enabled"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER TABLE public.test_table RESET (fillfactor, autovacuum_enabled)", - ); - }); - - test("replica identity default/nothing/full", async () => { - const baseProps: Omit< - TableProps, - "owner" | "options" | "replica_identity" - > = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const table = new Table({ - ...baseProps, - owner: "o1", - options: null, - replica_identity: "d", - }); - const toNothing = new Table({ - ...baseProps, - owner: "o1", - options: null, - replica_identity: "n", - }); - const toFull = new Table({ - ...baseProps, - owner: "o1", - options: null, - replica_identity: "f", - }); - expect( - new AlterTableSetReplicaIdentity({ - table, - mode: toNothing.replica_identity, - }).serialize(), - ).toBe("ALTER TABLE public.test_table REPLICA IDENTITY NOTHING"); - expect( - new AlterTableSetReplicaIdentity({ - table, - mode: toFull.replica_identity, - }).serialize(), - ).toBe("ALTER TABLE public.test_table REPLICA IDENTITY FULL"); - }); - - test("replica identity DEFAULT and USING INDEX", async () => { - const baseProps: Omit< - TableProps, - "owner" | "options" | "replica_identity" - > = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const table = new Table({ - ...baseProps, - owner: "o1", - options: null, - replica_identity: "n", - }); - expect( - new AlterTableSetReplicaIdentity({ - table, - mode: "d", - }).serialize(), - ).toBe("ALTER TABLE public.test_table REPLICA IDENTITY DEFAULT"); - const usingIndex = new AlterTableSetReplicaIdentity({ - table, - mode: "i", - indexName: "test_table_pkey", - }); - expect(usingIndex.serialize()).toBe( - "ALTER TABLE public.test_table REPLICA IDENTITY USING INDEX test_table_pkey", - ); - expect(usingIndex.requires).toContain( - "index:public.test_table.test_table_pkey", - ); - }); - - test("columns add/drop/alter", async () => { - const tableProps: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const colInt: ColumnProps = { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }; - const colText: ColumnProps = { - ...colInt, - name: "b", - data_type: "text", - data_type_str: "text", - }; - const colTextBefore: ColumnProps = { - ...colText, - data_type: "integer", - data_type_str: "integer", - }; - const withCols = new Table({ - ...tableProps, - owner: "o1", - options: null, - columns: [colInt], - }); - const changeAdd = new AlterTableAddColumn({ - table: withCols, - column: colInt, - }); - await assertValidSql(changeAdd.serialize()); - expect(changeAdd.serialize()).toBe( - "ALTER TABLE public.test_table ADD COLUMN a integer", - ); - - const dropFrom = new Table({ - ...tableProps, - owner: "o1", - options: null, - columns: [colInt, colText], - }); - const changeDrop = new AlterTableDropColumn({ - table: dropFrom, - column: colText, - }); - await assertValidSql(changeDrop.serialize()); - expect(changeDrop.serialize()).toBe( - "ALTER TABLE public.test_table DROP COLUMN b", - ); - - const changeType = new AlterTableAlterColumnType({ - table: withCols, - column: colText, - previousColumn: colTextBefore, - }); - await assertValidSql(changeType.serialize()); - expect(changeType.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN b TYPE text USING b::text", - ); - - const changeSetDefault = new AlterTableAlterColumnSetDefault({ - table: withCols, - column: { ...colInt, default: "0" }, - }); - await assertValidSql(changeSetDefault.serialize()); - expect(changeSetDefault.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN a SET DEFAULT 0", - ); - - const changeSetGeneratedExpression = new AlterTableAlterColumnSetDefault({ - table: withCols, - column: { - ...colText, - name: "computed_name", - is_generated: true, - default: "lower((b))", - }, - }); - await assertValidSql(changeSetGeneratedExpression.serialize()); - expect(changeSetGeneratedExpression.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN computed_name SET EXPRESSION AS (lower((b)))", - ); - - const changeDropDefault = new AlterTableAlterColumnDropDefault({ - table: withCols, - column: { ...colInt, default: null }, - }); - await assertValidSql(changeDropDefault.serialize()); - expect(changeDropDefault.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN a DROP DEFAULT", - ); - - const changeAddIdentity = new AlterTableAlterColumnAddIdentity({ - table: withCols, - column: { - ...colInt, - is_identity: true, - is_identity_always: true, - }, - }); - await assertValidSql(changeAddIdentity.serialize()); - expect(changeAddIdentity.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN a ADD GENERATED ALWAYS AS IDENTITY", - ); - - const changeSetGenerated = new AlterTableAlterColumnSetGenerated({ - table: withCols, - column: { - ...colInt, - is_identity: true, - is_identity_always: false, - }, - }); - await assertValidSql(changeSetGenerated.serialize()); - expect(changeSetGenerated.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN a SET GENERATED BY DEFAULT", - ); - - const changeDropIdentity = new AlterTableAlterColumnDropIdentity({ - table: withCols, - column: { ...colInt }, - }); - await assertValidSql(changeDropIdentity.serialize()); - expect(changeDropIdentity.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN a DROP IDENTITY", - ); - - const changeSetNotNull = new AlterTableAlterColumnSetNotNull({ - table: withCols, - column: { ...colInt, not_null: true }, - }); - await assertValidSql(changeSetNotNull.serialize()); - expect(changeSetNotNull.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN a SET NOT NULL", - ); - - const changeDropNotNull = new AlterTableAlterColumnDropNotNull({ - table: withCols, - column: { ...colInt, not_null: false }, - }); - await assertValidSql(changeDropNotNull.serialize()); - expect(changeDropNotNull.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN a DROP NOT NULL", - ); - }); - - test("add column with collation, default and not null", async () => { - const tableProps: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const withCols = new Table({ ...tableProps, owner: "o1", options: null }); - const col: ColumnProps = { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: "mycoll", - default: "0", - comment: null, - }; - const change = new AlterTableAddColumn({ table: withCols, column: col }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER TABLE public.test_table ADD COLUMN a integer COLLATE mycoll DEFAULT 0 NOT NULL", - ); - }); - - test("alter column type with collation", async () => { - const tableProps: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const withCols = new Table({ ...tableProps, owner: "o1", options: null }); - const col: ColumnProps = { - name: "b", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: "mycoll", - default: null, - comment: null, - }; - const change = new AlterTableAlterColumnType({ - table: withCols, - column: col, - previousColumn: { - ...col, - data_type: "integer", - data_type_str: "integer", - }, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN b TYPE text COLLATE mycoll USING b::text", - ); - }); - - test("set default NULL fallback", async () => { - const tableProps: Omit = { - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - partition_by: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], - }; - const withCols = new Table({ ...tableProps, owner: "o1", options: null }); - const col: ColumnProps = { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }; - const change = new AlterTableAlterColumnSetDefault({ - table: withCols, - column: col, - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER TABLE public.test_table ALTER COLUMN a SET DEFAULT NULL", - ); - }); - - test("constraints add/drop/validate and flavors", async () => { - const t = new Table({ - schema: "public", - name: "test_table", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "o1", - parent_schema: null, - parent_name: null, - columns: [ - { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const pkey = { - name: "pk_t", - constraint_type: "p" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["a"], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "o1", - definition: "PRIMARY KEY(a)", - }; - - expect( - new AlterTableAddConstraint({ table: t, constraint: pkey }).serialize(), - ).toBe( - "ALTER TABLE public.test_table ADD CONSTRAINT pk_t PRIMARY KEY(a)", - ); - - // drop + validate - expect( - new AlterTableDropConstraint({ - table: t, - constraint: pkey, - }).serialize(), - ).toBe("ALTER TABLE public.test_table DROP CONSTRAINT pk_t"); - expect( - new AlterTableValidateConstraint({ - table: t, - constraint: pkey, - }).serialize(), - ).toBe("ALTER TABLE public.test_table VALIDATE CONSTRAINT pk_t"); - }); - - test("attach/detach partition", async () => { - const table = new Table({ - schema: "public", - name: "events", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: "RANGE (created_at)", - owner: "o1", - parent_schema: null, - parent_name: null, - columns: [ - { - name: "created_at", - position: 1, - data_type: "timestamp without time zone", - data_type_str: "timestamp without time zone", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const part2025 = new Table({ - schema: "public", - name: "events_2025", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: true, - options: null, - partition_bound: - "FOR VALUES FROM ('2025-01-01 00:00:00') TO ('2026-01-01 00:00:00')", - partition_by: null, - owner: "o1", - parent_schema: "public", - parent_name: "events", - columns: [], - privileges: [], - }); - - const attach = new AlterTableAttachPartition({ - table, - partition: part2025, - }); - await assertValidSql(attach.serialize()); - expect(attach.serialize()).toBe( - "ALTER TABLE public.events ATTACH PARTITION public.events_2025 FOR VALUES FROM ('2025-01-01 00:00:00') TO ('2026-01-01 00:00:00')", - ); - - const detach = new AlterTableDetachPartition({ - table, - partition: part2025, - }); - await assertValidSql(detach.serialize()); - expect(detach.serialize()).toBe( - "ALTER TABLE public.events DETACH PARTITION public.events_2025", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/table/changes/table.alter.ts b/packages/pg-delta/src/core/objects/table/changes/table.alter.ts deleted file mode 100644 index 369d9efe5..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.alter.ts +++ /dev/null @@ -1,975 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { ColumnProps } from "../../base.model.ts"; -import { stableId } from "../../utils.ts"; -import type { Table, TableConstraintProps } from "../table.model.ts"; -import { AlterTableChange } from "./table.base.ts"; - -// No drop+create paths; destructive operations are out of scope - -/** - * Alter a table. - * - * @see https://www.postgresql.org/docs/17/sql-altertable.html - * - * Synopsis - * ```sql - * ALTER TABLE [ IF EXISTS ] [ ONLY ] name [ * ] - * action [, ... ] - * where action is one of: - * ADD [ COLUMN ] [ IF NOT EXISTS ] column_name data_type [ COLLATE collation ] [ column_constraint [ ... ] ] - * DROP [ COLUMN ] [ IF EXISTS ] column_name [ RESTRICT | CASCADE ] - * ALTER [ COLUMN ] column_name [ SET DATA ] TYPE data_type [ COLLATE collation ] [ USING expression ] - * ALTER [ COLUMN ] column_name SET DEFAULT expression - * ALTER [ COLUMN ] column_name DROP DEFAULT - * ALTER [ COLUMN ] column_name { SET | DROP } NOT NULL - * ALTER [ COLUMN ] column_name SET STATISTICS integer - * ALTER [ COLUMN ] column_name SET ( attribute_option = value [, ... ] ) - * ALTER [ COLUMN ] column_name RESET ( attribute_option [, ... ] ) - * ALTER [ COLUMN ] column_name SET STORAGE { PLAIN | EXTERNAL | EXTENDED | MAIN } - * ALTER [ COLUMN ] column_name SET COMPRESSION compression_method - * ADD table_constraint [ NOT VALID ] - * ADD table_constraint_using_index - * ALTER CONSTRAINT constraint_name [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ] - * VALIDATE CONSTRAINT constraint_name - * DROP CONSTRAINT [ IF EXISTS ] constraint_name [ RESTRICT | CASCADE ] - * DISABLE TRIGGER [ trigger_name | ALL | USER ] - * ENABLE TRIGGER [ trigger_name | ALL | USER ] - * ENABLE REPLICA TRIGGER trigger_name - * ENABLE ALWAYS TRIGGER trigger_name - * DISABLE RULE rewrite_rule_name - * ENABLE RULE rewrite_rule_name - * ENABLE REPLICA RULE rewrite_rule_name - * ENABLE ALWAYS RULE rewrite_rule_name - * CLUSTER ON index_name - * SET WITHOUT CLUSTER - * SET WITH OIDS - * SET WITHOUT OIDS - * SET ( storage_parameter [= value] [, ... ] ) - * RESET ( storage_parameter [, ... ] ) - * INHERIT parent_table - * NO INHERIT parent_table - * OF type_name - * NOT OF - * OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * SET TABLESPACE new_tablespace - * SET { LOGGED | UNLOGGED } - * SET ACCESS METHOD new_access_method - * REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] [ WITH [ NO ] DATA ] - * ATTACH PARTITION partition_name { FOR VALUES partition_bound_spec | DEFAULT } - * DETACH PARTITION partition_name [ CONCURRENTLY | FINALIZE ] - * ``` - */ - -export type AlterTable = - | AlterTableAddColumn - | AlterTableAddConstraint - | AlterTableAlterColumnAddIdentity - | AlterTableAlterColumnDropDefault - | AlterTableAlterColumnDropIdentity - | AlterTableAlterColumnDropNotNull - | AlterTableAlterColumnSetGenerated - | AlterTableAlterColumnSetDefault - | AlterTableAlterColumnSetNotNull - | AlterTableAlterColumnType - | AlterTableAttachPartition - | AlterTableChangeOwner - | AlterTableDetachPartition - | AlterTableDisableRowLevelSecurity - | AlterTableDropColumn - | AlterTableDropConstraint - | AlterTableEnableRowLevelSecurity - | AlterTableForceRowLevelSecurity - | AlterTableNoForceRowLevelSecurity - | AlterTableResetStorageParams - | AlterTableSetLogged - | AlterTableSetReplicaIdentity - | AlterTableSetStorageParams - | AlterTableSetUnlogged - | AlterTableValidateConstraint; - -/** - * ALTER TABLE ... OWNER TO ... - */ -export class AlterTableChangeOwner extends AlterTableChange { - public readonly table: Table; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { table: Table; owner: string }) { - super(); - this.table = props.table; - this.owner = props.owner; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * ALTER TABLE ... SET LOGGED - */ -export class AlterTableSetLogged extends AlterTableChange { - public readonly table: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "SET LOGGED", - ].join(" "); - } -} - -/** - * ALTER TABLE ... SET UNLOGGED - */ -export class AlterTableSetUnlogged extends AlterTableChange { - public readonly table: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "SET UNLOGGED", - ].join(" "); - } -} - -/** - * ALTER TABLE ... ENABLE ROW LEVEL SECURITY - */ -export class AlterTableEnableRowLevelSecurity extends AlterTableChange { - public readonly table: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ENABLE ROW LEVEL SECURITY", - ].join(" "); - } -} - -/** - * ALTER TABLE ... DISABLE ROW LEVEL SECURITY - */ -export class AlterTableDisableRowLevelSecurity extends AlterTableChange { - public readonly table: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "DISABLE ROW LEVEL SECURITY", - ].join(" "); - } -} - -/** - * ALTER TABLE ... FORCE ROW LEVEL SECURITY - */ -export class AlterTableForceRowLevelSecurity extends AlterTableChange { - public readonly table: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "FORCE ROW LEVEL SECURITY", - ].join(" "); - } -} - -/** - * ALTER TABLE ... NO FORCE ROW LEVEL SECURITY - */ -export class AlterTableNoForceRowLevelSecurity extends AlterTableChange { - public readonly table: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "NO FORCE ROW LEVEL SECURITY", - ].join(" "); - } -} - -/** - * ALTER TABLE ... SET ( storage_parameter = value [, ... ] ) - */ -export class AlterTableSetStorageParams extends AlterTableChange { - public readonly table: Table; - public readonly options: string[]; - public readonly scope = "object" as const; - - constructor(props: { table: Table; options: string[] }) { - super(); - this.table = props.table; - this.options = props.options; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const storageParams = this.options.join(", "); - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - `SET (${storageParams})`, - ].join(" "); - } -} - -/** - * ALTER TABLE ... RESET ( storage_parameter [, ... ] ) - */ -export class AlterTableResetStorageParams extends AlterTableChange { - public readonly table: Table; - public readonly params: string[]; - public readonly scope = "object" as const; - - constructor(props: { table: Table; params: string[] }) { - super(); - this.table = props.table; - this.params = props.params; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const paramsSql = this.params.join(", "); - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - `RESET (${paramsSql})`, - ].join(" "); - } -} - -// Intentionally no ReplaceTable: destructive changes are not emitted - -/** - * ALTER TABLE ... ADD CONSTRAINT ... - */ -export class AlterTableAddConstraint extends AlterTableChange { - public readonly table: Table; - public readonly constraint: TableConstraintProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; constraint: TableConstraintProps }) { - super(); - this.table = props.table; - this.constraint = props.constraint; - } - - get creates() { - return [ - stableId.constraint( - this.table.schema, - this.table.name, - this.constraint.name, - ), - ]; - } - - get requires() { - const reqs: string[] = [this.table.stableId]; - if (this.constraint.constraint_type === "f") { - const referencingColumns = this.constraint.key_columns.map((columnName) => - stableId.column(this.table.schema, this.table.name, columnName), - ); - const referencedColumns = - // biome-ignore lint/style/noNonNullAssertion: constraint_type "f" means foreign_key_columns is not null - this.constraint.foreign_key_columns!.map((columnName) => - stableId.column( - // biome-ignore lint/style/noNonNullAssertion: constraint_type "f" means foreign_key_schema is not null - this.constraint.foreign_key_schema!, - // biome-ignore lint/style/noNonNullAssertion: constraint_type "f" means foreign_key_table is not null - this.constraint.foreign_key_table!, - columnName, - ), - ); - reqs.push(...referencingColumns, ...referencedColumns); - } - return reqs; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ADD CONSTRAINT", - this.constraint.name, - this.constraint.definition, - ].join(" "); - } -} - -/** - * ALTER TABLE ... DROP CONSTRAINT ... - */ -export class AlterTableDropConstraint extends AlterTableChange { - public readonly table: Table; - public readonly constraint: TableConstraintProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; constraint: TableConstraintProps }) { - super(); - this.table = props.table; - this.constraint = props.constraint; - } - - get drops() { - return [ - stableId.constraint( - this.table.schema, - this.table.name, - this.constraint.name, - ), - ]; - } - - get requires() { - return [ - stableId.constraint( - this.table.schema, - this.table.name, - this.constraint.name, - ), - this.table.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "DROP CONSTRAINT", - this.constraint.name, - ].join(" "); - } -} - -/** - * ALTER TABLE ... VALIDATE CONSTRAINT ... - */ -export class AlterTableValidateConstraint extends AlterTableChange { - public readonly table: Table; - public readonly constraint: TableConstraintProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; constraint: TableConstraintProps }) { - super(); - this.table = props.table; - this.constraint = props.constraint; - } - - get requires() { - return [ - stableId.constraint( - this.table.schema, - this.table.name, - this.constraint.name, - ), - this.table.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "VALIDATE CONSTRAINT", - this.constraint.name, - ].join(" "); - } -} - -/** - * ALTER TABLE ... REPLICA IDENTITY ... - * - * When `mode === "i"` (USING INDEX), `indexName` is the name of the index to - * use. The extractor populates `Table.replica_identity_index` from - * `pg_index.indisreplident` whenever `Table.replica_identity` is `'i'`, so - * callers that source their props from a `Table` instance can rely on the - * pair being consistent. The non-null assertions in `requires` / `serialize` - * below are justified by that data invariant — the same pattern the FK - * branch of `AlterTableAddConstraint` uses for `foreign_key_columns!` / - * `foreign_key_table!` / `foreign_key_schema!`. - */ -export class AlterTableSetReplicaIdentity extends AlterTableChange { - public readonly table: Table; - public readonly mode: "d" | "n" | "f" | "i"; - public readonly indexName: string | null; - public readonly scope = "object" as const; - - constructor(props: { - table: Table; - mode: "d" | "n" | "f" | "i"; - indexName?: string | null; - }) { - super(); - this.table = props.table; - this.mode = props.mode; - this.indexName = props.indexName ?? null; - } - - get requires() { - const reqs: string[] = [this.table.stableId]; - if (this.mode === "i") { - reqs.push( - stableId.index( - this.table.schema, - this.table.name, - // biome-ignore lint/style/noNonNullAssertion: mode 'i' implies the extractor populated replica_identity_index - this.indexName!, - ), - ); - } - return reqs; - } - - serialize(_options?: SerializeOptions): string { - const clause = - this.mode === "d" - ? "DEFAULT" - : this.mode === "n" - ? "NOTHING" - : this.mode === "f" - ? "FULL" - : // biome-ignore lint/style/noNonNullAssertion: mode 'i' implies the extractor populated replica_identity_index - `USING INDEX ${this.indexName!}`; - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "REPLICA IDENTITY", - clause, - ].join(" "); - } -} - -/** - * ALTER TABLE ... ADD COLUMN ... - */ -export class AlterTableAddColumn extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get creates() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ADD COLUMN", - this.column.name, - this.column.data_type_str, - ]; - if (this.column.collation) { - parts.push("COLLATE", this.column.collation); - } - if (this.column.is_identity) { - parts.push( - this.column.is_identity_always - ? "GENERATED ALWAYS AS IDENTITY" - : "GENERATED BY DEFAULT AS IDENTITY", - ); - } else if (this.column.is_generated && this.column.default !== null) { - parts.push(`GENERATED ALWAYS AS (${this.column.default}) STORED`); - } else if (this.column.default !== null) { - parts.push("DEFAULT", this.column.default); - } - if (this.column.not_null) { - parts.push("NOT NULL"); - } - return parts.join(" "); - } -} - -/** - * ALTER TABLE ... DROP COLUMN ... - */ -export class AlterTableDropColumn extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - // Drop the implicit `requires(table)` edge. Only set by the lazy - // cycle-breaker for the publication↔column case, where the table survives - // the migration and the edge is therefore artificial. See - // `sort/cycle-breakers.ts` for the full justification. - public readonly omitTableRequirement: boolean; - - constructor(props: { - table: Table; - column: ColumnProps; - omitTableRequirement?: boolean; - }) { - super(); - this.table = props.table; - this.column = props.column; - this.omitTableRequirement = props.omitTableRequirement ?? false; - } - - get drops() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - get requires() { - const colId = stableId.column( - this.table.schema, - this.table.name, - this.column.name, - ); - return this.omitTableRequirement ? [colId] : [this.table.stableId, colId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "DROP COLUMN", - this.column.name, - ].join(" "); - } -} - -/** - * ALTER TABLE ... ALTER COLUMN ... TYPE ... - */ -export class AlterTableAlterColumnType extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly previousColumn?: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { - table: Table; - column: ColumnProps; - previousColumn?: ColumnProps; - }) { - super(); - this.table = props.table; - this.column = props.column; - this.previousColumn = props.previousColumn; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - get invalidates() { - // ALTER COLUMN ... TYPE rewrites the column in place. The column keeps its - // identity, but anything bound to its old type (views, rules, etc.) must be - // dropped before the rewrite and rebuilt after, so report it as invalidated. - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(_options?: SerializeOptions): string { - // previousColumn is optional so direct serializer tests/fixtures can keep - // emitting canonical ALTER TYPE SQL without forcing a USING expression. - // When provided, we can detect true type changes and add USING for casts - // PostgreSQL cannot perform automatically. - const hasTypeChangedWithPreviousDefinition = - this.previousColumn?.data_type_str !== undefined && - this.previousColumn.data_type_str !== this.column.data_type_str; - - const parts: string[] = [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ALTER COLUMN", - this.column.name, - "TYPE", - this.column.data_type_str, - ]; - if (this.column.collation) { - parts.push("COLLATE", this.column.collation); - } - if (hasTypeChangedWithPreviousDefinition) { - parts.push("USING", `${this.column.name}::${this.column.data_type_str}`); - } - return parts.join(" "); - } -} - -/** - * ALTER TABLE ... ALTER COLUMN ... SET DEFAULT ... - */ -export class AlterTableAlterColumnSetDefault extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(_options?: SerializeOptions): string { - const set = this.column.is_generated ? "SET EXPRESSION AS" : "SET DEFAULT"; - const value = this.column.is_generated - ? `(${this.column.default ?? "NULL"})` - : (this.column.default ?? "NULL"); - - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ALTER COLUMN", - this.column.name, - set, - value, - ].join(" "); - } -} - -/** - * ALTER TABLE ... ALTER COLUMN ... DROP DEFAULT - */ -export class AlterTableAlterColumnDropDefault extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ALTER COLUMN", - this.column.name, - "DROP DEFAULT", - ].join(" "); - } -} - -/** - * ALTER TABLE ... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY - */ -export class AlterTableAlterColumnAddIdentity extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ALTER COLUMN", - this.column.name, - "ADD", - this.column.is_identity_always - ? "GENERATED ALWAYS AS IDENTITY" - : "GENERATED BY DEFAULT AS IDENTITY", - ].join(" "); - } -} - -/** - * ALTER TABLE ... ALTER COLUMN ... DROP IDENTITY - */ -export class AlterTableAlterColumnDropIdentity extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ALTER COLUMN", - this.column.name, - "DROP IDENTITY", - ].join(" "); - } -} - -/** - * ALTER TABLE ... ALTER COLUMN ... SET GENERATED { ALWAYS | BY DEFAULT } - */ -export class AlterTableAlterColumnSetGenerated extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ALTER COLUMN", - this.column.name, - "SET GENERATED", - this.column.is_identity_always ? "ALWAYS" : "BY DEFAULT", - ].join(" "); - } -} - -/** - * ALTER TABLE ... ALTER COLUMN ... SET NOT NULL - */ -export class AlterTableAlterColumnSetNotNull extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ALTER COLUMN", - this.column.name, - "SET NOT NULL", - ].join(" "); - } -} - -/** - * ALTER TABLE ... ALTER COLUMN ... DROP NOT NULL - */ -export class AlterTableAlterColumnDropNotNull extends AlterTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "object" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ALTER COLUMN", - this.column.name, - "DROP NOT NULL", - ].join(" "); - } -} - -/** - * ALTER TABLE ... ATTACH PARTITION ... - */ -export class AlterTableAttachPartition extends AlterTableChange { - public readonly table: Table; - public readonly partition: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table; partition: Table }) { - super(); - this.table = props.table; - this.partition = props.partition; - } - - get requires() { - // Depend on the partition child so that it is created before attach - return [this.partition.stableId, this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const bound = this.partition.partition_bound ?? "DEFAULT"; - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "ATTACH PARTITION", - `${this.partition.schema}.${this.partition.name}`, - bound, - ].join(" "); - } -} - -/** - * ALTER TABLE ... DETACH PARTITION ... - */ -export class AlterTableDetachPartition extends AlterTableChange { - public readonly table: Table; - public readonly partition: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table; partition: Table }) { - super(); - this.table = props.table; - this.partition = props.partition; - } - - get requires() { - // Depend on the partition child for consistent ordering with potential drops - return [this.table.stableId, this.partition.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TABLE", - `${this.table.schema}.${this.table.name}`, - "DETACH PARTITION", - `${this.partition.schema}.${this.partition.name}`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/table/changes/table.base.ts b/packages/pg-delta/src/core/objects/table/changes/table.base.ts deleted file mode 100644 index 775062bc8..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Table } from "../table.model.ts"; - -abstract class BaseTableChange extends BaseChange { - abstract readonly table: Table; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "table" as const; -} - -export abstract class CreateTableChange extends BaseTableChange { - readonly operation = "create" as const; -} - -export abstract class AlterTableChange extends BaseTableChange { - readonly operation = "alter" as const; -} - -export abstract class DropTableChange extends BaseTableChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/table/changes/table.comment.ts b/packages/pg-delta/src/core/objects/table/changes/table.comment.ts deleted file mode 100644 index ac2bc3cff..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.comment.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import type { ColumnProps } from "../../base.model.ts"; -import { stableId } from "../../utils.ts"; -import type { Table, TableConstraintProps } from "../table.model.ts"; -import { CreateTableChange, DropTableChange } from "./table.base.ts"; - -/** - * Create a table/column/constraint comment. - * - * @see https://www.postgresql.org/docs/17/sql-comment.html - * - * Synopsis - * ```sql - * COMMENT ON - * { - * COLUMN relation_name.column_name | - * CONSTRAINT constraint_name ON table_name | - * TABLE object_name - * } IS { string_literal | NULL } - * - * ``` - */ - -export type CommentTable = - | CreateCommentOnColumn - | CreateCommentOnConstraint - | CreateCommentOnTable - | DropCommentOnColumn - | DropCommentOnConstraint - | DropCommentOnTable; - -/** - * COMMENT ON TABLE ... IS ... - */ -export class CreateCommentOnTable extends CreateTableChange { - public readonly table: Table; - public readonly scope = "comment" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get creates() { - return [stableId.comment(this.table.stableId)]; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TABLE", - `${this.table.schema}.${this.table.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: table comment is not nullable in this case - quoteLiteral(this.table.comment!), - ].join(" "); - } -} - -/** - * COMMENT ON TABLE ... IS ... - */ -export class DropCommentOnTable extends DropTableChange { - public readonly table: Table; - public readonly scope = "comment" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get drops() { - return [stableId.comment(this.table.stableId)]; - } - - get requires() { - return [stableId.comment(this.table.stableId), this.table.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TABLE", - `${this.table.schema}.${this.table.name}`, - "IS NULL", - ].join(" "); - } -} - -/** - * COMMENT ON COLUMN ... IS ... - */ -export class CreateCommentOnColumn extends CreateTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "comment" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get creates() { - const columnStableId = stableId.column( - this.table.schema, - this.table.name, - this.column.name, - ); - return [stableId.comment(columnStableId)]; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON COLUMN", - `${this.table.schema}.${this.table.name}.${this.column.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: column comment is not nullable in this case - quoteLiteral(this.column.comment!), - ].join(" "); - } -} - -/** - * COMMENT ON COLUMN ... IS ... - */ -export class DropCommentOnColumn extends DropTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly scope = "comment" as const; - - constructor(props: { table: Table; column: ColumnProps }) { - super(); - this.table = props.table; - this.column = props.column; - } - - get drops() { - const columnStableId = stableId.column( - this.table.schema, - this.table.name, - this.column.name, - ); - return [stableId.comment(columnStableId)]; - } - - get requires() { - const columnStableId = stableId.column( - this.table.schema, - this.table.name, - this.column.name, - ); - return [stableId.comment(columnStableId), columnStableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON COLUMN", - `${this.table.schema}.${this.table.name}.${this.column.name}`, - "IS NULL", - ].join(" "); - } -} - -/** - * COMMENT ON CONSTRAINT ... IS ... - */ -export class CreateCommentOnConstraint extends CreateTableChange { - public readonly table: Table; - public readonly constraint: TableConstraintProps; - public readonly scope = "comment" as const; - - constructor(props: { table: Table; constraint: TableConstraintProps }) { - super(); - this.table = props.table; - this.constraint = props.constraint; - } - - get creates() { - const constraintStableId = stableId.constraint( - this.table.schema, - this.table.name, - this.constraint.name, - ); - return [stableId.comment(constraintStableId)]; - } - - get requires() { - return [ - stableId.constraint( - this.table.schema, - this.table.name, - this.constraint.name, - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON CONSTRAINT", - this.constraint.name, - "ON", - `${this.table.schema}.${this.table.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: constraint comment is not nullable in this case - quoteLiteral(this.constraint.comment!), - ].join(" "); - } -} - -/** - * COMMENT ON CONSTRAINT ... IS ... - */ -export class DropCommentOnConstraint extends DropTableChange { - public readonly table: Table; - public readonly constraint: TableConstraintProps; - public readonly scope = "comment" as const; - - constructor(props: { table: Table; constraint: TableConstraintProps }) { - super(); - this.table = props.table; - this.constraint = props.constraint; - } - - get drops() { - const constraintStableId = stableId.constraint( - this.table.schema, - this.table.name, - this.constraint.name, - ); - return [stableId.comment(constraintStableId)]; - } - - get requires() { - const constraintStableId = stableId.constraint( - this.table.schema, - this.table.name, - this.constraint.name, - ); - return [stableId.comment(constraintStableId), constraintStableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON CONSTRAINT", - this.constraint.name, - "ON", - `${this.table.schema}.${this.table.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts b/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts deleted file mode 100644 index b7e60e8bd..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Table, type TableProps } from "../table.model.ts"; -import { CreateTable } from "./table.create.ts"; - -const base: TableProps = { - schema: "public", - name: "t", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "o1", - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], -}; - -describe.concurrent("table.create", () => { - test("minimal create with no columns", async () => { - const t = new Table(base); - const change = new CreateTable({ table: t }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("CREATE TABLE public.t ()"); - }); - - test("TEMPORARY with columns, inherits and options", async () => { - const t = new Table({ - ...base, - persistence: "t", - parent_schema: "public", - parent_name: "parent", - options: ["fillfactor=90", "autovacuum_enabled=true"], - columns: [ - { - name: "c1", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: true, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "0", - comment: null, - }, - { - name: "c2", - position: 2, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: '"en_US"', - default: null, - comment: null, - }, - { - name: "c3", - position: 3, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: true, - is_identity_always: true, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - { - name: "c4", - position: 4, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: true, - collation: null, - default: "lower((name))", - comment: null, - }, - { - name: "c5", - position: 5, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: true, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - }); - - const change = new CreateTable({ table: t }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'CREATE TEMPORARY TABLE public.t (c1 integer DEFAULT 0 NOT NULL, c2 text COLLATE "en_US", c3 integer GENERATED ALWAYS AS IDENTITY, c4 text GENERATED ALWAYS AS (lower((name))) STORED, c5 integer GENERATED BY DEFAULT AS IDENTITY) INHERITS (public.parent) WITH (fillfactor=90, autovacuum_enabled=true)', - ); - }); - - test("UNLOGGED minimal create (no columns)", async () => { - const t = new Table({ - ...base, - persistence: "u", - }); - const change = new CreateTable({ table: t }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("CREATE UNLOGGED TABLE public.t ()"); - }); - - test("requires does NOT include procedure stableIds from DEFAULT expressions (handled by pg_depend catalog constraints)", async () => { - // Function dependencies in DEFAULT expressions are resolved through pg_depend - // in the sort pipeline, which provides exact argument types and covers all - // expression contexts. The CreateTable change itself does not need to list them. - const t = new Table({ - ...base, - columns: [ - { - name: "auth_role", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "auth.role()", - comment: null, - }, - ], - }); - const change = new CreateTable({ table: t }); - const procedureRequires = change.requires.filter((r) => - r.startsWith("procedure:"), - ); - expect(procedureRequires).toEqual([]); - }); -}); diff --git a/packages/pg-delta/src/core/objects/table/changes/table.create.ts b/packages/pg-delta/src/core/objects/table/changes/table.create.ts deleted file mode 100644 index 4f2260d59..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.create.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { isUserDefinedTypeSchema, stableId } from "../../utils.ts"; -import type { Table } from "../table.model.ts"; -import { CreateTableChange } from "./table.base.ts"; - -/** - * Create a table. - * - * @see https://www.postgresql.org/docs/17/sql-createtable.html - * - * Synopsis - * ```sql - * CREATE [ [ GLOBAL | LOCAL ] { TEMPORARY | TEMP } | UNLOGGED ] TABLE [ IF NOT EXISTS ] table_name ( [ - * { column_name data_type [ COLLATE collation ] [ column_constraint [ ... ] ] - * | table_constraint - * | LIKE source_table [ like_option ... ] } - * [, ... ] - * ] ) - * [ INHERITS ( parent_table [, ... ] ) ] - * [ PARTITION BY { RANGE | LIST | HASH } ( { column_name | ( expression ) } [, ... ] ) ] - * [ USING method ] - * [ WITH ( storage_parameter [= value] [, ... ] ) | WITHOUT OIDS ] - * [ ON COMMIT { PRESERVE ROWS | DELETE ROWS | DROP } ] - * [ TABLESPACE tablespace_name ] - * ``` - */ -export class CreateTable extends CreateTableChange { - public readonly table: Table; - public readonly scope = "object" as const; - - constructor(props: { table: Table }) { - super(); - this.table = props.table; - } - - get creates() { - return [ - this.table.stableId, - ...this.table.columns.map((col) => - stableId.column(this.table.schema, this.table.name, col.name), - ), - ]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.table.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.table.owner)); - - // Parent table dependency (for inheritance or partitioning) - if (this.table.parent_schema && this.table.parent_name) { - dependencies.add( - stableId.table(this.table.parent_schema, this.table.parent_name), - ); - } - - // Column type dependencies (user-defined types only) - for (const col of this.table.columns) { - if ( - col.is_custom_type && - col.custom_type_schema && - col.custom_type_name - ) { - dependencies.add( - stableId.type(col.custom_type_schema, col.custom_type_name), - ); - } - - // Collation dependency (if non-default) - if (col.collation) { - // Collations are stored as schema-qualified strings like "public.collation_name" - // Note: The collation string may be quoted, so we need to handle that - const unquotedCollation = col.collation.replace(/^"|"$/g, ""); - const collationParts = unquotedCollation.split("."); - if (collationParts.length === 2) { - const [collationSchema, collationName] = collationParts; - if (isUserDefinedTypeSchema(collationSchema)) { - dependencies.add( - stableId.collation(collationSchema, collationName), - ); - } - } - } - - // Function dependencies from DEFAULT expressions are handled by pg_depend - // catalog constraints in the sort pipeline, which provides exact argument - // types and covers all expression contexts (not just column defaults). - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE"]; - - // Add TEMPORARY/UNLOGGED based on persistence - if (this.table.persistence === "t") { - parts.push("TEMPORARY"); - } else if (this.table.persistence === "u") { - parts.push("UNLOGGED"); - } - - parts.push("TABLE"); - - // Add schema and name - parts.push(`${this.table.schema}.${this.table.name}`); - - // If this is a partition (child) table, emit PARTITION OF ... FOR VALUES ... - if ( - this.table.parent_schema && - this.table.parent_name && - this.table.partition_bound - ) { - return [ - ...parts, - "PARTITION OF", - `${this.table.parent_schema}.${this.table.parent_name}`, - this.table.partition_bound, - ].join(" "); - } - - // Add columns definition - if (this.table.columns.length === 0) { - parts.push("()"); - } else { - const columnDefinitions = this.table.columns.map((col) => { - const tokens: string[] = []; - - // Column name - tokens.push(col.name); - - // Data type (use formatted type string from the catalog) - tokens.push(col.data_type_str); - - // Collation - if (col.collation) { - tokens.push("COLLATE", col.collation); - } - - // Identity / generated / default - if (col.is_identity) { - tokens.push( - col.is_identity_always - ? "GENERATED ALWAYS AS IDENTITY" - : "GENERATED BY DEFAULT AS IDENTITY", - ); - } else if (col.is_generated && col.default) { - // Generated stored columns expose their expression via pg_attrdef - tokens.push(`GENERATED ALWAYS AS (${col.default}) STORED`); - } else if (col.default) { - tokens.push("DEFAULT", col.default); - } - - // Nullability - if (col.not_null) { - tokens.push("NOT NULL"); - } - - return tokens.join(" "); - }); - - parts.push(`(${columnDefinitions.join(", ")})`); - } - - // Add INHERITS if parent table exists (non-partition inheritance only) - if ( - this.table.parent_schema && - this.table.parent_name && - !this.table.partition_bound - ) { - parts.push( - "INHERITS", - `(${this.table.parent_schema}.${this.table.parent_name})`, - ); - } - - // Add PARTITION BY if this is a partitioned table (parent) - if (this.table.partition_by) { - parts.push("PARTITION BY", this.table.partition_by); - } - - // Add storage parameters if specified - if (this.table.options && this.table.options.length > 0) { - parts.push("WITH", `(${this.table.options.join(", ")})`); - } - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/table/changes/table.drop.test.ts b/packages/pg-delta/src/core/objects/table/changes/table.drop.test.ts deleted file mode 100644 index d053069f8..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.drop.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Table, type TableProps } from "../table.model.ts"; -import { DropTable } from "./table.drop.ts"; - -const base: TableProps = { - schema: "public", - name: "t", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "o1", - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], -}; - -describe.concurrent("table.drop", () => { - test("drop table basic", async () => { - const t = new Table(base); - const change = new DropTable({ table: t }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("DROP TABLE public.t"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/table/changes/table.drop.ts b/packages/pg-delta/src/core/objects/table/changes/table.drop.ts deleted file mode 100644 index dac89d845..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.drop.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Table } from "../table.model.ts"; -import { DropTableChange } from "./table.base.ts"; - -/** - * Drop a table. - * - * @see https://www.postgresql.org/docs/17/sql-droptable.html - * - * Synopsis - * ```sql - * DROP TABLE [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropTable extends DropTableChange { - public readonly table: Table; - public readonly scope = "object" as const; - /** - * Names of constraints on this table that are dropped explicitly by a - * separate `AlterTableDropConstraint` change. Those constraints must not be - * claimed by `DropTable.drops` / `.requires`, otherwise catalog edges tied - * to the constraint stableId will attach to this DropTable node instead of - * the dedicated AlterTableDropConstraint node. When two tables with mutual - * FK references are dropped in the same phase, that misattribution - * produces an unbreakable cycle between the two DropTable changes. - */ - public readonly externallyDroppedConstraints: ReadonlySet; - - constructor(props: { - table: Table; - externallyDroppedConstraints?: ReadonlySet; - }) { - super(); - this.table = props.table; - this.externallyDroppedConstraints = - props.externallyDroppedConstraints ?? new Set(); - } - - private get claimedConstraints() { - return this.table.constraints.filter( - (constraint) => !this.externallyDroppedConstraints.has(constraint.name), - ); - } - - get drops() { - return [ - this.table.stableId, - ...this.table.columns.map((column) => - stableId.column(this.table.schema, this.table.name, column.name), - ), - // Include constraint stableIds so FK relationships that only exist at the - // constraint level still affect whole-table drop ordering. Skip any - // constraint that the diff layer is dropping via a dedicated - // AlterTableDropConstraint change — that node owns the stableId. - ...this.claimedConstraints.map((constraint) => - stableId.constraint( - this.table.schema, - this.table.name, - constraint.name, - ), - ), - ]; - } - - get requires() { - return [ - this.table.stableId, - ...this.table.columns.map((col) => - stableId.column(this.table.schema, this.table.name, col.name), - ), - // Mirror the dropped constraint ids in requires so drop-phase graph - // consumers can connect catalog FK edges back to this table drop. - ...this.claimedConstraints.map((constraint) => - stableId.constraint( - this.table.schema, - this.table.name, - constraint.name, - ), - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP TABLE", `${this.table.schema}.${this.table.name}`].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/table/changes/table.privilege.ts b/packages/pg-delta/src/core/objects/table/changes/table.privilege.ts deleted file mode 100644 index d5ac208be..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.privilege.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { Table } from "../table.model.ts"; -import { AlterTableChange } from "./table.base.ts"; - -export type TablePrivilege = - | GrantTablePrivileges - | RevokeTablePrivileges - | RevokeGrantOptionTablePrivileges; - -/** - * Grant privileges on a table. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER | MAINTAIN } - * [, ...] | ALL [ PRIVILEGES ] } - * ON { [ TABLE ] table_name [, ...] - * | ALL TABLES IN SCHEMA schema_name [, ...] } - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantTablePrivileges extends AlterTableChange { - public readonly table: Table; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - table: Table; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - columns?: string[]; - version?: number; - }) { - super(); - this.table = props.table; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.columns = props.columns; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.table.stableId, this.grantee)]; - } - - get requires() { - return [this.table.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantTablePrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("TABLE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("TABLE", list, this.version); - const tableName = `${this.table.schema}.${this.table.name}`; - const columnSpec = - this.columns && this.columns.length > 0 - ? ` (${this.columns.join(", ")})` - : ""; - return `GRANT ${privSql}${columnSpec} ${kindPrefix} ${tableName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a table. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER | MAINTAIN } - * [, ...] | ALL [ PRIVILEGES ] } - * ON { [ TABLE ] table_name [, ...] - * | ALL TABLES IN SCHEMA schema_name [, ...] } - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeTablePrivileges extends AlterTableChange { - public readonly table: Table; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - table: Table; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - columns?: string[]; - version?: number; - }) { - super(); - this.table = props.table; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.columns = props.columns; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.table.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.table.stableId, this.grantee), - this.table.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("TABLE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("TABLE", list, this.version); - const tableName = `${this.table.schema}.${this.table.name}`; - const columnSpec = - this.columns && this.columns.length > 0 - ? ` (${this.columns.join(", ")})` - : ""; - return `REVOKE ${privSql}${columnSpec} ${kindPrefix} ${tableName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a table. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionTablePrivileges extends AlterTableChange { - public readonly table: Table; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - table: Table; - grantee: string; - privilegeNames: string[]; - columns?: string[]; - version?: number; - }) { - super(); - this.table = props.table; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.columns = props.columns; - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.table.stableId, this.grantee), - this.table.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("TABLE"); - const privSql = formatObjectPrivilegeList( - "TABLE", - this.privilegeNames, - this.version, - ); - const tableName = `${this.table.schema}.${this.table.name}`; - const columnSpec = - this.columns && this.columns.length > 0 - ? ` (${this.columns.join(", ")})` - : ""; - return `REVOKE GRANT OPTION FOR ${privSql}${columnSpec} ${kindPrefix} ${tableName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/table/changes/table.security-label.test.ts b/packages/pg-delta/src/core/objects/table/changes/table.security-label.test.ts deleted file mode 100644 index 3030d7aab..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.security-label.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import type { ColumnProps } from "../../base.model.ts"; -import { stableId } from "../../utils.ts"; -import { Table, type TableProps } from "../table.model.ts"; -import { - CreateSecurityLabelOnColumn, - CreateSecurityLabelOnTable, - DropSecurityLabelOnColumn, - DropSecurityLabelOnTable, -} from "./table.security-label.ts"; - -const makeColumn = (overrides: Partial = {}): ColumnProps => ({ - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - ...overrides, -}); - -const makeTable = (): Table => - new Table({ - schema: "public", - name: "users", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - columns: [makeColumn({ name: "email" })], - privileges: [], - security_labels: [], - } as TableProps); - -describe("table.security-label", () => { - test("table create serializes and tracks dependencies", async () => { - const table = makeTable(); - const change = new CreateSecurityLabelOnTable({ - table, - securityLabel: { provider: "dummy", label: "classified" }, - }); - - expect(change.scope).toBe("security_label"); - expect(change.objectType).toBe("table"); - expect(change.operation).toBe("create"); - expect(change.creates).toEqual([ - stableId.securityLabel(table.stableId, "dummy"), - ]); - expect(change.requires).toEqual([table.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON TABLE public.users IS 'classified'", - ); - }); - - test("table drop serializes to IS NULL", async () => { - const table = makeTable(); - const change = new DropSecurityLabelOnTable({ - table, - securityLabel: { provider: "dummy", label: "classified" }, - }); - expect(change.drops).toEqual([ - stableId.securityLabel(table.stableId, "dummy"), - ]); - expect(change.requires).toEqual([ - stableId.securityLabel(table.stableId, "dummy"), - table.stableId, - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON TABLE public.users IS NULL", - ); - }); - - test("column create serializes and tracks dependencies", async () => { - const table = makeTable(); - const column = makeColumn({ name: "email" }); - const change = new CreateSecurityLabelOnColumn({ - table, - column, - securityLabel: { provider: "dummy", label: "classified" }, - }); - - const colStableId = stableId.column(table.schema, table.name, column.name); - expect(change.creates).toEqual([ - stableId.securityLabel(colStableId, "dummy"), - ]); - expect(change.requires).toEqual([colStableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON COLUMN public.users.email IS 'classified'", - ); - }); - - test("column drop serializes to IS NULL", async () => { - const table = makeTable(); - const column = makeColumn({ name: "email" }); - const change = new DropSecurityLabelOnColumn({ - table, - column, - securityLabel: { provider: "dummy", label: "x" }, - }); - const colStableId = stableId.column(table.schema, table.name, column.name); - expect(change.drops).toEqual([ - stableId.securityLabel(colStableId, "dummy"), - ]); - expect(change.requires).toEqual([ - stableId.securityLabel(colStableId, "dummy"), - colStableId, - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON COLUMN public.users.email IS NULL", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/table/changes/table.security-label.ts b/packages/pg-delta/src/core/objects/table/changes/table.security-label.ts deleted file mode 100644 index c32aba6b7..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.security-label.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { ColumnProps } from "../../base.model.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { Table } from "../table.model.ts"; -import { CreateTableChange, DropTableChange } from "./table.base.ts"; - -export type SecurityLabelTable = - | CreateSecurityLabelOnTable - | DropSecurityLabelOnTable - | CreateSecurityLabelOnColumn - | DropSecurityLabelOnColumn; - -/** - * SECURITY LABEL FOR ON TABLE .

IS - */ -export class CreateSecurityLabelOnTable extends CreateTableChange { - public readonly table: Table; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { table: Table; securityLabel: SecurityLabelProps }) { - super(); - this.table = props.table; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel(this.table.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [this.table.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON TABLE", - `${this.table.schema}.${this.table.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnTable extends DropTableChange { - public readonly table: Table; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { table: Table; securityLabel: SecurityLabelProps }) { - super(); - this.table = props.table; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel(this.table.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [ - stableId.securityLabel(this.table.stableId, this.securityLabel.provider), - this.table.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON TABLE", - `${this.table.schema}.${this.table.name}`, - "IS NULL", - ].join(" "); - } -} - -/** - * SECURITY LABEL FOR ON COLUMN .
. IS - */ -export class CreateSecurityLabelOnColumn extends CreateTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - table: Table; - column: ColumnProps; - securityLabel: SecurityLabelProps; - }) { - super(); - this.table = props.table; - this.column = props.column; - this.securityLabel = props.securityLabel; - } - - get creates() { - const columnStableId = stableId.column( - this.table.schema, - this.table.name, - this.column.name, - ); - return [ - stableId.securityLabel(columnStableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [ - stableId.column(this.table.schema, this.table.name, this.column.name), - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON COLUMN", - `${this.table.schema}.${this.table.name}.${this.column.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnColumn extends DropTableChange { - public readonly table: Table; - public readonly column: ColumnProps; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - table: Table; - column: ColumnProps; - securityLabel: SecurityLabelProps; - }) { - super(); - this.table = props.table; - this.column = props.column; - this.securityLabel = props.securityLabel; - } - - get drops() { - const columnStableId = stableId.column( - this.table.schema, - this.table.name, - this.column.name, - ); - return [ - stableId.securityLabel(columnStableId, this.securityLabel.provider), - ]; - } - - get requires() { - const columnStableId = stableId.column( - this.table.schema, - this.table.name, - this.column.name, - ); - return [ - stableId.securityLabel(columnStableId, this.securityLabel.provider), - columnStableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON COLUMN", - `${this.table.schema}.${this.table.name}.${this.column.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/table/changes/table.types.ts b/packages/pg-delta/src/core/objects/table/changes/table.types.ts deleted file mode 100644 index 64261f051..000000000 --- a/packages/pg-delta/src/core/objects/table/changes/table.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterTable } from "./table.alter.ts"; -import type { CommentTable } from "./table.comment.ts"; -import type { CreateTable } from "./table.create.ts"; -import type { DropTable } from "./table.drop.ts"; -import type { TablePrivilege } from "./table.privilege.ts"; -import type { SecurityLabelTable } from "./table.security-label.ts"; - -/** Union of all table-related change variants (`objectType: "table"`). @category Change Types */ -export type TableChange = - | AlterTable - | CommentTable - | CreateTable - | DropTable - | TablePrivilege - | SecurityLabelTable; diff --git a/packages/pg-delta/src/core/objects/table/table.diff.test.ts b/packages/pg-delta/src/core/objects/table/table.diff.test.ts deleted file mode 100644 index 72c370a77..000000000 --- a/packages/pg-delta/src/core/objects/table/table.diff.test.ts +++ /dev/null @@ -1,1382 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../base.default-privileges.ts"; -import { - AlterTableAddColumn, - AlterTableAddConstraint, - AlterTableAlterColumnAddIdentity, - AlterTableAlterColumnDropDefault, - AlterTableAlterColumnDropIdentity, - AlterTableAlterColumnDropNotNull, - AlterTableAlterColumnSetDefault, - AlterTableAlterColumnSetGenerated, - AlterTableAlterColumnSetNotNull, - AlterTableAlterColumnType, - AlterTableChangeOwner, - AlterTableDisableRowLevelSecurity, - AlterTableDropColumn, - AlterTableDropConstraint, - AlterTableEnableRowLevelSecurity, - AlterTableForceRowLevelSecurity, - AlterTableNoForceRowLevelSecurity, - AlterTableResetStorageParams, - AlterTableSetLogged, - AlterTableSetReplicaIdentity, - AlterTableSetStorageParams, - AlterTableSetUnlogged, - AlterTableValidateConstraint, -} from "./changes/table.alter.ts"; -import { CreateTable } from "./changes/table.create.ts"; -import { DropTable } from "./changes/table.drop.ts"; -import { - GrantTablePrivileges, - RevokeGrantOptionTablePrivileges, - RevokeTablePrivileges, -} from "./changes/table.privilege.ts"; -import { diffTables } from "./table.diff.ts"; -import { Table, type TableProps } from "./table.model.ts"; - -const base: TableProps = { - schema: "public", - name: "t", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "o1", - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], -}; - -// Test context with empty default privileges state -const testContext = { - version: 150014, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("table.diff", () => { - test("create and drop", () => { - const t = new Table(base); - const created = diffTables(testContext, {}, { [t.stableId]: t }); - expect(created[0]).toBeInstanceOf(CreateTable); - const dropped = diffTables(testContext, { [t.stableId]: t }, {}); - expect(dropped[0]).toBeInstanceOf(DropTable); - }); - - test("created NOT VALID CHECK emits AddConstraint only (no Validate)", () => { - const main = new Table({ - ...base, - name: "t_nv", - columns: [ - { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - constraints: [], - }); - const branch = new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...main, - constraints: [ - { - name: "ck_nv", - constraint_type: "c" as const, - deferrable: false, - initially_deferred: false, - validated: false, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: [], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: "a > 0", - owner: "o1", - definition: "CHECK (a > 0) NOT VALID", - }, - ], - }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - const add = changes.find((c) => c instanceof AlterTableAddConstraint); - expect(add).toBeInstanceOf(AlterTableAddConstraint); - expect(add?.serialize()).toContain("NOT VALID"); - expect(changes.some((c) => c instanceof AlterTableValidateConstraint)).toBe( - false, - ); - }); - - test("NOT VALID -> validated emits only VALIDATE CONSTRAINT (no drop+add)", () => { - const sharedConstraint = { - name: "ck_nv", - constraint_type: "c" as const, - deferrable: false, - initially_deferred: false, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: [], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: "a > 0", - owner: "o1", - comment: null, - }; - - const main = new Table({ - ...base, - name: "t_nv", - columns: [ - { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - constraints: [ - { - ...sharedConstraint, - validated: false, - definition: "CHECK (a > 0) NOT VALID", - }, - ], - }); - const branch = new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...main, - constraints: [ - { - ...sharedConstraint, - validated: true, - definition: "CHECK (a > 0)", - }, - ], - }); - - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - const validate = changes.find( - (c) => c instanceof AlterTableValidateConstraint, - ); - expect(validate).toBeInstanceOf(AlterTableValidateConstraint); - expect(validate?.serialize()).toMatchInlineSnapshot( - `"ALTER TABLE public.t_nv VALIDATE CONSTRAINT ck_nv"`, - ); - - expect(changes.some((c) => c instanceof AlterTableDropConstraint)).toBe( - false, - ); - expect(changes.some((c) => c instanceof AlterTableAddConstraint)).toBe( - false, - ); - }); - - test("NOT VALID -> validated + other field change still drops+adds (no shortcut)", () => { - const sharedConstraint = { - name: "ck_nv", - constraint_type: "c" as const, - deferrable: false, - initially_deferred: false, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: [], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - owner: "o1", - comment: null, - }; - - const main = new Table({ - ...base, - name: "t_nv", - columns: [ - { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - constraints: [ - { - ...sharedConstraint, - validated: false, - check_expression: "a > 0", - definition: "CHECK (a > 0) NOT VALID", - }, - ], - }); - const branch = new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...main, - constraints: [ - { - ...sharedConstraint, - validated: true, - check_expression: "a > 1", - definition: "CHECK (a > 1)", - }, - ], - }); - - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.some((c) => c instanceof AlterTableDropConstraint)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableAddConstraint)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableValidateConstraint)).toBe( - false, - ); - }); - - test("alter owner", () => { - const main = new Table(base); - const branch = new Table({ ...base, owner: "o2" }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterTableChangeOwner); - }); - - test("options change uses ALTER TABLE SET (...) instead of replace", () => { - const main = new Table(base); - const branch = new Table({ ...base, options: ["fillfactor=90"] }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterTableSetStorageParams); - }); - - test("option removed emits RESET", () => { - const main = new Table({ - ...base, - options: ["fillfactor=90", "autovacuum_enabled=true"], - }); - const branch = new Table({ - ...base, - options: ["autovacuum_enabled=true"], - }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterTableSetStorageParams)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableResetStorageParams)).toBe( - true, - ); - }); - - test("persistence p->u uses ALTER TABLE SET UNLOGGED", () => { - const main = new Table(base); - const branch = new Table({ ...base, persistence: "u" }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterTableSetUnlogged)).toBe(true); - }); - - test("persistence u->p uses ALTER TABLE SET LOGGED", () => { - const main = new Table({ ...base, persistence: "u" }); - const branch = new Table({ ...base, persistence: "p" }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterTableSetLogged)).toBe(true); - }); - - test("row level security toggles", () => { - const enable = diffTables( - testContext, - { - "table:public.t1": new Table({ - ...base, - name: "t1", - row_security: false, - }), - }, - { - "table:public.t1": new Table({ - ...base, - name: "t1", - row_security: true, - }), - }, - ); - expect( - enable.some((c) => c instanceof AlterTableEnableRowLevelSecurity), - ).toBe(true); - const disable = diffTables( - testContext, - { - "table:public.t2": new Table({ - ...base, - name: "t2", - row_security: true, - }), - }, - { - "table:public.t2": new Table({ - ...base, - name: "t2", - row_security: false, - }), - }, - ); - expect( - disable.some((c) => c instanceof AlterTableDisableRowLevelSecurity), - ).toBe(true); - }); - - test("force row level security toggles", () => { - const force = diffTables( - testContext, - { - "table:public.t3": new Table({ - ...base, - name: "t3", - row_security: true, - force_row_security: false, - }), - }, - { - "table:public.t3": new Table({ - ...base, - name: "t3", - row_security: true, - force_row_security: true, - }), - }, - ); - expect( - force.some((c) => c instanceof AlterTableForceRowLevelSecurity), - ).toBe(true); - - const noforce = diffTables( - testContext, - { - "table:public.t4": new Table({ - ...base, - name: "t4", - row_security: true, - force_row_security: true, - }), - }, - { - "table:public.t4": new Table({ - ...base, - name: "t4", - row_security: true, - force_row_security: false, - }), - }, - ); - expect( - noforce.some((c) => c instanceof AlterTableNoForceRowLevelSecurity), - ).toBe(true); - }); - - test("replica identity diff emits REPLICA IDENTITY", () => { - const main = new Table(base); - const branch = new Table({ ...base, replica_identity: "n" }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterTableSetReplicaIdentity)).toBe( - true, - ); - }); - - test("constraints create/drop/alter and validate", () => { - const t1 = new Table({ ...base, name: "t1", constraints: [] }); - const pkey = { - name: "pk_t1", - constraint_type: "p" as const, - deferrable: false, - initially_deferred: false, - validated: false, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["a"], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "o1", - definition: "PRIMARY KEY (a)", - }; - const created = diffTables( - testContext, - { [t1.stableId]: t1 }, - { - [t1.stableId]: new Table({ ...base, name: "t1", constraints: [pkey] }), - }, - ); - expect(created.some((c) => c instanceof AlterTableAddConstraint)).toBe( - true, - ); - expect(created.some((c) => c instanceof AlterTableValidateConstraint)).toBe( - false, - ); - - const dropped = diffTables( - testContext, - { - [t1.stableId]: new Table({ ...base, name: "t1", constraints: [pkey] }), - }, - { [t1.stableId]: t1 }, - ); - expect(dropped.some((c) => c instanceof AlterTableDropConstraint)).toBe( - true, - ); - - const altered = diffTables( - testContext, - { - [t1.stableId]: new Table({ ...base, name: "t1", constraints: [pkey] }), - }, - { - [t1.stableId]: new Table({ - ...base, - name: "t1", - constraints: [ - { - ...pkey, - deferrable: true, - initially_deferred: true, - validated: true, - }, - ], - }), - }, - ); - expect(altered.some((c) => c instanceof AlterTableDropConstraint)).toBe( - true, - ); - expect(altered.some((c) => c instanceof AlterTableAddConstraint)).toBe( - true, - ); - }); - - test("altered primary key columns triggers drop+add", () => { - const tMain = new Table({ - ...base, - name: "t_cols", - columns: [ - { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - { - name: "b", - position: 2, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - constraints: [ - { - name: "pk_cols", - constraint_type: "p", - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["a"], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "o1", - definition: "PRIMARY KEY (a)", - }, - ], - }); - const tBranch = new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...tMain, - constraints: [ - { - ...tMain.constraints[0], - key_columns: ["a", "b"], - }, - ], - }); - const changes = diffTables( - testContext, - { [tMain.stableId]: tMain }, - { [tBranch.stableId]: tBranch }, - ); - expect(changes.some((c) => c instanceof AlterTableDropConstraint)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableAddConstraint)).toBe( - true, - ); - }); - - test("altered foreign key to NOT VALID triggers drop+add without validate", () => { - const tMain = new Table({ - ...base, - name: "t_fk", - columns: [ - { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - constraints: [ - { - name: "fk_a", - constraint_type: "f", - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["a"], - foreign_key_columns: ["a"], - foreign_key_table: "other", - foreign_key_schema: "public", - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: "a", - on_delete: "a", - match_type: "u", - check_expression: null, - owner: "o1", - definition: "FOREIGN KEY (a) REFERENCES other(a)", - }, - ], - }); - const tBranch = new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...tMain, - constraints: [ - { - ...(tMain.constraints[0] as (typeof tMain.constraints)[number]), - on_delete: "c", - validated: false, - definition: "FOREIGN KEY (a) REFERENCES other(a) NOT VALID", - }, - ], - }); - const changes = diffTables( - testContext, - { [tMain.stableId]: tMain }, - { - [tBranch.stableId]: tBranch, - "table:public.other": new Table({ - ...base, - name: "other", - columns: [ - { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - }), - }, - ); - expect(changes.some((c) => c instanceof AlterTableDropConstraint)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableAddConstraint)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableValidateConstraint)).toBe( - false, - ); - }); - - test("altered temporal constraint metadata triggers drop+add", () => { - const tMain = new Table({ - ...base, - name: "t_temporal", - columns: [ - { - name: "room_id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - { - name: "booking_period", - position: 2, - data_type: "tstzrange", - data_type_str: "tstzrange", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - constraints: [ - { - name: "bookings_pkey", - constraint_type: "p", - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["room_id", "booking_period"], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "o1", - definition: "PRIMARY KEY (room_id, booking_period)", - }, - ], - }); - const tBranch = new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...tMain, - constraints: [ - { - ...tMain.constraints[0], - is_temporal: true, - definition: "PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS)", - }, - ], - }); - const changes = diffTables( - testContext, - { [tMain.stableId]: tMain }, - { [tBranch.stableId]: tBranch }, - ); - expect(changes.some((c) => c instanceof AlterTableDropConstraint)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableAddConstraint)).toBe( - true, - ); - }); - - test("columns added/dropped/altered (type, default, not null)", () => { - const main = new Table({ ...base, name: "t2", columns: [] }); - const withCol = new Table({ - ...base, - name: "t2", - columns: [ - { - name: "a", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - }); - const added = diffTables( - testContext, - { [main.stableId]: main }, - { [withCol.stableId]: withCol }, - ); - expect(added.some((c) => c instanceof AlterTableAddColumn)).toBe(true); - - const dropped = diffTables( - testContext, - { [withCol.stableId]: withCol }, - { [main.stableId]: main }, - ); - expect(dropped.some((c) => c instanceof AlterTableDropColumn)).toBe(true); - - const typeChanged = new Table({ - ...base, - name: "t2", - columns: [ - { - ...withCol.columns[0], - data_type: "text", - data_type_str: "text", - }, - ], - }); - const typeChanges = diffTables( - testContext, - { [withCol.stableId]: withCol }, - { [typeChanged.stableId]: typeChanged }, - ); - expect( - typeChanges.some((c) => c instanceof AlterTableAlterColumnType), - ).toBe(true); - expect(typeChanges.map((c) => c.serialize())).toContain( - "ALTER TABLE public.t2 ALTER COLUMN a TYPE text USING a::text", - ); - - const defaultAdded = new Table({ - ...base, - name: "t2", - columns: [{ ...withCol.columns[0], default: "0" }], - }); - const defaultAddedChanges = diffTables( - testContext, - { [withCol.stableId]: withCol }, - { [defaultAdded.stableId]: defaultAdded }, - ); - expect( - defaultAddedChanges.some( - (c) => c instanceof AlterTableAlterColumnSetDefault, - ), - ).toBe(true); - - const defaultDropped = diffTables( - testContext, - { [defaultAdded.stableId]: defaultAdded }, - { [withCol.stableId]: withCol }, - ); - expect( - defaultDropped.some((c) => c instanceof AlterTableAlterColumnDropDefault), - ).toBe(true); - - const notNullSet = new Table({ - ...base, - name: "t2", - columns: [{ ...withCol.columns[0], not_null: true }], - }); - const notNullSetChanges = diffTables( - testContext, - { [withCol.stableId]: withCol }, - { [notNullSet.stableId]: notNullSet }, - ); - expect( - notNullSetChanges.some( - (c) => c instanceof AlterTableAlterColumnSetNotNull, - ), - ).toBe(true); - - const notNullDropped = diffTables( - testContext, - { [notNullSet.stableId]: notNullSet }, - { [withCol.stableId]: withCol }, - ); - expect( - notNullDropped.some((c) => c instanceof AlterTableAlterColumnDropNotNull), - ).toBe(true); - - const withDefault = new Table({ - ...base, - name: "t2", - columns: [ - { - ...withCol.columns[0], - data_type: "text", - data_type_str: "text", - default: "'active'", - }, - ], - }); - const typeChangedWithDefault = new Table({ - ...base, - name: "t2", - columns: [ - { - ...withDefault.columns[0], - data_type: "USER-DEFINED", - data_type_str: "test_schema.status", - is_custom_type: true, - custom_type_type: "e", - custom_type_category: "E", - custom_type_schema: "test_schema", - custom_type_name: "status", - default: "'active'::test_schema.status", - }, - ], - }); - const typeChangesWithDefault = diffTables( - testContext, - { [withDefault.stableId]: withDefault }, - { [typeChangedWithDefault.stableId]: typeChangedWithDefault }, - ); - expect(typeChangesWithDefault.map((c) => c.serialize())).toEqual([ - "ALTER TABLE public.t2 ALTER COLUMN a DROP DEFAULT", - "ALTER TABLE public.t2 ALTER COLUMN a TYPE test_schema.status USING a::test_schema.status", - "ALTER TABLE public.t2 ALTER COLUMN a SET DEFAULT 'active'::test_schema.status", - ]); - }); - - test("identity transitions emit drop/add/set-generated changes", () => { - const serialColumn = { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: "nextval('public.t_identity_id_seq'::regclass)", - comment: null, - }; - - const identityAlwaysColumn = { - ...serialColumn, - is_identity: true, - is_identity_always: true, - default: null, - }; - - const identityByDefaultColumn = { - ...identityAlwaysColumn, - is_identity_always: false, - }; - - const serialToIdentityMain = new Table({ - ...base, - name: "t_identity", - columns: [serialColumn], - }); - const serialToIdentityBranch = new Table({ - ...base, - name: "t_identity", - columns: [identityAlwaysColumn], - }); - - const serialToIdentityChanges = diffTables( - testContext, - { [serialToIdentityMain.stableId]: serialToIdentityMain }, - { [serialToIdentityBranch.stableId]: serialToIdentityBranch }, - ); - expect( - serialToIdentityChanges.some( - (c) => c instanceof AlterTableAlterColumnDropDefault, - ), - ).toBe(true); - expect( - serialToIdentityChanges.some( - (c) => c instanceof AlterTableAlterColumnAddIdentity, - ), - ).toBe(true); - - const identityToSerialChanges = diffTables( - testContext, - { [serialToIdentityBranch.stableId]: serialToIdentityBranch }, - { [serialToIdentityMain.stableId]: serialToIdentityMain }, - ); - expect( - identityToSerialChanges.some( - (c) => c instanceof AlterTableAlterColumnDropIdentity, - ), - ).toBe(true); - expect( - identityToSerialChanges.some( - (c) => c instanceof AlterTableAlterColumnSetDefault, - ), - ).toBe(true); - - const alwaysToByDefaultMain = new Table({ - ...base, - name: "t_identity_mode", - columns: [identityAlwaysColumn], - }); - const alwaysToByDefaultBranch = new Table({ - ...base, - name: "t_identity_mode", - columns: [identityByDefaultColumn], - }); - const alwaysToByDefaultChanges = diffTables( - testContext, - { [alwaysToByDefaultMain.stableId]: alwaysToByDefaultMain }, - { [alwaysToByDefaultBranch.stableId]: alwaysToByDefaultBranch }, - ); - expect( - alwaysToByDefaultChanges.some( - (c) => c instanceof AlterTableAlterColumnSetGenerated, - ), - ).toBe(true); - - const byDefaultToAlwaysMain = new Table({ - ...base, - name: "t_identity_mode_reverse", - columns: [identityByDefaultColumn], - }); - const byDefaultToAlwaysBranch = new Table({ - ...base, - name: "t_identity_mode_reverse", - columns: [identityAlwaysColumn], - }); - const byDefaultToAlwaysChanges = diffTables( - testContext, - { [byDefaultToAlwaysMain.stableId]: byDefaultToAlwaysMain }, - { [byDefaultToAlwaysBranch.stableId]: byDefaultToAlwaysBranch }, - ); - expect( - byDefaultToAlwaysChanges.some( - (c) => c instanceof AlterTableAlterColumnSetGenerated, - ), - ).toBe(true); - }); - - test("postgres 17+ recreates a column when switching from regular to generated", () => { - const pg17Context = { - ...testContext, - version: 170000, - }; - - const regularColumn = { - name: "confirmed_at", - position: 1, - data_type: "timestamp with time zone", - data_type_str: "timestamp with time zone", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }; - - const generatedColumn = { - ...regularColumn, - is_generated: true, - default: "LEAST(email_confirmed_at, phone_confirmed_at)", - }; - - const mainTable = new Table({ - ...base, - name: "auth_users_like", - columns: [regularColumn], - }); - const branchTable = new Table({ - ...base, - name: "auth_users_like", - columns: [generatedColumn], - }); - - const changes = diffTables( - pg17Context, - { [mainTable.stableId]: mainTable }, - { [branchTable.stableId]: branchTable }, - ); - - expect(changes.some((c) => c instanceof AlterTableDropColumn)).toBe(true); - expect(changes.some((c) => c instanceof AlterTableAddColumn)).toBe(true); - expect( - changes.some((c) => c instanceof AlterTableAlterColumnSetDefault), - ).toBe(false); - }); - - test("created table with privileges emits grant changes", () => { - const t = new Table({ - ...base, - privileges: [ - { grantee: "role_sel", privilege: "SELECT", grantable: false }, - { grantee: "role_ins", privilege: "INSERT", grantable: true }, - ], - }); - const changes = diffTables(testContext, {}, { [t.stableId]: t }); - expect(changes[0]).toBeInstanceOf(CreateTable); - expect(changes.some((c) => c instanceof GrantTablePrivileges)).toBe(true); - }); - - test("created table with default privilege revoke grant option", () => { - const defaultPrivilegeState = new DefaultPrivilegeState({}); - defaultPrivilegeState.applyGrant("postgres", "r", "public", "role_a", [ - { privilege: "SELECT", grantable: true }, - ]); - const ctx = { - ...testContext, - defaultPrivilegeState, - }; - const t = new Table({ - ...base, - owner: "postgres", - privileges: [ - { grantee: "role_a", privilege: "SELECT", grantable: false }, - ], - }); - const changes = diffTables(ctx, {}, { [t.stableId]: t }); - expect(changes[0]).toBeInstanceOf(CreateTable); - expect( - changes.some((c) => c instanceof RevokeGrantOptionTablePrivileges), - ).toBe(true); - }); - - test("altered table privileges emit grant, revoke, and revoke grant option", () => { - const main = new Table({ - ...base, - privileges: [ - { grantee: "role_sel", privilege: "SELECT", grantable: false }, - { grantee: "role_with_option", privilege: "SELECT", grantable: true }, - { grantee: "role_removed", privilege: "SELECT", grantable: false }, - ], - }); - const branch = new Table({ - ...base, - privileges: [ - { grantee: "role_sel", privilege: "SELECT", grantable: true }, - { grantee: "role_with_option", privilege: "SELECT", grantable: false }, - { grantee: "role_new", privilege: "SELECT", grantable: false }, - ], - }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof GrantTablePrivileges)).toBe(true); - expect(changes.some((c) => c instanceof RevokeTablePrivileges)).toBe(true); - expect( - changes.some((c) => c instanceof RevokeGrantOptionTablePrivileges), - ).toBe(true); - }); - - test("altered table privileges emit revokes before grants", () => { - const main = new Table({ - ...base, - privileges: [ - { grantee: "authenticated", privilege: "INSERT", grantable: false }, - { grantee: "authenticated", privilege: "UPDATE", grantable: false }, - ], - }); - const branch = new Table({ - ...base, - privileges: [ - { - grantee: "authenticated", - privilege: "INSERT", - grantable: false, - columns: ["org_id", "name"], - }, - { - grantee: "authenticated", - privilege: "UPDATE", - grantable: false, - columns: ["name"], - }, - ], - }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - const privilegeChanges = changes.filter( - (c) => - c instanceof GrantTablePrivileges || - c instanceof RevokeTablePrivileges || - c instanceof RevokeGrantOptionTablePrivileges, - ); - expect(privilegeChanges.length).toBeGreaterThan(1); - - const firstRevokeIndex = privilegeChanges.findIndex( - (c) => c instanceof RevokeTablePrivileges, - ); - const firstGrantIndex = privilegeChanges.findIndex( - (c) => c instanceof GrantTablePrivileges, - ); - expect(firstRevokeIndex).not.toBe(-1); - expect(firstGrantIndex).not.toBe(-1); - expect(firstRevokeIndex).toBeLessThan(firstGrantIndex); - }); - - test("storage params: set when added from null", () => { - const main = new Table(base); - const branch = new Table({ - ...base, - options: ["autovacuum_enabled=false"], - }); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterTableSetStorageParams)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableResetStorageParams)).toBe( - false, - ); - }); - - test("storage params: reset when removed to null", () => { - const main = new Table({ - ...base, - options: ["fillfactor=90"], - }); - const branch = new Table(base); - const changes = diffTables( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterTableResetStorageParams)).toBe( - true, - ); - expect(changes.some((c) => c instanceof AlterTableSetStorageParams)).toBe( - false, - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/table/table.diff.ts b/packages/pg-delta/src/core/objects/table/table.diff.ts deleted file mode 100644 index c9426e127..000000000 --- a/packages/pg-delta/src/core/objects/table/table.diff.ts +++ /dev/null @@ -1,1034 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { - diffPrivileges, - emitColumnPrivilegeChanges, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { deepEqual } from "../utils.ts"; -import { - AlterTableAddColumn, - AlterTableAddConstraint, - AlterTableAlterColumnAddIdentity, - AlterTableAlterColumnDropDefault, - AlterTableAlterColumnDropIdentity, - AlterTableAlterColumnDropNotNull, - AlterTableAlterColumnSetDefault, - AlterTableAlterColumnSetGenerated, - AlterTableAlterColumnSetNotNull, - AlterTableAlterColumnType, - AlterTableAttachPartition, - AlterTableChangeOwner, - AlterTableDetachPartition, - AlterTableDisableRowLevelSecurity, - AlterTableDropColumn, - AlterTableDropConstraint, - AlterTableEnableRowLevelSecurity, - AlterTableForceRowLevelSecurity, - AlterTableNoForceRowLevelSecurity, - AlterTableResetStorageParams, - AlterTableSetLogged, - AlterTableSetReplicaIdentity, - AlterTableSetStorageParams, - AlterTableSetUnlogged, - AlterTableValidateConstraint, -} from "./changes/table.alter.ts"; -import { - CreateCommentOnColumn, - CreateCommentOnConstraint, - CreateCommentOnTable, - DropCommentOnColumn, - DropCommentOnConstraint, - DropCommentOnTable, -} from "./changes/table.comment.ts"; -import { CreateTable } from "./changes/table.create.ts"; -import { DropTable } from "./changes/table.drop.ts"; -import { - GrantTablePrivileges, - RevokeGrantOptionTablePrivileges, - RevokeTablePrivileges, -} from "./changes/table.privilege.ts"; -import { - CreateSecurityLabelOnColumn, - CreateSecurityLabelOnTable, - DropSecurityLabelOnColumn, - DropSecurityLabelOnTable, -} from "./changes/table.security-label.ts"; -import type { TableChange } from "./changes/table.types.ts"; -import { Table } from "./table.model.ts"; - -function createAlterConstraintChange(mainTable: Table, branchTable: Table) { - const changes: TableChange[] = []; - - // Note: Table renaming would also use ALTER TABLE ... RENAME TO ... - // But since our Table model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - - // TABLE CONSTRAINTS - const mainByName = new Map( - (mainTable.constraints ?? []).map((c) => [c.name, c]), - ); - const branchByName = new Map( - (branchTable.constraints ?? []).map((c) => [c.name, c]), - ); - - // Created constraints - for (const [name, c] of branchByName) { - // Skip constraint clones on partitions - they are automatically created when the parent constraint is created - if (c.is_partition_clone) { - continue; - } - - if (!mainByName.has(name)) { - changes.push( - new AlterTableAddConstraint({ - table: branchTable, - constraint: c, - }), - ); - // Add comment for newly created constraint - if (c.comment !== null) { - changes.push( - new CreateCommentOnConstraint({ - table: branchTable, - constraint: c, - }), - ); - } - } - } - - // Dropped constraints - for (const [name, c] of mainByName) { - // Skip constraint clones on partitions - they are automatically dropped when the parent constraint is dropped - if (c.is_partition_clone) { - continue; - } - - if (!branchByName.has(name)) { - changes.push( - new AlterTableDropConstraint({ table: mainTable, constraint: c }), - ); - } - } - - // Altered constraints -> drop + add (or VALIDATE-only shortcut) - for (const [name, mainC] of mainByName) { - const branchC = branchByName.get(name); - if (!branchC) continue; - - // Skip constraint clones on partitions - they are automatically updated when the parent constraint is updated - if (mainC.is_partition_clone || branchC.is_partition_clone) { - continue; - } - - // Cheap scalar `===` checks first; only fall through to JSON.stringify - // on the array fields when every scalar has already matched. - const fieldsEqualExceptValidated = - mainC.constraint_type === branchC.constraint_type && - mainC.deferrable === branchC.deferrable && - mainC.initially_deferred === branchC.initially_deferred && - mainC.is_local === branchC.is_local && - mainC.no_inherit === branchC.no_inherit && - mainC.is_temporal === branchC.is_temporal && - mainC.foreign_key_table === branchC.foreign_key_table && - mainC.foreign_key_schema === branchC.foreign_key_schema && - mainC.on_update === branchC.on_update && - mainC.on_delete === branchC.on_delete && - mainC.match_type === branchC.match_type && - mainC.check_expression === branchC.check_expression && - JSON.stringify(mainC.key_columns) === - JSON.stringify(branchC.key_columns) && - JSON.stringify(mainC.foreign_key_columns) === - JSON.stringify(branchC.foreign_key_columns); - - // Safe-migration shortcut: when the only difference is `validated` - // flipping from false to true, emit a single `ALTER TABLE ... VALIDATE - // CONSTRAINT` instead of drop+add. VALIDATE CONSTRAINT only takes - // SHARE UPDATE EXCLUSIVE (concurrent reads/writes proceed), whereas - // dropping and re-adding takes ACCESS EXCLUSIVE for the entire scan. - // Postgres has no reverse command, so `true -> false` must still go - // through drop+add below. - if ( - fieldsEqualExceptValidated && - mainC.validated === false && - branchC.validated === true - ) { - changes.push( - new AlterTableValidateConstraint({ - table: branchTable, - constraint: branchC, - }), - ); - // VALIDATE preserves the constraint OID, so its comment is preserved - // too. Only emit a comment change if it actually differs. - if (mainC.comment !== branchC.comment) { - if (branchC.comment === null) { - changes.push( - new DropCommentOnConstraint({ - table: mainTable, - constraint: mainC, - }), - ); - } else { - changes.push( - new CreateCommentOnConstraint({ - table: branchTable, - constraint: branchC, - }), - ); - } - } - continue; - } - - const changed = - mainC.validated !== branchC.validated || !fieldsEqualExceptValidated; - if (changed) { - changes.push( - new AlterTableDropConstraint({ - table: mainTable, - constraint: mainC, - }), - ); - changes.push( - new AlterTableAddConstraint({ - table: branchTable, - constraint: branchC, - }), - ); - // Ensure constraint comment is applied after re-creation - if (branchC.comment !== null) { - changes.push( - new CreateCommentOnConstraint({ - table: branchTable, - constraint: branchC, - }), - ); - } - } else { - // Comment-only change on constraint - if (mainC.comment !== branchC.comment) { - if (branchC.comment === null) { - changes.push( - new DropCommentOnConstraint({ - table: mainTable, - constraint: mainC, - }), - ); - } else { - changes.push( - new CreateCommentOnConstraint({ - table: branchTable, - constraint: branchC, - }), - ); - } - } - } - } - - return changes; -} - -/** - * Diff two sets of tables from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The tables in the main catalog. - * @param branch - The tables in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffTables( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): TableChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: TableChange[] = []; - - for (const tableId of created) { - changes.push(new CreateTable({ table: branch[tableId] })); - const branchTable = branch[tableId]; - - // OWNER: If the table should be owned by someone other than the current user, - // emit ALTER TABLE ... OWNER TO after creation - if (branchTable.owner !== ctx.currentUser) { - changes.push( - new AlterTableChangeOwner({ - table: branchTable, - owner: branchTable.owner, - }), - ); - } - - // ROW LEVEL SECURITY: If RLS should be enabled, emit ALTER TABLE ... ENABLE ROW LEVEL SECURITY - if (branchTable.row_security) { - changes.push( - new AlterTableEnableRowLevelSecurity({ table: branchTable }), - ); - } - - // FORCE ROW LEVEL SECURITY: If force RLS should be enabled, emit ALTER TABLE ... FORCE ROW LEVEL SECURITY - if (branchTable.force_row_security) { - changes.push(new AlterTableForceRowLevelSecurity({ table: branchTable })); - } - - // REPLICA IDENTITY: If non-default, emit ALTER TABLE ... REPLICA IDENTITY - if (branchTable.replica_identity !== "d") { - changes.push( - new AlterTableSetReplicaIdentity({ - table: branchTable, - mode: branchTable.replica_identity, - indexName: branchTable.replica_identity_index, - }), - ); - } - - changes.push( - ...createAlterConstraintChange( - // Create a dummy table with no constraints do diff constraints against - new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...branchTable, - constraints: [], - }), - branchTable, - ), - ); - - // Table comment on creation - if (branchTable.comment !== null && branchTable.comment !== undefined) { - changes.push(new CreateCommentOnTable({ table: branchTable })); - } - - // Column comments on creation - for (const col of branchTable.columns) { - if (col.comment !== null && col.comment !== undefined) { - changes.push( - new CreateCommentOnColumn({ table: branchTable, column: col }), - ); - } - } - - // Table security labels on creation - for (const label of branchTable.security_labels) { - changes.push( - new CreateSecurityLabelOnTable({ - table: branchTable, - securityLabel: label, - }), - ); - } - - // Column security labels on creation - for (const col of branchTable.columns) { - for (const label of col.security_labels ?? []) { - changes.push( - new CreateSecurityLabelOnColumn({ - table: branchTable, - column: col, - securityLabel: label, - }), - ); - } - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "table", - branchTable.schema ?? "", - ); - const creatorFilteredDefaults = - branchTable.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - const desiredPrivileges = branchTable.privileges; - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the table owner as the reference. - const privilegeResults = diffPrivileges( - creatorFilteredDefaults, - desiredPrivileges, - branchTable.owner, - ); - - changes.push( - ...(emitColumnPrivilegeChanges( - privilegeResults, - branchTable, - branchTable, - "table", - { - Grant: GrantTablePrivileges, - Revoke: RevokeTablePrivileges, - RevokeGrantOption: RevokeGrantOptionTablePrivileges, - }, - effectiveDefaults, - ctx.version, - ) as TableChange[]), - ); - } - - for (const tableId of dropped) { - changes.push(new DropTable({ table: main[tableId] })); - } - - for (const tableId of altered) { - const mainTable = main[tableId]; - const branchTable = branch[tableId]; - - // Dangerous operations (drop+create) are not performed by this tool. - // Only emit safe ALTER statements below. - // Only alterable properties changed - check each one - - // PERSISTENCE (LOGGED/UNLOGGED) - if (mainTable.persistence !== branchTable.persistence) { - if (branchTable.persistence === "u" && mainTable.persistence === "p") { - changes.push(new AlterTableSetUnlogged({ table: mainTable })); - } else if ( - branchTable.persistence === "p" && - mainTable.persistence === "u" - ) { - changes.push(new AlterTableSetLogged({ table: mainTable })); - } - } - - // ROW LEVEL SECURITY - if (mainTable.row_security !== branchTable.row_security) { - if (branchTable.row_security) { - changes.push( - new AlterTableEnableRowLevelSecurity({ table: mainTable }), - ); - } else { - changes.push( - new AlterTableDisableRowLevelSecurity({ table: mainTable }), - ); - } - } - - // FORCE ROW LEVEL SECURITY - if (mainTable.force_row_security !== branchTable.force_row_security) { - if (branchTable.force_row_security) { - changes.push(new AlterTableForceRowLevelSecurity({ table: mainTable })); - } else { - changes.push( - new AlterTableNoForceRowLevelSecurity({ table: mainTable }), - ); - } - } - - // STORAGE PARAMS (WITH (...)) - if (!deepEqual(mainTable.options, branchTable.options)) { - const mainOpts = mainTable.options ?? []; - const branchOpts = branchTable.options ?? []; - - // Always set branch options when provided - if (branchOpts.length > 0) { - changes.push( - new AlterTableSetStorageParams({ - table: mainTable, - options: branchOpts, - }), - ); - } - - // Reset any params that are present in main but absent in branch - if (mainOpts.length > 0) { - const mainNames = new Set(mainOpts.map((opt) => opt.split("=")[0])); - const branchNames = new Set(branchOpts.map((opt) => opt.split("=")[0])); - const removed: string[] = []; - for (const name of mainNames) { - if (!branchNames.has(name)) removed.push(name); - } - if (removed.length > 0) { - changes.push( - new AlterTableResetStorageParams({ - table: mainTable, - params: removed, - }), - ); - } - } - } - - // REPLICA IDENTITY - // Re-emit when the mode changes, or when staying in 'i' mode but pointing - // at a different index. The index named on the branch must already exist - // before this ALTER runs; AlterTableSetReplicaIdentity declares that - // dependency in its `requires`. - const replicaIdentityChanged = - mainTable.replica_identity !== branchTable.replica_identity || - (branchTable.replica_identity === "i" && - mainTable.replica_identity_index !== - branchTable.replica_identity_index); - if (replicaIdentityChanged) { - changes.push( - new AlterTableSetReplicaIdentity({ - table: mainTable, - mode: branchTable.replica_identity, - indexName: branchTable.replica_identity_index, - }), - ); - } - - // OWNER - if (mainTable.owner !== branchTable.owner) { - changes.push( - new AlterTableChangeOwner({ - table: mainTable, - owner: branchTable.owner, - }), - ); - } - - // TABLE COMMENT (create/drop when comment changes) - if (mainTable.comment !== branchTable.comment) { - if (branchTable.comment === null) { - changes.push(new DropCommentOnTable({ table: mainTable })); - } else { - changes.push(new CreateCommentOnTable({ table: branchTable })); - } - } - - // TABLE SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnTable | DropSecurityLabelOnTable - >( - mainTable.security_labels, - branchTable.security_labels, - (securityLabel) => - new CreateSecurityLabelOnTable({ - table: branchTable, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnTable({ - table: mainTable, - securityLabel, - }), - ), - ); - - // PARTITION ATTACH/DETACH - const mainIsPartition = Boolean( - mainTable.parent_schema && mainTable.parent_name, - ); - const branchIsPartition = Boolean( - branchTable.parent_schema && branchTable.parent_name, - ); - - // Helper to resolve parent table from catalogs - const resolveParent = ( - catalog: Record, - schema: string, - name: string, - ): Table | undefined => catalog[`table:${schema}.${name}`]; - - if (!mainIsPartition && branchIsPartition) { - const table = resolveParent( - branch, - branchTable.parent_schema as string, - branchTable.parent_name as string, - ); - if (table) { - changes.push( - new AlterTableAttachPartition({ table, partition: branchTable }), - ); - } - } else if (mainIsPartition && !branchIsPartition) { - const table = resolveParent( - main, - mainTable.parent_schema as string, - mainTable.parent_name as string, - ); - if (table) { - changes.push( - new AlterTableDetachPartition({ table, partition: mainTable }), - ); - } - } else if (mainIsPartition && branchIsPartition) { - const parentChanged = - mainTable.parent_schema !== branchTable.parent_schema || - mainTable.parent_name !== branchTable.parent_name; - const boundChanged = - mainTable.partition_bound !== branchTable.partition_bound; - if (parentChanged || boundChanged) { - const oldParent = resolveParent( - main, - mainTable.parent_schema as string, - mainTable.parent_name as string, - ); - if (oldParent) { - changes.push( - new AlterTableDetachPartition({ - table: oldParent, - partition: mainTable, - }), - ); - } - const newParent = resolveParent( - branch, - branchTable.parent_schema as string, - branchTable.parent_name as string, - ); - if (newParent) { - changes.push( - new AlterTableAttachPartition({ - table: newParent, - partition: branchTable, - }), - ); - } - } - } - - changes.push(...createAlterConstraintChange(mainTable, branchTable)); - - // COLUMNS - const mainCols = new Map(mainTable.columns.map((c) => [c.name, c])); - const branchCols = new Map(branchTable.columns.map((c) => [c.name, c])); - - // Helper to get parent tables if this is a partition - // PostgreSQL automatically propagates column changes from parent to partitions, - // so we should skip changes on partitions when the parent has the same change - const getParentTables = (): { - parentMain: Table | null; - parentBranch: Table | null; - } => { - if ( - !branchIsPartition || - !branchTable.parent_schema || - !branchTable.parent_name - ) { - return { parentMain: null, parentBranch: null }; - } - - const parentBranch = resolveParent( - branch, - branchTable.parent_schema, - branchTable.parent_name, - ); - const parentMain = resolveParent( - main, - branchTable.parent_schema, - branchTable.parent_name, - ); - - return { - parentMain: parentMain ?? null, - parentBranch: parentBranch ?? null, - }; - }; - - // Helper to check if parent has the same column property change - const parentHasSameColumnPropertyChange = ( - columnName: string, - property: "type" | "default" | "not_null" | "identity", - ): boolean => { - const { parentMain, parentBranch } = getParentTables(); - if (!parentMain || !parentBranch) { - return false; - } - - const parentMainCol = parentMain.columns.find( - (c) => c.name === columnName, - ); - const parentBranchCol = parentBranch.columns.find( - (c) => c.name === columnName, - ); - const branchCol = branchCols.get(columnName); - const mainCol = mainCols.get(columnName); - - if (!parentMainCol || !parentBranchCol || !branchCol || !mainCol) { - return false; - } - - switch (property) { - case "type": { - const parentTypeChanged = - parentMainCol.data_type_str !== parentBranchCol.data_type_str || - parentMainCol.collation !== parentBranchCol.collation; - const partitionTypeChanged = - mainCol.data_type_str !== branchCol.data_type_str || - mainCol.collation !== branchCol.collation; - return ( - parentTypeChanged && - partitionTypeChanged && - parentBranchCol.data_type_str === branchCol.data_type_str && - parentBranchCol.collation === branchCol.collation - ); - } - case "default": { - const parentDefaultChanged = - parentMainCol.default !== parentBranchCol.default; - const partitionDefaultChanged = mainCol.default !== branchCol.default; - return ( - parentDefaultChanged && - partitionDefaultChanged && - parentBranchCol.default === branchCol.default - ); - } - case "not_null": { - const parentNotNullChanged = - parentMainCol.not_null !== parentBranchCol.not_null; - const partitionNotNullChanged = - mainCol.not_null !== branchCol.not_null; - return ( - parentNotNullChanged && - partitionNotNullChanged && - parentBranchCol.not_null === branchCol.not_null - ); - } - case "identity": { - const parentIdentityChanged = - parentMainCol.is_identity !== parentBranchCol.is_identity || - parentMainCol.is_identity_always !== - parentBranchCol.is_identity_always; - const partitionIdentityChanged = - mainCol.is_identity !== branchCol.is_identity || - mainCol.is_identity_always !== branchCol.is_identity_always; - return ( - parentIdentityChanged && - partitionIdentityChanged && - parentBranchCol.is_identity === branchCol.is_identity && - parentBranchCol.is_identity_always === branchCol.is_identity_always - ); - } - } - }; - - // Helper to check if parent has the same column add/drop - const shouldSkipColumnAddDropOnPartition = ( - columnName: string, - changeType: "add" | "drop", - ): boolean => { - const { parentMain, parentBranch } = getParentTables(); - if (!parentMain || !parentBranch) { - return false; - } - - const parentMainHasCol = parentMain.columns.some( - (c) => c.name === columnName, - ); - const parentBranchHasCol = parentBranch.columns.some( - (c) => c.name === columnName, - ); - - if (changeType === "add") { - // Check if parent also has this column added and final states match - if (!parentMainHasCol && parentBranchHasCol) { - const parentBranchCol = parentBranch.columns.find( - (c) => c.name === columnName, - ); - const branchCol = branchCols.get(columnName); - return ( - parentBranchCol !== undefined && - branchCol !== undefined && - parentBranchCol.data_type_str === branchCol.data_type_str && - parentBranchCol.collation === branchCol.collation && - parentBranchCol.default === branchCol.default && - parentBranchCol.not_null === branchCol.not_null - ); - } - } else { - // changeType === "drop" - // If parent is dropping the column, skip on partition - return parentMainHasCol && !parentBranchHasCol; - } - - return false; - }; - - // Added columns - for (const [name, col] of branchCols) { - if (!mainCols.has(name)) { - // Skip if this is a partition and parent has the same column added - if (shouldSkipColumnAddDropOnPartition(name, "add")) { - continue; - } - changes.push( - new AlterTableAddColumn({ table: branchTable, column: col }), - ); - if (col.comment !== null && col.comment !== undefined) { - changes.push( - new CreateCommentOnColumn({ table: branchTable, column: col }), - ); - } - } - } - - // Dropped columns - for (const [name, col] of mainCols) { - if (!branchCols.has(name)) { - // Skip if this is a partition and parent has the same column dropped - if (shouldSkipColumnAddDropOnPartition(name, "drop")) { - continue; - } - changes.push( - new AlterTableDropColumn({ table: mainTable, column: col }), - ); - } - } - - // Altered columns - for (const [name, mainCol] of mainCols) { - const branchCol = branchCols.get(name); - if (!branchCol) continue; - - const columnTypeChanged = - mainCol.data_type_str !== branchCol.data_type_str; - const columnCollationChanged = mainCol.collation !== branchCol.collation; - const needsDefaultSafeFlow = - columnTypeChanged && mainCol.default !== null; - - // TYPE or COLLATION change - if (columnTypeChanged || columnCollationChanged) { - // Skip if parent has the same type/collation change - if (!parentHasSameColumnPropertyChange(name, "type")) { - if (needsDefaultSafeFlow) { - changes.push( - new AlterTableAlterColumnDropDefault({ - table: branchTable, - column: branchCol, - }), - ); - } - changes.push( - new AlterTableAlterColumnType({ - table: branchTable, - column: branchCol, - previousColumn: mainCol, - }), - ); - if (needsDefaultSafeFlow && branchCol.default !== null) { - changes.push( - new AlterTableAlterColumnSetDefault({ - table: branchTable, - column: branchCol, - }), - ); - } - } - } - - // PostgreSQL rejects SET DEFAULT while the column still has identity metadata, - // so identity removal must lead the IDENTITY -> serial/default transition. - if (mainCol.is_identity && !branchCol.is_identity) { - if (!parentHasSameColumnPropertyChange(name, "identity")) { - changes.push( - new AlterTableAlterColumnDropIdentity({ - table: branchTable, - column: branchCol, - }), - ); - } - } - - // DEFAULT change - if (mainCol.default !== branchCol.default) { - // Skip if parent has the same default change - if (!parentHasSameColumnPropertyChange(name, "default")) { - if (needsDefaultSafeFlow) { - // Defaults were already dropped/re-set in the type-change flow above. - continue; - } - if (branchCol.default === null) { - // Drop default value - changes.push( - new AlterTableAlterColumnDropDefault({ - table: branchTable, - column: branchCol, - }), - ); - } else { - // Set new default value - const isGeneratedColumn = branchCol.is_generated; - const isPostgresLowerThan17 = ctx.version < 170000; - const generatedStatusChanged = - mainCol.is_generated !== branchCol.is_generated; - - if ( - isGeneratedColumn && - (isPostgresLowerThan17 || generatedStatusChanged) - ) { - // For generated columns in < PostgreSQL 17, we need to drop and recreate - // instead of using SET EXPRESSION AS for computed columns. We also - // need to recreate the column when switching between regular and - // generated states because SET EXPRESSION only applies to existing - // generated columns. - // cf: https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=5d06e99a3 - // cf: https://www.postgresql.org/docs/release/17.0/ - // > Allow ALTER TABLE to change a column's generation expression - changes.push( - new AlterTableDropColumn({ - table: mainTable, - column: mainCol, - }), - ); - changes.push( - new AlterTableAddColumn({ - table: branchTable, - column: branchCol, - }), - ); - } else { - // Use standard SET DEFAULT or SET EXPRESSION AS for newer PostgreSQL versions - changes.push( - new AlterTableAlterColumnSetDefault({ - table: branchTable, - column: branchCol, - }), - ); - } - } - } - } - - // Serial-like defaults have to be cleared before ADD GENERATED AS IDENTITY, - // while mode-only flips stay in-place on an existing identity column. - if ( - (!mainCol.is_identity && branchCol.is_identity) || - (mainCol.is_identity && - branchCol.is_identity && - mainCol.is_identity_always !== branchCol.is_identity_always) - ) { - // Skip if parent has the same identity change - if (!parentHasSameColumnPropertyChange(name, "identity")) { - if (!mainCol.is_identity && branchCol.is_identity) { - changes.push( - new AlterTableAlterColumnAddIdentity({ - table: branchTable, - column: branchCol, - }), - ); - } else if ( - mainCol.is_identity && - branchCol.is_identity && - mainCol.is_identity_always !== branchCol.is_identity_always - ) { - changes.push( - new AlterTableAlterColumnSetGenerated({ - table: branchTable, - column: branchCol, - }), - ); - } - } - } - - // NOT NULL change - if (mainCol.not_null !== branchCol.not_null) { - // Skip if parent has the same NOT NULL change - if (!parentHasSameColumnPropertyChange(name, "not_null")) { - if (branchCol.not_null) { - changes.push( - new AlterTableAlterColumnSetNotNull({ - table: branchTable, - column: branchCol, - }), - ); - } else { - changes.push( - new AlterTableAlterColumnDropNotNull({ - table: branchTable, - column: branchCol, - }), - ); - } - } - } - - // COMMENT change on column - // Note: Comments are NOT automatically propagated from parent to partitions, - // so we should NOT skip comment changes even if parent has the same change - if (mainCol.comment !== branchCol.comment) { - if (branchCol.comment === null) { - changes.push( - new DropCommentOnColumn({ table: mainTable, column: mainCol }), - ); - } else { - changes.push( - new CreateCommentOnColumn({ - table: branchTable, - column: branchCol, - }), - ); - } - } - - // SECURITY LABELS on column - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnColumn | DropSecurityLabelOnColumn - >( - mainCol.security_labels ?? [], - branchCol.security_labels ?? [], - (securityLabel) => - new CreateSecurityLabelOnColumn({ - table: branchTable, - column: branchCol, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnColumn({ - table: mainTable, - column: mainCol, - securityLabel, - }), - ), - ); - } - - // Added columns with security labels (for created columns on existing tables) - for (const [name, col] of branchCols) { - if (!mainCols.has(name)) { - for (const label of col.security_labels ?? []) { - changes.push( - new CreateSecurityLabelOnColumn({ - table: branchTable, - column: col, - securityLabel: label, - }), - ); - } - } - } - - // PRIVILEGES (unified object and column privileges) - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainTable.privileges, - branchTable.privileges, - branchTable.owner, - ); - - changes.push( - ...(emitColumnPrivilegeChanges( - privilegeResults, - branchTable, - mainTable, - "table", - { - Grant: GrantTablePrivileges, - Revoke: RevokeTablePrivileges, - RevokeGrantOption: RevokeGrantOptionTablePrivileges, - }, - mainTable.privileges, - ctx.version, - ) as TableChange[]), - ); - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/table/table.model.test.ts b/packages/pg-delta/src/core/objects/table/table.model.test.ts deleted file mode 100644 index a8a10a184..000000000 --- a/packages/pg-delta/src/core/objects/table/table.model.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Pool } from "pg"; -import { extractTables, Table } from "./table.model.ts"; - -// Minimal fields required by tablePropsSchema; individual tests override the -// constraints array (and any other relevant fields). -const baseTableRow = { - schema: "public", - name: '"users"', - persistence: "p" as const, - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - columns: [], - privileges: [], -}; - -const baseConstraint = { - name: '"users_pkey"', - constraint_type: "p" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ['"id"'], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "postgres", - comment: null, -}; - -const mockPool = (rows: unknown[]): Pool => - ({ query: async () => ({ rows }) }) as unknown as Pool; - -const mockPoolSequence = (...attempts: unknown[][]): Pool => { - let i = 0; - return { - query: async () => ({ - rows: attempts[Math.min(i++, attempts.length - 1)], - }), - } as unknown as Pool; -}; - -const NO_BACKOFF = { backoffMs: 0 } as const; - -describe("extractTables", () => { - test("skips constraints where pg_get_constraintdef returned NULL after exhausting retries", async () => { - const tables = await extractTables( - mockPool([ - { - ...baseTableRow, - constraints: [ - { - ...baseConstraint, - name: '"users_pkey"', - definition: "PRIMARY KEY (id)", - }, - { - ...baseConstraint, - name: '"users_orphan_chk"', - constraint_type: "c", - key_columns: [], - definition: null, - }, - ], - }, - ]), - NO_BACKOFF, - ); - - expect(tables).toHaveLength(1); - expect(tables[0]).toBeInstanceOf(Table); - expect(tables[0]?.constraints).toHaveLength(1); - expect(tables[0]?.constraints[0]?.name).toBe('"users_pkey"'); - expect(tables[0]?.constraints[0]?.definition).toBe("PRIMARY KEY (id)"); - }); - - test("does not throw ZodError when every constraint has a null definition", async () => { - const tables = await extractTables( - mockPool([ - { - ...baseTableRow, - constraints: [ - { - ...baseConstraint, - name: '"orphan_a"', - constraint_type: "c", - key_columns: [], - definition: null, - }, - { - ...baseConstraint, - name: '"orphan_b"', - constraint_type: "c", - key_columns: [], - definition: null, - }, - ], - }, - ]), - NO_BACKOFF, - ); - - expect(tables).toHaveLength(1); - expect(tables[0]?.constraints).toEqual([]); - }); - - test("returns all constraints when every definition is valid", async () => { - const tables = await extractTables( - mockPool([ - { - ...baseTableRow, - constraints: [ - { - ...baseConstraint, - name: '"users_pkey"', - definition: "PRIMARY KEY (id)", - }, - { - ...baseConstraint, - name: '"users_email_key"', - constraint_type: "u", - key_columns: ['"email"'], - definition: "UNIQUE (email)", - }, - ], - }, - ]), - NO_BACKOFF, - ); - - expect(tables[0]?.constraints.map((c) => c.name)).toEqual([ - '"users_pkey"', - '"users_email_key"', - ]); - }); - - test("recovers when pg_get_constraintdef is NULL on first attempt but resolved on retry", async () => { - const tables = await extractTables( - mockPoolSequence( - // attempt 1: one constraint has NULL definition - [ - { - ...baseTableRow, - constraints: [ - { - ...baseConstraint, - name: '"users_racy_chk"', - constraint_type: "c", - key_columns: [], - definition: null, - }, - ], - }, - ], - // attempt 2: constraint resolves on retry - [ - { - ...baseTableRow, - constraints: [ - { - ...baseConstraint, - name: '"users_racy_chk"', - constraint_type: "c", - key_columns: [], - definition: "CHECK (id > 0)", - }, - ], - }, - ], - ), - { retries: 2, backoffMs: 0 }, - ); - expect(tables).toHaveLength(1); - expect(tables[0]?.constraints).toHaveLength(1); - expect(tables[0]?.constraints[0]?.name).toBe('"users_racy_chk"'); - expect(tables[0]?.constraints[0]?.definition).toBe("CHECK (id > 0)"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/table/table.model.ts b/packages/pg-delta/src/core/objects/table/table.model.ts deleted file mode 100644 index 652e826bf..000000000 --- a/packages/pg-delta/src/core/objects/table/table.model.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { - BasePgModel, - columnPropsSchema, - normalizeColumns, - type TableLikeObject, -} from "../base.model.ts"; -import { normalizePrivileges } from "../base.privilege.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; -import { - type ExtractRetryOptions, - extractWithDefinitionRetry, -} from "../extract-with-retry.ts"; -import { - normalizeSecurityLabels, - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; - -const RelationPersistenceSchema = z.enum([ - "p", // permanent - "u", // unlogged - "t", // temporary -]); - -export const ReplicaIdentitySchema = z.enum([ - "d", // DEFAULT (use default key) - "n", // NOTHING (no replica identity) - "f", // FULL (all columns) - "i", // INDEX (specific index) -]); - -const ForeignKeyActionSchema = z.enum([ - "a", // NO ACTION - "r", // RESTRICT - "c", // CASCADE - "n", // SET NULL - "d", // SET DEFAULT -]); - -const ForeignKeyMatchTypeSchema = z.enum([ - "f", // FULL - "p", // PARTIAL - "s", // SIMPLE - "u", // UNSPECIFIED (default) -]); - -const tableConstraintPropsSchema = z.object({ - name: z.string(), - constraint_type: z.enum([ - "c", // CHECK constraint - "f", // FOREIGN KEY constraint - "p", // PRIMARY KEY constraint - "t", // TRIGGER constraint - "u", // UNIQUE constraint - "x", // EXCLUDE constraint - ]), - deferrable: z.boolean(), - initially_deferred: z.boolean(), - validated: z.boolean(), - is_local: z.boolean(), - no_inherit: z.boolean(), - is_temporal: z.boolean(), - is_partition_clone: z.boolean(), - parent_constraint_schema: z.string().nullable(), - parent_constraint_name: z.string().nullable(), - parent_table_schema: z.string().nullable(), - parent_table_name: z.string().nullable(), - key_columns: z.array(z.string()), - foreign_key_columns: z.array(z.string()).nullable(), - foreign_key_table: z.string().nullable(), - foreign_key_schema: z.string().nullable(), - foreign_key_table_is_partition: z.boolean().nullable(), - foreign_key_parent_schema: z.string().nullable(), - foreign_key_parent_table: z.string().nullable(), - foreign_key_effective_schema: z.string().nullable(), - foreign_key_effective_table: z.string().nullable(), - on_update: ForeignKeyActionSchema.nullable(), - on_delete: ForeignKeyActionSchema.nullable(), - match_type: ForeignKeyMatchTypeSchema.nullable(), - check_expression: z.string().nullable(), - owner: z.string(), - definition: z.string(), - comment: z.string().nullable().optional(), -}); - -export type TableConstraintProps = z.infer; - -// pg_get_constraintdef(oid, pretty) can return NULL under the same conditions -// as pg_get_indexdef: races with concurrent DDL, transient catalog -// inconsistencies, recovery edges. An unreadable constraint cannot be diffed, -// so we accept NULL here and filter the constraint out at extraction time -// rather than crashing the whole catalog parse with a ZodError. -const tableConstraintRowSchema = tableConstraintPropsSchema.extend({ - definition: z.string().nullable(), -}); - -const tablePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - persistence: RelationPersistenceSchema, - row_security: z.boolean(), - force_row_security: z.boolean(), - has_indexes: z.boolean(), - has_rules: z.boolean(), - has_triggers: z.boolean(), - has_subclasses: z.boolean(), - is_populated: z.boolean(), - replica_identity: ReplicaIdentitySchema, - replica_identity_index: z.string().nullable().optional(), - is_partition: z.boolean(), - options: z.array(z.string()).nullable(), - partition_bound: z.string().nullable(), - partition_by: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable().optional(), - parent_schema: z.string().nullable(), - parent_name: z.string().nullable(), - columns: z.array(columnPropsSchema), - constraints: z.array(tableConstraintPropsSchema).optional(), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -const tableRowSchema = tablePropsSchema.extend({ - constraints: z.array(tableConstraintRowSchema).optional(), -}); - -type TablePrivilegeProps = PrivilegeProps; -/** - * Table input props. `security_labels` is optional on direct construction - * (defaults to `[]`); extraction always produces it via the Zod default. - */ -export type TableProps = z.infer; -type TableRow = z.infer; - -export class Table extends BasePgModel implements TableLikeObject { - public readonly schema: TableProps["schema"]; - public readonly name: TableProps["name"]; - public readonly persistence: TableProps["persistence"]; - public readonly row_security: TableProps["row_security"]; - public readonly force_row_security: TableProps["force_row_security"]; - public readonly has_indexes: TableProps["has_indexes"]; - public readonly has_rules: TableProps["has_rules"]; - public readonly has_triggers: TableProps["has_triggers"]; - public readonly has_subclasses: TableProps["has_subclasses"]; - public readonly is_populated: TableProps["is_populated"]; - public readonly replica_identity: TableProps["replica_identity"]; - public readonly replica_identity_index: TableProps["replica_identity_index"]; - public readonly is_partition: TableProps["is_partition"]; - public readonly options: TableProps["options"]; - public readonly partition_bound: TableProps["partition_bound"]; - public readonly partition_by: TableProps["partition_by"]; - public readonly owner: TableProps["owner"]; - public readonly comment: TableProps["comment"]; - public readonly parent_schema: TableProps["parent_schema"]; - public readonly parent_name: TableProps["parent_name"]; - public readonly columns: TableProps["columns"]; - public readonly constraints: TableConstraintProps[]; - public readonly privileges: TablePrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: TableProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.persistence = props.persistence; - this.row_security = props.row_security; - this.force_row_security = props.force_row_security; - this.has_indexes = props.has_indexes; - this.has_rules = props.has_rules; - this.has_triggers = props.has_triggers; - this.has_subclasses = props.has_subclasses; - this.is_populated = props.is_populated; - this.replica_identity = props.replica_identity; - this.replica_identity_index = props.replica_identity_index ?? null; - this.is_partition = props.is_partition; - this.options = props.options; - this.partition_bound = props.partition_bound; - this.partition_by = props.partition_by; - this.owner = props.owner; - this.comment = props.comment; - this.parent_schema = props.parent_schema; - this.parent_name = props.parent_name; - this.columns = props.columns; - this.constraints = props.constraints ?? []; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `table:${string}` { - return `table:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - // Only include fields that can be managed via ALTER safely - persistence: this.persistence, - row_security: this.row_security, - force_row_security: this.force_row_security, - replica_identity: this.replica_identity, - replica_identity_index: this.replica_identity_index, - options: this.options, - // Partition membership can be altered via ATTACH/DETACH - parent_schema: this.parent_schema, - parent_name: this.parent_name, - partition_bound: this.partition_bound, - owner: this.owner, - comment: this.comment, - columns: this.columns, - constraints: this.constraints, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } - - override stableSnapshot() { - const normalizeConstraints = () => - [...this.constraints].sort((a, b) => { - const nameA = (a.name as string | undefined) ?? ""; - const nameB = (b.name as string | undefined) ?? ""; - return nameA.localeCompare(nameB); - }); - - return { - identity: this.identityFields, - data: { - ...this.dataFields, - columns: normalizeColumns(this.columns), - options: this.options ? [...this.options].sort() : this.options, - constraints: normalizeConstraints(), - privileges: normalizePrivileges(this.privileges), - security_labels: normalizeSecurityLabels(this.security_labels), - }, - }; - } -} - -export async function extractTables( - pool: Pool, - options?: ExtractRetryOptions, -): Promise { - const tableRows = await extractWithDefinitionRetry({ - label: "table constraints", - options, - hasNullDefinition: (row: TableRow) => - row.constraints?.some((c) => c.definition === null) ?? false, - query: async () => { - const result = await pool.query(sql` -with extension_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass -), tables as ( - select - c.relnamespace::regnamespace::text as schema, - quote_ident(c.relname) as name, - c.relpersistence as persistence, - c.relrowsecurity as row_security, - c.relforcerowsecurity as force_row_security, - c.relhasindex as has_indexes, - c.relhasrules as has_rules, - c.relhastriggers as has_triggers, - c.relhassubclass as has_subclasses, - c.relispopulated as is_populated, - c.relreplident as replica_identity, - ( - select quote_ident(ri_class.relname) - from pg_index ri - join pg_class ri_class on ri_class.oid = ri.indexrelid - where ri.indrelid = c.oid - and ri.indisreplident is true - limit 1 - ) as replica_identity_index, - c.relispartition as is_partition, - c.reloptions as options, - pg_get_expr(c.relpartbound, c.oid) as partition_bound, - pg_get_partkeydef(c.oid) as partition_by, - c.relowner::regrole::text as owner, - c_parent.relnamespace::regnamespace as parent_schema, - quote_ident(c_parent.relname) as parent_name, - c.oid as oid - from - pg_class c - left join extension_oids e1 on c.oid = e1.objid - left join pg_inherits i on i.inhrelid = c.oid - left join pg_class c_parent on i.inhparent = c_parent.oid - where - c.relkind in ('r', 'p') - and not c.relnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e1.objid is null -) -select - t.schema, - t.name, - t.persistence, - t.row_security, - t.force_row_security, - t.has_indexes, - t.has_rules, - t.has_triggers, - t.has_subclasses, - t.is_populated, - t.replica_identity, - t.replica_identity_index, - t.is_partition, - t.options, - t.partition_bound, - t.partition_by, - t.owner, - obj_description(t.oid, 'pg_class') as comment, - t.parent_schema, - t.parent_name, - coalesce( - ( - select json_agg( - json_build_object( - 'name', quote_ident(c.conname), - 'constraint_type', c.contype, - 'deferrable', c.condeferrable, - 'initially_deferred', c.condeferred, - 'validated', c.convalidated, - 'is_local', c.conislocal, - 'no_inherit', c.connoinherit, - 'is_temporal', coalesce((to_jsonb(c)->>'conperiod')::boolean, false), - - -- Inherited from a parent (partition or classical inheritance). - -- coninhcount > 0 is the canonical signal across every constraint - -- kind. We previously used conparentid <> 0, but PostgreSQL only - -- populates conparentid for PK / UNIQUE / FK on partitions; CHECK - -- constraints on partitions always have conparentid = 0 and were - -- being re-emitted on every child, failing apply with 42710. - 'is_partition_clone', (c.coninhcount > 0), - 'parent_constraint_schema', case when c.conparentid <> 0::oid then pc.connamespace::regnamespace::text end, - 'parent_constraint_name', case when c.conparentid <> 0::oid then quote_ident(pc.conname) end, - 'parent_table_schema', case when c.conparentid <> 0::oid then pc_rel.relnamespace::regnamespace::text end, - 'parent_table_name', case when c.conparentid <> 0::oid then quote_ident(pc_rel.relname) end, - - 'key_columns', - case - when c.conkey is not null then coalesce( - ( - select json_agg(quote_ident(att.attname) order by pk.ordinality) - from unnest(c.conkey) with ordinality as pk(attnum, ordinality) - join pg_attribute att - on att.attrelid = c.conrelid - and att.attnum = pk.attnum - and att.attisdropped = false - ), - '[]'::json - ) - else '[]'::json - end, - - 'foreign_key_columns', - case - when c.contype = 'f' then ( - select json_agg(quote_ident(att.attname) order by fk.ordinality) - from unnest(c.confkey) with ordinality as fk(attnum, ordinality) - join pg_attribute att - on att.attrelid = c.confrelid - and att.attnum = fk.attnum - and att.attisdropped = false - ) - else null - end, - - -- existing FK target - 'foreign_key_table', quote_ident(ftc.relname), - 'foreign_key_schema', ftc.relnamespace::regnamespace::text, - - -- NEW: if FK points at a *partition*, expose its parent + an "effective" target - 'foreign_key_table_is_partition', - case when c.contype = 'f' then coalesce(ftc.relispartition, false) else null end, - 'foreign_key_parent_schema', - case when c.contype = 'f' and ftc.relispartition then ftc_parent.relnamespace::regnamespace::text else null end, - 'foreign_key_parent_table', - case when c.contype = 'f' and ftc.relispartition then quote_ident(ftc_parent.relname) else null end, - 'foreign_key_effective_schema', - case - when c.contype <> 'f' then null - when ftc.relispartition then ftc_parent.relnamespace::regnamespace::text - else ftc.relnamespace::regnamespace::text - end, - 'foreign_key_effective_table', - case - when c.contype <> 'f' then null - when ftc.relispartition then quote_ident(ftc_parent.relname) - else quote_ident(ftc.relname) - end, - - 'on_update', case when c.contype = 'f' then c.confupdtype else null end, - 'on_delete', case when c.contype = 'f' then c.confdeltype else null end, - 'match_type', case when c.contype = 'f' then c.confmatchtype else null end, - - 'check_expression', pg_get_expr(c.conbin, c.conrelid), - 'owner', t.owner, - 'definition', pg_get_constraintdef(c.oid, true), - 'comment', obj_description(c.oid, 'pg_constraint') - ) - order by c.conname - ) - from pg_catalog.pg_constraint c - - -- NEW: parent constraint/table lookup (for propagated constraints) - left join pg_catalog.pg_constraint pc on pc.oid = c.conparentid - left join pg_catalog.pg_class pc_rel on pc_rel.oid = pc.conrelid - - -- FK referenced table + parent table if it’s a partition - left join pg_catalog.pg_class ftc on ftc.oid = c.confrelid - left join pg_catalog.pg_inherits fi on fi.inhrelid = ftc.oid - left join pg_catalog.pg_class ftc_parent on ftc_parent.oid = fi.inhparent - - left join pg_depend de - on de.classid = 'pg_constraint'::regclass - and de.objid = c.oid - and de.refclassid = 'pg_extension'::regclass - - where c.conrelid = t.oid - -- Skip constraint triggers and PG18 NOT NULL constraints; they are modeled elsewhere - and c.contype not in ('t', 'n') - and not c.connamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and de.objid is null - ), - '[]' - ) as constraints, - coalesce(json_agg( - case when a.attname is not null then - json_build_object( - 'name', quote_ident(a.attname), - 'position', a.attnum, - 'data_type', a.atttypid::regtype::text, - 'data_type_str', format_type(a.atttypid, a.atttypmod), - 'is_custom_type', ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema'), - 'custom_type_type', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typtype else null end, - 'custom_type_category', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typcategory else null end, - 'custom_type_schema', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typnamespace::regnamespace else null end, - 'custom_type_name', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then quote_ident(ty.typname) else null end, - 'not_null', a.attnotnull, - 'is_identity', a.attidentity != '', - 'is_identity_always', a.attidentity = 'a', - 'is_generated', a.attgenerated != '', - 'collation', ( - select quote_ident(c2.collname) - from pg_collation c2, pg_type t2 - where c2.oid = a.attcollation - and t2.oid = a.atttypid - and a.attcollation <> t2.typcollation - ), - 'default', pg_get_expr(ad.adbin, ad.adrelid), - 'comment', col_description(a.attrelid, a.attnum), - 'security_labels', coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = t.oid - and sl.classoid = 'pg_class'::regclass - and sl.objsubid = a.attnum - ), - '[]'::json - ) - ) - end - order by a.attnum - ) filter (where a.attname is not null), '[]') as columns, - coalesce(( - select json_agg( - json_build_object( - 'grantee', case when grp.grantee = 0 then 'PUBLIC' else grp.grantee::regrole::text end, - 'privilege', grp.privilege_type, - 'grantable', grp.is_grantable, - 'columns', case when grp.cols is not null and array_length(grp.cols,1) > 0 - then grp.cols - else null end - ) - order by grp.grantee, grp.privilege_type - ) - from ( - select - x.grantee, - x.privilege_type, - bool_or(x.is_grantable) as is_grantable, - array_agg(quote_ident(src.attname) order by src.attname) - filter (where src.attname is not null) as cols - from ( - -- one row for object ACL + one row per column ACL - select null::name as attname, t.oid as relacl_oid, ( - select COALESCE(c_rel.relacl, acldefault('r', c_rel.relowner)) from pg_class c_rel where c_rel.oid = t.oid - ) as acl - union all - select a2.attname, t.oid as relacl_oid, a2.attacl - from pg_attribute a2 - where a2.attrelid = t.oid - and a2.attnum > 0 - and not a2.attisdropped - and a2.attacl is not null - ) as src - join lateral aclexplode(src.acl) as x(grantor, grantee, privilege_type, is_grantable) on true - group by x.grantee, x.privilege_type - ) as grp - ), '[]') as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = t.oid - and sl.classoid = 'pg_class'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from - tables t - left join pg_attribute a on a.attrelid = t.oid and a.attnum > 0 and not a.attisdropped - left join pg_attrdef ad on a.attrelid = ad.adrelid and a.attnum = ad.adnum - left join pg_type ty on ty.oid = a.atttypid -group by - t.oid, t.schema, t.name, t.persistence, t.row_security, t.force_row_security, t.has_indexes, t.has_rules, t.has_triggers, t.has_subclasses, t.is_populated, t.replica_identity, t.replica_identity_index, t.is_partition, t.options, t.partition_bound, t.partition_by, t.owner, t.parent_schema, t.parent_name -order by - t.schema, t.name - `); - return result.rows.map((row: unknown) => tableRowSchema.parse(row)); - }, - }); - const validatedRows = tableRows.map((row): TableProps => { - const filteredConstraints = row.constraints?.filter( - (c): c is TableConstraintProps => c.definition !== null, - ); - return { ...row, constraints: filteredConstraints }; - }); - return validatedRows.map((row: TableProps) => new Table(row)); -} diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts deleted file mode 100644 index f50673090..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Trigger, type TriggerProps } from "../trigger.model.ts"; -import { ReplaceTrigger } from "./trigger.alter.ts"; - -describe.concurrent("trigger", () => { - describe("alter", () => { - test("replace trigger", async () => { - const props: Omit = { - schema: "public", - name: "test_trigger", - table_name: "test_table", - table_relkind: "r", - function_schema: "public", - function_name: "test_function", - trigger_type: 1 << 4, // UPDATE (1<<4) = 16, AFTER is default (0), STATEMENT is default (0) - is_internal: false, - deferrable: false, - initially_deferred: false, - argument_count: 0, - column_numbers: null, - arguments: [], - when_condition: null, - old_table: null, - new_table: null, - is_partition_clone: false, - parent_trigger_name: null, - parent_table_schema: null, - parent_table_name: null, - is_on_partitioned_table: false, - owner: "test", - definition: - "CREATE TRIGGER test_trigger AFTER UPDATE ON public.test_table EXECUTE FUNCTION public.test_function()", - comment: null, - }; - const branch = new Trigger({ - ...props, - enabled: "D", - }); - - const change = new ReplaceTrigger({ trigger: branch }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE OR REPLACE TRIGGER test_trigger AFTER UPDATE ON public.test_table EXECUTE FUNCTION public.test_function()", - ); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts deleted file mode 100644 index 1552f4c8d..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import type { TableLikeObject } from "../../base.model.ts"; -import type { Trigger } from "../trigger.model.ts"; -import { AlterTriggerChange } from "./trigger.base.ts"; -import { CreateTrigger } from "./trigger.create.ts"; -import { DropTrigger } from "./trigger.drop.ts"; - -/** - * Alter a trigger. - * - * @see https://www.postgresql.org/docs/17/sql-altertrigger.html - * - * Synopsis - * ```sql - * ALTER TRIGGER name ON table_name RENAME TO new_name - * ``` - */ - -export type AlterTrigger = ReplaceTrigger; - -/** - * Replace a trigger by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER TRIGGER change. - */ -export class ReplaceTrigger extends AlterTriggerChange { - public readonly trigger: Trigger; - public readonly indexableObject?: TableLikeObject; - public readonly scope = "object" as const; - - constructor(props: { trigger: Trigger; indexableObject?: TableLikeObject }) { - super(); - this.trigger = props.trigger; - this.indexableObject = props.indexableObject; - } - - get requires() { - return [this.trigger.stableId]; - } - - serialize(_options?: SerializeOptions): string { - if (this.trigger.isConstraintTrigger) { - const dropChange = new DropTrigger({ trigger: this.trigger }); - const createChange = new CreateTrigger({ - trigger: this.trigger, - indexableObject: this.indexableObject, - orReplace: false, - }); - const commentSql = - this.trigger.comment !== null - ? [ - "COMMENT ON TRIGGER", - this.trigger.name, - "ON", - `${this.trigger.schema}.${this.trigger.table_name}`, - "IS", - quoteLiteral(this.trigger.comment), - ].join(" ") - : null; - - return [dropChange.serialize(), createChange.serialize(), commentSql] - .filter(Boolean) - .join(";\n"); - } - - const createChange = new CreateTrigger({ - trigger: this.trigger, - indexableObject: this.indexableObject, - orReplace: true, - }); - - return createChange.serialize(); - } -} diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.base.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.base.ts deleted file mode 100644 index e945bb549..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { Trigger } from "../trigger.model.ts"; - -abstract class BaseTriggerChange extends BaseChange { - abstract readonly trigger: Trigger; - abstract readonly scope: "object" | "comment"; - readonly objectType = "trigger" as const; -} - -export abstract class CreateTriggerChange extends BaseTriggerChange { - readonly operation = "create" as const; -} - -export abstract class AlterTriggerChange extends BaseTriggerChange { - readonly operation = "alter" as const; -} - -export abstract class DropTriggerChange extends BaseTriggerChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.comment.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.comment.ts deleted file mode 100644 index 55ec2f23c..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.comment.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { Trigger } from "../trigger.model.ts"; -import { CreateTriggerChange, DropTriggerChange } from "./trigger.base.ts"; - -export type CommentTrigger = CreateCommentOnTrigger | DropCommentOnTrigger; - -export class CreateCommentOnTrigger extends CreateTriggerChange { - public readonly trigger: Trigger; - public readonly scope = "comment" as const; - - constructor(props: { trigger: Trigger }) { - super(); - this.trigger = props.trigger; - } - - get creates() { - return [stableId.comment(this.trigger.stableId)]; - } - - get requires() { - return [this.trigger.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TRIGGER", - this.trigger.name, - "ON", - `${this.trigger.schema}.${this.trigger.table_name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: trigger comment is not nullable in this case - quoteLiteral(this.trigger.comment!), - ].join(" "); - } -} - -export class DropCommentOnTrigger extends DropTriggerChange { - public readonly trigger: Trigger; - public readonly scope = "comment" as const; - - constructor(props: { trigger: Trigger }) { - super(); - this.trigger = props.trigger; - } - - get drops() { - return [stableId.comment(this.trigger.stableId)]; - } - - get requires() { - return [stableId.comment(this.trigger.stableId), this.trigger.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TRIGGER", - this.trigger.name, - "ON", - `${this.trigger.schema}.${this.trigger.table_name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.create.test.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.create.test.ts deleted file mode 100644 index 903db5574..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.create.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Trigger } from "../trigger.model.ts"; -import { CreateTrigger } from "./trigger.create.ts"; - -describe("trigger", () => { - test("create", async () => { - const trigger = new Trigger({ - schema: "public", - name: "test_trigger", - table_name: "test_table", - table_relkind: "r", - function_schema: "public", - function_name: "test_function", - trigger_type: (1 << 1) | (1 << 2) | (1 << 0), // BEFORE (1<<1) | INSERT (1<<2) | ROW (1<<0) = 7 - enabled: "O", - is_internal: false, - deferrable: false, - initially_deferred: false, - argument_count: 0, - column_numbers: null, - arguments: [], - when_condition: null, - old_table: null, - new_table: null, - is_partition_clone: false, - parent_trigger_name: null, - parent_table_schema: null, - parent_table_name: null, - is_on_partitioned_table: false, - owner: "test", - definition: - "CREATE TRIGGER test_trigger BEFORE INSERT ON public.test_table FOR EACH ROW EXECUTE FUNCTION public.test_function()", - comment: null, - }); - - const change = new CreateTrigger({ - trigger, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE TRIGGER test_trigger BEFORE INSERT ON public.test_table FOR EACH ROW EXECUTE FUNCTION public.test_function()", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.create.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.create.ts deleted file mode 100644 index b45a1c7b6..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.create.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { TableLikeObject } from "../../base.model.ts"; -import { stableId } from "../../utils.ts"; -import type { Trigger } from "../trigger.model.ts"; -import { CreateTriggerChange } from "./trigger.base.ts"; - -/** - * Create a trigger. - * - * @see https://www.postgresql.org/docs/17/sql-createtrigger.html - * - * Synopsis - * ```sql - * CREATE [ OR REPLACE ] [ CONSTRAINT ] TRIGGER name { BEFORE | AFTER | INSTEAD OF } { event [ OR ... ] } - * ON table_name - * [ FROM referenced_table_name ] - * [ NOT DEFERRABLE | [ DEFERRABLE ] [ INITIALLY IMMEDIATE | INITIALLY DEFERRED ] ] - * [ REFERENCING { { OLD | NEW } TABLE [ AS ] transition_relation_name } [ ... ] ] - * [ FOR [ EACH ] { ROW | STATEMENT } ] - * [ WHEN ( condition ) ] - * EXECUTE { FUNCTION | PROCEDURE } function_name ( arguments ) - * - * where event can be one of: - * - * INSERT - * UPDATE [ OF column_name [, ... ] ] - * DELETE - * TRUNCATE - * ``` - */ -export class CreateTrigger extends CreateTriggerChange { - public readonly trigger: Trigger; - public readonly indexableObject?: TableLikeObject; - public readonly orReplace?: boolean; - public readonly scope = "object" as const; - - constructor(props: { - trigger: Trigger; - indexableObject?: TableLikeObject; - orReplace?: boolean; - }) { - super(); - this.trigger = props.trigger; - this.indexableObject = props.indexableObject; - this.orReplace = props.orReplace; - } - - get creates() { - return [this.trigger.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.trigger.schema)); - - // Table dependency - dependencies.add( - stableId.table(this.trigger.schema, this.trigger.table_name), - ); - - // Function dependency - // Trigger functions always have signature () RETURNS trigger, so no arguments. - dependencies.add( - stableId.procedure( - this.trigger.function_schema, - this.trigger.function_name, - ), - ); - - // Owner dependency - dependencies.add(stableId.role(this.trigger.owner)); - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - let definition = this.trigger.definition.trim(); - const isConstraintTrigger = this.trigger.isConstraintTrigger; - - definition = definition.replace( - /^CREATE\s+(?:OR\s+REPLACE\s+)?/i, - `CREATE ${this.orReplace && !isConstraintTrigger ? "OR REPLACE " : ""}`, - ); - - return definition; - } -} diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.drop.test.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.drop.test.ts deleted file mode 100644 index 3676b3dfb..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.drop.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { Trigger } from "../trigger.model.ts"; -import { DropTrigger } from "./trigger.drop.ts"; - -describe("trigger", () => { - test("drop", async () => { - const trigger = new Trigger({ - schema: "public", - name: "test_trigger", - table_name: "test_table", - table_relkind: "r", - function_schema: "public", - function_name: "test_function", - trigger_type: 66, - enabled: "O", - is_internal: false, - deferrable: false, - initially_deferred: false, - argument_count: 0, - column_numbers: null, - arguments: [], - when_condition: null, - old_table: null, - new_table: null, - is_partition_clone: false, - parent_trigger_name: null, - parent_table_schema: null, - parent_table_name: null, - is_on_partitioned_table: false, - owner: "test", - definition: - "CREATE TRIGGER test_trigger BEFORE UPDATE ON public.test_table FOR EACH ROW EXECUTE FUNCTION public.test_function()", - comment: null, - }); - - const change = new DropTrigger({ - trigger, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "DROP TRIGGER test_trigger ON public.test_table", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.drop.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.drop.ts deleted file mode 100644 index 435fbdb50..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.drop.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { Trigger } from "../trigger.model.ts"; -import { DropTriggerChange } from "./trigger.base.ts"; - -/** - * Drop a trigger. - * - * @see https://www.postgresql.org/docs/17/sql-droptrigger.html - * - * Synopsis - * ```sql - * DROP TRIGGER [ IF EXISTS ] name ON table_name [ CASCADE | RESTRICT ] - * ``` - */ -export class DropTrigger extends DropTriggerChange { - public readonly trigger: Trigger; - public readonly scope = "object" as const; - - constructor(props: { trigger: Trigger }) { - super(); - this.trigger = props.trigger; - } - - get drops() { - return [this.trigger.stableId]; - } - - get requires() { - return [this.trigger.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "DROP TRIGGER", - this.trigger.name, - "ON", - `${this.trigger.schema}.${this.trigger.table_name}`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.types.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.types.ts deleted file mode 100644 index 78e3e2382..000000000 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AlterTrigger } from "./trigger.alter.ts"; -import type { CommentTrigger } from "./trigger.comment.ts"; -import type { CreateTrigger } from "./trigger.create.ts"; -import type { DropTrigger } from "./trigger.drop.ts"; - -/** Union of all trigger-related change variants (`objectType: "trigger"`). @category Change Types */ -export type TriggerChange = - | AlterTrigger - | CommentTrigger - | CreateTrigger - | DropTrigger; diff --git a/packages/pg-delta/src/core/objects/trigger/trigger.diff.test.ts b/packages/pg-delta/src/core/objects/trigger/trigger.diff.test.ts deleted file mode 100644 index 4fa2e0d9b..000000000 --- a/packages/pg-delta/src/core/objects/trigger/trigger.diff.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { ReplaceTrigger } from "./changes/trigger.alter.ts"; -import { CreateTrigger } from "./changes/trigger.create.ts"; -import { DropTrigger } from "./changes/trigger.drop.ts"; -import { diffTriggers } from "./trigger.diff.ts"; -import { Trigger, type TriggerProps } from "./trigger.model.ts"; - -const base: Omit< - TriggerProps, - | "function_name" - | "column_numbers" - | "arguments" - | "when_condition" - | "old_table" - | "new_table" -> = { - schema: "public", - name: "trg", - table_name: "t", - table_relkind: "r", - function_schema: "public", - trigger_type: 1, - enabled: "O", - is_internal: false, - deferrable: false, - initially_deferred: false, - argument_count: 0, - is_partition_clone: false, - parent_trigger_name: null, - parent_table_schema: null, - parent_table_name: null, - is_on_partitioned_table: false, - owner: "o1", - definition: "CREATE TRIGGER trg ON t FOR EACH ROW EXECUTE FUNCTION fn1()", - comment: null, -}; - -describe.concurrent("trigger.diff", () => { - test("create and drop", () => { - const trg = new Trigger({ - ...base, - function_name: "fn1", - column_numbers: null, - arguments: [], - when_condition: null, - old_table: null, - new_table: null, - }); - - const created = diffTriggers({}, { [trg.stableId]: trg }); - expect(created[0]).toBeInstanceOf(CreateTrigger); - - const dropped = diffTriggers({ [trg.stableId]: trg }, {}); - expect(dropped[0]).toBeInstanceOf(DropTrigger); - }); - - test("replace when non-alterable changes", () => { - const main = new Trigger({ - ...base, - function_name: "fn1", - column_numbers: null, - arguments: [], - when_condition: null, - old_table: null, - new_table: null, - }); - const branch = new Trigger({ - ...base, - function_name: "fn2", - column_numbers: null, - arguments: [], - when_condition: null, - old_table: null, - new_table: null, - }); - - const changes = diffTriggers( - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(ReplaceTrigger); - }); -}); diff --git a/packages/pg-delta/src/core/objects/trigger/trigger.diff.ts b/packages/pg-delta/src/core/objects/trigger/trigger.diff.ts deleted file mode 100644 index 051a6baa9..000000000 --- a/packages/pg-delta/src/core/objects/trigger/trigger.diff.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import type { TableLikeObject } from "../base.model.ts"; -import { deepEqual, hasNonAlterableChanges, stableId } from "../utils.ts"; -import { ReplaceTrigger } from "./changes/trigger.alter.ts"; -import { - CreateCommentOnTrigger, - DropCommentOnTrigger, -} from "./changes/trigger.comment.ts"; -import { CreateTrigger } from "./changes/trigger.create.ts"; -import { DropTrigger } from "./changes/trigger.drop.ts"; -import type { TriggerChange } from "./changes/trigger.types.ts"; -import type { Trigger } from "./trigger.model.ts"; - -/** - * Diff two sets of triggers from main and branch catalogs. - * - * @param main - The triggers in the main catalog. - * @param branch - The triggers in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffTriggers( - main: Record, - branch: Record, - branchIndexableObjects?: Record, -): TriggerChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: TriggerChange[] = []; - - for (const triggerId of created) { - const trigger = branch[triggerId]; - - // Skip trigger clones on partitions - they are automatically created when the parent trigger is created - if (trigger.is_partition_clone) { - continue; - } - - const tableStableId = stableId.table(trigger.schema, trigger.table_name); - changes.push( - new CreateTrigger({ - trigger: trigger, - indexableObject: branchIndexableObjects?.[tableStableId], - }), - ); - if (trigger.comment !== null) { - changes.push(new CreateCommentOnTrigger({ trigger: trigger })); - } - } - - for (const triggerId of dropped) { - const trigger = main[triggerId]; - - // Skip trigger clones on partitions - they are automatically dropped when the parent trigger is dropped - if (trigger.is_partition_clone) { - continue; - } - - changes.push(new DropTrigger({ trigger })); - } - - for (const triggerId of altered) { - const mainTrigger = main[triggerId]; - const branchTrigger = branch[triggerId]; - - // Skip trigger clones on partitions - they are automatically updated when the parent trigger is updated - if (mainTrigger.is_partition_clone || branchTrigger.is_partition_clone) { - continue; - } - - // Note: column_numbers is excluded because it contains pg_trigger.tgattr - // attnums that differ between databases when physical column layouts - // diverge but logical (named) columns match. The definition field - // (pg_get_triggerdef) already captures the UPDATE OF column list by name, - // so we compare by definition instead. - const NON_ALTERABLE_FIELDS: Array = [ - "function_schema", - "function_name", - "trigger_type", - "enabled", - "is_internal", - "deferrable", - "initially_deferred", - "argument_count", - "arguments", - "when_condition", - "old_table", - "new_table", - "owner", - "definition", // Compare by definition instead of column_numbers (tgattr) - ]; - const shouldReplace = hasNonAlterableChanges( - mainTrigger, - branchTrigger, - NON_ALTERABLE_FIELDS, - { arguments: deepEqual }, - ); - if (shouldReplace) { - const tableStableId = stableId.table( - branchTrigger.schema, - branchTrigger.table_name, - ); - changes.push( - new ReplaceTrigger({ - trigger: branchTrigger, - indexableObject: branchIndexableObjects?.[tableStableId], - }), - ); - } else { - // COMMENT - if (mainTrigger.comment !== branchTrigger.comment) { - if (branchTrigger.comment === null) { - changes.push(new DropCommentOnTrigger({ trigger: mainTrigger })); - } else { - changes.push(new CreateCommentOnTrigger({ trigger: branchTrigger })); - } - } - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/trigger/trigger.model.test.ts b/packages/pg-delta/src/core/objects/trigger/trigger.model.test.ts deleted file mode 100644 index 9bad78afa..000000000 --- a/packages/pg-delta/src/core/objects/trigger/trigger.model.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Pool } from "pg"; -import { extractTriggers, Trigger } from "./trigger.model.ts"; - -const baseRow = { - schema: "public", - table_name: '"users"', - table_relkind: "r" as const, - function_schema: "public", - function_name: '"my_fn"', - trigger_type: 7, - enabled: "O" as const, - is_internal: false, - deferrable: false, - initially_deferred: false, - argument_count: 0, - column_numbers: null, - arguments: [] as string[], - when_condition: null, - old_table: null, - new_table: null, - is_partition_clone: false, - parent_trigger_name: null, - parent_table_schema: null, - parent_table_name: null, - is_on_partitioned_table: false, - owner: "postgres", - comment: null, -}; - -const mockPool = (rows: unknown[]): Pool => - ({ query: async () => ({ rows }) }) as unknown as Pool; - -const mockPoolSequence = (...attempts: unknown[][]): Pool => { - let i = 0; - return { - query: async () => ({ - rows: attempts[Math.min(i++, attempts.length - 1)], - }), - } as unknown as Pool; -}; - -const NO_BACKOFF = { backoffMs: 0 } as const; - -describe("extractTriggers", () => { - test("skips rows where pg_get_triggerdef returned NULL after exhausting retries", async () => { - const triggers = await extractTriggers( - mockPool([ - { - ...baseRow, - name: '"good_trg"', - definition: - "CREATE TRIGGER good_trg BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION my_fn()", - }, - { ...baseRow, name: '"orphan_trg"', definition: null }, - ]), - NO_BACKOFF, - ); - - expect(triggers).toHaveLength(1); - expect(triggers[0]).toBeInstanceOf(Trigger); - expect(triggers[0]?.name).toBe('"good_trg"'); - }); - - test("does not throw ZodError when the only row has a null definition", async () => { - await expect( - extractTriggers( - mockPool([{ ...baseRow, name: '"orphan"', definition: null }]), - NO_BACKOFF, - ), - ).resolves.toEqual([]); - }); - - test("returns all triggers when every row has a valid definition", async () => { - const triggers = await extractTriggers( - mockPool([ - { - ...baseRow, - name: '"a"', - definition: - "CREATE TRIGGER a BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION my_fn()", - }, - { - ...baseRow, - name: '"b"', - definition: - "CREATE TRIGGER b AFTER UPDATE ON users FOR EACH ROW EXECUTE FUNCTION my_fn()", - }, - ]), - NO_BACKOFF, - ); - expect(triggers.map((t) => t.name)).toEqual(['"a"', '"b"']); - }); - - test("recovers when pg_get_triggerdef is NULL on first attempt but resolved on retry", async () => { - const triggers = await extractTriggers( - mockPoolSequence( - [{ ...baseRow, name: '"racy_trg"', definition: null }], - [ - { - ...baseRow, - name: '"racy_trg"', - definition: - "CREATE TRIGGER racy_trg BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION my_fn()", - }, - ], - ), - { retries: 2, backoffMs: 0 }, - ); - expect(triggers).toHaveLength(1); - expect(triggers[0]?.name).toBe('"racy_trg"'); - }); -}); diff --git a/packages/pg-delta/src/core/objects/trigger/trigger.model.ts b/packages/pg-delta/src/core/objects/trigger/trigger.model.ts deleted file mode 100644 index 2c63de0d5..000000000 --- a/packages/pg-delta/src/core/objects/trigger/trigger.model.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../base.model.ts"; -import { - type ExtractRetryOptions, - extractWithDefinitionRetry, -} from "../extract-with-retry.ts"; - -const TriggerEnabledSchema = z.enum([ - "O", // ORIGIN - trigger fires in "origin" and "local" replica modes - "D", // DISABLED - trigger is disabled - "R", // REPLICA - trigger fires only in "replica" mode - "A", // ALWAYS - trigger fires regardless of replication mode -]); - -const TriggerTableRelkindSchema = z.enum([ - "r", // ordinary table - "p", // partitioned table - "f", // foreign table - "v", // view - "m", // materialized view -]); - -const triggerPropsSchema = z.object({ - schema: z.string(), - name: z.string(), - table_name: z.string(), - table_relkind: TriggerTableRelkindSchema, - function_schema: z.string(), - function_name: z.string(), - trigger_type: z.number(), - enabled: TriggerEnabledSchema, - is_internal: z.boolean(), - deferrable: z.boolean(), - initially_deferred: z.boolean(), - argument_count: z.number(), - column_numbers: z.array(z.number()).nullable(), - arguments: z.array(z.string()), - when_condition: z.string().nullable(), - old_table: z.string().nullable(), - new_table: z.string().nullable(), - is_partition_clone: z.boolean(), - parent_trigger_name: z.string().nullable(), - parent_table_schema: z.string().nullable(), - parent_table_name: z.string().nullable(), - is_on_partitioned_table: z.boolean(), - owner: z.string(), - definition: z.string(), - comment: z.string().nullable(), -}); - -// pg_get_triggerdef(oid, pretty) can return NULL when the trigger (its -// pg_trigger row) is dropped between catalog scan and resolution, or under -// transient catalog state. An unreadable trigger cannot be diffed, so we -// accept NULL here and filter the row out at extraction time rather than -// crashing the whole catalog parse with a ZodError. -const triggerRowSchema = triggerPropsSchema.extend({ - definition: z.string().nullable(), -}); - -export type TriggerProps = z.infer; - -export class Trigger extends BasePgModel { - public readonly schema: TriggerProps["schema"]; - public readonly name: TriggerProps["name"]; - public readonly table_name: TriggerProps["table_name"]; - public readonly table_relkind: TriggerProps["table_relkind"]; - public readonly function_schema: TriggerProps["function_schema"]; - public readonly function_name: TriggerProps["function_name"]; - public readonly trigger_type: TriggerProps["trigger_type"]; - public readonly enabled: TriggerProps["enabled"]; - public readonly is_internal: TriggerProps["is_internal"]; - public readonly deferrable: TriggerProps["deferrable"]; - public readonly initially_deferred: TriggerProps["initially_deferred"]; - public readonly argument_count: TriggerProps["argument_count"]; - public readonly column_numbers: TriggerProps["column_numbers"]; - public readonly arguments: TriggerProps["arguments"]; - public readonly when_condition: TriggerProps["when_condition"]; - public readonly old_table: TriggerProps["old_table"]; - public readonly new_table: TriggerProps["new_table"]; - public readonly is_partition_clone: TriggerProps["is_partition_clone"]; - public readonly parent_trigger_name: TriggerProps["parent_trigger_name"]; - public readonly parent_table_schema: TriggerProps["parent_table_schema"]; - public readonly parent_table_name: TriggerProps["parent_table_name"]; - public readonly is_on_partitioned_table: TriggerProps["is_on_partitioned_table"]; - public readonly owner: TriggerProps["owner"]; - public readonly definition: TriggerProps["definition"]; - public readonly comment: TriggerProps["comment"]; - - constructor(props: TriggerProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - this.table_name = props.table_name; - this.table_relkind = props.table_relkind; - - // Data fields - this.function_schema = props.function_schema; - this.function_name = props.function_name; - this.trigger_type = props.trigger_type; - this.enabled = props.enabled; - this.is_internal = props.is_internal; - this.deferrable = props.deferrable; - this.initially_deferred = props.initially_deferred; - this.argument_count = props.argument_count; - this.column_numbers = props.column_numbers; - this.arguments = props.arguments; - this.when_condition = props.when_condition; - this.old_table = props.old_table; - this.new_table = props.new_table; - this.is_partition_clone = props.is_partition_clone; - this.parent_trigger_name = props.parent_trigger_name; - this.parent_table_schema = props.parent_table_schema; - this.parent_table_name = props.parent_table_name; - this.is_on_partitioned_table = props.is_on_partitioned_table; - this.owner = props.owner; - this.definition = props.definition; - this.comment = props.comment; - } - - get isConstraintTrigger(): boolean { - return /^CREATE\s+CONSTRAINT\s+TRIGGER/i.test(this.definition.trim()); - } - - get stableId(): `trigger:${string}` { - return `trigger:${this.schema}.${this.table_name}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - table_name: this.table_name, - }; - } - - get dataFields() { - return { - function_schema: this.function_schema, - function_name: this.function_name, - trigger_type: this.trigger_type, - enabled: this.enabled, - is_internal: this.is_internal, - deferrable: this.deferrable, - initially_deferred: this.initially_deferred, - argument_count: this.argument_count, - // column_numbers excluded: contains pg_trigger.tgattr attnums that differ - // between databases when physical column layouts diverge but logical - // (named) columns match. The definition field (pg_get_triggerdef) captures - // the UPDATE OF column list by name, so we compare by definition instead. - arguments: this.arguments, - when_condition: this.when_condition, - old_table: this.old_table, - new_table: this.new_table, - is_partition_clone: this.is_partition_clone, - parent_trigger_name: this.parent_trigger_name, - parent_table_schema: this.parent_table_schema, - parent_table_name: this.parent_table_name, - is_on_partitioned_table: this.is_on_partitioned_table, - owner: this.owner, - definition: this.definition, - comment: this.comment, - }; - } -} - -export async function extractTriggers( - pool: Pool, - options?: ExtractRetryOptions, -): Promise { - const triggerRows = await extractWithDefinitionRetry({ - label: "triggers", - options, - hasNullDefinition: (row) => row.definition === null, - query: async () => { - const result = await pool.query(sql` - with extension_trigger_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_trigger'::regclass - ), - extension_table_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass - and d.deptype = 'e' - ), - extension_function_oids as ( - select objid - from pg_depend d - where d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_proc'::regclass - ) - select - tc.relnamespace::regnamespace::text as schema, - quote_ident(t.tgname) as name, - quote_ident(tc.relname) as table_name, - tc.relkind as table_relkind, - - fc.pronamespace::regnamespace::text as function_schema, - quote_ident(fc.proname) as function_name, - - t.tgtype as trigger_type, - t.tgenabled as enabled, - t.tgisinternal as is_internal, - t.tgdeferrable as deferrable, - t.tginitdeferred as initially_deferred, - t.tgnargs as argument_count, - t.tgattr as column_numbers, - - case when t.tgnargs > 0 - then array_fill(''::text, array[t.tgnargs]) - else array[]::text[] - end as arguments, - - -- identify triggers cloned onto partitions (created/attached partitions) - (t.tgparentid <> 0::oid) as is_partition_clone, - case when t.tgparentid <> 0::oid - then quote_ident(parent_t.tgname) - else null - end as parent_trigger_name, - case when t.tgparentid <> 0::oid - then parent_tc.relnamespace::regnamespace::text - else null - end as parent_table_schema, - case when t.tgparentid <> 0::oid - then quote_ident(parent_tc.relname) - else null - end as parent_table_name, - - (tc.relkind = 'p') as is_on_partitioned_table, - - ( - case - when strpos(defn.definition, ' WHEN (') > 0 - and strpos(defn.definition, ') EXECUTE') > - strpos(defn.definition, ' WHEN (') + 7 - then substr( - defn.definition, - strpos(defn.definition, ' WHEN (') + 7, - strpos(defn.definition, ') EXECUTE') - - (strpos(defn.definition, ' WHEN (') + 7) - ) - else null - end - ) as when_condition, - - t.tgoldtable as old_table, - t.tgnewtable as new_table, - tc.relowner::regrole::text as owner, - defn.definition as definition, - obj_description(t.oid, 'pg_trigger') as comment - - from pg_catalog.pg_trigger t - join pg_catalog.pg_class tc on tc.oid = t.tgrelid - join pg_catalog.pg_proc fc on fc.oid = t.tgfoid - - -- compute trigger definition once - left join lateral ( - select pg_get_triggerdef(t.oid, true) as definition - ) defn on true - - -- parent trigger/table linkage for cloned (partition) triggers - left join pg_catalog.pg_trigger parent_t on parent_t.oid = t.tgparentid - left join pg_catalog.pg_class parent_tc on parent_tc.oid = parent_t.tgrelid - - left join extension_trigger_oids e_trigger on t.oid = e_trigger.objid - left join extension_table_oids e_table on tc.oid = e_table.objid - left join extension_function_oids e_function on fc.oid = e_function.objid - - where not tc.relnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e_trigger.objid is null - and e_table.objid is null - and e_function.objid is null - and not t.tgisinternal - - order by 1, 2 - `); - return result.rows.map((row: unknown) => triggerRowSchema.parse(row)); - }, - }); - const validatedRows = triggerRows.filter( - (row): row is TriggerProps => row.definition !== null, - ); - return validatedRows.map((row: TriggerProps) => new Trigger(row)); -} diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.alter.test.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.alter.test.ts deleted file mode 100644 index 7876517c0..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.alter.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { - CompositeType, - type CompositeTypeProps, -} from "../composite-type.model.ts"; -import { - AlterCompositeTypeAddAttribute, - AlterCompositeTypeAlterAttributeType, - AlterCompositeTypeChangeOwner, - AlterCompositeTypeDropAttribute, -} from "./composite-type.alter.ts"; - -describe.concurrent("composite-type", () => { - describe("alter", () => { - test("change owner", async () => { - const props: Omit = { - schema: "public", - name: "test_type", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - comment: null, - columns: [], - privileges: [], - }; - const main = new CompositeType({ - ...props, - owner: "old_owner", - }); - const change = new AlterCompositeTypeChangeOwner({ - compositeType: main, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER TYPE public.test_type OWNER TO new_owner", - ); - }); - }); - - test("add attribute", async () => { - const base = { - schema: "public", - name: "ct", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "o1", - comment: null, - privileges: [], - } as const; - const branch = new CompositeType({ - ...base, - columns: [ - { - name: "a", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const change = new AlterCompositeTypeAddAttribute({ - compositeType: branch, - attribute: branch.columns[0], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER TYPE public.ct ADD ATTRIBUTE a text", - ); - }); - - test("drop attribute", async () => { - const base = { - schema: "public", - name: "ct", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "o1", - comment: null, - privileges: [], - } as const; - const main = new CompositeType({ - ...base, - columns: [ - { - name: "a", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const change = new AlterCompositeTypeDropAttribute({ - compositeType: main, - attribute: main.columns[0], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER TYPE public.ct DROP ATTRIBUTE a"); - }); - - test("alter attribute type and collation", async () => { - const base = { - schema: "public", - name: "ct", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "o1", - comment: null, - privileges: [], - } as const; - const branch = new CompositeType({ - ...base, - columns: [ - { - name: "a", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: '"en_US"', - default: null, - comment: null, - }, - ], - privileges: [], - }); - const change = new AlterCompositeTypeAlterAttributeType({ - compositeType: branch, - attribute: branch.columns[0], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'ALTER TYPE public.ct ALTER ATTRIBUTE a TYPE text COLLATE "en_US"', - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.alter.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.alter.ts deleted file mode 100644 index 22c913a97..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.alter.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { CompositeType } from "../composite-type.model.ts"; -import { AlterCompositeTypeChange } from "./composite-type.base.ts"; - -/** - * Alter a composite type. - * - * @see https://www.postgresql.org/docs/17/sql-altertype.html - * - * Synopsis - * ```sql - * ALTER TYPE name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ALTER TYPE name RENAME TO new_name - * ALTER TYPE name SET SCHEMA new_schema - * -- Attribute actions (composite types): - * ALTER TYPE name ADD ATTRIBUTE attribute_name data_type [ COLLATE collation ] [ CASCADE | RESTRICT ] - * ALTER TYPE name DROP ATTRIBUTE [ IF EXISTS ] attribute_name [ CASCADE | RESTRICT ] - * ALTER TYPE name ALTER ATTRIBUTE attribute_name [ SET DATA ] TYPE data_type [ COLLATE collation ] [ CASCADE | RESTRICT ] - * ``` - */ - -export type AlterCompositeType = - | AlterCompositeTypeAddAttribute - | AlterCompositeTypeAlterAttributeType - | AlterCompositeTypeChangeOwner - | AlterCompositeTypeDropAttribute; - -/** - * ALTER TYPE ... OWNER TO ... - */ -export class AlterCompositeTypeChangeOwner extends AlterCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { compositeType: CompositeType; owner: string }) { - super(); - this.compositeType = props.compositeType; - this.owner = props.owner; - } - - get requires() { - return [this.compositeType.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * ALTER TYPE ... ADD ATTRIBUTE ... - */ -export class AlterCompositeTypeAddAttribute extends AlterCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly attribute: CompositeType["columns"][number]; - public readonly scope = "object" as const; - - constructor(props: { - compositeType: CompositeType; - attribute: CompositeType["columns"][number]; - }) { - super(); - this.compositeType = props.compositeType; - this.attribute = props.attribute; - } - - get creates() { - return [`${this.compositeType.stableId}:${this.attribute.name}`]; - } - - get requires() { - return [this.compositeType.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const parts = [ - "ALTER TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - "ADD ATTRIBUTE", - this.attribute.name, - this.attribute.data_type_str, - ]; - if (this.attribute.collation) { - parts.push("COLLATE", this.attribute.collation); - } - return parts.join(" "); - } -} - -/** - * ALTER TYPE ... DROP ATTRIBUTE ... - */ -export class AlterCompositeTypeDropAttribute extends AlterCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly attribute: CompositeType["columns"][number]; - public readonly scope = "object" as const; - - constructor(props: { - compositeType: CompositeType; - attribute: CompositeType["columns"][number]; - }) { - super(); - this.compositeType = props.compositeType; - this.attribute = props.attribute; - } - - get requires() { - return [ - `${this.compositeType.stableId}:${this.attribute.name}`, - this.compositeType.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - "DROP ATTRIBUTE", - this.attribute.name, - ].join(" "); - } -} - -/** - * ALTER TYPE ... ALTER ATTRIBUTE ... TYPE ... [ COLLATE ... ] - */ -export class AlterCompositeTypeAlterAttributeType extends AlterCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly attribute: CompositeType["columns"][number]; - public readonly scope = "object" as const; - - constructor(props: { - compositeType: CompositeType; - attribute: CompositeType["columns"][number]; - }) { - super(); - this.compositeType = props.compositeType; - this.attribute = props.attribute; - } - - get requires() { - return [ - `${this.compositeType.stableId}:${this.attribute.name}`, - this.compositeType.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - const parts = [ - "ALTER TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - "ALTER ATTRIBUTE", - this.attribute.name, - "TYPE", - this.attribute.data_type_str, - ]; - if (this.attribute.collation) { - parts.push("COLLATE", this.attribute.collation); - } - return parts.join(" "); - } -} - -/** - * Replace a composite type by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER TYPE change. - * Note: Attribute list changes are modeled as drop+create via diff. - */ -// NOTE: ReplaceCompositeType removed. Non-alterable changes are emitted as Drop + Create in composite-type.diff.ts. diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.base.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.base.ts deleted file mode 100644 index 4318f4f9f..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../../base.change.ts"; -import type { CompositeType } from "../composite-type.model.ts"; - -abstract class BaseCompositeTypeChange extends BaseChange { - abstract readonly compositeType: CompositeType; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "composite_type" as const; -} - -export abstract class CreateCompositeTypeChange extends BaseCompositeTypeChange { - readonly operation = "create" as const; -} - -export abstract class AlterCompositeTypeChange extends BaseCompositeTypeChange { - readonly operation = "alter" as const; -} - -export abstract class DropCompositeTypeChange extends BaseCompositeTypeChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.comment.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.comment.ts deleted file mode 100644 index d0189e9c4..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.comment.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import type { ColumnProps } from "../../../base.model.ts"; -import { stableId } from "../../../utils.ts"; -import type { CompositeType } from "../composite-type.model.ts"; -import { - CreateCompositeTypeChange, - DropCompositeTypeChange, -} from "./composite-type.base.ts"; - -/** - * Create/drop comments on composite types or their attributes. - * - * @see https://www.postgresql.org/docs/17/sql-comment.html - */ - -export type CommentCompositeType = - | CreateCommentOnCompositeType - | CreateCommentOnCompositeTypeAttribute - | DropCommentOnCompositeType - | DropCommentOnCompositeTypeAttribute; - -export class CreateCommentOnCompositeType extends CreateCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly scope = "comment" as const; - - constructor(props: { compositeType: CompositeType }) { - super(); - this.compositeType = props.compositeType; - } - - get creates() { - return [stableId.comment(this.compositeType.stableId)]; - } - - get requires() { - return [this.compositeType.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: type comment is not nullable in this case - quoteLiteral(this.compositeType.comment!), - ].join(" "); - } -} - -export class DropCommentOnCompositeType extends DropCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly scope = "comment" as const; - - constructor(props: { compositeType: CompositeType }) { - super(); - this.compositeType = props.compositeType; - } - - get drops() { - return [stableId.comment(this.compositeType.stableId)]; - } - - get requires() { - return [ - stableId.comment(this.compositeType.stableId), - this.compositeType.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - "IS NULL", - ].join(" "); - } -} - -export class CreateCommentOnCompositeTypeAttribute extends CreateCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly attribute: ColumnProps; - public readonly scope = "comment" as const; - - constructor(props: { compositeType: CompositeType; attribute: ColumnProps }) { - super(); - this.compositeType = props.compositeType; - this.attribute = props.attribute; - } - - get creates() { - const attributeStableId = `${this.compositeType.stableId}:${this.attribute.name}`; - return [stableId.comment(attributeStableId)]; - } - - get requires() { - return [ - `${this.compositeType.stableId}:${this.attribute.name}`, - this.compositeType.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON COLUMN", - `${this.compositeType.schema}.${this.compositeType.name}.${this.attribute.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: attribute comment is not nullable in this case - quoteLiteral(this.attribute.comment!), - ].join(" "); - } -} - -export class DropCommentOnCompositeTypeAttribute extends DropCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly attribute: ColumnProps; - public readonly scope = "comment" as const; - - constructor(props: { compositeType: CompositeType; attribute: ColumnProps }) { - super(); - this.compositeType = props.compositeType; - this.attribute = props.attribute; - } - - get drops() { - const attributeStableId = `${this.compositeType.stableId}:${this.attribute.name}`; - return [stableId.comment(attributeStableId)]; - } - - get requires() { - const attributeStableId = `${this.compositeType.stableId}:${this.attribute.name}`; - return [ - stableId.comment(attributeStableId), - attributeStableId, - this.compositeType.stableId, - ]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON COLUMN", - `${this.compositeType.schema}.${this.compositeType.name}.${this.attribute.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.create.test.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.create.test.ts deleted file mode 100644 index 7cb5b8758..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.create.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { CompositeType } from "../composite-type.model.ts"; -import { CreateCompositeType } from "./composite-type.create.ts"; - -describe("composite-type", () => { - test("create", async () => { - const compositeType = new CompositeType({ - schema: "public", - name: "test_type", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "test", - comment: null, - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const change = new CreateCompositeType({ - compositeType, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE TYPE public.test_type AS (id integer)", - ); - }); - - test("create with collate", async () => { - const compositeType = new CompositeType({ - schema: "public", - name: "test_type", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "test", - comment: null, - columns: [ - { - name: "name", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: '"en_US"', - default: null, - comment: null, - }, - ], - privileges: [], - }); - - const change = new CreateCompositeType({ compositeType }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - 'CREATE TYPE public.test_type AS (name text COLLATE "en_US")', - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.create.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.create.ts deleted file mode 100644 index 3a0797d25..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.create.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { isUserDefinedTypeSchema, stableId } from "../../../utils.ts"; -import type { CompositeType } from "../composite-type.model.ts"; -import { CreateCompositeTypeChange } from "./composite-type.base.ts"; - -/** - * Create a composite type. - * - * @see https://www.postgresql.org/docs/17/sql-createtype.html - * - * Synopsis - * ```sql - * CREATE TYPE name AS - * ( [ attribute_name data_type [ COLLATE collation ] [, ... ] ] ) - * ``` - */ -export class CreateCompositeType extends CreateCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly scope = "object" as const; - - constructor(props: { compositeType: CompositeType }) { - super(); - this.compositeType = props.compositeType; - } - - get creates() { - return [this.compositeType.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.compositeType.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.compositeType.owner)); - - // Column type dependencies (user-defined types only) - for (const col of this.compositeType.columns) { - if ( - col.is_custom_type && - col.custom_type_schema && - col.custom_type_name - ) { - dependencies.add( - stableId.type(col.custom_type_schema, col.custom_type_name), - ); - } - - // Collation dependency (if non-default) - if (col.collation) { - const unquotedCollation = col.collation.replace(/^"|"$/g, ""); - const collationParts = unquotedCollation.split("."); - if (collationParts.length === 2) { - const [collationSchema, collationName] = collationParts; - if (isUserDefinedTypeSchema(collationSchema)) { - dependencies.add( - stableId.collation(collationSchema, collationName), - ); - } - } - } - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE TYPE"]; - - // Add schema and name - parts.push(`${this.compositeType.schema}.${this.compositeType.name}`); - - // Add AS keyword - parts.push("AS"); - - parts.push( - `(${this.compositeType.columns - .map((column) => { - const tokens: string[] = []; - // attribute name and data type - tokens.push(column.name); - tokens.push(column.data_type_str); - // Collation (only when non-default, already filtered by extractor) - if (column.collation) { - tokens.push("COLLATE", column.collation); - } - return tokens.join(" "); - }) - .join(", ")})`, - ); - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.drop.test.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.drop.test.ts deleted file mode 100644 index f2f17c77e..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.drop.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { CompositeType } from "../composite-type.model.ts"; -import { DropCompositeType } from "./composite-type.drop.ts"; - -describe("composite-type", () => { - test("drop", async () => { - const compositeType = new CompositeType({ - schema: "public", - name: "test_type", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }); - - const change = new DropCompositeType({ - compositeType, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP TYPE public.test_type"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.drop.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.drop.ts deleted file mode 100644 index be92105c3..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.drop.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { CompositeType } from "../composite-type.model.ts"; -import { DropCompositeTypeChange } from "./composite-type.base.ts"; - -/** - * Drop a composite type. - * - * @see https://www.postgresql.org/docs/17/sql-droptype.html - * - * Synopsis - * ```sql - * DROP TYPE [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropCompositeType extends DropCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly scope = "object" as const; - - constructor(props: { compositeType: CompositeType }) { - super(); - this.compositeType = props.compositeType; - } - - get drops() { - return [this.compositeType.stableId]; - } - - get requires() { - return [this.compositeType.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "DROP TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.privilege.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.privilege.ts deleted file mode 100644 index d064ebcb5..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.privilege.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../../base.privilege.ts"; -import { stableId } from "../../../utils.ts"; -import type { CompositeType } from "../composite-type.model.ts"; -import { AlterCompositeTypeChange } from "./composite-type.base.ts"; - -export type CompositeTypePrivilege = - | GrantCompositeTypePrivileges - | RevokeCompositeTypePrivileges - | RevokeGrantOptionCompositeTypePrivileges; - -/** - * Grant privileges on a composite type. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { USAGE | ALL [ PRIVILEGES ] } - * ON TYPE type_name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantCompositeTypePrivileges extends AlterCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - compositeType: CompositeType; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.compositeType = props.compositeType; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.compositeType.stableId, this.grantee)]; - } - - get requires() { - return [this.compositeType.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantCompositeTypePrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("TYPE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("TYPE", list, this.version); - const typeName = `${this.compositeType.schema}.${this.compositeType.name}`; - return `GRANT ${privSql} ${kindPrefix} ${typeName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a composite type. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { USAGE | ALL [ PRIVILEGES ] } - * ON TYPE type_name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeCompositeTypePrivileges extends AlterCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - compositeType: CompositeType; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.compositeType = props.compositeType; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.compositeType.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.compositeType.stableId, this.grantee), - this.compositeType.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("TYPE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("TYPE", list, this.version); - const typeName = `${this.compositeType.schema}.${this.compositeType.name}`; - return `REVOKE ${privSql} ${kindPrefix} ${typeName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a composite type. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionCompositeTypePrivileges extends AlterCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - compositeType: CompositeType; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.compositeType = props.compositeType; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.compositeType.stableId, this.grantee), - this.compositeType.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("TYPE"); - const privSql = formatObjectPrivilegeList( - "TYPE", - this.privilegeNames, - this.version, - ); - const typeName = `${this.compositeType.schema}.${this.compositeType.name}`; - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${typeName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.security-label.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.security-label.ts deleted file mode 100644 index 0d8c3b980..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.security-label.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { quoteLiteral } from "../../../base.change.ts"; -import type { SecurityLabelProps } from "../../../security-label.types.ts"; -import { stableId } from "../../../utils.ts"; -import type { CompositeType } from "../composite-type.model.ts"; -import { - CreateCompositeTypeChange, - DropCompositeTypeChange, -} from "./composite-type.base.ts"; - -export type SecurityLabelCompositeType = - | CreateSecurityLabelOnCompositeType - | DropSecurityLabelOnCompositeType; - -export class CreateSecurityLabelOnCompositeType extends CreateCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - compositeType: CompositeType; - securityLabel: SecurityLabelProps; - }) { - super(); - this.compositeType = props.compositeType; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel( - this.compositeType.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [this.compositeType.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnCompositeType extends DropCompositeTypeChange { - public readonly compositeType: CompositeType; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { - compositeType: CompositeType; - securityLabel: SecurityLabelProps; - }) { - super(); - this.compositeType = props.compositeType; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel( - this.compositeType.stableId, - this.securityLabel.provider, - ), - ]; - } - - get requires() { - return [ - stableId.securityLabel( - this.compositeType.stableId, - this.securityLabel.provider, - ), - this.compositeType.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON TYPE", - `${this.compositeType.schema}.${this.compositeType.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.types.ts b/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.types.ts deleted file mode 100644 index bb0de35a2..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/changes/composite-type.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterCompositeType } from "./composite-type.alter.ts"; -import type { CommentCompositeType } from "./composite-type.comment.ts"; -import type { CreateCompositeType } from "./composite-type.create.ts"; -import type { DropCompositeType } from "./composite-type.drop.ts"; -import type { CompositeTypePrivilege } from "./composite-type.privilege.ts"; -import type { SecurityLabelCompositeType } from "./composite-type.security-label.ts"; - -/** Union of all composite-type-related change variants (`objectType: "composite_type"`). @category Change Types */ -export type CompositeTypeChange = - | AlterCompositeType - | CommentCompositeType - | CreateCompositeType - | DropCompositeType - | CompositeTypePrivilege - | SecurityLabelCompositeType; diff --git a/packages/pg-delta/src/core/objects/type/composite-type/composite-type.diff.test.ts b/packages/pg-delta/src/core/objects/type/composite-type/composite-type.diff.test.ts deleted file mode 100644 index 22ef46f02..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/composite-type.diff.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../../base.default-privileges.ts"; -import { - AlterCompositeTypeAddAttribute, - AlterCompositeTypeAlterAttributeType, - AlterCompositeTypeChangeOwner, - AlterCompositeTypeDropAttribute, -} from "./changes/composite-type.alter.ts"; -import { - CreateCommentOnCompositeType, - DropCommentOnCompositeType, -} from "./changes/composite-type.comment.ts"; -import { CreateCompositeType } from "./changes/composite-type.create.ts"; -import { DropCompositeType } from "./changes/composite-type.drop.ts"; -import { - GrantCompositeTypePrivileges, - RevokeCompositeTypePrivileges, - RevokeGrantOptionCompositeTypePrivileges, -} from "./changes/composite-type.privilege.ts"; -import { diffCompositeTypes } from "./composite-type.diff.ts"; -import { - CompositeType, - type CompositeTypeProps, -} from "./composite-type.model.ts"; - -const base: CompositeTypeProps = { - schema: "public", - name: "ct", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "o1", - comment: null, - columns: [], - privileges: [], -}; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("composite-type.diff", () => { - test("create and drop", () => { - const ct = new CompositeType(base); - const created = diffCompositeTypes(testContext, {}, { [ct.stableId]: ct }); - expect(created[0]).toBeInstanceOf(CreateCompositeType); - const dropped = diffCompositeTypes(testContext, { [ct.stableId]: ct }, {}); - expect(dropped[0]).toBeInstanceOf(DropCompositeType); - }); - - test("alter owner", () => { - const main = new CompositeType(base); - const branch = new CompositeType({ ...base, owner: "o2" }); - const changes = diffCompositeTypes( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterCompositeTypeChangeOwner); - }); - - test("add attribute", () => { - const main = new CompositeType(base); - const branch = new CompositeType({ - ...base, - columns: [ - { - name: "a", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - privileges: [], - }); - const changes = diffCompositeTypes( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterCompositeTypeAddAttribute), - ).toBe(true); - }); - - test("drop attribute", () => { - const main = new CompositeType({ - ...base, - columns: [ - { - name: "a", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - }); - const branch = new CompositeType(base); - const changes = diffCompositeTypes( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterCompositeTypeDropAttribute), - ).toBe(true); - }); - - test("alter attribute type/collation", () => { - const main = new CompositeType({ - ...base, - columns: [ - { - name: "a", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - }); - const branch = new CompositeType({ - ...base, - columns: [ - { - name: "a", - position: 1, - data_type: "text", - data_type_str: "text", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: "en_US", - default: null, - comment: null, - }, - ], - }); - const changes = diffCompositeTypes( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect( - changes.some((c) => c instanceof AlterCompositeTypeAlterAttributeType), - ).toBe(true); - }); - - test("created with privileges emits grant/revoke/revoke grant option", () => { - const ct = new CompositeType({ - ...base, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: false }, - { grantee: "role_grant", privilege: "USAGE", grantable: true }, - ], - }); - const changes = diffCompositeTypes(testContext, {}, { [ct.stableId]: ct }); - expect(changes[0]).toBeInstanceOf(CreateCompositeType); - expect(changes.some((c) => c instanceof GrantCompositeTypePrivileges)).toBe( - true, - ); - }); - - test("altered comment emits create/drop comment", () => { - const main = new CompositeType(base); - const withComment = new CompositeType({ ...base, comment: "my type" }); - - const addComment = diffCompositeTypes( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment[0]).toBeInstanceOf(CreateCommentOnCompositeType); - - const dropComment = diffCompositeTypes( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment[0]).toBeInstanceOf(DropCommentOnCompositeType); - }); - - test("altered privileges emit grant, revoke, and revoke grant option", () => { - const main = new CompositeType({ - ...base, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: false }, - { grantee: "role_with_option", privilege: "USAGE", grantable: true }, - { grantee: "role_removed", privilege: "USAGE", grantable: false }, - ], - }); - const branch = new CompositeType({ - ...base, - privileges: [ - { grantee: "role_usage", privilege: "USAGE", grantable: true }, - { grantee: "role_with_option", privilege: "USAGE", grantable: false }, - { grantee: "role_new", privilege: "USAGE", grantable: false }, - ], - }); - const changes = diffCompositeTypes( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof GrantCompositeTypePrivileges)).toBe( - true, - ); - expect( - changes.some((c) => c instanceof RevokeCompositeTypePrivileges), - ).toBe(true); - expect( - changes.some( - (c) => c instanceof RevokeGrantOptionCompositeTypePrivileges, - ), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/composite-type/composite-type.diff.ts b/packages/pg-delta/src/core/objects/type/composite-type/composite-type.diff.ts deleted file mode 100644 index 417060454..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/composite-type.diff.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { diffObjects } from "../../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../../diff-context.ts"; -import { diffSecurityLabels } from "../../security-label.types.ts"; -import { deepEqual, hasNonAlterableChanges } from "../../utils.ts"; -import { - AlterCompositeTypeAddAttribute, - AlterCompositeTypeAlterAttributeType, - AlterCompositeTypeChangeOwner, - AlterCompositeTypeDropAttribute, -} from "./changes/composite-type.alter.ts"; -import { - CreateCommentOnCompositeType, - CreateCommentOnCompositeTypeAttribute, - DropCommentOnCompositeType, - DropCommentOnCompositeTypeAttribute, -} from "./changes/composite-type.comment.ts"; -import { CreateCompositeType } from "./changes/composite-type.create.ts"; -import { DropCompositeType } from "./changes/composite-type.drop.ts"; -import { - GrantCompositeTypePrivileges, - RevokeCompositeTypePrivileges, - RevokeGrantOptionCompositeTypePrivileges, -} from "./changes/composite-type.privilege.ts"; -import { - CreateSecurityLabelOnCompositeType, - DropSecurityLabelOnCompositeType, -} from "./changes/composite-type.security-label.ts"; -import type { CompositeTypeChange } from "./changes/composite-type.types.ts"; -import type { CompositeType } from "./composite-type.model.ts"; - -/** - * Diff two sets of composite types from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The composite types in the main catalog. - * @param branch - The composite types in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffCompositeTypes( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): CompositeTypeChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: CompositeTypeChange[] = []; - - for (const compositeTypeId of created) { - const ct = branch[compositeTypeId]; - changes.push(new CreateCompositeType({ compositeType: ct })); - - // OWNER: If the composite type should be owned by someone other than the current user, - // emit ALTER TYPE ... OWNER TO after creation - if (ct.owner !== ctx.currentUser) { - changes.push( - new AlterCompositeTypeChangeOwner({ - compositeType: ct, - owner: ct.owner, - }), - ); - } - - // Type comment on creation - if (ct.comment !== null) { - changes.push(new CreateCommentOnCompositeType({ compositeType: ct })); - } - for (const label of ct.security_labels) { - changes.push( - new CreateSecurityLabelOnCompositeType({ - compositeType: ct, - securityLabel: label, - }), - ); - } - // Attribute comments on creation - for (const attr of ct.columns) { - if (attr.comment !== null) { - changes.push( - new CreateCommentOnCompositeTypeAttribute({ - compositeType: ct, - attribute: attr, - }), - ); - } - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "composite_type", - ct.schema ?? "", - ); - const creatorFilteredDefaults = - ct.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - // Filter out PUBLIC's built-in default USAGE privilege (PostgreSQL grants it automatically) - // Reference: https://www.postgresql.org/docs/17/ddl-priv.html Table 5.2 - // This prevents generating unnecessary "GRANT USAGE TO PUBLIC" statements - const desiredPrivileges = filterPublicBuiltInDefaults( - "composite_type", - ct.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the composite type owner as the reference. - const privilegeResults = diffPrivileges( - filterPublicBuiltInDefaults("composite_type", creatorFilteredDefaults), - desiredPrivileges, - ct.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - ct, - ct, - "compositeType", - { - Grant: GrantCompositeTypePrivileges, - Revoke: RevokeCompositeTypePrivileges, - RevokeGrantOption: RevokeGrantOptionCompositeTypePrivileges, - }, - ctx.version, - ) as CompositeTypeChange[]), - ); - } - - for (const compositeTypeId of dropped) { - changes.push( - new DropCompositeType({ compositeType: main[compositeTypeId] }), - ); - } - - for (const compositeTypeId of altered) { - const mainCompositeType = main[compositeTypeId]; - const branchCompositeType = branch[compositeTypeId]; - - // Check if non-alterable properties have changed - // These require dropping and recreating the composite type - const NON_ALTERABLE_FIELDS: Array = [ - "row_security", - "force_row_security", - "has_indexes", - "has_rules", - "has_triggers", - "has_subclasses", - "is_populated", - "replica_identity", - "is_partition", - "options", - "partition_bound", - ]; - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainCompositeType, - branchCompositeType, - NON_ALTERABLE_FIELDS, - { options: deepEqual }, - ); - - if (nonAlterablePropsChanged) { - // Replacement is not performed automatically for composite types - // to avoid destructive operations; keep changes minimal. - } else { - // Only alterable properties changed - check each one - - // OWNER - if (mainCompositeType.owner !== branchCompositeType.owner) { - changes.push( - new AlterCompositeTypeChangeOwner({ - compositeType: mainCompositeType, - owner: branchCompositeType.owner, - }), - ); - } - - // TYPE COMMENT (create/drop when comment changes) - if (mainCompositeType.comment !== branchCompositeType.comment) { - if (branchCompositeType.comment === null) { - changes.push( - new DropCommentOnCompositeType({ - compositeType: mainCompositeType, - }), - ); - } else { - changes.push( - new CreateCommentOnCompositeType({ - compositeType: branchCompositeType, - }), - ); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnCompositeType | DropSecurityLabelOnCompositeType - >( - mainCompositeType.security_labels, - branchCompositeType.security_labels, - (securityLabel) => - new CreateSecurityLabelOnCompositeType({ - compositeType: branchCompositeType, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnCompositeType({ - compositeType: mainCompositeType, - securityLabel, - }), - ), - ); - - // ATTRIBUTE diffs - const mainAttrs = new Map( - mainCompositeType.columns.map((c) => [c.name, c]), - ); - const branchAttrs = new Map( - branchCompositeType.columns.map((c) => [c.name, c]), - ); - - // Added attributes - for (const [name, attr] of branchAttrs) { - if (!mainAttrs.has(name)) { - changes.push( - new AlterCompositeTypeAddAttribute({ - compositeType: branchCompositeType, - attribute: attr, - }), - ); - if (attr.comment !== null) { - changes.push( - new CreateCommentOnCompositeTypeAttribute({ - compositeType: branchCompositeType, - attribute: attr, - }), - ); - } - } - } - - // Dropped attributes - for (const [name, attr] of mainAttrs) { - if (!branchAttrs.has(name)) { - changes.push( - new AlterCompositeTypeDropAttribute({ - compositeType: mainCompositeType, - attribute: attr, - }), - ); - } - } - - // Altered attribute type/collation - for (const [name, mainAttr] of mainAttrs) { - const branchAttr = branchAttrs.get(name); - if (!branchAttr) continue; - if ( - mainAttr.data_type_str !== branchAttr.data_type_str || - mainAttr.collation !== branchAttr.collation - ) { - changes.push( - new AlterCompositeTypeAlterAttributeType({ - compositeType: branchCompositeType, - attribute: branchAttr, - }), - ); - } - - // COMMENT change on attribute - if (mainAttr.comment !== branchAttr.comment) { - if (branchAttr.comment === null) { - changes.push( - new DropCommentOnCompositeTypeAttribute({ - compositeType: mainCompositeType, - attribute: mainAttr, - }), - ); - } else { - changes.push( - new CreateCommentOnCompositeTypeAttribute({ - compositeType: branchCompositeType, - attribute: branchAttr, - }), - ); - } - } - } - - // PRIVILEGES - // Filter out PUBLIC's built-in default USAGE privilege from main catalog - // (PostgreSQL grants it automatically, so we shouldn't compare it) - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "composite_type", - mainCompositeType.privileges, - ); - // Filter out PUBLIC's built-in default USAGE privilege from branch catalog - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "composite_type", - branchCompositeType.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchCompositeType.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchCompositeType, - mainCompositeType, - "compositeType", - { - Grant: GrantCompositeTypePrivileges, - Revoke: RevokeCompositeTypePrivileges, - RevokeGrantOption: RevokeGrantOptionCompositeTypePrivileges, - }, - ctx.version, - ) as CompositeTypeChange[]), - ); - - // Note: Composite type renaming would also use ALTER TYPE ... RENAME TO ... - // But since our CompositeType model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/type/composite-type/composite-type.model.ts b/packages/pg-delta/src/core/objects/type/composite-type/composite-type.model.ts deleted file mode 100644 index 5a7568e00..000000000 --- a/packages/pg-delta/src/core/objects/type/composite-type/composite-type.model.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { - BasePgModel, - columnPropsSchema, - type TableLikeObject, -} from "../../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../../base.privilege-diff.ts"; -import { - normalizeSecurityLabels, - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../../security-label.types.ts"; -import { ReplicaIdentitySchema } from "../../table/table.model.ts"; - -const compositeTypePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - row_security: z.boolean(), - force_row_security: z.boolean(), - has_indexes: z.boolean(), - has_rules: z.boolean(), - has_triggers: z.boolean(), - has_subclasses: z.boolean(), - is_populated: z.boolean(), - replica_identity: ReplicaIdentitySchema, - is_partition: z.boolean(), - options: z.array(z.string()).nullable(), - partition_bound: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable(), - columns: z.array(columnPropsSchema), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -type CompositeTypePrivilegeProps = PrivilegeProps; -export type CompositeTypeProps = z.infer; - -export class CompositeType extends BasePgModel implements TableLikeObject { - public readonly schema: CompositeTypeProps["schema"]; - public readonly name: CompositeTypeProps["name"]; - public readonly row_security: CompositeTypeProps["row_security"]; - public readonly force_row_security: CompositeTypeProps["force_row_security"]; - public readonly has_indexes: CompositeTypeProps["has_indexes"]; - public readonly has_rules: CompositeTypeProps["has_rules"]; - public readonly has_triggers: CompositeTypeProps["has_triggers"]; - public readonly has_subclasses: CompositeTypeProps["has_subclasses"]; - public readonly is_populated: CompositeTypeProps["is_populated"]; - public readonly replica_identity: CompositeTypeProps["replica_identity"]; - public readonly is_partition: CompositeTypeProps["is_partition"]; - public readonly options: CompositeTypeProps["options"]; - public readonly partition_bound: CompositeTypeProps["partition_bound"]; - public readonly owner: CompositeTypeProps["owner"]; - public readonly comment: CompositeTypeProps["comment"]; - public readonly columns: CompositeTypeProps["columns"]; - public readonly privileges: CompositeTypePrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: CompositeTypeProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.row_security = props.row_security; - this.force_row_security = props.force_row_security; - this.has_indexes = props.has_indexes; - this.has_rules = props.has_rules; - this.has_triggers = props.has_triggers; - this.has_subclasses = props.has_subclasses; - this.is_populated = props.is_populated; - this.replica_identity = props.replica_identity; - this.is_partition = props.is_partition; - this.options = props.options; - this.partition_bound = props.partition_bound; - this.owner = props.owner; - this.comment = props.comment; - this.columns = props.columns; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `type:${string}` { - return `type:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - row_security: this.row_security, - force_row_security: this.force_row_security, - has_indexes: this.has_indexes, - has_rules: this.has_rules, - has_triggers: this.has_triggers, - has_subclasses: this.has_subclasses, - is_populated: this.is_populated, - replica_identity: this.replica_identity, - is_partition: this.is_partition, - options: this.options, - partition_bound: this.partition_bound, - owner: this.owner, - comment: this.comment, - columns: this.columns, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } - - override stableSnapshot() { - const normalizeColumns = () => - [...this.columns] - .map((col) => { - const { position: _pos, ...rest } = col as unknown as Record< - string, - unknown - >; - return rest; - }) - .sort((a, b) => { - const nameA = (a.name as string | undefined) ?? ""; - const nameB = (b.name as string | undefined) ?? ""; - return nameA.localeCompare(nameB); - }); - - return { - identity: this.identityFields, - data: { - ...this.dataFields, - columns: normalizeColumns(), - security_labels: normalizeSecurityLabels(this.security_labels), - }, - }; - } -} - -export async function extractCompositeTypes( - pool: Pool, -): Promise { - const { rows: compositeTypeRows } = await pool.query(sql` - WITH extension_oids AS ( - SELECT objid - FROM pg_depend d - WHERE d.refclassid = 'pg_extension'::regclass - AND d.classid = 'pg_type'::regclass - ), - composite_types AS ( - SELECT - c.relnamespace::regnamespace::text AS schema, - quote_ident(c.relname) AS name, - c.relrowsecurity AS row_security, - c.relforcerowsecurity AS force_row_security, - c.relhasindex AS has_indexes, - c.relhasrules AS has_rules, - c.relhastriggers AS has_triggers, - c.relhassubclass AS has_subclasses, - c.relispopulated AS is_populated, - c.relreplident AS replica_identity, - c.relispartition AS is_partition, - c.reloptions AS options, - pg_get_expr(c.relpartbound, c.oid) AS partition_bound, - c.relowner::regrole::text AS owner, - obj_description(c.reltype, 'pg_type') AS comment, - c.relacl AS relacl, -- used by privileges LATERAL - c.relowner AS relowner, - c.oid AS oid, - c.reltype AS reltype - FROM pg_catalog.pg_class c - LEFT JOIN extension_oids e ON c.reltype = e.objid - WHERE NOT c.relnamespace::regnamespace::text LIKE ANY (ARRAY['pg\\_%', 'information\\_schema']) - AND e.objid IS NULL - AND c.relkind = 'c' - ) - SELECT - ct.schema, - ct.name, - ct.row_security, - ct.force_row_security, - ct.has_indexes, - ct.has_rules, - ct.has_triggers, - ct.has_subclasses, - ct.is_populated, - ct.replica_identity, - ct.is_partition, - ct.options, - ct.partition_bound, - ct.owner, - ct.comment, - COALESCE(priv.privileges, '[]') AS privileges, - COALESCE(cols.columns, '[]') AS columns, - COALESCE( - ( - SELECT json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - ORDER BY sl.provider - ) - FROM pg_catalog.pg_seclabel sl - WHERE sl.objoid = ct.reltype - AND sl.classoid = 'pg_type'::regclass - AND sl.objsubid = 0 - ), - '[]'::json - ) AS security_labels - FROM composite_types ct - - -- privileges as a per-row LATERAL subquery - LEFT JOIN LATERAL ( - SELECT json_agg( - json_build_object( - 'grantee', CASE WHEN x.grantee = 0 THEN 'PUBLIC' ELSE x.grantee::regrole::text END, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - ORDER BY x.grantee, x.privilege_type - ) AS privileges - FROM LATERAL aclexplode(COALESCE(ct.relacl, acldefault('T', ct.relowner))) AS x(grantor, grantee, privilege_type, is_grantable) - ) priv ON TRUE - - -- columns as a per-row LATERAL subquery (so no GROUP BY needed) - LEFT JOIN LATERAL ( - SELECT json_agg( - json_build_object( - 'name', quote_ident(a.attname), - 'position', a.attnum, - 'data_type', a.atttypid::regtype::text, - 'data_type_str', format_type(a.atttypid, a.atttypmod), - 'is_custom_type', ty.typnamespace::regnamespace::text NOT IN ('pg_catalog','information_schema'), - 'custom_type_type', CASE WHEN ty.typnamespace::regnamespace::text NOT IN ('pg_catalog','information_schema') THEN ty.typtype ELSE NULL END, - 'custom_type_category', CASE WHEN ty.typnamespace::regnamespace::text NOT IN ('pg_catalog','information_schema') THEN ty.typcategory ELSE NULL END, - 'custom_type_schema', CASE WHEN ty.typnamespace::regnamespace::text NOT IN ('pg_catalog','information_schema') THEN ty.typnamespace::regnamespace ELSE NULL END, - 'custom_type_name', CASE WHEN ty.typnamespace::regnamespace::text NOT IN ('pg_catalog','information_schema') THEN quote_ident(ty.typname) ELSE NULL END, - 'not_null', a.attnotnull, - 'is_identity', a.attidentity <> '', - 'is_identity_always', a.attidentity = 'a', - 'is_generated', a.attgenerated <> '', - 'collation', ( - SELECT quote_ident(c2.collname) - FROM pg_collation c2, pg_type t2 - WHERE c2.oid = a.attcollation - AND t2.oid = a.atttypid - AND a.attcollation <> t2.typcollation - ), - 'default', pg_get_expr(ad.adbin, ad.adrelid), - 'comment', col_description(a.attrelid, a.attnum) - ) - ORDER BY a.attnum - ) AS columns - FROM pg_attribute a - LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum - LEFT JOIN pg_type ty ON ty.oid = a.atttypid - WHERE a.attrelid = ct.oid - AND a.attnum > 0 - AND NOT a.attisdropped - ) cols ON TRUE - - ORDER BY ct.schema, ct.name - `); - - // Validate and parse each row using the Zod schema - const validatedRows = compositeTypeRows.map((row: unknown) => - compositeTypePropsSchema.parse(row), - ); - return validatedRows.map((row: CompositeTypeProps) => new CompositeType(row)); -} diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.alter.test.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.alter.test.ts deleted file mode 100644 index d18e76836..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.alter.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Enum, type EnumProps } from "../enum.model.ts"; -import { AlterEnumAddValue, AlterEnumChangeOwner } from "./enum.alter.ts"; - -describe.concurrent("enum", () => { - describe("alter", () => { - test("change owner", async () => { - const props: Omit = { - schema: "public", - name: "test_enum", - labels: [ - { sort_order: 1, label: "value1" }, - { sort_order: 2, label: "value2" }, - ], - comment: null, - privileges: [], - }; - const main = new Enum({ - ...props, - owner: "old_owner", - }); - const change = new AlterEnumChangeOwner({ - enum: main, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER TYPE public.test_enum OWNER TO new_owner", - ); - }); - - test("add value", async () => { - const props: EnumProps = { - schema: "public", - name: "test_enum", - owner: "test", - labels: [ - { sort_order: 1, label: "value1" }, - { sort_order: 2, label: "value2" }, - ], - comment: null, - privileges: [], - }; - const main = new Enum(props); - const change = new AlterEnumAddValue({ enum: main, newValue: "value3" }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER TYPE public.test_enum ADD VALUE 'value3'", - ); - }); - - test("add value before", async () => { - const props: EnumProps = { - schema: "public", - name: "test_enum", - owner: "test", - labels: [ - { sort_order: 1, label: "value1" }, - { sort_order: 2, label: "value2" }, - ], - comment: null, - privileges: [], - }; - const main = new Enum(props); - const change = new AlterEnumAddValue({ - enum: main, - newValue: "value1_5", - position: { before: "value2" }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER TYPE public.test_enum ADD VALUE 'value1_5' BEFORE 'value2'", - ); - }); - - test("add value after", async () => { - const props: EnumProps = { - schema: "public", - name: "test_enum", - owner: "test", - labels: [ - { sort_order: 1, label: "value1" }, - { sort_order: 2, label: "value2" }, - ], - comment: null, - privileges: [], - }; - const main = new Enum(props); - const change = new AlterEnumAddValue({ - enum: main, - newValue: "value1_5", - position: { after: "value1" }, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER TYPE public.test_enum ADD VALUE 'value1_5' AFTER 'value1'", - ); - }); - - test("complex enum changes are not auto-replaced", async () => { - expect(1).toBe(1); - }); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.alter.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.alter.ts deleted file mode 100644 index 33e4004b9..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.alter.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import type { Enum } from "../enum.model.ts"; -import { AlterEnumChange } from "./enum.base.ts"; - -/** - * Alter an enum. - * - * @see https://www.postgresql.org/docs/17/sql-altertype.html - * - * Synopsis - * ```sql - * ALTER TYPE name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ALTER TYPE name RENAME TO new_name - * ALTER TYPE name ADD VALUE [ IF NOT EXISTS ] new_enum_value [ { BEFORE | AFTER } neighbor_enum_value ] - * ALTER TYPE name RENAME VALUE existing_enum_value TO new_enum_value - * ``` - */ - -export type AlterEnum = AlterEnumAddValue | AlterEnumChangeOwner; - -/** - * ALTER TYPE ... OWNER TO ... - */ -export class AlterEnumChangeOwner extends AlterEnumChange { - public readonly enum: Enum; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { enum: Enum; owner: string }) { - super(); - this.enum = props.enum; - this.owner = props.owner; - } - - get requires() { - return [this.enum.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TYPE", - `${this.enum.schema}.${this.enum.name}`, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * ALTER TYPE ... ADD VALUE ... - */ -export class AlterEnumAddValue extends AlterEnumChange { - public readonly enum: Enum; - public readonly newValue: string; - public readonly position?: { before?: string; after?: string }; - public readonly scope = "object" as const; - - constructor(props: { - enum: Enum; - newValue: string; - position?: { before?: string; after?: string }; - }) { - super(); - this.enum = props.enum; - this.newValue = props.newValue; - this.position = props.position; - } - - get requires() { - return [this.enum.stableId]; - } - - // New enum values are not usable until the transaction commits (55P04). - override get commitBoundary() { - return "enum_value_visibility" as const; - } - - serialize(_options?: SerializeOptions): string { - const parts = [ - "ALTER TYPE", - `${this.enum.schema}.${this.enum.name}`, - "ADD VALUE", - quoteLiteral(this.newValue), - ]; - - if (this.position?.before) { - parts.push("BEFORE", quoteLiteral(this.position.before)); - } else if (this.position?.after) { - parts.push("AFTER", quoteLiteral(this.position.after)); - } - - return parts.join(" "); - } -} - -// NOTE: ReplaceEnum removed. Complex enum changes should be handled in diff with Drop + Create when needed. diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.base.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.base.ts deleted file mode 100644 index d7e5275c7..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../../base.change.ts"; -import type { Enum } from "../enum.model.ts"; - -abstract class BaseEnumChange extends BaseChange { - abstract readonly enum: Enum; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "enum" as const; -} - -export abstract class CreateEnumChange extends BaseEnumChange { - readonly operation = "create" as const; -} - -export abstract class AlterEnumChange extends BaseEnumChange { - readonly operation = "alter" as const; -} - -export abstract class DropEnumChange extends BaseEnumChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.comment.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.comment.ts deleted file mode 100644 index 3348dd3c3..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.comment.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import type { Enum } from "../enum.model.ts"; -import { CreateEnumChange, DropEnumChange } from "./enum.base.ts"; - -/** - * Create/drop comments on enum types. - */ - -export type CommentEnum = CreateCommentOnEnum | DropCommentOnEnum; - -export class CreateCommentOnEnum extends CreateEnumChange { - public readonly enum: Enum; - public readonly scope = "comment" as const; - - constructor(props: { enum: Enum }) { - super(); - this.enum = props.enum; - } - - get creates() { - return [stableId.comment(this.enum.stableId)]; - } - - get requires() { - return [this.enum.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TYPE", - `${this.enum.schema}.${this.enum.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: enum comment is not nullable in this case - quoteLiteral(this.enum.comment!), - ].join(" "); - } -} - -export class DropCommentOnEnum extends DropEnumChange { - public readonly enum: Enum; - public readonly scope = "comment" as const; - - constructor(props: { enum: Enum }) { - super(); - this.enum = props.enum; - } - - get drops() { - return [stableId.comment(this.enum.stableId)]; - } - - get requires() { - return [stableId.comment(this.enum.stableId), this.enum.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TYPE", - `${this.enum.schema}.${this.enum.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.create.test.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.create.test.ts deleted file mode 100644 index d2329dff7..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.create.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Enum } from "../enum.model.ts"; -import { CreateEnum } from "./enum.create.ts"; - -describe("enum", () => { - test("create", async () => { - const enumType = new Enum({ - schema: "public", - name: "test_enum", - owner: "test", - labels: [ - { sort_order: 1, label: "value1" }, - { sort_order: 2, label: "value2" }, - { sort_order: 3, label: "value3" }, - ], - comment: null, - privileges: [], - }); - - const change = new CreateEnum({ - enum: enumType, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE TYPE public.test_enum AS ENUM ('value1', 'value2', 'value3')", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.create.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.create.ts deleted file mode 100644 index bece25178..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.create.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import type { Enum } from "../enum.model.ts"; -import { CreateEnumChange } from "./enum.base.ts"; - -/** - * Create an enum. - * - * @see https://www.postgresql.org/docs/17/sql-createtype.html - * - * Synopsis - * ```sql - * CREATE TYPE name AS ENUM ( [ label [, ...] ] ) - * ``` - */ -export class CreateEnum extends CreateEnumChange { - public readonly enum: Enum; - public readonly scope = "object" as const; - - constructor(props: { enum: Enum }) { - super(); - this.enum = props.enum; - } - - get creates() { - return [this.enum.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.enum.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.enum.owner)); - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = ["CREATE TYPE"]; - - // Add schema and name - parts.push(`${this.enum.schema}.${this.enum.name}`); - - // Add AS ENUM - parts.push("AS ENUM"); - - // Add labels - const labels = this.enum.labels.map((label) => quoteLiteral(label.label)); - parts.push(`(${labels.join(", ")})`); - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.drop.test.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.drop.test.ts deleted file mode 100644 index c16de2743..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.drop.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Enum } from "../enum.model.ts"; -import { DropEnum } from "./enum.drop.ts"; - -describe("enum", () => { - test("drop", async () => { - const enumType = new Enum({ - schema: "public", - name: "test_enum", - owner: "test", - labels: [ - { sort_order: 1, label: "value1" }, - { sort_order: 2, label: "value2" }, - ], - comment: null, - privileges: [], - }); - - const change = new DropEnum({ - enum: enumType, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP TYPE public.test_enum"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.drop.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.drop.ts deleted file mode 100644 index 8a4b92579..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { Enum } from "../enum.model.ts"; -import { DropEnumChange } from "./enum.base.ts"; - -/** - * Drop an enum. - * - * @see https://www.postgresql.org/docs/17/sql-droptype.html - * - * Synopsis - * ```sql - * DROP TYPE [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropEnum extends DropEnumChange { - public readonly enum: Enum; - public readonly scope = "object" as const; - - constructor(props: { enum: Enum }) { - super(); - this.enum = props.enum; - } - - get drops() { - return [this.enum.stableId]; - } - - get requires() { - return [this.enum.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP TYPE", `${this.enum.schema}.${this.enum.name}`].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.privilege.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.privilege.ts deleted file mode 100644 index 7b03b65ea..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.privilege.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../../base.privilege.ts"; -import { stableId } from "../../../utils.ts"; -import type { Enum } from "../enum.model.ts"; -import { AlterEnumChange } from "./enum.base.ts"; - -export type EnumPrivilege = - | GrantEnumPrivileges - | RevokeEnumPrivileges - | RevokeGrantOptionEnumPrivileges; - -/** - * Grant privileges on an enum type. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { USAGE | ALL [ PRIVILEGES ] } - * ON TYPE type_name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantEnumPrivileges extends AlterEnumChange { - public readonly enum: Enum; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - enum: Enum; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.enum = props.enum; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.enum.stableId, this.grantee)]; - } - - get requires() { - return [this.enum.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantEnumPrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("TYPE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("TYPE", list, this.version); - const typeName = `${this.enum.schema}.${this.enum.name}`; - return `GRANT ${privSql} ${kindPrefix} ${typeName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on an enum type. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { USAGE | ALL [ PRIVILEGES ] } - * ON TYPE type_name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeEnumPrivileges extends AlterEnumChange { - public readonly enum: Enum; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - enum: Enum; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.enum = props.enum; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.enum.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.enum.stableId, this.grantee), - this.enum.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("TYPE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("TYPE", list, this.version); - const typeName = `${this.enum.schema}.${this.enum.name}`; - return `REVOKE ${privSql} ${kindPrefix} ${typeName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on an enum type. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionEnumPrivileges extends AlterEnumChange { - public readonly enum: Enum; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - enum: Enum; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.enum = props.enum; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.enum.stableId, this.grantee), - this.enum.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("TYPE"); - const privSql = formatObjectPrivilegeList( - "TYPE", - this.privilegeNames, - this.version, - ); - const typeName = `${this.enum.schema}.${this.enum.name}`; - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${typeName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.security-label.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.security-label.ts deleted file mode 100644 index 0e73c7b88..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.security-label.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { quoteLiteral } from "../../../base.change.ts"; -import type { SecurityLabelProps } from "../../../security-label.types.ts"; -import { stableId } from "../../../utils.ts"; -import type { Enum } from "../enum.model.ts"; -import { CreateEnumChange, DropEnumChange } from "./enum.base.ts"; - -export type SecurityLabelEnum = - | CreateSecurityLabelOnEnum - | DropSecurityLabelOnEnum; - -export class CreateSecurityLabelOnEnum extends CreateEnumChange { - public readonly enum: Enum; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { enum: Enum; securityLabel: SecurityLabelProps }) { - super(); - this.enum = props.enum; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel(this.enum.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [this.enum.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON TYPE", - `${this.enum.schema}.${this.enum.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnEnum extends DropEnumChange { - public readonly enum: Enum; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { enum: Enum; securityLabel: SecurityLabelProps }) { - super(); - this.enum = props.enum; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel(this.enum.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [ - stableId.securityLabel(this.enum.stableId, this.securityLabel.provider), - this.enum.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON TYPE", - `${this.enum.schema}.${this.enum.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/enum/changes/enum.types.ts b/packages/pg-delta/src/core/objects/type/enum/changes/enum.types.ts deleted file mode 100644 index 23367aab5..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/changes/enum.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterEnum } from "./enum.alter.ts"; -import type { CommentEnum } from "./enum.comment.ts"; -import type { CreateEnum } from "./enum.create.ts"; -import type { DropEnum } from "./enum.drop.ts"; -import type { EnumPrivilege } from "./enum.privilege.ts"; -import type { SecurityLabelEnum } from "./enum.security-label.ts"; - -/** Union of all enum-related change variants (`objectType: "enum"`). @category Change Types */ -export type EnumChange = - | AlterEnum - | CommentEnum - | CreateEnum - | DropEnum - | EnumPrivilege - | SecurityLabelEnum; diff --git a/packages/pg-delta/src/core/objects/type/enum/enum.diff.test.ts b/packages/pg-delta/src/core/objects/type/enum/enum.diff.test.ts deleted file mode 100644 index 280cb3205..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/enum.diff.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../../base.default-privileges.ts"; -import { - AlterEnumAddValue, - AlterEnumChangeOwner, -} from "./changes/enum.alter.ts"; -import { - CreateCommentOnEnum, - DropCommentOnEnum, -} from "./changes/enum.comment.ts"; -import { CreateEnum } from "./changes/enum.create.ts"; -import { DropEnum } from "./changes/enum.drop.ts"; -import { - GrantEnumPrivileges, - RevokeEnumPrivileges, - RevokeGrantOptionEnumPrivileges, -} from "./changes/enum.privilege.ts"; -import { diffEnums } from "./enum.diff.ts"; -import { Enum, type EnumProps } from "./enum.model.ts"; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("enum.diff", () => { - test("create and drop", () => { - const props: EnumProps = { - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "b", sort_order: 2 }, - ], - comment: null, - privileges: [], - }; - const e = new Enum(props); - - const created = diffEnums(testContext, {}, { [e.stableId]: e }); - expect(created[0]).toBeInstanceOf(CreateEnum); - - const dropped = diffEnums(testContext, { [e.stableId]: e }, {}); - expect(dropped[0]).toBeInstanceOf(DropEnum); - }); - - test("alter: owner change and add value positioning", () => { - const main = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "c", sort_order: 3 }, - ], - comment: null, - privileges: [], - }); - const branch = new Enum({ - schema: "public", - name: "e1", - owner: "o2", - labels: [ - { label: "a", sort_order: 1 }, - { label: "b", sort_order: 2 }, - { label: "c", sort_order: 3 }, - ], - comment: null, - privileges: [], - }); - - const changes = diffEnums( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterEnumChangeOwner)).toBe(true); - const add = changes.find((c) => c instanceof AlterEnumAddValue) as - | AlterEnumAddValue - | undefined; - expect(add).toBeDefined(); - }); - - test("add value at beginning (BEFORE first)", () => { - const main = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "b", sort_order: 2 }, - { label: "c", sort_order: 3 }, - ], - comment: null, - privileges: [], - }); - const branch = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "b", sort_order: 2 }, - { label: "c", sort_order: 3 }, - ], - comment: null, - privileges: [], - }); - - const changes = diffEnums( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - const add = changes.find((c) => c instanceof AlterEnumAddValue) as - | AlterEnumAddValue - | undefined; - expect(add).toBeDefined(); - expect(add?.position?.before).toBe("b"); - expect(add?.position?.after).toBeUndefined(); - }); - - test("add value in middle (AFTER previous)", () => { - const main = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "c", sort_order: 3 }, - ], - comment: null, - privileges: [], - }); - const branch = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "b", sort_order: 2 }, - { label: "c", sort_order: 3 }, - ], - comment: null, - privileges: [], - }); - - const changes = diffEnums( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - const add = changes.find((c) => c instanceof AlterEnumAddValue) as - | AlterEnumAddValue - | undefined; - expect(add).toBeDefined(); - expect(add?.position?.after).toBe("a"); - expect(add?.position?.before).toBeUndefined(); - }); - - test("add value at end (AFTER last)", () => { - const main = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "b", sort_order: 2 }, - ], - comment: null, - privileges: [], - }); - const branch = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "b", sort_order: 2 }, - { label: "c", sort_order: 3 }, - ], - comment: null, - privileges: [], - }); - - const changes = diffEnums( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - const add = changes.find((c) => c instanceof AlterEnumAddValue) as - | AlterEnumAddValue - | undefined; - expect(add).toBeDefined(); - expect(add?.position?.after).toBe("b"); - expect(add?.position?.before).toBeUndefined(); - }); - - test("create with comment emits CreateCommentOnEnum", () => { - const e = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [{ label: "a", sort_order: 1 }], - comment: "my enum", - privileges: [], - }); - const changes = diffEnums(testContext, {}, { [e.stableId]: e }); - expect(changes[0]).toBeInstanceOf(CreateEnum); - expect(changes.some((c) => c instanceof CreateCommentOnEnum)).toBe(true); - }); - - test("create with privileges that trigger revoke grant option", () => { - const dpState = new DefaultPrivilegeState({}); - dpState.applyGrant("postgres", "T", null, "role_downgrade", [ - { privilege: "USAGE", grantable: true }, - ]); - const ctx = { ...testContext, defaultPrivilegeState: dpState }; - const e = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [{ label: "a", sort_order: 1 }], - comment: null, - privileges: [ - { grantee: "role_downgrade", privilege: "USAGE", grantable: false }, - ], - }); - const changes = diffEnums(ctx, {}, { [e.stableId]: e }); - expect(changes[0]).toBeInstanceOf(CreateEnum); - expect( - changes.some((c) => c instanceof RevokeGrantOptionEnumPrivileges), - ).toBe(true); - }); - - test("alter with removed labels triggers drop and recreate", () => { - const main = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "b", sort_order: 2 }, - { label: "c", sort_order: 3 }, - ], - comment: null, - privileges: [], - }); - const branch = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "c", sort_order: 2 }, - ], - comment: null, - privileges: [], - }); - const changes = diffEnums( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(DropEnum); - expect(changes[1]).toBeInstanceOf(CreateEnum); - }); - - test("alter with removed labels preserves comment and privileges", () => { - const main = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [ - { label: "a", sort_order: 1 }, - { label: "b", sort_order: 2 }, - ], - comment: "my enum", - privileges: [{ grantee: "role_a", privilege: "USAGE", grantable: false }], - }); - const branch = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [{ label: "a", sort_order: 1 }], - comment: "my enum", - privileges: [{ grantee: "role_a", privilege: "USAGE", grantable: false }], - }); - const changes = diffEnums( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(DropEnum); - expect(changes[1]).toBeInstanceOf(CreateEnum); - expect(changes.some((c) => c instanceof CreateCommentOnEnum)).toBe(true); - expect(changes.some((c) => c instanceof GrantEnumPrivileges)).toBe(true); - }); - - test("alter comment emits create and drop comment", () => { - const main = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [{ label: "a", sort_order: 1 }], - comment: null, - privileges: [], - }); - const withComment = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [{ label: "a", sort_order: 1 }], - comment: "my enum", - privileges: [], - }); - - const addComment = diffEnums( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment.some((c) => c instanceof CreateCommentOnEnum)).toBe(true); - - const dropComment = diffEnums( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment.some((c) => c instanceof DropCommentOnEnum)).toBe(true); - }); - - test("alter privileges emits grant, revoke, and revoke grant option", () => { - const main = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [{ label: "a", sort_order: 1 }], - comment: null, - privileges: [ - { grantee: "role_a", privilege: "USAGE", grantable: false }, - { grantee: "role_b", privilege: "USAGE", grantable: true }, - { grantee: "role_removed", privilege: "USAGE", grantable: false }, - ], - }); - const branch = new Enum({ - schema: "public", - name: "e1", - owner: "o1", - labels: [{ label: "a", sort_order: 1 }], - comment: null, - privileges: [ - { grantee: "role_a", privilege: "USAGE", grantable: true }, - { grantee: "role_b", privilege: "USAGE", grantable: false }, - { grantee: "role_new", privilege: "USAGE", grantable: false }, - ], - }); - - const changes = diffEnums( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof GrantEnumPrivileges)).toBe(true); - expect(changes.some((c) => c instanceof RevokeEnumPrivileges)).toBe(true); - expect( - changes.some((c) => c instanceof RevokeGrantOptionEnumPrivileges), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/enum/enum.diff.ts b/packages/pg-delta/src/core/objects/type/enum/enum.diff.ts deleted file mode 100644 index 3a44b6caa..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/enum.diff.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { diffObjects } from "../../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../../diff-context.ts"; -import { diffSecurityLabels } from "../../security-label.types.ts"; -import { - AlterEnumAddValue, - AlterEnumChangeOwner, -} from "./changes/enum.alter.ts"; -import { - CreateCommentOnEnum, - DropCommentOnEnum, -} from "./changes/enum.comment.ts"; -import { CreateEnum } from "./changes/enum.create.ts"; -import { DropEnum } from "./changes/enum.drop.ts"; -import { - GrantEnumPrivileges, - RevokeEnumPrivileges, - RevokeGrantOptionEnumPrivileges, -} from "./changes/enum.privilege.ts"; -import { - CreateSecurityLabelOnEnum, - DropSecurityLabelOnEnum, -} from "./changes/enum.security-label.ts"; -import type { EnumChange } from "./changes/enum.types.ts"; -import type { Enum } from "./enum.model.ts"; - -/** - * Diff two sets of enums from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The enums in the main catalog. - * @param branch - The enums in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffEnums( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): EnumChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: EnumChange[] = []; - - for (const enumId of created) { - const createdEnum = branch[enumId]; - changes.push(new CreateEnum({ enum: createdEnum })); - - // OWNER: If the enum should be owned by someone other than the current user, - // emit ALTER TYPE ... OWNER TO after creation - if (createdEnum.owner !== ctx.currentUser) { - changes.push( - new AlterEnumChangeOwner({ - enum: createdEnum, - owner: createdEnum.owner, - }), - ); - } - - if (createdEnum.comment !== null) { - changes.push(new CreateCommentOnEnum({ enum: createdEnum })); - } - for (const label of createdEnum.security_labels) { - changes.push( - new CreateSecurityLabelOnEnum({ - enum: createdEnum, - securityLabel: label, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "enum", - createdEnum.schema ?? "", - ); - const creatorFilteredDefaults = - createdEnum.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - // Filter out PUBLIC's built-in default USAGE privilege (PostgreSQL grants it automatically) - // Reference: https://www.postgresql.org/docs/17/ddl-priv.html Table 5.2 - // This prevents generating unnecessary "GRANT USAGE TO PUBLIC" statements - const desiredPrivileges = filterPublicBuiltInDefaults( - "enum", - createdEnum.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the enum owner as the reference. - const privilegeResults = diffPrivileges( - filterPublicBuiltInDefaults("enum", creatorFilteredDefaults), - desiredPrivileges, - createdEnum.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - createdEnum, - createdEnum, - "enum", - { - Grant: GrantEnumPrivileges, - Revoke: RevokeEnumPrivileges, - RevokeGrantOption: RevokeGrantOptionEnumPrivileges, - }, - ctx.version, - ) as EnumChange[]), - ); - } - - for (const enumId of dropped) { - changes.push(new DropEnum({ enum: main[enumId] })); - } - - for (const enumId of altered) { - const mainEnum = main[enumId]; - const branchEnum = branch[enumId]; - - // If labels were removed (branch is missing labels present in main), - // recreate the enum to avoid relying on unsupported DROP VALUE operations. - const removedLabels = mainEnum.labels - .map((l) => l.label) - .filter((label) => !branchEnum.labels.some((b) => b.label === label)); - if (removedLabels.length > 0) { - changes.push(new DropEnum({ enum: mainEnum })); - changes.push(new CreateEnum({ enum: branchEnum })); - - if (branchEnum.owner !== ctx.currentUser) { - changes.push( - new AlterEnumChangeOwner({ - enum: branchEnum, - owner: branchEnum.owner, - }), - ); - } - - if (branchEnum.comment !== null) { - changes.push(new CreateCommentOnEnum({ enum: branchEnum })); - } - - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "enum", - branchEnum.schema ?? "", - ); - const creatorFilteredDefaults = - branchEnum.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - const desiredPrivileges = filterPublicBuiltInDefaults( - "enum", - branchEnum.privileges, - ); - const privilegeResults = diffPrivileges( - filterPublicBuiltInDefaults("enum", creatorFilteredDefaults), - desiredPrivileges, - branchEnum.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchEnum, - branchEnum, - "enum", - { - Grant: GrantEnumPrivileges, - Revoke: RevokeEnumPrivileges, - RevokeGrantOption: RevokeGrantOptionEnumPrivileges, - }, - ctx.version, - ) as EnumChange[]), - ); - - continue; - } - - // OWNER - if (mainEnum.owner !== branchEnum.owner) { - changes.push( - new AlterEnumChangeOwner({ enum: mainEnum, owner: branchEnum.owner }), - ); - } - - // LABELS (enum values) - if (JSON.stringify(mainEnum.labels) !== JSON.stringify(branchEnum.labels)) { - const labelChanges = diffEnumLabels(mainEnum, branchEnum); - changes.push(...labelChanges); - } - - // COMMENT - if (mainEnum.comment !== branchEnum.comment) { - if (branchEnum.comment === null) { - changes.push(new DropCommentOnEnum({ enum: mainEnum })); - } else { - changes.push(new CreateCommentOnEnum({ enum: branchEnum })); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnEnum | DropSecurityLabelOnEnum - >( - mainEnum.security_labels, - branchEnum.security_labels, - (securityLabel) => - new CreateSecurityLabelOnEnum({ - enum: branchEnum, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnEnum({ - enum: mainEnum, - securityLabel, - }), - ), - ); - - // PRIVILEGES - // Filter out PUBLIC's built-in default USAGE privilege from main catalog - // (PostgreSQL grants it automatically, so we shouldn't compare it) - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "enum", - mainEnum.privileges, - ); - // Filter out PUBLIC's built-in default USAGE privilege from branch catalog - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "enum", - branchEnum.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchEnum.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchEnum, - mainEnum, - "enum", - { - Grant: GrantEnumPrivileges, - Revoke: RevokeEnumPrivileges, - RevokeGrantOption: RevokeGrantOptionEnumPrivileges, - }, - ctx.version, - ) as EnumChange[]), - ); - - // Note: Enum renaming would also use ALTER TYPE ... RENAME TO ... - // But since our Enum model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - } - - return changes; -} - -/** - * Diff enum labels to determine what ALTER TYPE statements are needed. - * This implementation properly handles enum value positioning using sort_order. - * Note: We cannot reliably detect renames, so we only handle additions. - */ -function diffEnumLabels(mainEnum: Enum, branchEnum: Enum): EnumChange[] { - const changes: EnumChange[] = []; - - // Create maps for efficient lookup - const mainLabelMap = new Map( - mainEnum.labels.map((label) => [label.label, label.sort_order]), - ); - const branchLabelMap = new Map( - branchEnum.labels.map((label) => [label.label, label.sort_order]), - ); - - // Find added values (values in branch but not in main) - const addedValues = Array.from(branchLabelMap.keys()).filter( - (label) => !mainLabelMap.has(label), - ); - - // Maintain a working list of labels (by name) to calculate correct BEFORE/AFTER - // anchors as we simulate applying the additions in order. - const branchOrdered = [...branchEnum.labels].sort( - (a, b) => a.sort_order - b.sort_order, - ); - const workingLabels = [...mainEnum.labels].map((l) => l.label); - - for (const newValue of addedValues) { - const branchIdx = branchOrdered.findIndex((l) => l.label === newValue); - if (branchIdx === -1) continue; - - const prevBranch = branchOrdered[branchIdx - 1]?.label; - const nextBranch = branchOrdered[branchIdx + 1]?.label; - - let position: { before?: string; after?: string } | undefined; - - // Prefer AFTER when prevBranch exists in workingLabels (more natural for sequential additions) - // Use BEFORE only when we need to insert before the first value or when prevBranch doesn't exist - if (prevBranch && workingLabels.includes(prevBranch)) { - position = { after: prevBranch }; - // Insert after the previous label in our working list - const prevIdx = workingLabels.indexOf(prevBranch); - workingLabels.splice(prevIdx + 1, 0, newValue); - } else if (nextBranch && workingLabels.includes(nextBranch)) { - // Insert before nextBranch when prevBranch doesn't exist (e.g., adding at beginning) - position = { before: nextBranch }; - const nextIdx = workingLabels.indexOf(nextBranch); - workingLabels.splice(nextIdx, 0, newValue); - } else if (nextBranch) { - // nextBranch exists but not in workingLabels yet (shouldn't happen in normal flow) - position = { before: nextBranch }; - workingLabels.push(newValue); - } else { - // Fallback: append to the end - position = { after: workingLabels[workingLabels.length - 1] }; - workingLabels.push(newValue); - } - - changes.push(new AlterEnumAddValue({ enum: mainEnum, newValue, position })); - } - - // Complex changes (removals, resorting) are currently not auto-handled. - // We intentionally avoid emitting drop+create to prevent data loss. - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/type/enum/enum.model.ts b/packages/pg-delta/src/core/objects/type/enum/enum.model.ts deleted file mode 100644 index 5d7f862b2..000000000 --- a/packages/pg-delta/src/core/objects/type/enum/enum.model.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../../base.privilege-diff.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../../security-label.types.ts"; - -const enumLabelSchema = z.object({ - sort_order: z.number(), - label: z.string(), -}); - -/** - * All properties exposed by CREATE TYPE AS ENUM statement are included in diff output. - * https://www.postgresql.org/docs/current/sql-createtype.html - * - * ALTER TYPE statement can be generated for changes to the following properties: - * - name, owner, schema, add or rename value - * https://www.postgresql.org/docs/current/sql-altertype.html - * - * Sort order of values may be negative or fractional. - * https://www.postgresql.org/docs/current/catalog-pg-enum.html - * - * Type ACL will be supported separately. - * https://www.postgresql.org/docs/current/ddl-priv.html - */ -const enumPropsSchema = z.object({ - schema: z.string(), - name: z.string(), - owner: z.string(), - labels: z.array(enumLabelSchema), - comment: z.string().nullable(), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -type EnumPrivilegeProps = PrivilegeProps; -export type EnumProps = z.infer; - -export class Enum extends BasePgModel { - public readonly schema: EnumProps["schema"]; - public readonly name: EnumProps["name"]; - public readonly owner: EnumProps["owner"]; - public readonly labels: EnumProps["labels"]; - public readonly comment: EnumProps["comment"]; - public readonly privileges: EnumPrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: EnumProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.owner = props.owner; - this.labels = props.labels; - this.comment = props.comment; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `type:${string}` { - return `type:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - const orderedLabels = [...this.labels] - .map((label) => ({ ...label })) - .sort( - (a, b) => a.sort_order - b.sort_order || a.label.localeCompare(b.label), - ); - // Normalize sort_order to a deterministic 1..N sequence to avoid float gaps - // that occur when adding multiple enum values with AFTER clauses. - const labels = orderedLabels.map((label, idx) => ({ - sort_order: idx + 1, - label: label.label, - })); - - const privileges = [...this.privileges] - .map((priv) => ({ - ...priv, - columns: priv.columns ? [...priv.columns].sort() : priv.columns, - })) - .sort((a, b) => { - const byGrantee = a.grantee.localeCompare(b.grantee); - if (byGrantee !== 0) return byGrantee; - const byPriv = a.privilege.localeCompare(b.privilege); - if (byPriv !== 0) return byPriv; - if (a.grantable !== b.grantable) return a.grantable ? 1 : -1; - const colsA = (a.columns ?? []).join(","); - const colsB = (b.columns ?? []).join(","); - return colsA.localeCompare(colsB); - }); - - return { - owner: this.owner, - labels, - comment: this.comment, - privileges, - security_labels: this.security_labels, - }; - } -} - -export async function extractEnums(pool: Pool): Promise { - const { rows: enumRows } = await pool.query<{ - schema: string; - name: string; - sort_order: number; - label: string; - owner: string; - comment: string | null; - privileges: { grantee: string; privilege: string; grantable: boolean }[]; - security_labels: { provider: string; label: string }[]; - }>(sql` -with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_type'::regclass -) -select - t.typnamespace::regnamespace::text as schema, - quote_ident(t.typname) as name, - e.enumsortorder as sort_order, - e.enumlabel as label, - t.typowner::regrole::text as owner, - obj_description(t.oid, 'pg_type') as comment, - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(COALESCE(t.typacl, acldefault('T', t.typowner))) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = t.oid - and sl.classoid = 'pg_type'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from - pg_catalog.pg_enum e - inner join pg_catalog.pg_type t on t.oid = e.enumtypid - left outer join extension_oids ext on t.oid = ext.objid - where not t.typnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and ext.objid is null -order by - 1, 2, 3 - `); - const grouped: Record< - string, - { - schema: string; - name: string; - owner: string; - labels: { sort_order: number; label: string }[]; - comment: string | null; - privileges: { - grantee: string; - privilege: string; - grantable: boolean; - }[]; - security_labels: { provider: string; label: string }[]; - } - > = {}; - for (const e of enumRows) { - const key = `${e.schema}.${e.name}`; - if (!grouped[key]) { - grouped[key] = { - schema: e.schema, - name: e.name, - owner: e.owner, - labels: [], - comment: e.comment, - privileges: e.privileges, - security_labels: e.security_labels, - }; - } - grouped[key].labels.push({ sort_order: e.sort_order, label: e.label }); - } - // Validate and parse each enum using the Zod schema - const validatedEnums = Object.values(grouped).map((e) => - enumPropsSchema.parse(e), - ); - return validatedEnums.map((e: EnumProps) => new Enum(e)); -} diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.alter.test.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.alter.test.ts deleted file mode 100644 index 54fa09e98..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.alter.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Range, type RangeProps } from "../range.model.ts"; -import { AlterRangeChangeOwner } from "./range.alter.ts"; - -describe.concurrent("range", () => { - test("change owner", async () => { - const base: RangeProps = { - schema: "public", - name: "ts_custom", - owner: "o1", - subtype_schema: "pg_catalog", - subtype_str: "int4", - collation: null, - canonical_function_schema: null, - canonical_function_name: null, - subtype_diff_schema: null, - subtype_diff_name: null, - subtype_opclass_schema: null, - subtype_opclass_name: null, - comment: null, - privileges: [], - }; - const main = new Range(base); - const change = new AlterRangeChangeOwner({ range: main, owner: "o2" }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("ALTER TYPE public.ts_custom OWNER TO o2"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.alter.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.alter.ts deleted file mode 100644 index 673c08013..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.alter.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { Range } from "../range.model.ts"; -import { AlterRangeChange } from "./range.base.ts"; - -/** - * Alter a range type. - * - * @see https://www.postgresql.org/docs/17/sql-altertype.html - * - * Synopsis - * ```sql - * ALTER TYPE name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ALTER TYPE name RENAME TO new_name - * ALTER TYPE name SET SCHEMA new_schema - * ``` - */ - -export type AlterRange = AlterRangeChangeOwner; - -/** - * ALTER TYPE ... OWNER TO ... - */ -export class AlterRangeChangeOwner extends AlterRangeChange { - public readonly range: Range; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { range: Range; owner: string }) { - super(); - this.range = props.range; - this.owner = props.owner; - } - - get requires() { - return [this.range.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER TYPE", - `${this.range.schema}.${this.range.name}`, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -/** - * Replace a range type by dropping and recreating it. - * This is used when properties that cannot be altered via ALTER TYPE change. - */ -// NOTE: ReplaceRange removed. Non-alterable changes are emitted as Drop + Create in range.diff.ts. diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.base.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.base.ts deleted file mode 100644 index 80e2b5bd0..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../../base.change.ts"; -import type { Range } from "../range.model.ts"; - -abstract class BaseRangeChange extends BaseChange { - abstract readonly range: Range; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "range" as const; -} - -export abstract class CreateRangeChange extends BaseRangeChange { - readonly operation = "create" as const; -} - -export abstract class AlterRangeChange extends BaseRangeChange { - readonly operation = "alter" as const; -} - -export abstract class DropRangeChange extends BaseRangeChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.comment.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.comment.ts deleted file mode 100644 index d759633aa..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.comment.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../../base.change.ts"; -import { stableId } from "../../../utils.ts"; -import type { Range } from "../range.model.ts"; -import { CreateRangeChange, DropRangeChange } from "./range.base.ts"; - -/** - * Create/drop comments on range types. - */ - -export type CommentRange = CreateCommentOnRange | DropCommentOnRange; - -export class CreateCommentOnRange extends CreateRangeChange { - public readonly range: Range; - public readonly scope = "comment" as const; - - constructor(props: { range: Range }) { - super(); - this.range = props.range; - } - - get creates() { - return [stableId.comment(this.range.stableId)]; - } - - get requires() { - return [this.range.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TYPE", - `${this.range.schema}.${this.range.name}`, - "IS", - // biome-ignore lint/style/noNonNullAssertion: range comment is not nullable in this case - quoteLiteral(this.range.comment!), - ].join(" "); - } -} - -export class DropCommentOnRange extends DropRangeChange { - public readonly range: Range; - public readonly scope = "comment" as const; - - constructor(props: { range: Range }) { - super(); - this.range = props.range; - } - - get drops() { - return [stableId.comment(this.range.stableId)]; - } - - get requires() { - return [stableId.comment(this.range.stableId), this.range.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON TYPE", - `${this.range.schema}.${this.range.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.create.test.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.create.test.ts deleted file mode 100644 index 16e459549..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.create.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Range } from "../range.model.ts"; -import { CreateRange } from "./range.create.ts"; - -describe("range", () => { - test("create minimal", async () => { - const r = new Range({ - schema: "public", - name: "tsrange_custom", - owner: "owner1", - subtype_schema: "pg_catalog", - subtype_str: "int4", - collation: null, - canonical_function_schema: null, - canonical_function_name: null, - subtype_diff_schema: null, - subtype_diff_name: null, - subtype_opclass_schema: null, - subtype_opclass_name: null, - comment: null, - privileges: [], - }); - const change = new CreateRange({ range: r }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "CREATE TYPE public.tsrange_custom AS RANGE (SUBTYPE = int4)", - ); - }); - - test("create with options", async () => { - const r = new Range({ - schema: "public", - name: "daterange_custom", - owner: "owner1", - subtype_schema: "pg_catalog", - subtype_str: "date", - collation: '"en_US"', - canonical_function_schema: "public", - canonical_function_name: "canon_fn", - subtype_diff_schema: "public", - subtype_diff_name: "diff_fn", - subtype_opclass_schema: "public", - subtype_opclass_name: "date_ops", - comment: null, - privileges: [], - }); - const change = new CreateRange({ range: r }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - 'CREATE TYPE public.daterange_custom AS RANGE (SUBTYPE = date, SUBTYPE_OPCLASS = public.date_ops, COLLATION = "en_US", CANONICAL = public.canon_fn, SUBTYPE_DIFF = public.diff_fn)', - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.create.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.create.ts deleted file mode 100644 index d305e131a..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.create.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { - isUserDefinedTypeSchema, - parseProcedureReference, - parseTypeString, - stableId, -} from "../../../utils.ts"; -import type { Range } from "../range.model.ts"; -import { CreateRangeChange } from "./range.base.ts"; - -/** - * Create a range type. - * - * @see https://www.postgresql.org/docs/17/sql-createtype.html - * - * Synopsis - * ```sql - * CREATE TYPE name AS RANGE ( - * SUBTYPE = subtype - * [ , SUBTYPE_OPCLASS = subtype_operator_class ] - * [ , COLLATION = collation ] - * [ , CANONICAL = canonical_function ] - * [ , SUBTYPE_DIFF = subtype_diff_function ] - * ) - * ``` - * - * Notes - * - Only non-default options are emitted in the generated SQL. - */ -export class CreateRange extends CreateRangeChange { - public readonly range: Range; - public readonly scope = "object" as const; - - constructor(props: { range: Range }) { - super(); - this.range = props.range; - } - - get creates() { - return [this.range.stableId]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.range.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.range.owner)); - - // Subtype dependency (if user-defined) - if ( - this.range.subtype_schema && - isUserDefinedTypeSchema(this.range.subtype_schema) - ) { - // subtype_str is the type name without schema (e.g., "integer", "text") - // subtype_schema is the schema name - dependencies.add( - stableId.type(this.range.subtype_schema, this.range.subtype_str), - ); - } - - // Canonical function dependency - if ( - this.range.canonical_function_schema && - this.range.canonical_function_name - ) { - const procRef = parseProcedureReference( - `${this.range.canonical_function_schema}.${this.range.canonical_function_name}()`, - ); - if (procRef) { - dependencies.add(stableId.procedure(procRef.schema, procRef.name)); - } - } - - // Subtype diff function dependency - if (this.range.subtype_diff_schema && this.range.subtype_diff_name) { - const procRef = parseProcedureReference( - `${this.range.subtype_diff_schema}.${this.range.subtype_diff_name}()`, - ); - if (procRef) { - dependencies.add(stableId.procedure(procRef.schema, procRef.name)); - } - } - - // Collation dependency (if non-default and user-defined) - if (this.range.collation) { - const unquotedCollation = this.range.collation.replace(/^"|"$/g, ""); - const collationParts = unquotedCollation.split("."); - if (collationParts.length === 2) { - const [collationSchema, collationName] = collationParts; - if (isUserDefinedTypeSchema(collationSchema)) { - dependencies.add(stableId.collation(collationSchema, collationName)); - } - } - } - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const name = `${this.range.schema}.${this.range.name}`; - const prefix: string = ["CREATE TYPE", name, "AS RANGE"].join(" "); - - const opts: string[] = []; - - // Required subtype (format_type may already return schema-qualified name) - const alreadyQualified = parseTypeString(this.range.subtype_str); - const subtypeQualified = - !alreadyQualified && - this.range.subtype_schema && - this.range.subtype_schema !== "pg_catalog" - ? `${this.range.subtype_schema}.${this.range.subtype_str}` - : this.range.subtype_str; - opts.push(`SUBTYPE = ${subtypeQualified}`); - - // Optional opclass - if (this.range.subtype_opclass_name) { - const opclassQualified = - this.range.subtype_opclass_schema && - this.range.subtype_opclass_schema !== "pg_catalog" - ? `${this.range.subtype_opclass_schema}.${this.range.subtype_opclass_name}` - : this.range.subtype_opclass_name; - opts.push(`SUBTYPE_OPCLASS = ${opclassQualified}`); - } - - // Optional collation - if (this.range.collation) { - opts.push(`COLLATION = ${this.range.collation}`); - } - - // Optional canonical function - if (this.range.canonical_function_name) { - const canonQualified = - this.range.canonical_function_schema && - this.range.canonical_function_schema !== "pg_catalog" - ? `${this.range.canonical_function_schema}.${this.range.canonical_function_name}` - : this.range.canonical_function_name; - opts.push(`CANONICAL = ${canonQualified}`); - } - - // Optional subtype diff function - if (this.range.subtype_diff_name) { - const diffQualified = - this.range.subtype_diff_schema && - this.range.subtype_diff_schema !== "pg_catalog" - ? `${this.range.subtype_diff_schema}.${this.range.subtype_diff_name}` - : this.range.subtype_diff_name; - opts.push(`SUBTYPE_DIFF = ${diffQualified}`); - } - - const body = `(${opts.join(", ")})`; - return `${prefix} ${body}`; - } -} diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.drop.test.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.drop.test.ts deleted file mode 100644 index c268435d9..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.drop.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../../test-utils/assert-valid-sql.ts"; -import { Range } from "../range.model.ts"; -import { DropRange } from "./range.drop.ts"; - -describe("range", () => { - test("drop", async () => { - const r = new Range({ - schema: "public", - name: "tsrange_custom", - owner: "owner1", - subtype_schema: "pg_catalog", - subtype_str: "int4", - collation: null, - canonical_function_schema: null, - canonical_function_name: null, - subtype_diff_schema: null, - subtype_diff_name: null, - subtype_opclass_schema: null, - subtype_opclass_name: null, - comment: null, - privileges: [], - }); - const change = new DropRange({ range: r }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe("DROP TYPE public.tsrange_custom"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.drop.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.drop.ts deleted file mode 100644 index 9dfcac0cf..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.drop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import type { Range } from "../range.model.ts"; -import { DropRangeChange } from "./range.base.ts"; - -/** - * Drop a range type. - * - * @see https://www.postgresql.org/docs/17/sql-droptype.html - * - * Synopsis - * ```sql - * DROP TYPE [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] - * ``` - */ -export class DropRange extends DropRangeChange { - public readonly range: Range; - public readonly scope = "object" as const; - - constructor(props: { range: Range }) { - super(); - this.range = props.range; - } - - get drops() { - return [this.range.stableId]; - } - - get requires() { - return [this.range.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP TYPE", `${this.range.schema}.${this.range.name}`].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.privilege.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.privilege.ts deleted file mode 100644 index ea01a5a25..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.privilege.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../../base.privilege.ts"; -import { stableId } from "../../../utils.ts"; -import type { Range } from "../range.model.ts"; -import { AlterRangeChange } from "./range.base.ts"; - -export type RangePrivilege = - | GrantRangePrivileges - | RevokeRangePrivileges - | RevokeGrantOptionRangePrivileges; - -/** - * Grant privileges on a range type. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { USAGE | ALL [ PRIVILEGES ] } - * ON TYPE type_name [, ...] - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantRangePrivileges extends AlterRangeChange { - public readonly range: Range; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - range: Range; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.range = props.range; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.range.stableId, this.grantee)]; - } - - get requires() { - return [this.range.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantRangePrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("TYPE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("TYPE", list, this.version); - const typeName = `${this.range.schema}.${this.range.name}`; - return `GRANT ${privSql} ${kindPrefix} ${typeName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a range type. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { USAGE | ALL [ PRIVILEGES ] } - * ON TYPE type_name [, ...] - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeRangePrivileges extends AlterRangeChange { - public readonly range: Range; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - range: Range; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - version?: number; - }) { - super(); - this.range = props.range; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.range.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.range.stableId, this.grantee), - this.range.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("TYPE"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("TYPE", list, this.version); - const typeName = `${this.range.schema}.${this.range.name}`; - return `REVOKE ${privSql} ${kindPrefix} ${typeName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a range type. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionRangePrivileges extends AlterRangeChange { - public readonly range: Range; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - range: Range; - grantee: string; - privilegeNames: string[]; - version?: number; - }) { - super(); - this.range = props.range; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.range.stableId, this.grantee), - this.range.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("TYPE"); - const privSql = formatObjectPrivilegeList( - "TYPE", - this.privilegeNames, - this.version, - ); - const typeName = `${this.range.schema}.${this.range.name}`; - return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${typeName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.security-label.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.security-label.ts deleted file mode 100644 index 6ecdf41bf..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.security-label.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { quoteLiteral } from "../../../base.change.ts"; -import type { SecurityLabelProps } from "../../../security-label.types.ts"; -import { stableId } from "../../../utils.ts"; -import type { Range } from "../range.model.ts"; -import { CreateRangeChange, DropRangeChange } from "./range.base.ts"; - -export type SecurityLabelRange = - | CreateSecurityLabelOnRange - | DropSecurityLabelOnRange; - -export class CreateSecurityLabelOnRange extends CreateRangeChange { - public readonly range: Range; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { range: Range; securityLabel: SecurityLabelProps }) { - super(); - this.range = props.range; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel(this.range.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [this.range.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON TYPE", - `${this.range.schema}.${this.range.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnRange extends DropRangeChange { - public readonly range: Range; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { range: Range; securityLabel: SecurityLabelProps }) { - super(); - this.range = props.range; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel(this.range.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [ - stableId.securityLabel(this.range.stableId, this.securityLabel.provider), - this.range.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON TYPE", - `${this.range.schema}.${this.range.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/type/range/changes/range.types.ts b/packages/pg-delta/src/core/objects/type/range/changes/range.types.ts deleted file mode 100644 index 6812d88a9..000000000 --- a/packages/pg-delta/src/core/objects/type/range/changes/range.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterRange } from "./range.alter.ts"; -import type { CommentRange } from "./range.comment.ts"; -import type { CreateRange } from "./range.create.ts"; -import type { DropRange } from "./range.drop.ts"; -import type { RangePrivilege } from "./range.privilege.ts"; -import type { SecurityLabelRange } from "./range.security-label.ts"; - -/** Union of all range-related change variants (`objectType: "range"`). @category Change Types */ -export type RangeChange = - | AlterRange - | CommentRange - | CreateRange - | DropRange - | RangePrivilege - | SecurityLabelRange; diff --git a/packages/pg-delta/src/core/objects/type/range/range.diff.test.ts b/packages/pg-delta/src/core/objects/type/range/range.diff.test.ts deleted file mode 100644 index 5ca6bf205..000000000 --- a/packages/pg-delta/src/core/objects/type/range/range.diff.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../../base.default-privileges.ts"; -import { AlterRangeChangeOwner } from "./changes/range.alter.ts"; -import { - CreateCommentOnRange, - DropCommentOnRange, -} from "./changes/range.comment.ts"; -import { CreateRange } from "./changes/range.create.ts"; -import { DropRange } from "./changes/range.drop.ts"; -import { - GrantRangePrivileges, - RevokeGrantOptionRangePrivileges, - RevokeRangePrivileges, -} from "./changes/range.privilege.ts"; -import { diffRanges } from "./range.diff.ts"; -import { Range, type RangeProps } from "./range.model.ts"; - -const base: RangeProps = { - schema: "public", - name: "ts_custom", - owner: "o1", - subtype_schema: "pg_catalog", - subtype_str: "int4", - collation: null, - canonical_function_schema: null, - canonical_function_name: null, - subtype_diff_schema: null, - subtype_diff_name: null, - subtype_opclass_schema: null, - subtype_opclass_name: null, - comment: null, - privileges: [], -}; - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("range.diff", () => { - test("create and drop", () => { - const r = new Range(base); - const created = diffRanges(testContext, {}, { [r.stableId]: r }); - expect(created[0]).toBeInstanceOf(CreateRange); - const dropped = diffRanges(testContext, { [r.stableId]: r }, {}); - expect(dropped[0]).toBeInstanceOf(DropRange); - }); - - test("alter owner", () => { - const main = new Range(base); - const branch = new Range({ ...base, owner: "o2" }); - const changes = diffRanges( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterRangeChangeOwner); - }); - - test("drop and create when non-alterable property changes", () => { - const main = new Range(base); - const branch = new Range({ - ...base, - subtype_schema: "pg_catalog", - subtype_str: "text", - collation: "en_US", - }); - const changes = diffRanges( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropRange); - expect(changes[1]).toBeInstanceOf(CreateRange); - }); - - test("create with comment emits CreateCommentOnRange", () => { - const r = new Range({ ...base, comment: "my range" }); - const changes = diffRanges(testContext, {}, { [r.stableId]: r }); - expect(changes[0]).toBeInstanceOf(CreateRange); - expect(changes.some((c) => c instanceof CreateCommentOnRange)).toBe(true); - }); - - test("create with privileges emits grant changes", () => { - const r = new Range({ - ...base, - privileges: [{ grantee: "role_a", privilege: "USAGE", grantable: false }], - }); - const changes = diffRanges(testContext, {}, { [r.stableId]: r }); - expect(changes[0]).toBeInstanceOf(CreateRange); - expect(changes.some((c) => c instanceof GrantRangePrivileges)).toBe(true); - }); - - test("alter comment emits create and drop comment", () => { - const main = new Range(base); - const withComment = new Range({ ...base, comment: "my range" }); - - const addComment = diffRanges( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment.some((c) => c instanceof CreateCommentOnRange)).toBe( - true, - ); - - const dropComment = diffRanges( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment.some((c) => c instanceof DropCommentOnRange)).toBe(true); - }); - - test("alter privileges emits grant, revoke, and revoke grant option", () => { - const main = new Range({ - ...base, - privileges: [ - { grantee: "role_a", privilege: "USAGE", grantable: false }, - { grantee: "role_b", privilege: "USAGE", grantable: true }, - { grantee: "role_removed", privilege: "USAGE", grantable: false }, - ], - }); - const branch = new Range({ - ...base, - privileges: [ - { grantee: "role_a", privilege: "USAGE", grantable: true }, - { grantee: "role_b", privilege: "USAGE", grantable: false }, - { grantee: "role_new", privilege: "USAGE", grantable: false }, - ], - }); - - const changes = diffRanges( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof GrantRangePrivileges)).toBe(true); - expect(changes.some((c) => c instanceof RevokeRangePrivileges)).toBe(true); - expect( - changes.some((c) => c instanceof RevokeGrantOptionRangePrivileges), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/type/range/range.diff.ts b/packages/pg-delta/src/core/objects/type/range/range.diff.ts deleted file mode 100644 index 0a6565376..000000000 --- a/packages/pg-delta/src/core/objects/type/range/range.diff.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { diffObjects } from "../../base.diff.ts"; -import { - diffPrivileges, - emitObjectPrivilegeChanges, - filterPublicBuiltInDefaults, -} from "../../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../../diff-context.ts"; -import { diffSecurityLabels } from "../../security-label.types.ts"; -import { hasNonAlterableChanges } from "../../utils.ts"; -import { AlterRangeChangeOwner } from "./changes/range.alter.ts"; -import { - CreateCommentOnRange, - DropCommentOnRange, -} from "./changes/range.comment.ts"; -import { CreateRange } from "./changes/range.create.ts"; -import { DropRange } from "./changes/range.drop.ts"; -import { - GrantRangePrivileges, - RevokeGrantOptionRangePrivileges, - RevokeRangePrivileges, -} from "./changes/range.privilege.ts"; -import { - CreateSecurityLabelOnRange, - DropSecurityLabelOnRange, -} from "./changes/range.security-label.ts"; -import type { RangeChange } from "./changes/range.types.ts"; -import type { Range } from "./range.model.ts"; - -/** - * Diff two sets of range types from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The ranges in the main catalog. - * @param branch - The ranges in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffRanges( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): RangeChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: RangeChange[] = []; - - for (const id of created) { - const createdRange = branch[id]; - changes.push(new CreateRange({ range: createdRange })); - - // OWNER: If the range type should be owned by someone other than the current user, - // emit ALTER TYPE ... OWNER TO after creation - if (createdRange.owner !== ctx.currentUser) { - changes.push( - new AlterRangeChangeOwner({ - range: createdRange, - owner: createdRange.owner, - }), - ); - } - - if (createdRange.comment !== null) { - changes.push(new CreateCommentOnRange({ range: createdRange })); - } - for (const label of createdRange.security_labels) { - changes.push( - new CreateSecurityLabelOnRange({ - range: createdRange, - securityLabel: label, - }), - ); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "range", - createdRange.schema ?? "", - ); - const creatorFilteredDefaults = - createdRange.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - // Filter out PUBLIC's built-in default USAGE privilege (PostgreSQL grants it automatically) - // Reference: https://www.postgresql.org/docs/17/ddl-priv.html Table 5.2 - // This prevents generating unnecessary "GRANT USAGE TO PUBLIC" statements - const desiredPrivileges = filterPublicBuiltInDefaults( - "range", - createdRange.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the range owner as the reference. - const privilegeResults = diffPrivileges( - filterPublicBuiltInDefaults("range", creatorFilteredDefaults), - desiredPrivileges, - createdRange.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - createdRange, - createdRange, - "range", - { - Grant: GrantRangePrivileges, - Revoke: RevokeRangePrivileges, - RevokeGrantOption: RevokeGrantOptionRangePrivileges, - }, - ctx.version, - ) as RangeChange[]), - ); - } - - for (const id of dropped) { - changes.push(new DropRange({ range: main[id] })); - } - - for (const id of altered) { - const mainRange = main[id]; - const branchRange = branch[id]; - - const NON_ALTERABLE_FIELDS: Array = [ - // Changes to these require DROP + CREATE - "subtype_schema", - "subtype_str", - "collation", - "canonical_function_schema", - "canonical_function_name", - "subtype_diff_schema", - "subtype_diff_name", - "subtype_opclass_schema", - "subtype_opclass_name", - ]; - - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainRange, - branchRange, - NON_ALTERABLE_FIELDS, - ); - - if (nonAlterablePropsChanged) { - changes.push( - new DropRange({ range: mainRange }), - new CreateRange({ range: branchRange }), - ); - } else { - if (mainRange.owner !== branchRange.owner) { - changes.push( - new AlterRangeChangeOwner({ - range: mainRange, - owner: branchRange.owner, - }), - ); - } - - // COMMENT - if (mainRange.comment !== branchRange.comment) { - if (branchRange.comment === null) { - changes.push(new DropCommentOnRange({ range: mainRange })); - } else { - changes.push(new CreateCommentOnRange({ range: branchRange })); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnRange | DropSecurityLabelOnRange - >( - mainRange.security_labels, - branchRange.security_labels, - (securityLabel) => - new CreateSecurityLabelOnRange({ - range: branchRange, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnRange({ - range: mainRange, - securityLabel, - }), - ), - ); - - // PRIVILEGES - // Filter out PUBLIC's built-in default USAGE privilege from main catalog - // (PostgreSQL grants it automatically, so we shouldn't compare it) - const mainPrivilegesFiltered = filterPublicBuiltInDefaults( - "range", - mainRange.privileges, - ); - // Filter out PUBLIC's built-in default USAGE privilege from branch catalog - const branchPrivilegesFiltered = filterPublicBuiltInDefaults( - "range", - branchRange.privileges, - ); - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainPrivilegesFiltered, - branchPrivilegesFiltered, - branchRange.owner, - ); - - changes.push( - ...(emitObjectPrivilegeChanges( - privilegeResults, - branchRange, - mainRange, - "range", - { - Grant: GrantRangePrivileges, - Revoke: RevokeRangePrivileges, - RevokeGrantOption: RevokeGrantOptionRangePrivileges, - }, - ctx.version, - ) as RangeChange[]), - ); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/type/range/range.model.ts b/packages/pg-delta/src/core/objects/type/range/range.model.ts deleted file mode 100644 index ce7b32687..000000000 --- a/packages/pg-delta/src/core/objects/type/range/range.model.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { BasePgModel } from "../../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../../base.privilege-diff.ts"; -import { - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../../security-label.types.ts"; - -const rangePropsSchema = z.object({ - schema: z.string(), - name: z.string(), - owner: z.string(), - comment: z.string().nullable(), - - // Subtype information - subtype_schema: z.string(), - subtype_str: z.string(), - - // Optional, only present when non-default relative to subtype - collation: z.string().nullable(), - - // Canonical and diff functions when present (non-default) - canonical_function_schema: z.string().nullable(), - canonical_function_name: z.string().nullable(), - subtype_diff_schema: z.string().nullable(), - subtype_diff_name: z.string().nullable(), - - // Optional: print only when non-default (see extractor logic) - subtype_opclass_schema: z.string().nullable(), - subtype_opclass_name: z.string().nullable(), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -type RangePrivilegeProps = PrivilegeProps; -export type RangeProps = z.infer; - -export class Range extends BasePgModel { - public readonly schema: RangeProps["schema"]; - public readonly name: RangeProps["name"]; - public readonly owner: RangeProps["owner"]; - public readonly comment: RangeProps["comment"]; - - public readonly subtype_schema: RangeProps["subtype_schema"]; - public readonly subtype_str: RangeProps["subtype_str"]; - - public readonly collation: RangeProps["collation"]; - - public readonly canonical_function_schema: RangeProps["canonical_function_schema"]; - public readonly canonical_function_name: RangeProps["canonical_function_name"]; - public readonly subtype_diff_schema: RangeProps["subtype_diff_schema"]; - public readonly subtype_diff_name: RangeProps["subtype_diff_name"]; - - public readonly subtype_opclass_schema: RangeProps["subtype_opclass_schema"]; - public readonly subtype_opclass_name: RangeProps["subtype_opclass_name"]; - public readonly privileges: RangePrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: RangeProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.owner = props.owner; - this.comment = props.comment; - this.subtype_schema = props.subtype_schema; - this.subtype_str = props.subtype_str; - this.collation = props.collation; - this.canonical_function_schema = props.canonical_function_schema; - this.canonical_function_name = props.canonical_function_name; - this.subtype_diff_schema = props.subtype_diff_schema; - this.subtype_diff_name = props.subtype_diff_name; - this.subtype_opclass_schema = props.subtype_opclass_schema; - this.subtype_opclass_name = props.subtype_opclass_name; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `type:${string}` { - return `type:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - owner: this.owner, - subtype_schema: this.subtype_schema, - subtype_str: this.subtype_str, - collation: this.collation, - canonical_function_schema: this.canonical_function_schema, - canonical_function_name: this.canonical_function_name, - subtype_diff_schema: this.subtype_diff_schema, - subtype_diff_name: this.subtype_diff_name, - subtype_opclass_schema: this.subtype_opclass_schema, - subtype_opclass_name: this.subtype_opclass_name, - comment: this.comment, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } -} - -/** - * Extract all range types from the database. - * - * We intentionally capture only non-default options for CREATE TYPE AS RANGE: - * - SUBTYPE is required and always present - * - SUBTYPE_OPCLASS is included only when it differs from the default btree opclass - * - COLLATION is included only when it differs from the subtype's typcollation - * - CANONICAL and SUBTYPE_DIFF are included only when set - * - MULTIRANGE_TYPE_NAME is not included (we currently do not attempt to infer - * whether it differs from the default auto-generated name) - */ -export async function extractRanges(pool: Pool): Promise { - const { rows } = await pool.query(sql` -with extension_oids as ( - select objid from pg_depend d - where d.refclassid = 'pg_extension'::regclass and d.classid = 'pg_type'::regclass -), default_btree_opclass as ( - -- For each input type, find its default btree operator class - select oc2.opcintype as type_oid, oc2.oid as opclass_oid - from pg_opclass oc2 - join pg_am am on am.oid = oc2.opcmethod and am.amname = 'btree' - where oc2.opcdefault -) -select - -- range type identity - t.typnamespace::regnamespace::text as schema, - quote_ident(t.typname) as name, - t.typowner::regrole::text as owner, - obj_description(t.oid, 'pg_type') as comment, - - -- subtype info - subt.typnamespace::regnamespace::text as subtype_schema, - format_type(r.rngsubtype, 0) as subtype_str, - - -- include collation only if not default - case when r.rngcollation is not null and r.rngcollation <> 0 and r.rngcollation <> subt.typcollation then quote_ident(c.collname) else null end as collation, - - -- include canonical/subtype_diff when set - case when r.rngcanonical <> 0 then pn_subcanon.nspname::regnamespace::text else null end as canonical_function_schema, - case when r.rngcanonical <> 0 then quote_ident(p_subcanon.proname) else null end as canonical_function_name, - case when r.rngsubdiff <> 0 then pn_subdiff.nspname::regnamespace::text else null end as subtype_diff_schema, - case when r.rngsubdiff <> 0 then quote_ident(p_subdiff.proname) else null end as subtype_diff_name, - - -- include opclass only when not default for btree - case when r.rngsubopc is not null and r.rngsubopc <> 0 and r.rngsubopc <> dbo.opclass_oid then opc.opcnamespace::regnamespace::text else null end as subtype_opclass_schema, - case when r.rngsubopc is not null and r.rngsubopc <> 0 and r.rngsubopc <> dbo.opclass_oid then quote_ident(opc.opcname) else null end as subtype_opclass_name, - - -- privileges - coalesce( - ( - select json_agg( - json_build_object( - 'grantee', case when x.grantee = 0 then 'PUBLIC' else x.grantee::regrole::text end, - 'privilege', x.privilege_type, - 'grantable', x.is_grantable - ) - order by x.grantee, x.privilege_type - ) - from lateral aclexplode(COALESCE(t.typacl, acldefault('T', t.typowner))) as x(grantor, grantee, privilege_type, is_grantable) - ), '[]' - ) as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = t.oid - and sl.classoid = 'pg_type'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from pg_catalog.pg_range r -join pg_catalog.pg_type t on t.oid = r.rngtypid -join pg_catalog.pg_type subt on subt.oid = r.rngsubtype -left join default_btree_opclass dbo on dbo.type_oid = r.rngsubtype -left join pg_catalog.pg_opclass opc on opc.oid = r.rngsubopc -left join pg_catalog.pg_collation c on c.oid = r.rngcollation -left join pg_catalog.pg_proc p_subcanon on p_subcanon.oid = r.rngcanonical -left join pg_catalog.pg_namespace pn_subcanon on pn_subcanon.oid = p_subcanon.pronamespace -left join pg_catalog.pg_proc p_subdiff on p_subdiff.oid = r.rngsubdiff -left join pg_catalog.pg_namespace pn_subdiff on pn_subdiff.oid = p_subdiff.pronamespace -left outer join extension_oids e on t.oid = e.objid -where not t.typnamespace::regnamespace::text like any(array['pg\_%', 'information\_schema']) - and e.objid is null -order by 1, 2 - `); - const validated = rows.map((row: unknown) => rangePropsSchema.parse(row)); - return validated.map((row: RangeProps) => new Range(row)); -} diff --git a/packages/pg-delta/src/core/objects/type/type.types.ts b/packages/pg-delta/src/core/objects/type/type.types.ts deleted file mode 100644 index babfd51c7..000000000 --- a/packages/pg-delta/src/core/objects/type/type.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CompositeTypeChange } from "./composite-type/changes/composite-type.types.ts"; -import type { EnumChange } from "./enum/changes/enum.types.ts"; -import type { RangeChange } from "./range/changes/range.types.ts"; - -/** Union of all type-related change variants (`objectType: "composite_type" | "enum" | "range"`). @category Change Types */ -export type TypeChange = CompositeTypeChange | EnumChange | RangeChange; diff --git a/packages/pg-delta/src/core/objects/utils.ts b/packages/pg-delta/src/core/objects/utils.ts deleted file mode 100644 index 80933f50b..000000000 --- a/packages/pg-delta/src/core/objects/utils.ts +++ /dev/null @@ -1,177 +0,0 @@ -type Comparator = (a: T, b: T) => boolean; - -type Indexable = { [P in keyof T]: unknown }; - -/** - * JSON.stringify replacement that safely serializes BigInt values by converting - * them to strings. This ensures stable serialization for deep equality checks - * without throwing on BigInt instances. - */ -export function stringifyWithBigInt(value: unknown, space: number = 2): string { - return JSON.stringify( - value, - (_key, v) => (typeof v === "bigint" ? v.toString() : v), - space, - ); -} - -export function hasNonAlterableChanges( - main: T, - branch: T, - keys: ReadonlyArray, - comparators?: Partial>>, -): boolean { - const mainIndexable = main as unknown as Indexable; - const branchIndexable = branch as unknown as Indexable; - for (const key of keys) { - // Prefer custom comparator when provided; fallback to strict equality - const equals = - (comparators?.[key] as Comparator) ?? - ((a: unknown, b: unknown) => a === b); - if (!equals(mainIndexable[key], branchIndexable[key])) return true; - } - return false; -} - -export const deepEqual: Comparator = (a: unknown, b: unknown) => - stringifyWithBigInt(a) === stringifyWithBigInt(b); - -// Helpers for stableId that aren't encoded in a class, mostly for sub-entities or meta entities. -export const stableId = { - schema(schema: string) { - return `schema:${schema}` as const; - }, - table(schema: string, table: string) { - return `table:${schema}.${table}` as const; - }, - view(schema: string, view: string) { - return `view:${schema}.${view}` as const; - }, - materializedView(schema: string, view: string) { - return `materializedView:${schema}.${view}` as const; - }, - acl(objectStableId: string, grantee: string) { - return `acl:${objectStableId}::grantee:${grantee}` as const; - }, - /** - * - * 'defacl:' || grantor || ':' || objtype || ':' || coalesce('schema:' || in_schema, 'global') || ':grantee:' || grantee as dependent_stable_id, - */ - defacl( - grantor: string, - objtype: string, - schema: string | null, - grantee: string, - ) { - return `defacl:${grantor}:${objtype}:${schema ? `schema:${schema}` : "global"}:grantee:${grantee}` as const; - }, - column(schema: string, table: string, column: string) { - return `column:${schema}.${table}.${column}` as const; - }, - constraint(schema: string, table: string, constraint: string) { - return `constraint:${schema}.${table}.${constraint}` as const; - }, - index(schema: string, table: string, indexName: string) { - return `index:${schema}.${table}.${indexName}` as const; - }, - comment(objectStableId: string) { - return `comment:${objectStableId}` as const; - }, - securityLabel(objectStableId: string, provider: string) { - return `securityLabel:${objectStableId}::provider:${provider}` as const; - }, - role(role: string) { - return `role:${role}` as const; - }, - type(schema: string, name: string) { - return `type:${schema}.${name}` as const; - }, - collation(schema: string, name: string) { - return `collation:${schema}.${name}` as const; - }, - procedure(schema: string, name: string, args: string = "") { - return `procedure:${schema}.${name}(${args})` as const; - }, - membership(role: string, member: string) { - return `membership:${role}->${member}` as const; - }, - foreignDataWrapper(name: string) { - return `foreignDataWrapper:${name}` as const; - }, - server(name: string) { - return `server:${name}` as const; - }, - userMapping(server: string, user: string) { - return `userMapping:${server}:${user}` as const; - }, - foreignTable(schema: string, name: string) { - return `foreignTable:${schema}.${name}` as const; - }, -}; - -/** - * Check if a schema name represents a user-defined type (not pg_catalog or information_schema). - * Used to filter out system types when building dependency lists. - */ -export function isUserDefinedTypeSchema( - schema: string | null | undefined, -): boolean { - return ( - schema != null && schema !== "pg_catalog" && schema !== "information_schema" - ); -} - -/** - * Parse a procedure reference string (from regprocedure::text) to extract schema and function name. - * Format: "schema.function_name(argtypes)" or "function_name(argtypes)" - * Returns null if parsing fails or if it's a system procedure. - */ -export function parseProcedureReference( - procRef: string | null | undefined, -): { schema: string; name: string } | null { - if (!procRef) return null; - - // Format is "schema.function_name(argtypes)" or "function_name(argtypes)" - // Extract everything before the opening parenthesis - const match = procRef.match(/^([^(]+)\(/); - if (!match) return null; - - const qualifiedName = match[1]; - const parts = qualifiedName.split("."); - if (parts.length === 1) { - // No schema prefix - assume current schema (we can't determine it here) - // For now, skip these as we need schema info - return null; - } - if (parts.length === 2) { - const [schema, name] = parts; - if (isUserDefinedTypeSchema(schema)) { - return { schema, name }; - } - } - return null; -} - -/** - * Parse a type string (from format_type) to extract schema and type name if it's schema-qualified. - * Format: "type_name" or "schema.type_name" or "schema.type_name[]" - * Returns null if it's not schema-qualified or if it's a system type. - */ -export function parseTypeString( - typeStr: string | null | undefined, -): { schema: string; name: string } | null { - if (!typeStr) return null; - - // Remove array brackets for parsing - const baseType = typeStr.replace(/\[\]+$/, ""); - - // Check if it's schema-qualified (contains a dot) - const parts = baseType.split("."); - if (parts.length === 2) { - const [schema, name] = parts; - if (isUserDefinedTypeSchema(schema)) { - return { schema, name }; - } - } - return null; -} diff --git a/packages/pg-delta/src/core/objects/view/changes/view.alter.test.ts b/packages/pg-delta/src/core/objects/view/changes/view.alter.test.ts deleted file mode 100644 index 293e05a47..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.alter.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { View, type ViewProps } from "../view.model.ts"; -import { - AlterViewChangeOwner, - AlterViewResetOptions, - AlterViewSetOptions, -} from "./view.alter.ts"; - -describe.concurrent("view", () => { - describe("alter", () => { - test("change owner", async () => { - const props: Omit = { - schema: "public", - name: "test_view", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - comment: null, - columns: [], - privileges: [], - }; - const main = new View({ - ...props, - owner: "old_owner", - }); - // branch no longer needed for constructor; we only pass explicit owner - - const change = new AlterViewChangeOwner({ - view: main, - owner: "new_owner", - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "ALTER VIEW public.test_view OWNER TO new_owner", - ); - }); - }); - - test("set options", async () => { - const props: Omit = { - schema: "public", - name: "test_view", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }; - const main = new View({ ...props, options: ["security_barrier=true"] }); - // branch no longer needed; we pass explicit options list - - const change = new AlterViewSetOptions({ - view: main, - options: ["security_barrier=false"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER VIEW public.test_view SET (security_barrier=false)", - ); - }); - - test("reset options", async () => { - const view = new View({ - schema: "public", - name: "test_view", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: ["security_barrier=true", "check_option=local"], - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }); - - const change = new AlterViewResetOptions({ - view, - params: ["check_option"], - }); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "ALTER VIEW public.test_view RESET (check_option)", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/view/changes/view.alter.ts b/packages/pg-delta/src/core/objects/view/changes/view.alter.ts deleted file mode 100644 index 9307b2703..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.alter.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import type { View } from "../view.model.ts"; -import { AlterViewChange } from "./view.base.ts"; - -/** - * Alter a view. - * - * @see https://www.postgresql.org/docs/17/sql-alterview.html - * - * Synopsis - * ```sql - * ALTER VIEW [ IF EXISTS ] name ALTER [ COLUMN ] column_name SET DEFAULT expression - * ALTER VIEW [ IF EXISTS ] name ALTER [ COLUMN ] column_name DROP DEFAULT - * ALTER VIEW [ IF EXISTS ] name OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } - * ALTER VIEW [ IF EXISTS ] name RENAME [ COLUMN ] column_name TO new_column_name - * ALTER VIEW [ IF EXISTS ] name RENAME TO new_name - * ALTER VIEW [ IF EXISTS ] name SET SCHEMA new_schema - * ALTER VIEW [ IF EXISTS ] name SET ( view_option_name [= view_option_value] [, ... ] ) - * ALTER VIEW [ IF EXISTS ] name RESET ( view_option_name [, ... ] ) - * ``` - */ - -export type AlterView = - | AlterViewChangeOwner - | AlterViewResetOptions - | AlterViewSetOptions; - -/** - * ALTER VIEW ... OWNER TO ... - */ -export class AlterViewChangeOwner extends AlterViewChange { - public readonly view: View; - public readonly owner: string; - public readonly scope = "object" as const; - - constructor(props: { view: View; owner: string }) { - super(); - this.view = props.view; - this.owner = props.owner; - } - - get requires() { - return [this.view.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER VIEW", - `${this.view.schema}.${this.view.name}`, - "OWNER TO", - this.owner, - ].join(" "); - } -} - -// NOTE: ReplaceView removed. Non-alterable changes are emitted as CREATE OR REPLACE in view.diff.ts. - -/** - * ALTER VIEW ... SET ( ... ) - */ -export class AlterViewSetOptions extends AlterViewChange { - public readonly view: View; - public readonly options: string[]; - public readonly scope = "object" as const; - - constructor(props: { view: View; options: string[] }) { - super(); - this.view = props.view; - this.options = props.options; - } - - get requires() { - return [this.view.stableId]; - } - - serialize(_options?: SerializeOptions): string { - const opts = this.options.join(", "); - return [ - "ALTER VIEW", - `${this.view.schema}.${this.view.name}`, - "SET", - `(${opts})`, - ].join(" "); - } -} - -/** - * ALTER VIEW ... RESET ( ... ) - */ -export class AlterViewResetOptions extends AlterViewChange { - public readonly view: View; - public readonly params: string[]; - public readonly scope = "object" as const; - - constructor(props: { view: View; params: string[] }) { - super(); - this.view = props.view; - this.params = props.params; - } - - get requires() { - return [this.view.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "ALTER VIEW", - `${this.view.schema}.${this.view.name}`, - "RESET", - `(${this.params.join(", ")})`, - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/view/changes/view.base.ts b/packages/pg-delta/src/core/objects/view/changes/view.base.ts deleted file mode 100644 index 5cfbb04ae..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.base.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseChange } from "../../base.change.ts"; -import type { View } from "../view.model.ts"; - -abstract class BaseViewChange extends BaseChange { - abstract readonly view: View; - abstract readonly scope: - | "object" - | "comment" - | "privilege" - | "security_label"; - readonly objectType = "view" as const; -} - -export abstract class CreateViewChange extends BaseViewChange { - readonly operation = "create" as const; -} - -export abstract class AlterViewChange extends BaseViewChange { - readonly operation = "alter" as const; -} - -export abstract class DropViewChange extends BaseViewChange { - readonly operation = "drop" as const; -} diff --git a/packages/pg-delta/src/core/objects/view/changes/view.comment.ts b/packages/pg-delta/src/core/objects/view/changes/view.comment.ts deleted file mode 100644 index 8c9602641..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.comment.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { quoteLiteral } from "../../base.change.ts"; -import { stableId } from "../../utils.ts"; -import type { View } from "../view.model.ts"; -import { CreateViewChange, DropViewChange } from "./view.base.ts"; - -export type CommentView = CreateCommentOnView | DropCommentOnView; - -export class CreateCommentOnView extends CreateViewChange { - public readonly view: View; - public readonly scope = "comment" as const; - - constructor(props: { view: View }) { - super(); - this.view = props.view; - } - - get creates() { - return [stableId.comment(this.view.stableId)]; - } - - get requires() { - return [this.view.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON VIEW", - `${this.view.schema}.${this.view.name}`, - "IS", - quoteLiteral(this.view.comment as string), - ].join(" "); - } -} - -export class DropCommentOnView extends DropViewChange { - public readonly view: View; - public readonly scope = "comment" as const; - - constructor(props: { view: View }) { - super(); - this.view = props.view; - } - - get drops() { - return [stableId.comment(this.view.stableId)]; - } - - get requires() { - return [stableId.comment(this.view.stableId), this.view.stableId]; - } - - serialize(_options?: SerializeOptions): string { - return [ - "COMMENT ON VIEW", - `${this.view.schema}.${this.view.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/view/changes/view.create.test.ts b/packages/pg-delta/src/core/objects/view/changes/view.create.test.ts deleted file mode 100644 index 97dd42f54..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.create.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { View } from "../view.model.ts"; -import { CreateView } from "./view.create.ts"; - -describe("view", () => { - test("create", async () => { - const view = new View({ - schema: "public", - name: "test_view", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }); - - const change = new CreateView({ - view, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE VIEW public.test_view AS SELECT * FROM test_table", - ); - }); - - test("create with options", async () => { - const view = new View({ - schema: "public", - name: "test_view", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: ["security_barrier=true", "check_option=local"], - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }); - - const change = new CreateView({ view }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe( - "CREATE VIEW public.test_view WITH (security_barrier=true, check_option=local) AS SELECT * FROM test_table", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/view/changes/view.create.ts b/packages/pg-delta/src/core/objects/view/changes/view.create.ts deleted file mode 100644 index 38b1b0856..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.create.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { View } from "../view.model.ts"; -import { CreateViewChange } from "./view.base.ts"; - -/** - * Create a view. - * - * @see https://www.postgresql.org/docs/17/sql-createview.html - * - * Synopsis - * ```sql - * CREATE [ OR REPLACE ] [ TEMP | TEMPORARY ] [ RECURSIVE ] VIEW name [ ( column_name [, ...] ) ] - * [ WITH ( view_option_name [= view_option_value] [, ... ] ) ] - * AS query - * [ WITH [ CASCADE | LOCAL ] CHECK OPTION ] - * ``` - */ -export class CreateView extends CreateViewChange { - public readonly view: View; - public readonly orReplace?: boolean; - public readonly scope = "object" as const; - - constructor(props: { view: View; orReplace?: boolean }) { - super(); - this.view = props.view; - this.orReplace = props.orReplace; - } - - get creates() { - return [ - this.view.stableId, - ...this.view.columns.map((column) => - stableId.column(this.view.schema, this.view.name, column.name), - ), - ]; - } - - get requires() { - const dependencies = new Set(); - - // Schema dependency - dependencies.add(stableId.schema(this.view.schema)); - - // Owner dependency - dependencies.add(stableId.role(this.view.owner)); - - // Note: View definition dependencies (tables, types, procedures referenced in the query) - // are handled via pg_depend for existing objects. For new objects, parsing the SQL - // definition would be complex and error-prone, so we rely on pg_depend extraction - // for those dependencies. - - return Array.from(dependencies); - } - - serialize(_options?: SerializeOptions): string { - const parts: string[] = [ - `CREATE${this.orReplace ? " OR REPLACE" : ""} VIEW`, - ]; - - // Add schema and name - parts.push(`${this.view.schema}.${this.view.name}`); - - // Add WITH options if specified - if (this.view.options && this.view.options.length > 0) { - parts.push("WITH", `(${this.view.options.join(", ")})`); - } - - // Add AS query (trim to avoid double spaces before SELECT) - parts.push("AS", this.view.definition.trim()); - - return parts.join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/view/changes/view.drop.test.ts b/packages/pg-delta/src/core/objects/view/changes/view.drop.test.ts deleted file mode 100644 index 53b67978b..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.drop.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { View } from "../view.model.ts"; -import { DropView } from "./view.drop.ts"; - -describe("view", () => { - test("drop", async () => { - const view = new View({ - schema: "public", - name: "test_view", - definition: "SELECT * FROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "test", - comment: null, - columns: [], - privileges: [], - }); - - const change = new DropView({ - view, - }); - - await assertValidSql(change.serialize()); - - expect(change.serialize()).toBe("DROP VIEW public.test_view"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/view/changes/view.drop.ts b/packages/pg-delta/src/core/objects/view/changes/view.drop.ts deleted file mode 100644 index 7ac908a5a..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.drop.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { stableId } from "../../utils.ts"; -import type { View } from "../view.model.ts"; -import { DropViewChange } from "./view.base.ts"; - -/** - * Drops a view from the database. - * - * @see https://www.postgresql.org/docs/17/sql-dropview.html - */ -export class DropView extends DropViewChange { - public readonly view: View; - public readonly scope = "object" as const; - - constructor(props: { view: View }) { - super(); - this.view = props.view; - } - - get drops() { - return [ - this.view.stableId, - ...this.view.columns.map((column) => - stableId.column(this.view.schema, this.view.name, column.name), - ), - ]; - } - - get requires() { - return [ - this.view.stableId, - ...this.view.columns.map((column) => - stableId.column(this.view.schema, this.view.name, column.name), - ), - ]; - } - - serialize(_options?: SerializeOptions): string { - return ["DROP VIEW", `${this.view.schema}.${this.view.name}`].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/view/changes/view.privilege.ts b/packages/pg-delta/src/core/objects/view/changes/view.privilege.ts deleted file mode 100644 index a7d0e6291..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.privilege.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; -import { - formatObjectPrivilegeList, - getObjectKindPrefix, -} from "../../base.privilege.ts"; -import { stableId } from "../../utils.ts"; -import type { View } from "../view.model.ts"; -import { AlterViewChange } from "./view.base.ts"; - -export type ViewPrivilege = - | GrantViewPrivileges - | RevokeViewPrivileges - | RevokeGrantOptionViewPrivileges; - -/** - * Grant privileges on a view. - * - * @see https://www.postgresql.org/docs/17/sql-grant.html - * - * Synopsis - * ```sql - * GRANT { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER } - * [, ...] | ALL [ PRIVILEGES ] } - * ON { [ TABLE ] view_name [, ...] - * | ALL TABLES IN SCHEMA schema_name [, ...] } - * TO role_specification [, ...] [ WITH GRANT OPTION ] - * [ GRANTED BY role_specification ] - * ``` - */ -export class GrantViewPrivileges extends AlterViewChange { - public readonly view: View; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - view: View; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - columns?: string[]; - version?: number; - }) { - super(); - this.view = props.view; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.columns = props.columns; - this.version = props.version; - } - - get creates() { - return [stableId.acl(this.view.stableId, this.grantee)]; - } - - get requires() { - return [this.view.stableId, stableId.role(this.grantee)]; - } - - serialize(_options?: SerializeOptions): string { - const hasGrantable = this.privileges.some((p) => p.grantable); - const hasBase = this.privileges.some((p) => !p.grantable); - if (hasGrantable && hasBase) { - throw new Error( - "GrantViewPrivileges expects privileges with uniform grantable flag", - ); - } - const withGrant = hasGrantable ? " WITH GRANT OPTION" : ""; - const kindPrefix = getObjectKindPrefix("VIEW"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("VIEW", list, this.version); - const viewName = `${this.view.schema}.${this.view.name}`; - const columnSpec = - this.columns && this.columns.length > 0 - ? ` (${this.columns.join(", ")})` - : ""; - return `GRANT ${privSql}${columnSpec} ${kindPrefix} ${viewName} TO ${this.grantee}${withGrant}`; - } -} - -/** - * Revoke privileges on a view. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - * - * Synopsis - * ```sql - * REVOKE [ GRANT OPTION FOR ] - * { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER } - * [, ...] | ALL [ PRIVILEGES ] } - * ON { [ TABLE ] view_name [, ...] - * | ALL TABLES IN SCHEMA schema_name [, ...] } - * FROM role_specification [, ...] - * [ GRANTED BY role_specification ] - * [ CASCADE | RESTRICT ] - * ``` - */ -export class RevokeViewPrivileges extends AlterViewChange { - public readonly view: View; - public readonly grantee: string; - public readonly privileges: { privilege: string; grantable: boolean }[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - view: View; - grantee: string; - privileges: { privilege: string; grantable: boolean }[]; - columns?: string[]; - version?: number; - }) { - super(); - this.view = props.view; - this.grantee = props.grantee; - this.privileges = props.privileges; - this.columns = props.columns; - this.version = props.version; - } - - get drops() { - // Return ACL ID for dependency tracking, even though this is an ALTER operation - // Phase assignment now uses operation type, so this won't affect phase placement - return [stableId.acl(this.view.stableId, this.grantee)]; - } - - get requires() { - return [ - stableId.acl(this.view.stableId, this.grantee), - this.view.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("VIEW"); - const list = this.privileges.map((p) => p.privilege); - const privSql = formatObjectPrivilegeList("VIEW", list, this.version); - const viewName = `${this.view.schema}.${this.view.name}`; - const columnSpec = - this.columns && this.columns.length > 0 - ? ` (${this.columns.join(", ")})` - : ""; - return `REVOKE ${privSql}${columnSpec} ${kindPrefix} ${viewName} FROM ${this.grantee}`; - } -} - -/** - * Revoke grant option for privileges on a view. - * - * This removes the ability to grant the privilege to others, but keeps the privilege itself. - * - * @see https://www.postgresql.org/docs/17/sql-revoke.html - */ -export class RevokeGrantOptionViewPrivileges extends AlterViewChange { - public readonly view: View; - public readonly grantee: string; - public readonly privilegeNames: string[]; - public readonly columns?: string[]; - public readonly version: number | undefined; - public readonly scope = "privilege" as const; - - constructor(props: { - view: View; - grantee: string; - privilegeNames: string[]; - columns?: string[]; - version?: number; - }) { - super(); - this.view = props.view; - this.grantee = props.grantee; - this.privilegeNames = [...new Set(props.privilegeNames)].sort(); - this.columns = props.columns; - this.version = props.version; - } - - get requires() { - return [ - stableId.acl(this.view.stableId, this.grantee), - this.view.stableId, - stableId.role(this.grantee), - ]; - } - - serialize(_options?: SerializeOptions): string { - const kindPrefix = getObjectKindPrefix("VIEW"); - const privSql = formatObjectPrivilegeList( - "VIEW", - this.privilegeNames, - this.version, - ); - const viewName = `${this.view.schema}.${this.view.name}`; - const columnSpec = - this.columns && this.columns.length > 0 - ? ` (${this.columns.join(", ")})` - : ""; - return `REVOKE GRANT OPTION FOR ${privSql}${columnSpec} ${kindPrefix} ${viewName} FROM ${this.grantee}`; - } -} diff --git a/packages/pg-delta/src/core/objects/view/changes/view.security-label.test.ts b/packages/pg-delta/src/core/objects/view/changes/view.security-label.test.ts deleted file mode 100644 index cb5061111..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.security-label.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; -import { stableId } from "../../utils.ts"; -import { View, type ViewProps } from "../view.model.ts"; -import { - CreateSecurityLabelOnView, - DropSecurityLabelOnView, -} from "./view.security-label.ts"; - -const makeView = (): View => - new View({ - schema: "public", - name: "v", - definition: "SELECT 1", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "postgres", - comment: null, - columns: [], - privileges: [], - } as ViewProps); - -describe("view.security-label", () => { - test("create serializes and tracks dependencies", async () => { - const view = makeView(); - const change = new CreateSecurityLabelOnView({ - view, - securityLabel: { provider: "dummy", label: "classified" }, - }); - expect(change.scope).toBe("security_label"); - expect(change.creates).toEqual([ - stableId.securityLabel(view.stableId, "dummy"), - ]); - expect(change.requires).toEqual([view.stableId]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON VIEW public.v IS 'classified'", - ); - }); - - test("drop serializes to IS NULL", async () => { - const view = makeView(); - const change = new DropSecurityLabelOnView({ - view, - securityLabel: { provider: "dummy", label: "classified" }, - }); - expect(change.drops).toEqual([ - stableId.securityLabel(view.stableId, "dummy"), - ]); - await assertValidSql(change.serialize()); - expect(change.serialize()).toBe( - "SECURITY LABEL FOR dummy ON VIEW public.v IS NULL", - ); - }); -}); diff --git a/packages/pg-delta/src/core/objects/view/changes/view.security-label.ts b/packages/pg-delta/src/core/objects/view/changes/view.security-label.ts deleted file mode 100644 index 4a6aaac71..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.security-label.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { quoteLiteral } from "../../base.change.ts"; -import type { SecurityLabelProps } from "../../security-label.types.ts"; -import { stableId } from "../../utils.ts"; -import type { View } from "../view.model.ts"; -import { CreateViewChange, DropViewChange } from "./view.base.ts"; - -export type SecurityLabelView = - | CreateSecurityLabelOnView - | DropSecurityLabelOnView; - -export class CreateSecurityLabelOnView extends CreateViewChange { - public readonly view: View; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { view: View; securityLabel: SecurityLabelProps }) { - super(); - this.view = props.view; - this.securityLabel = props.securityLabel; - } - - get creates() { - return [ - stableId.securityLabel(this.view.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [this.view.stableId]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON VIEW", - `${this.view.schema}.${this.view.name}`, - "IS", - quoteLiteral(this.securityLabel.label), - ].join(" "); - } -} - -export class DropSecurityLabelOnView extends DropViewChange { - public readonly view: View; - public readonly securityLabel: SecurityLabelProps; - public readonly scope = "security_label" as const; - - constructor(props: { view: View; securityLabel: SecurityLabelProps }) { - super(); - this.view = props.view; - this.securityLabel = props.securityLabel; - } - - get drops() { - return [ - stableId.securityLabel(this.view.stableId, this.securityLabel.provider), - ]; - } - - get requires() { - return [ - stableId.securityLabel(this.view.stableId, this.securityLabel.provider), - this.view.stableId, - ]; - } - - serialize(): string { - return [ - "SECURITY LABEL FOR", - this.securityLabel.provider, - "ON VIEW", - `${this.view.schema}.${this.view.name}`, - "IS NULL", - ].join(" "); - } -} diff --git a/packages/pg-delta/src/core/objects/view/changes/view.types.ts b/packages/pg-delta/src/core/objects/view/changes/view.types.ts deleted file mode 100644 index 6922f0682..000000000 --- a/packages/pg-delta/src/core/objects/view/changes/view.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AlterView } from "./view.alter.ts"; -import type { CommentView } from "./view.comment.ts"; -import type { CreateView } from "./view.create.ts"; -import type { DropView } from "./view.drop.ts"; -import type { ViewPrivilege } from "./view.privilege.ts"; -import type { SecurityLabelView } from "./view.security-label.ts"; - -/** Union of all view-related change variants (`objectType: "view"`). @category Change Types */ -export type ViewChange = - | AlterView - | CommentView - | CreateView - | DropView - | ViewPrivilege - | SecurityLabelView; diff --git a/packages/pg-delta/src/core/objects/view/view.diff.test.ts b/packages/pg-delta/src/core/objects/view/view.diff.test.ts deleted file mode 100644 index b87d22edb..000000000 --- a/packages/pg-delta/src/core/objects/view/view.diff.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { DefaultPrivilegeState } from "../base.default-privileges.ts"; -import { - AlterViewChangeOwner, - AlterViewResetOptions, - AlterViewSetOptions, -} from "./changes/view.alter.ts"; -import { - CreateCommentOnView, - DropCommentOnView, -} from "./changes/view.comment.ts"; -import { CreateView } from "./changes/view.create.ts"; -import { DropView } from "./changes/view.drop.ts"; -import { - GrantViewPrivileges, - RevokeGrantOptionViewPrivileges, - RevokeViewPrivileges, -} from "./changes/view.privilege.ts"; -import { diffViews } from "./view.diff.ts"; -import { View, type ViewProps } from "./view.model.ts"; - -const base: ViewProps = { - schema: "public", - name: "v", - definition: "select 1", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "o1", - comment: null, - columns: [], - privileges: [], -}; - -const makeView = (override: Partial = {}) => - new View({ - ...base, - ...override, - privileges: override.privileges ?? [...base.privileges], - }); - -const testContext = { - version: 170000, - currentUser: "postgres", - defaultPrivilegeState: new DefaultPrivilegeState({}), - mainRoles: {}, -}; - -describe.concurrent("view.diff", () => { - test("create and drop", () => { - const v = new View(base); - const created = diffViews(testContext, {}, { [v.stableId]: v }); - expect(created[0]).toBeInstanceOf(CreateView); - const dropped = diffViews(testContext, { [v.stableId]: v }, {}); - expect(dropped[0]).toBeInstanceOf(DropView); - }); - - test("alter owner", () => { - const main = new View(base); - const branch = new View({ ...base, owner: "o2" }); - const changes = diffViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes[0]).toBeInstanceOf(AlterViewChangeOwner); - }); - - test("alter: set and reset options", () => { - const main = new View({ - ...base, - options: ["security_barrier=true", "check_option=local"], - }); - const branch = new View({ ...base, options: ["security_barrier=false"] }); - const changes = diffViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof AlterViewSetOptions)).toBe(true); - expect(changes.some((c) => c instanceof AlterViewResetOptions)).toBe(true); - }); - - test("create or replace when non-alterable property changes", () => { - const main = new View(base); - const branch = new View({ - ...base, - definition: "select 2", - row_security: true, - }); - const changes = diffViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(1); - expect(changes[0]).toBeInstanceOf(CreateView); - }); - - test("drop and recreate when view columns change", () => { - const main = makeView({ - owner: "postgres", - columns: [ - { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - }); - const branch = makeView({ - owner: "postgres", - columns: [ - ...main.columns, - { - name: "priority", - position: 2, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }, - ], - }); - const changes = diffViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes).toHaveLength(2); - expect(changes[0]).toBeInstanceOf(DropView); - expect(changes[1]).toBeInstanceOf(CreateView); - }); - - test("column position-only change does not trigger drop+create", () => { - const col = { - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: "my column", - }; - const main = makeView({ - owner: "postgres", - comment: "old comment", - columns: [{ ...col, position: 1 }], - }); - const branch = makeView({ - owner: "postgres", - comment: "new comment", - columns: [{ ...col, position: 2 }], - }); - const changes = diffViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - expect(changes.some((c) => c instanceof DropView)).toBe(false); - expect(changes.some((c) => c instanceof CreateCommentOnView)).toBe(true); - }); - - test("create with privileges emits grant changes", () => { - const v = makeView({ - privileges: [ - { grantee: "role_select", privilege: "SELECT", grantable: false }, - ], - }); - const changes = diffViews(testContext, {}, { [v.stableId]: v }); - expect(changes[0]).toBeInstanceOf(CreateView); - expect(changes.some((c) => c instanceof GrantViewPrivileges)).toBe(true); - }); - - test("create with comment emits create comment change", () => { - const v = makeView({ comment: "my view" }); - const changes = diffViews(testContext, {}, { [v.stableId]: v }); - expect(changes[0]).toBeInstanceOf(CreateView); - expect(changes.some((c) => c instanceof CreateCommentOnView)).toBe(true); - }); - - test("comment changes emit create/drop comment statements", () => { - const main = makeView(); - const withComment = makeView({ comment: "view comment" }); - - const addComment = diffViews( - testContext, - { [main.stableId]: main }, - { [withComment.stableId]: withComment }, - ); - expect(addComment[0]).toBeInstanceOf(CreateCommentOnView); - - const dropComment = diffViews( - testContext, - { [withComment.stableId]: withComment }, - { [main.stableId]: main }, - ); - expect(dropComment[0]).toBeInstanceOf(DropCommentOnView); - }); - - test("privilege diffs emit grant, revoke, and revoke grant option statements", () => { - const main = makeView({ - privileges: [ - { grantee: "role_select", privilege: "SELECT", grantable: false }, - { grantee: "role_with_option", privilege: "SELECT", grantable: true }, - { grantee: "role_removed", privilege: "SELECT", grantable: false }, - ], - }); - const branch = makeView({ - privileges: [ - { grantee: "role_select", privilege: "SELECT", grantable: true }, - { grantee: "role_with_option", privilege: "SELECT", grantable: false }, - { grantee: "role_new", privilege: "SELECT", grantable: false }, - ], - }); - - const changes = diffViews( - testContext, - { [main.stableId]: main }, - { [branch.stableId]: branch }, - ); - - expect(changes.some((c) => c instanceof GrantViewPrivileges)).toBe(true); - expect(changes.some((c) => c instanceof RevokeViewPrivileges)).toBe(true); - expect( - changes.some((c) => c instanceof RevokeGrantOptionViewPrivileges), - ).toBe(true); - }); -}); diff --git a/packages/pg-delta/src/core/objects/view/view.diff.ts b/packages/pg-delta/src/core/objects/view/view.diff.ts deleted file mode 100644 index baa37ec6d..000000000 --- a/packages/pg-delta/src/core/objects/view/view.diff.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { diffObjects } from "../base.diff.ts"; -import { normalizeColumns } from "../base.model.ts"; -import { - diffPrivileges, - emitColumnPrivilegeChanges, -} from "../base.privilege-diff.ts"; -import type { ObjectDiffContext } from "../diff-context.ts"; -import { diffSecurityLabels } from "../security-label.types.ts"; -import { deepEqual, hasNonAlterableChanges } from "../utils.ts"; -import { - AlterViewChangeOwner, - AlterViewResetOptions, - AlterViewSetOptions, -} from "./changes/view.alter.ts"; -import { - CreateCommentOnView, - DropCommentOnView, -} from "./changes/view.comment.ts"; -import { CreateView } from "./changes/view.create.ts"; -import { DropView } from "./changes/view.drop.ts"; -import { - GrantViewPrivileges, - RevokeGrantOptionViewPrivileges, - RevokeViewPrivileges, -} from "./changes/view.privilege.ts"; -import { - CreateSecurityLabelOnView, - DropSecurityLabelOnView, -} from "./changes/view.security-label.ts"; -import type { ViewChange } from "./changes/view.types.ts"; -import type { View } from "./view.model.ts"; - -export function buildCreateViewChanges( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - view: View, -): ViewChange[] { - const changes: ViewChange[] = [new CreateView({ view })]; - - // OWNER: If the view should be owned by someone other than the current user, - // emit ALTER VIEW ... OWNER TO after creation - if (view.owner !== ctx.currentUser) { - changes.push(new AlterViewChangeOwner({ view, owner: view.owner })); - } - - if (view.comment !== null) { - changes.push(new CreateCommentOnView({ view })); - } - - for (const label of view.security_labels) { - changes.push(new CreateSecurityLabelOnView({ view, securityLabel: label })); - } - - // PRIVILEGES: For created objects, compare against default privileges state - // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so objects are created with the default privileges state in effect. - // We compare default privileges against desired privileges to generate REVOKE/GRANT statements - // needed to reach the final desired state. - const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults( - ctx.currentUser, - "view", - view.schema ?? "", - ); - const creatorFilteredDefaults = - view.owner !== ctx.currentUser - ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser) - : effectiveDefaults; - const desiredPrivileges = view.privileges; - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use the view owner as the reference. - const privilegeResults = diffPrivileges( - creatorFilteredDefaults, - desiredPrivileges, - view.owner, - ); - - changes.push( - ...(emitColumnPrivilegeChanges( - privilegeResults, - view, - view, - "view", - { - Grant: GrantViewPrivileges, - Revoke: RevokeViewPrivileges, - RevokeGrantOption: RevokeGrantOptionViewPrivileges, - }, - effectiveDefaults, - ctx.version, - ) as ViewChange[]), - ); - - return changes; -} - -/** - * Diff two sets of views from main and branch catalogs. - * - * @param ctx - Context containing version, currentUser, and defaultPrivilegeState - * @param main - The views in the main catalog. - * @param branch - The views in the branch catalog. - * @returns A list of changes to apply to main to make it match branch. - */ -export function diffViews( - ctx: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >, - main: Record, - branch: Record, -): ViewChange[] { - const { created, dropped, altered } = diffObjects(main, branch); - - const changes: ViewChange[] = []; - - for (const viewId of created) { - changes.push(...buildCreateViewChanges(ctx, branch[viewId])); - } - - for (const viewId of dropped) { - changes.push(new DropView({ view: main[viewId] })); - } - - for (const viewId of altered) { - const mainView = main[viewId]; - const branchView = branch[viewId]; - - // Check if non-alterable properties have changed - // These require dropping and recreating the view - const NON_ALTERABLE_FIELDS: Array = [ - "definition", - "row_security", - "force_row_security", - "has_indexes", - "has_rules", - "has_triggers", - "has_subclasses", - "is_populated", - "replica_identity", - "is_partition", - "partition_bound", - ]; - const nonAlterablePropsChanged = hasNonAlterableChanges( - mainView, - branchView, - NON_ALTERABLE_FIELDS, - { options: deepEqual }, - ); - - // Normalize columns (strip position, sort by name) to match stableSnapshot(). - // Position-only differences are safe to ignore here because column order in a - // view is determined by its definition, which is already checked above via - // NON_ALTERABLE_FIELDS - a position change always implies a definition change. - if ( - !deepEqual( - normalizeColumns(mainView.columns), - normalizeColumns(branchView.columns), - ) - ) { - changes.push(new DropView({ view: mainView })); - changes.push(...buildCreateViewChanges(ctx, branchView)); - } else if (nonAlterablePropsChanged) { - // Replace the entire view using CREATE OR REPLACE to avoid drop when possible - changes.push(new CreateView({ view: branchView, orReplace: true })); - } else { - // Only alterable properties changed - check each one - - // OWNER - if (mainView.owner !== branchView.owner) { - changes.push( - new AlterViewChangeOwner({ view: mainView, owner: branchView.owner }), - ); - } - - // VIEW OPTIONS (WITH (...)) - if (!deepEqual(mainView.options, branchView.options)) { - const mainOpts = mainView.options ?? []; - const branchOpts = branchView.options ?? []; - - // Always set branch options when provided - if (branchOpts.length > 0) { - changes.push( - new AlterViewSetOptions({ view: mainView, options: branchOpts }), - ); - } - - // Reset any params that are present in main but absent in branch - if (mainOpts.length > 0) { - const mainNames = new Set(mainOpts.map((opt) => opt.split("=")[0])); - const branchNames = new Set( - branchOpts.map((opt) => opt.split("=")[0]), - ); - const removed: string[] = []; - for (const name of mainNames) { - if (!branchNames.has(name)) removed.push(name); - } - if (removed.length > 0) { - changes.push( - new AlterViewResetOptions({ view: mainView, params: removed }), - ); - } - } - } - - // COMMENT - if (mainView.comment !== branchView.comment) { - if (branchView.comment === null) { - changes.push(new DropCommentOnView({ view: mainView })); - } else { - changes.push(new CreateCommentOnView({ view: branchView })); - } - } - - // SECURITY LABELS - changes.push( - ...diffSecurityLabels< - CreateSecurityLabelOnView | DropSecurityLabelOnView - >( - mainView.security_labels, - branchView.security_labels, - (securityLabel) => - new CreateSecurityLabelOnView({ - view: branchView, - securityLabel, - }), - (securityLabel) => - new DropSecurityLabelOnView({ - view: mainView, - securityLabel, - }), - ), - ); - - // Note: View renaming would also use ALTER VIEW ... RENAME TO ... - // But since our View model uses 'name' as the identity field, - // a name change would be handled as drop + create by diffObjects() - - // PRIVILEGES (unified object and column privileges) - // Filter out owner privileges - owner always has ALL privileges implicitly - // and shouldn't be compared. Use branch owner as the reference. - const privilegeResults = diffPrivileges( - mainView.privileges, - branchView.privileges, - branchView.owner, - ); - - changes.push( - ...(emitColumnPrivilegeChanges( - privilegeResults, - branchView, - mainView, - "view", - { - Grant: GrantViewPrivileges, - Revoke: RevokeViewPrivileges, - RevokeGrantOption: RevokeGrantOptionViewPrivileges, - }, - mainView.privileges, - ctx.version, - ) as ViewChange[]), - ); - } - } - - return changes; -} diff --git a/packages/pg-delta/src/core/objects/view/view.model.test.ts b/packages/pg-delta/src/core/objects/view/view.model.test.ts deleted file mode 100644 index 81ba5946b..000000000 --- a/packages/pg-delta/src/core/objects/view/view.model.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Pool } from "pg"; -import { extractViews, View } from "./view.model.ts"; - -const baseRow = { - schema: "public", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - owner: "postgres", - comment: null, - columns: [], - privileges: [], -}; - -const mockPool = (rows: unknown[]): Pool => - ({ query: async () => ({ rows }) }) as unknown as Pool; - -const mockPoolSequence = (...attempts: unknown[][]): Pool => { - let i = 0; - return { - query: async () => ({ - rows: attempts[Math.min(i++, attempts.length - 1)], - }), - } as unknown as Pool; -}; - -const NO_BACKOFF = { backoffMs: 0 } as const; - -describe("extractViews", () => { - test("skips rows where pg_get_viewdef returned NULL after exhausting retries", async () => { - const views = await extractViews( - mockPool([ - { - ...baseRow, - name: '"good_view"', - definition: "SELECT 1", - }, - { ...baseRow, name: '"orphan_view"', definition: null }, - ]), - NO_BACKOFF, - ); - - expect(views).toHaveLength(1); - expect(views[0]).toBeInstanceOf(View); - expect(views[0]?.name).toBe('"good_view"'); - expect(views[0]?.definition).toBe("SELECT 1"); - }); - - test("does not throw ZodError when the only row has a null definition", async () => { - await expect( - extractViews( - mockPool([{ ...baseRow, name: '"orphan"', definition: null }]), - NO_BACKOFF, - ), - ).resolves.toEqual([]); - }); - - test("returns all views when every row has a valid definition", async () => { - const views = await extractViews( - mockPool([ - { ...baseRow, name: '"a"', definition: "SELECT 1" }, - { ...baseRow, name: '"b"', definition: "SELECT 2" }, - ]), - NO_BACKOFF, - ); - expect(views.map((v) => v.name)).toEqual(['"a"', '"b"']); - }); - - test("recovers when pg_get_viewdef is NULL on first attempt but resolved on retry", async () => { - const views = await extractViews( - mockPoolSequence( - [{ ...baseRow, name: '"racy_view"', definition: null }], - [{ ...baseRow, name: '"racy_view"', definition: "SELECT 42" }], - ), - { retries: 2, backoffMs: 0 }, - ); - expect(views).toHaveLength(1); - expect(views[0]?.name).toBe('"racy_view"'); - expect(views[0]?.definition).toBe("SELECT 42"); - }); -}); diff --git a/packages/pg-delta/src/core/objects/view/view.model.ts b/packages/pg-delta/src/core/objects/view/view.model.ts deleted file mode 100644 index a46288a3b..000000000 --- a/packages/pg-delta/src/core/objects/view/view.model.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { sql } from "@ts-safeql/sql-tag"; -import type { Pool } from "pg"; -import z from "zod"; -import { - BasePgModel, - columnPropsSchema, - normalizeColumns, - type TableLikeObject, -} from "../base.model.ts"; -import { - type PrivilegeProps, - privilegePropsSchema, -} from "../base.privilege-diff.ts"; -import { - type ExtractRetryOptions, - extractWithDefinitionRetry, -} from "../extract-with-retry.ts"; -import { - normalizeSecurityLabels, - type SecurityLabelProps, - securityLabelPropsSchema, -} from "../security-label.types.ts"; -import { ReplicaIdentitySchema } from "../table/table.model.ts"; - -const viewPropsSchema = z.object({ - schema: z.string(), - name: z.string(), - definition: z.string(), - row_security: z.boolean(), - force_row_security: z.boolean(), - has_indexes: z.boolean(), - has_rules: z.boolean(), - has_triggers: z.boolean(), - has_subclasses: z.boolean(), - is_populated: z.boolean(), - replica_identity: ReplicaIdentitySchema, - is_partition: z.boolean(), - options: z.array(z.string()).nullable(), - partition_bound: z.string().nullable(), - owner: z.string(), - comment: z.string().nullable(), - columns: z.array(columnPropsSchema), - privileges: z.array(privilegePropsSchema), - security_labels: z.array(securityLabelPropsSchema).default([]).optional(), -}); - -// pg_get_viewdef(oid) can return NULL when the underlying view (or its -// pg_rewrite row) is dropped between catalog scan and resolution, or under -// transient catalog state during recovery. An unreadable view cannot be -// diffed, so we accept NULL here and filter the row out at extraction time -// rather than crashing the whole catalog parse with a ZodError. -const viewRowSchema = viewPropsSchema.extend({ - definition: z.string().nullable(), -}); - -type ViewPrivilegeProps = PrivilegeProps; -export type ViewProps = z.infer; - -export class View extends BasePgModel implements TableLikeObject { - public readonly schema: ViewProps["schema"]; - public readonly name: ViewProps["name"]; - public readonly definition: ViewProps["definition"]; - public readonly row_security: ViewProps["row_security"]; - public readonly force_row_security: ViewProps["force_row_security"]; - public readonly has_indexes: ViewProps["has_indexes"]; - public readonly has_rules: ViewProps["has_rules"]; - public readonly has_triggers: ViewProps["has_triggers"]; - public readonly has_subclasses: ViewProps["has_subclasses"]; - public readonly is_populated: ViewProps["is_populated"]; - public readonly replica_identity: ViewProps["replica_identity"]; - public readonly is_partition: ViewProps["is_partition"]; - public readonly options: ViewProps["options"]; - public readonly partition_bound: ViewProps["partition_bound"]; - public readonly owner: ViewProps["owner"]; - public readonly comment: ViewProps["comment"]; - public readonly columns: ViewProps["columns"]; - public readonly privileges: ViewPrivilegeProps[]; - public readonly security_labels: SecurityLabelProps[]; - - constructor(props: ViewProps) { - super(); - - // Identity fields - this.schema = props.schema; - this.name = props.name; - - // Data fields - this.definition = props.definition; - this.row_security = props.row_security; - this.force_row_security = props.force_row_security; - this.has_indexes = props.has_indexes; - this.has_rules = props.has_rules; - this.has_triggers = props.has_triggers; - this.has_subclasses = props.has_subclasses; - this.is_populated = props.is_populated; - this.replica_identity = props.replica_identity; - this.is_partition = props.is_partition; - this.options = props.options; - this.partition_bound = props.partition_bound; - this.owner = props.owner; - this.comment = props.comment; - this.columns = props.columns; - this.privileges = props.privileges; - this.security_labels = props.security_labels ?? []; - } - - get stableId(): `view:${string}` { - return `view:${this.schema}.${this.name}`; - } - - get identityFields() { - return { - schema: this.schema, - name: this.name, - }; - } - - get dataFields() { - return { - definition: this.definition, - row_security: this.row_security, - force_row_security: this.force_row_security, - has_indexes: this.has_indexes, - has_rules: this.has_rules, - has_triggers: this.has_triggers, - has_subclasses: this.has_subclasses, - is_populated: this.is_populated, - replica_identity: this.replica_identity, - is_partition: this.is_partition, - options: this.options, - partition_bound: this.partition_bound, - owner: this.owner, - comment: this.comment, - columns: this.columns, - privileges: this.privileges, - security_labels: this.security_labels, - }; - } - - override stableSnapshot() { - return { - identity: this.identityFields, - data: { - ...this.dataFields, - columns: normalizeColumns(this.columns), - security_labels: normalizeSecurityLabels(this.security_labels), - }, - }; - } -} - -export async function extractViews( - pool: Pool, - options?: ExtractRetryOptions, -): Promise { - const viewRows = await extractWithDefinitionRetry({ - label: "views", - options, - hasNullDefinition: (row) => row.definition === null, - query: async () => { - const result = await pool.query(sql` -with extension_oids as ( - select - objid - from - pg_depend d - where - d.refclassid = 'pg_extension'::regclass - and d.classid = 'pg_class'::regclass -), views as ( - select - c.relnamespace::regnamespace::text as schema, - quote_ident(c.relname) as name, - rtrim(pg_get_viewdef(c.oid), ';') as definition, - c.relrowsecurity as row_security, - c.relforcerowsecurity as force_row_security, - c.relhasindex as has_indexes, - c.relhasrules as has_rules, - c.relhastriggers as has_triggers, - c.relhassubclass as has_subclasses, - c.relispopulated as is_populated, - c.relreplident as replica_identity, - c.relispartition as is_partition, - c.reloptions as options, - pg_get_expr(c.relpartbound, c.oid) as partition_bound, - c.relowner::regrole::text as owner, - obj_description(c.oid, 'pg_class') as comment, - c.oid as oid - from - pg_catalog.pg_class c - left outer join extension_oids e on c.oid = e.objid - where not c.relnamespace::regnamespace::text like any(array['pg\\_%', 'information\\_schema']) - and e.objid is null - and c.relkind = 'v' -) -select - v.schema, - v.name, - v.definition, - v.row_security, - v.force_row_security, - v.has_indexes, - v.has_rules, - v.has_triggers, - v.has_subclasses, - v.is_populated, - v.replica_identity, - v.is_partition, - v.options, - v.partition_bound, - v.owner, - v.comment, - coalesce(json_agg( - case when a.attname is not null then - json_build_object( - 'name', quote_ident(a.attname), - 'position', a.attnum, - 'data_type', a.atttypid::regtype::text, - 'data_type_str', format_type(a.atttypid, a.atttypmod), - 'is_custom_type', ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema'), - 'custom_type_type', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typtype else null end, - 'custom_type_category', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typcategory else null end, - 'custom_type_schema', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typnamespace::regnamespace else null end, - 'custom_type_name', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then quote_ident(ty.typname) else null end, - 'not_null', a.attnotnull, - 'is_identity', a.attidentity != '', - 'is_identity_always', a.attidentity = 'a', - 'is_generated', a.attgenerated != '', - 'collation', ( - select quote_ident(c2.collname) - from pg_collation c2, pg_type t2 - where c2.oid = a.attcollation - and t2.oid = a.atttypid - and a.attcollation <> t2.typcollation - ), - 'default', pg_get_expr(ad.adbin, ad.adrelid), - 'comment', col_description(a.attrelid, a.attnum) - ) - end - order by a.attnum - ) filter (where a.attname is not null), '[]') as columns, - coalesce(( - select json_agg( - json_build_object( - 'grantee', case when grp.grantee = 0 then 'PUBLIC' else grp.grantee::regrole::text end, - 'privilege', grp.privilege_type, - 'grantable', grp.is_grantable, - 'columns', case when grp.cols is not null and array_length(grp.cols,1) > 0 - then grp.cols - else null end - ) - order by grp.grantee, grp.privilege_type - ) - from ( - select - x.grantee, - x.privilege_type, - bool_or(x.is_grantable) as is_grantable, - array_agg(quote_ident(src.attname) order by src.attname) - filter (where src.attname is not null) as cols - from ( - -- one row for object ACL + one row per column ACL - select null::name as attname, v.oid as relacl_oid, ( - select COALESCE(c_rel.relacl, acldefault('r', c_rel.relowner)) from pg_class c_rel where c_rel.oid = v.oid - ) as acl - union all - select a2.attname, v.oid as relacl_oid, a2.attacl - from pg_attribute a2 - where a2.attrelid = v.oid - and a2.attnum > 0 - and not a2.attisdropped - and a2.attacl is not null - ) as src - join lateral aclexplode(src.acl) as x(grantor, grantee, privilege_type, is_grantable) on true - group by x.grantee, x.privilege_type - ) as grp - ), '[]') as privileges, - coalesce( - ( - select json_agg( - json_build_object('provider', sl.provider, 'label', sl.label) - order by sl.provider - ) - from pg_catalog.pg_seclabel sl - where sl.objoid = v.oid - and sl.classoid = 'pg_class'::regclass - and sl.objsubid = 0 - ), - '[]'::json - ) as security_labels -from - views v - left join pg_attribute a on a.attrelid = v.oid and a.attnum > 0 and not a.attisdropped - left join pg_attrdef ad on a.attrelid = ad.adrelid and a.attnum = ad.adnum - left join pg_type ty on ty.oid = a.atttypid -group by - v.oid, v.schema, v.name, v.definition, v.row_security, v.force_row_security, v.has_indexes, v.has_rules, v.has_triggers, v.has_subclasses, v.is_populated, v.replica_identity, v.is_partition, v.options, v.partition_bound, v.owner, v.comment -order by - v.schema, v.name - `); - return result.rows.map((row: unknown) => viewRowSchema.parse(row)); - }, - }); - const validatedRows = viewRows.filter( - (row): row is ViewProps => row.definition !== null, - ); - return validatedRows.map((row) => new View(row)); -} diff --git a/packages/pg-delta/src/core/plan/apply.ts b/packages/pg-delta/src/core/plan/apply.ts deleted file mode 100644 index 6235f51a6..000000000 --- a/packages/pg-delta/src/core/plan/apply.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Plan application - execute migration plans against target databases. - */ - -import type { Pool, PoolClient } from "pg"; -import { diffCatalogs } from "../catalog.diff.ts"; -import { extractCatalog } from "../catalog.model.ts"; -import type { DiffContext } from "../context.ts"; -import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts"; -import { compileFilterDSL } from "../integrations/filter/dsl.ts"; -import { createManagedPool, endPool } from "../postgres-config.ts"; -import { sortChanges } from "../sort/sort-changes.ts"; -import { normalizePlan } from "./normalize.ts"; -import { renderPlanSql } from "./render.ts"; -import type { MigrationUnit, Plan } from "./types.ts"; - -type ApplyPlanResult = - | { status: "invalid_plan"; message: string } - | { status: "fingerprint_mismatch"; current: string; expected: string } - | { status: "already_applied" } - | { - status: "applied"; - statements: number; - units: number; - warnings?: string[]; - } - | { - status: "failed"; - error: unknown; - script: string; - /** 0-based index of the unit that failed; undefined if a session statement failed. */ - failedUnitIndex?: number; - /** Number of units fully committed before the failure. */ - completedUnits: number; - }; - -interface ApplyPlanOptions { - verifyPostApply?: boolean; -} - -type ConnectionInput = string | Pool; - -/** - * Apply a plan's migration units to a target database with integrity checks. - * Validates fingerprints before and after application to ensure plan integrity. - * - * Units are applied in order on a single session: transactional units inside - * an explicit BEGIN/COMMIT, non-transactional units without a wrapper. A - * failure does not roll back units that already committed. - */ -export async function applyPlan( - plan: Plan, - source: ConnectionInput, - target: ConnectionInput, - options: ApplyPlanOptions = {}, -): Promise { - const normalizedPlan = normalizePlan(plan); - const units = normalizedPlan.units; - if (units.length === 0) { - return { - status: "invalid_plan", - message: "Plan contains no SQL statements to execute.", - }; - } - - let currentPool: Pool; - let desiredPool: Pool; - let shouldCloseCurrent = false; - let shouldCloseDesired = false; - - if (typeof source === "string") { - const managed = await createManagedPool(source, { - role: normalizedPlan.role, - label: "source", - }); - currentPool = managed.pool; - shouldCloseCurrent = true; - } else { - currentPool = source; - } - - if (typeof target === "string") { - const managed = await createManagedPool(target, { - role: normalizedPlan.role, - label: "target", - }); - desiredPool = managed.pool; - shouldCloseDesired = true; - } else { - desiredPool = target; - } - - try { - // Recompute stableIds and fingerprints from current and desired catalogs - const [currentCatalog, desiredCatalog] = await Promise.all([ - extractCatalog(currentPool), - extractCatalog(desiredPool), - ]); - - const changes = diffCatalogs(currentCatalog, desiredCatalog); - const ctx: DiffContext = { - mainCatalog: currentCatalog, - branchCatalog: desiredCatalog, - }; - - // Apply the same filter that was used to create the plan (if any) - let filteredChanges = changes; - if (normalizedPlan.filter) { - const filterFn = compileFilterDSL(normalizedPlan.filter); - filteredChanges = filteredChanges.filter((change) => filterFn(change)); - } - - const sortedChanges = sortChanges(ctx, filteredChanges); - const { hash: fingerprintFrom, stableIds } = buildPlanScopeFingerprint( - ctx.mainCatalog, - sortedChanges, - ); - // We intentionally recompute target fingerprint only after applying. - - // Pre-apply fingerprint validation - if (fingerprintFrom === normalizedPlan.target.fingerprint) { - return { status: "already_applied" }; - } - - if (fingerprintFrom !== normalizedPlan.source.fingerprint) { - return { - status: "fingerprint_mismatch", - current: fingerprintFrom, - expected: normalizedPlan.source.fingerprint, - }; - } - - const script = renderPlanSql(normalizedPlan); - let completedUnits = 0; - let unitStarted = false; - - // A single session for the whole plan: session statements (SET ROLE, - // SET check_function_bodies) must stay in effect across all units. - const client = await currentPool.connect(); - try { - for (const statement of normalizedPlan.sessionStatements) { - await client.query(statement); - } - for (const unit of units) { - unitStarted = true; - await applyUnit(client, unit); - completedUnits++; - } - } catch (error) { - return { - status: "failed", - error, - script, - failedUnitIndex: unitStarted ? completedUnits : undefined, - completedUnits, - }; - } finally { - client.release(); - } - - const warnings: string[] = []; - - if (options.verifyPostApply !== false) { - try { - const updatedCatalog = await extractCatalog(currentPool); - const updatedFingerprint = hashStableIds(updatedCatalog, stableIds); - if (updatedFingerprint !== normalizedPlan.target.fingerprint) { - warnings.push( - "Post-apply fingerprint does not match the plan target fingerprint.", - ); - } - } catch (error) { - warnings.push( - `Could not verify post-apply fingerprint: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return { - status: "applied", - // Units contain only change statements; session statements are not counted. - statements: units.reduce((sum, unit) => sum + unit.statements.length, 0), - units: units.length, - warnings: warnings.length ? warnings : undefined, - }; - } finally { - const closers: Promise[] = []; - if (shouldCloseCurrent) closers.push(endPool(currentPool)); - if (shouldCloseDesired) closers.push(endPool(desiredPool)); - if (closers.length) { - await Promise.all(closers); - } - } -} - -async function applyUnit( - client: PoolClient, - unit: MigrationUnit, -): Promise { - if (unit.transactionMode === "transactional") { - await client.query("BEGIN"); - try { - for (const statement of unit.statements) { - await client.query(statement); - } - await client.query("COMMIT"); - } catch (error) { - await client.query("ROLLBACK").catch(() => {}); - throw error; - } - return; - } - - for (const statement of unit.statements) { - await client.query(statement); - } -} diff --git a/packages/pg-delta/src/core/plan/create.ts b/packages/pg-delta/src/core/plan/create.ts deleted file mode 100644 index b028ad01a..000000000 --- a/packages/pg-delta/src/core/plan/create.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Plan creation - the main entry point for creating migration plans. - */ - -import type { Pool } from "pg"; -import { diffCatalogs } from "../catalog.diff.ts"; -import type { Catalog } from "../catalog.model.ts"; -import { createEmptyCatalog, extractCatalog } from "../catalog.model.ts"; -import type { Change } from "../change.types.ts"; -import type { DiffContext } from "../context.ts"; -import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts"; -import type { FilterDSL } from "../integrations/filter/dsl.ts"; -import { - type ResolvedIntegration, - resolveIntegration, -} from "../integrations/integration.types.ts"; -import type { SerializeDSL } from "../integrations/serialize/dsl.ts"; -import { createManagedPool, endPool } from "../postgres-config.ts"; -import { sortChanges } from "../sort/sort-changes.ts"; -import type { PgDependRow } from "../sort/types.ts"; -import { buildExecutionPlan } from "./execution.ts"; -import { classifyChangesRisk } from "./risk.ts"; -import type { CreatePlanOptions, Plan } from "./types.ts"; - -// ============================================================================ -// Plan Creation -// ============================================================================ - -/** - * Input for source/target: a postgres connection URL, an existing Pool, or - * an already-resolved Catalog (e.g. deserialized from a snapshot file). - */ -export type CatalogInput = string | Pool | Catalog; - -/** - * Bundle-safe catalog detection: treat input as a resolved Catalog when it has - * the catalog shape and is not a pg Pool. Deserialized or cross-bundle Catalog - * instances may fail `instanceof Catalog` but pass this guard. - */ -function isResolvedCatalog(input: CatalogInput): input is Catalog { - return ( - typeof input === "object" && - input !== null && - typeof (input as { query?: unknown }).query !== "function" && - "version" in input && - "currentUser" in input && - "depends" in input && - "schemas" in input && - "tables" in input && - "views" in input - ); -} - -/** - * Create a migration plan by comparing two catalog states. - * - * Each input can be: - * - A postgres connection URL (string) -- a pool is created and catalog extracted - * - An existing pg Pool -- catalog is extracted directly - * - A Catalog instance -- used as-is (e.g. from a deserialized snapshot) - * - * When `source` is `null`, a minimal empty catalog (`createEmptyCatalog`) is - * used as the baseline. For a more accurate baseline, pass a Catalog - * deserialized from a snapshot of `template1` or another reference database. - * - * @param source - Source catalog input (current state), or null for empty baseline - * @param target - Target catalog input (desired state) - * @param options - Optional configuration - * @returns A Plan if there are changes, null if databases are identical - */ -export async function createPlan( - source: CatalogInput | null, - target: CatalogInput, - options: CreatePlanOptions = {}, -): Promise<{ plan: Plan; sortedChanges: Change[]; ctx: DiffContext } | null> { - const resolvePool = async ( - input: string | Pool, - label: "source" | "target", - ): Promise<{ pool: Pool; shouldClose: boolean }> => { - if (typeof input === "string") { - const managed = await createManagedPool(input, { - role: options.role, - label, - }); - return { pool: managed.pool, shouldClose: true }; - } - return { pool: input, shouldClose: false }; - }; - - /** - * Resolve a CatalogInput to a Catalog, tracking pools that need cleanup. - */ - const resolveCatalog = async ( - input: CatalogInput, - label: "source" | "target", - pools: Array<{ pool: Pool; shouldClose: boolean }>, - ): Promise => { - if (isResolvedCatalog(input)) { - return input; - } - const resolved = await resolvePool(input, label); - pools.push(resolved); - return extractCatalog(resolved.pool, { - extractRetries: options.extractRetries, - }); - }; - - const pools: Array<{ pool: Pool; shouldClose: boolean }> = []; - - try { - const toCatalog = await resolveCatalog(target, "target", pools); - - const fromCatalog = - source !== null - ? await resolveCatalog(source, "source", pools) - : await createEmptyCatalog(toCatalog.version, toCatalog.currentUser); - - return buildPlanForCatalogs(fromCatalog, toCatalog, options); - } finally { - const closers = pools - .filter((p) => p.shouldClose) - .map((p) => endPool(p.pool)); - if (closers.length) await Promise.all(closers); - } -} - -/** - * Build a plan (and supporting artifacts) from already extracted catalogs. - */ -function buildPlanForCatalogs( - fromCatalog: Catalog, - toCatalog: Catalog, - options: CreatePlanOptions = {}, -): { plan: Plan; sortedChanges: Change[]; ctx: DiffContext } | null { - const changes = diffCatalogs(fromCatalog, toCatalog, { - role: options.role, - skipDefaultPrivilegeSubtraction: options.skipDefaultPrivilegeSubtraction, - }); - - const filterOption = options.filter; - const serializeOption = options.serialize; - const ctx: DiffContext = { - mainCatalog: fromCatalog, - branchCatalog: toCatalog, - }; - - // Determine if filter/serialize are DSL or functions, and extract DSL for storage - const isFilterDSL = filterOption && typeof filterOption !== "function"; - const isSerializeDSL = - serializeOption && typeof serializeOption !== "function"; - const filterDSL = isFilterDSL ? (filterOption as FilterDSL) : undefined; - const serializeDSL = isSerializeDSL - ? (serializeOption as SerializeDSL) - : undefined; - - // Build final integration: compile DSL if needed, use functions directly otherwise - const finalIntegration = resolveIntegration({ - filter: filterOption, - serialize: serializeOption, - }); - - // Use filter from final integration - const filterFn = finalIntegration?.filter; - - let filteredChanges = filterFn - ? changes.filter((change) => filterFn(change)) - : changes; - - // Cascade dependency exclusions: when a change is excluded by the filter, - // also exclude changes that depend on it (via requires or pg_depend). - // DSL filters: cascade only if explicitly opted in (cascade: true). Function filters: cascade by default. - const shouldCascade = isFilterDSL - ? (filterDSL as Record)?.cascade === true - : true; - if (filterFn && filteredChanges.length < changes.length && shouldCascade) { - filteredChanges = cascadeExclusions( - filteredChanges, - changes, - toCatalog.depends, - ); - } - - if (filteredChanges.length === 0) { - return null; - } - - const sortedChanges = sortChanges(ctx, filteredChanges); - const plan = buildPlan( - ctx, - sortedChanges, - options, - filterDSL, - serializeDSL, - finalIntegration, - ); - - return { plan, sortedChanges, ctx }; -} - -// ============================================================================ -// Dependency Cascading -// ============================================================================ - -/** - * Cascade exclusions through dependency relationships. - * - * When a change is excluded by the filter, any change that depends on it - * (via explicit `requires` or via catalog `pg_depend`) should also be excluded. - * This runs as a fixpoint loop, bounded by the total number of changes to - * guarantee deterministic termination. - * - * @param filteredChanges - Changes that passed the initial filter - * @param allChanges - All changes before filtering - * @param catalogDepends - Dependency rows from the target catalog (pg_depend) - * @returns The filtered changes with cascading exclusions applied - */ -function cascadeExclusions( - filteredChanges: Change[], - allChanges: Change[], - catalogDepends: PgDependRow[], -): Change[] { - // Collect stableIds created by initially-excluded changes - const filteredSet = new Set(filteredChanges); - const excludedIds = new Set(); - for (const change of allChanges) { - if (!filteredSet.has(change)) { - for (const id of change.creates ?? []) { - excludedIds.add(id); - } - } - } - - if (excludedIds.size === 0) { - return filteredChanges; - } - - // Build reverse dependency map: referenced_stable_id -> Set(dependent_stable_ids) - const catalogDependents = new Map>(); - for (const dep of catalogDepends) { - const existing = catalogDependents.get(dep.referenced_stable_id); - if (existing) { - existing.add(dep.dependent_stable_id); - } else { - catalogDependents.set( - dep.referenced_stable_id, - new Set([dep.dependent_stable_id]), - ); - } - } - - // Fixpoint loop: bounded by total changes to guarantee termination. - // Each iteration must remove at least one change, otherwise we break. - let result = filteredChanges; - for (let i = 0; i < allChanges.length; i++) { - const beforeLength = result.length; - result = result.filter((change) => { - // Check explicit requirements: does this change require an excluded id? - const requires = change.requires ?? []; - if (requires.some((dep) => excludedIds.has(dep))) { - for (const id of change.creates ?? []) { - excludedIds.add(id); - } - return false; - } - - // Check catalog dependencies: does anything this change creates - // depend on an excluded id via pg_depend? - const creates = change.creates ?? []; - for (const createdId of creates) { - for (const excludedId of excludedIds) { - const dependents = catalogDependents.get(excludedId); - if (dependents?.has(createdId)) { - for (const id of creates) { - excludedIds.add(id); - } - return false; - } - } - } - - return true; - }); - - // No changes removed this iteration — fixpoint reached - if (result.length === beforeLength) { - break; - } - } - - return result; -} - -// ============================================================================ -// Plan Building -// ============================================================================ - -/** - * Build a Plan from sorted changes. - */ -function buildPlan( - ctx: DiffContext, - changes: Change[], - options?: CreatePlanOptions, - filterDSL?: FilterDSL, - serializeDSL?: SerializeDSL, - integration?: ResolvedIntegration, -): Plan { - const role = options?.role; - const execution = buildExecutionPlan(changes, { integration, role }); - const risk = classifyChangesRisk(changes); - - const { hash: fingerprintFrom, stableIds } = buildPlanScopeFingerprint( - ctx.mainCatalog, - changes, - ); - const fingerprintTo = hashStableIds(ctx.branchCatalog, stableIds); - - return { - version: 2, - source: { fingerprint: fingerprintFrom }, - target: { fingerprint: fingerprintTo }, - units: execution.units, - sessionStatements: execution.sessionStatements, - role, - filter: filterDSL, - serialize: serializeDSL, - risk, - }; -} diff --git a/packages/pg-delta/src/core/plan/execution.test.ts b/packages/pg-delta/src/core/plan/execution.test.ts deleted file mode 100644 index f7ebc2d82..000000000 --- a/packages/pg-delta/src/core/plan/execution.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../change.types.ts"; -import { BaseChange } from "../objects/base.change.ts"; -import type { ColumnProps } from "../objects/base.model.ts"; -import { AlterTableAlterColumnSetDefault } from "../objects/table/changes/table.alter.ts"; -import type { Table } from "../objects/table/table.model.ts"; -import { AlterEnumAddValue } from "../objects/type/enum/changes/enum.alter.ts"; -import { Enum } from "../objects/type/enum/enum.model.ts"; -import { buildExecutionPlan } from "./execution.ts"; - -describe("buildExecutionPlan", () => { - test("splits after enum values before subsequent statements", () => { - const userRole = createEnum(["admin", "user", "store"]); - const column = createEnumColumn("'store'::public.user_role"); - - const execution = buildExecutionPlan([ - new AlterEnumAddValue({ - enum: userRole, - newValue: "store", - position: { after: "user" }, - }), - new AlterTableAlterColumnSetDefault({ - table: createTable(column), - column, - }), - ]); - - expect(execution.units).toHaveLength(2); - expect(execution.units[0].statements).toHaveLength(1); - expect(execution.units[1].reason).toBe("enum_value_visibility"); - expect(execution.units[1].transactionMode).toBe("transactional"); - }); - - test("keeps newly added enum values in one unit when nothing uses them", () => { - const userRole = createEnum(["admin", "user", "store"]); - - const execution = buildExecutionPlan([ - new AlterEnumAddValue({ - enum: userRole, - newValue: "store", - position: { after: "user" }, - }), - ]); - - expect(execution.units).toHaveLength(1); - expect(execution.units[0].reason).toBe("default"); - }); - - test("groups multiple enum additions before a dependent consumer", () => { - const userRole = createEnum(["admin", "user", "store", "auditor"]); - const column = createEnumColumn("'auditor'::public.user_role"); - - const execution = buildExecutionPlan([ - new AlterEnumAddValue({ - enum: userRole, - newValue: "store", - position: { after: "user" }, - }), - new AlterEnumAddValue({ - enum: userRole, - newValue: "auditor", - position: { after: "store" }, - }), - new AlterTableAlterColumnSetDefault({ - table: createTable(column), - column, - }), - ]); - - expect(execution.units).toHaveLength(2); - expect(execution.units[0].statements).toMatchInlineSnapshot(` - [ - "ALTER TYPE public.user_role ADD VALUE 'store' AFTER 'user'", - "ALTER TYPE public.user_role ADD VALUE 'auditor' AFTER 'store'", - ] - `); - }); - - test("splits after enum values before opaque later statements", () => { - const userRole = createEnum(["admin", "user", "store"]); - - const execution = buildExecutionPlan([ - new AlterEnumAddValue({ - enum: userRole, - newValue: "store", - position: { after: "user" }, - }), - new OpaqueEnumConsumerChange() as unknown as Change, - ]); - - expect(execution.units).toHaveLength(2); - expect(execution.units[1].reason).toBe("enum_value_visibility"); - expect(execution.units[1].statements[0]).toBe( - "CREATE VIEW public.store_profiles AS SELECT 'store'::public.user_role AS role", - ); - }); - - test("puts non-transactional statements in their own unit", () => { - const userRole = createEnum(["admin", "user", "store"]); - - const execution = buildExecutionPlan([ - new AlterEnumAddValue({ - enum: userRole, - newValue: "store", - position: { after: "user" }, - }), - new NonTransactionalChange() as unknown as Change, - new OpaqueEnumConsumerChange() as unknown as Change, - ]); - - expect(execution.units).toMatchInlineSnapshot(` - [ - { - "reason": "default", - "statements": [ - "ALTER TYPE public.user_role ADD VALUE 'store' AFTER 'user'", - ], - "transactionMode": "transactional", - }, - { - "reason": "non_transactional", - "statements": [ - "CREATE INDEX CONCURRENTLY users_email_idx ON public.users (email)", - ], - "transactionMode": "none", - }, - { - "reason": "default", - "statements": [ - "CREATE VIEW public.store_profiles AS SELECT 'store'::public.user_role AS role", - ], - "transactionMode": "transactional", - }, - ] - `); - }); - - test("routes SET ROLE and check_function_bodies into session statements, not units", () => { - const execution = buildExecutionPlan( - [new ProcedureChange() as unknown as Change], - { role: "app_owner" }, - ); - - expect(execution.sessionStatements).toEqual([ - 'SET ROLE "app_owner"', - "SET check_function_bodies = false", - ]); - expect(execution.units).toHaveLength(1); - expect(execution.units[0].statements).toEqual([ - "CREATE PROCEDURE public.noop() LANGUAGE sql AS $$ SELECT 1 $$", - ]); - }); -}); - -class NonTransactionalChange extends BaseChange { - readonly operation = "create"; - readonly objectType = "index"; - readonly scope = "object"; - - override get nonTransactional() { - return true; - } - - serialize(): string { - return "CREATE INDEX CONCURRENTLY users_email_idx ON public.users (email)"; - } -} - -class OpaqueEnumConsumerChange extends BaseChange { - readonly operation = "create"; - readonly objectType = "view"; - readonly scope = "object"; - - serialize(): string { - return "CREATE VIEW public.store_profiles AS SELECT 'store'::public.user_role AS role"; - } -} - -class ProcedureChange extends BaseChange { - readonly operation = "create"; - readonly objectType = "procedure"; - readonly scope = "object"; - - serialize(): string { - return "CREATE PROCEDURE public.noop() LANGUAGE sql AS $$ SELECT 1 $$"; - } -} - -function createEnum(labels: string[]): Enum { - return new Enum({ - schema: "public", - name: "user_role", - owner: "postgres", - labels: labels.map((label, index) => ({ - label, - sort_order: index + 1, - })), - comment: null, - privileges: [], - }); -} - -function createEnumColumn(defaultValue: string): ColumnProps { - return { - name: "role", - position: 1, - data_type: "USER-DEFINED", - data_type_str: "public.user_role", - is_custom_type: true, - custom_type_type: "e", - custom_type_category: "E", - custom_type_schema: "public", - custom_type_name: "user_role", - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: defaultValue, - comment: null, - }; -} - -function createTable(column: ColumnProps): Table { - return { - schema: "public", - name: "profiles", - stableId: "table:public.profiles", - columns: [column], - } as unknown as Table; -} diff --git a/packages/pg-delta/src/core/plan/execution.ts b/packages/pg-delta/src/core/plan/execution.ts deleted file mode 100644 index 331a41050..000000000 --- a/packages/pg-delta/src/core/plan/execution.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Execution planning - group sorted changes into transaction-aware - * migration units. - * - * Execution semantics come from the `nonTransactional` and `commitBoundary` - * traits declared on the change classes (see `base.change.ts`), never from - * inspecting rendered SQL. - */ - -import { escapeIdentifier } from "pg"; -import type { Change } from "../change.types.ts"; -import type { ResolvedIntegration } from "../integrations/integration.types.ts"; -import type { CommitBoundaryReason } from "../objects/base.change.ts"; -import type { ExecutionBoundaryReason, MigrationUnit } from "./types.ts"; - -interface BuildExecutionPlanOptions { - integration?: ResolvedIntegration; - role?: string; -} - -interface ExecutionPlan { - units: MigrationUnit[]; - sessionStatements: string[]; -} - -export function buildExecutionPlan( - changes: Change[], - options: BuildExecutionPlanOptions = {}, -): ExecutionPlan { - return { - units: buildMigrationUnits(changes, options.integration), - sessionStatements: buildSessionStatements(changes, options), - }; -} - -function buildSessionStatements( - changes: Change[], - options: BuildExecutionPlanOptions, -): string[] { - const statements: string[] = []; - - if (options.role) { - statements.push(`SET ROLE ${escapeIdentifier(options.role)}`); - } - - if (hasRoutineChanges(changes)) { - statements.push("SET check_function_bodies = false"); - } - - return statements; -} - -/** - * Check if any changes involve routines (procedures or aggregates). - * Used to determine if we need to disable function body checking. - */ -function hasRoutineChanges(changes: Change[]): boolean { - return changes.some( - (change) => - change.objectType === "procedure" || change.objectType === "aggregate", - ); -} - -function buildMigrationUnits( - changes: Change[], - integration?: ResolvedIntegration, -): MigrationUnit[] { - const units: MigrationUnit[] = []; - let current: string[] = []; - let reason: ExecutionBoundaryReason = "default"; - let pendingBoundary: CommitBoundaryReason | null = null; - - function flush(): void { - if (current.length === 0) return; - units.push({ - transactionMode: "transactional", - reason, - statements: current, - }); - current = []; - } - - for (const change of changes) { - const sql = integration?.serialize?.(change) ?? change.serialize(); - const boundary = change.commitBoundary; - - if (change.nonTransactional) { - flush(); - pendingBoundary = null; - reason = "default"; - units.push({ - transactionMode: "none", - reason: "non_transactional", - statements: [sql], - }); - continue; - } - - // Only producers of the same boundary kind share a unit; anything else - // (a different kind or a non-producer) runs after the producers' COMMIT. - if (pendingBoundary !== null && boundary !== pendingBoundary) { - flush(); - reason = pendingBoundary; - pendingBoundary = null; - } - - current.push(sql); - if (boundary !== null) { - pendingBoundary = boundary; - } - } - - flush(); - return units; -} diff --git a/packages/pg-delta/src/core/plan/hierarchy.ts b/packages/pg-delta/src/core/plan/hierarchy.ts deleted file mode 100644 index cec88c00a..000000000 --- a/packages/pg-delta/src/core/plan/hierarchy.ts +++ /dev/null @@ -1,574 +0,0 @@ -/** - * Hierarchical grouping of changes for tree display. - */ - -import type { Change } from "../change.types.ts"; -import type { DiffContext } from "../context.ts"; -import { - AlterTableAddColumn, - AlterTableAlterColumnDropDefault, - AlterTableAlterColumnDropNotNull, - AlterTableAlterColumnSetDefault, - AlterTableAlterColumnSetNotNull, - AlterTableAlterColumnType, - AlterTableDropColumn, -} from "../objects/table/changes/table.alter.ts"; -import { CreateTable } from "../objects/table/changes/table.create.ts"; -import { getObjectSchema, getParentInfo } from "./serialize.ts"; -import type { - ChangeGroup, - ClusterGroup, - HierarchicalPlan, - MaterializedViewChildren, - SchemaGroup, - TableChildren, - TypeGroup, -} from "./types.ts"; - -// ============================================================================ -// Empty Structure Factories -// ============================================================================ - -/** - * Create an empty ChangeGroup. - */ -function emptyChangeGroup(): ChangeGroup { - return { create: [], alter: [], drop: [] }; -} - -/** - * Create an empty TableChildren structure. - */ -function emptyTableChildren(): TableChildren { - return { - changes: emptyChangeGroup(), - columns: emptyChangeGroup(), - indexes: emptyChangeGroup(), - triggers: emptyChangeGroup(), - rules: emptyChangeGroup(), - policies: emptyChangeGroup(), - partitions: {}, - }; -} - -/** - * Create an empty MaterializedViewChildren structure. - */ -function emptyMaterializedViewChildren(): MaterializedViewChildren { - return { - changes: emptyChangeGroup(), - indexes: emptyChangeGroup(), - }; -} - -/** - * Create an empty TypeGroup structure. - */ -function emptyTypeGroup(): TypeGroup { - return { - enums: emptyChangeGroup(), - composites: emptyChangeGroup(), - ranges: emptyChangeGroup(), - domains: emptyChangeGroup(), - }; -} - -/** - * Create an empty SchemaGroup structure. - */ -function emptySchemaGroup(): SchemaGroup { - return { - changes: emptyChangeGroup(), - tables: {}, - views: {}, - materializedViews: {}, - functions: emptyChangeGroup(), - procedures: emptyChangeGroup(), - aggregates: emptyChangeGroup(), - sequences: emptyChangeGroup(), - types: emptyTypeGroup(), - collations: emptyChangeGroup(), - foreignTables: {}, - }; -} - -/** - * Create an empty ClusterGroup structure. - */ -function emptyClusterGroup(): ClusterGroup { - return { - roles: emptyChangeGroup(), - extensions: emptyChangeGroup(), - eventTriggers: emptyChangeGroup(), - publications: emptyChangeGroup(), - subscriptions: emptyChangeGroup(), - foreignDataWrappers: emptyChangeGroup(), - servers: emptyChangeGroup(), - userMappings: emptyChangeGroup(), - }; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/** - * Add a change to a ChangeGroup based on its operation. - */ -function addToChangeGroup(group: ChangeGroup, change: Change): void { - group[change.operation].push({ original: change }); -} - -/** - * Check if a Change is a column operation (ADD/DROP/ALTER COLUMN). - * Uses instanceof checks for type safety. - */ -function isColumnOperation(change: Change): string | null { - if (change.objectType !== "table") { - return null; - } - - if ( - change instanceof AlterTableAddColumn || - change instanceof AlterTableDropColumn || - change instanceof AlterTableAlterColumnType || - change instanceof AlterTableAlterColumnSetDefault || - change instanceof AlterTableAlterColumnDropDefault || - change instanceof AlterTableAlterColumnSetNotNull || - change instanceof AlterTableAlterColumnDropNotNull - ) { - return change.column.name; - } - - return null; -} - -/** - * Check if a Change creates a partition table. - * Returns the parent table name if it's a partition, null otherwise. - * Uses instanceof checks for type safety. - * - * IMPORTANT: This function should ONLY be called for table changes. - * Materialized views and other object types should never reach this function. - */ -function isPartitionTable(change: Change): string | null { - // First check: must be a table change - if (change.objectType !== "table") { - return null; - } - - // Second check: must be a CreateTable change (only CreateTable can create partitions) - // Use instanceof to safely verify the change type - if (!(change instanceof CreateTable)) { - return null; - } - - // Third check: verify the table is actually marked as a partition - // Both is_partition flag AND parent_name must be set - if (!change.table.is_partition || !change.table.parent_name) { - return null; - } - - return change.table.parent_name; -} - -/** - * Check if a table name (from an AlterTable change) is an existing partition. - * Checks both mainCatalog and branchCatalog to see if the table is a partition. - * Returns the parent table name if found, null otherwise. - */ -function isExistingPartition( - ctx: DiffContext, - schemaName: string, - tableName: string, -): string | null { - const tableKey = `${schemaName}.${tableName}`; - - // Check branchCatalog first (target state - where partitions should be) - const branchTable = ctx.branchCatalog.tables[tableKey]; - if (branchTable?.is_partition && branchTable.parent_name) { - return branchTable.parent_name; - } - - // Also check mainCatalog (source state) - const mainTable = ctx.mainCatalog.tables[tableKey]; - if (mainTable?.is_partition && mainTable.parent_name) { - return mainTable.parent_name; - } - - return null; -} - -// ============================================================================ -// Main Grouping Function -// ============================================================================ - -/** - * Group changes into a hierarchical structure for tree display. - * - * This function takes original Change objects (not SerializedChange) to enable - * detection of column operations, partitions, and other type-specific details. - * - * Organizes changes by: - * 1. Cluster-wide vs schema-scoped - * 2. Schema > Object Type > Object Name - * 3. Parent > Child (e.g., Table > Index, Table > Column) - * 4. Partitioned Table > Partition - */ -export function groupChangesHierarchically( - ctx: DiffContext, - changes: Change[], -): HierarchicalPlan { - const result: HierarchicalPlan = { - cluster: emptyClusterGroup(), - schemas: {}, - }; - - for (const change of changes) { - const columnName = isColumnOperation(change); - // Check for partitions: either creating a new partition (CreateTable) or any change on an existing partition - let partitionOf: string | null = null; - const changeSchema = getObjectSchema(change); - if (change.objectType === "table" && changeSchema) { - // First check if this is a CreateTable creating a partition - partitionOf = isPartitionTable(change); - // If not, check if this table is an existing partition (for any table change including privilege changes) - if (!partitionOf) { - partitionOf = isExistingPartition(ctx, changeSchema, change.table.name); - } - } - - if (!changeSchema) { - addClusterChange(result.cluster, change); - continue; - } - - if (!result.schemas[changeSchema]) { - result.schemas[changeSchema] = emptySchemaGroup(); - } - const schemaGroup = result.schemas[changeSchema]; - - const parent = getParentInfo(change); - if (parent) { - addChildChange(schemaGroup, change); - continue; - } - - addSchemaLevelChange(schemaGroup, change, { - columnName, - partitionOf, - }); - } - - return result; -} - -// ============================================================================ -// Add Functions (exhaustive on object types) -// ============================================================================ - -/** - * Add a change to the cluster group (exhaustive on cluster-wide types). - */ -function addClusterChange(cluster: ClusterGroup, change: Change): void { - const objectType = change.objectType; - - switch (objectType) { - case "role": - addToChangeGroup(cluster.roles, change); - break; - case "extension": - addToChangeGroup(cluster.extensions, change); - break; - case "event_trigger": - addToChangeGroup(cluster.eventTriggers, change); - break; - case "language": - // Languages are cluster-wide, but we don't have a group for them yet - break; - case "publication": - addToChangeGroup(cluster.publications, change); - break; - case "subscription": - addToChangeGroup(cluster.subscriptions, change); - break; - case "foreign_data_wrapper": - addToChangeGroup(cluster.foreignDataWrappers, change); - break; - case "server": - addToChangeGroup(cluster.servers, change); - break; - case "user_mapping": - addToChangeGroup(cluster.userMappings, change); - break; - case "aggregate": - case "collation": - case "composite_type": - case "domain": - case "enum": - case "foreign_table": - case "index": - case "materialized_view": - case "procedure": - case "range": - case "rls_policy": - case "rule": - case "schema": - case "sequence": - case "table": - case "trigger": - case "view": - // These have schemas and shouldn't be added to cluster group - break; - default: { - const _exhaustive: never = objectType; - throw new Error(`Unhandled object type: ${JSON.stringify(_exhaustive)}`); - } - } -} - -/** - * Add a child change (index, trigger, policy, rule) to its parent (exhaustive). - */ -function addChildChange(schema: SchemaGroup, change: Change): void { - const parentInfo = getParentInfo(change); - if (!parentInfo) return; - - const parentName = parentInfo.name; - const parentType = parentInfo.type; - - let parentGroup: TableChildren | MaterializedViewChildren; - - switch (parentType) { - case "table": - if (!schema.tables[parentName]) { - schema.tables[parentName] = emptyTableChildren(); - } - parentGroup = schema.tables[parentName]; - break; - case "view": - if (!schema.views[parentName]) { - schema.views[parentName] = emptyTableChildren(); - } - parentGroup = schema.views[parentName]; - break; - case "materialized_view": - if (!schema.materializedViews[parentName]) { - schema.materializedViews[parentName] = emptyMaterializedViewChildren(); - } - parentGroup = schema.materializedViews[parentName]; - break; - case "foreign_table": - if (!schema.foreignTables[parentName]) { - schema.foreignTables[parentName] = emptyTableChildren(); - } - parentGroup = schema.foreignTables[parentName]; - break; - default: { - const _exhaustive: never = parentType; - throw new Error(`Unhandled parent type: ${JSON.stringify(_exhaustive)}`); - } - } - - const objectType = change.objectType; - - switch (objectType) { - case "index": - addToChangeGroup(parentGroup.indexes, change); - break; - case "trigger": - if ("triggers" in parentGroup) { - addToChangeGroup(parentGroup.triggers, change); - } - break; - case "rule": - if ("rules" in parentGroup) { - addToChangeGroup(parentGroup.rules, change); - } - break; - case "rls_policy": - if ("policies" in parentGroup) { - addToChangeGroup(parentGroup.policies, change); - } - break; - case "aggregate": - case "collation": - case "composite_type": - case "domain": - case "enum": - case "event_trigger": - case "extension": - case "foreign_data_wrapper": - case "foreign_table": - case "language": - case "materialized_view": - case "procedure": - case "publication": - case "range": - case "role": - case "schema": - case "sequence": - case "server": - case "subscription": - case "table": - case "user_mapping": - case "view": - break; - default: { - const _exhaustive: never = objectType; - throw new Error(`Unhandled object type: ${JSON.stringify(_exhaustive)}`); - } - } -} - -/** - * Enrichment info detected from original Change objects. - */ -interface ChangeEnrichment { - columnName: string | null; - partitionOf: string | null; -} - -/** - * Add a schema-level change to the appropriate group (exhaustive). - */ -function addSchemaLevelChange( - schema: SchemaGroup, - change: Change, - enrichment: ChangeEnrichment, -): void { - const objectType = change.objectType; - - switch (objectType) { - case "schema": - addToChangeGroup(schema.changes, change); - break; - case "table": { - // Verify the original change is actually a table change - // (safeguard against materialized views or other objects being incorrectly routed here) - if (change.objectType !== "table") { - // This shouldn't happen, but if it does, skip this change - // It will be handled by its correct objectType case - break; - } - - if (enrichment.columnName) { - const tableName = change.table.name; - if (!schema.tables[tableName]) { - schema.tables[tableName] = emptyTableChildren(); - } - addToChangeGroup(schema.tables[tableName].columns, change); - break; - } - - if (enrichment.partitionOf) { - // For CreateTable changes, verify it's actually a partition - if (change instanceof CreateTable) { - // Additional verification: ensure the table is actually marked as a partition - if (!change.table.is_partition || !change.table.parent_name) { - // Table has parent_name but is_partition is false (inheritance, not partitioning) - // Treat as regular table change - const tableName = change.table.name; - if (!schema.tables[tableName]) { - schema.tables[tableName] = emptyTableChildren(); - } - addToChangeGroup(schema.tables[tableName].changes, change); - break; - } - } - // For AlterTable changes on existing partitions, enrichment.partitionOf comes from catalog lookup - // which is already verified, so we can trust it - - const parentName = enrichment.partitionOf; - if (!schema.tables[parentName]) { - schema.tables[parentName] = emptyTableChildren(); - } - const partitionName = change.table.name; - if (!schema.tables[parentName].partitions[partitionName]) { - schema.tables[parentName].partitions[partitionName] = - emptyTableChildren(); - } - addToChangeGroup( - schema.tables[parentName].partitions[partitionName].changes, - change, - ); - break; - } - - const tableName = change.table.name; - if (!schema.tables[tableName]) { - schema.tables[tableName] = emptyTableChildren(); - } - addToChangeGroup(schema.tables[tableName].changes, change); - break; - } - case "view": { - const viewName = change.view.name; - if (!schema.views[viewName]) { - schema.views[viewName] = emptyTableChildren(); - } - addToChangeGroup(schema.views[viewName].changes, change); - break; - } - case "materialized_view": { - const matviewName = change.materializedView.name; - if (!schema.materializedViews[matviewName]) { - schema.materializedViews[matviewName] = emptyMaterializedViewChildren(); - } - addToChangeGroup(schema.materializedViews[matviewName].changes, change); - break; - } - case "foreign_table": { - const ftName = change.foreignTable.name; - if (!schema.foreignTables[ftName]) { - schema.foreignTables[ftName] = emptyTableChildren(); - } - addToChangeGroup(schema.foreignTables[ftName].changes, change); - break; - } - case "procedure": - addToChangeGroup(schema.functions, change); - break; - case "aggregate": - addToChangeGroup(schema.aggregates, change); - break; - case "sequence": - addToChangeGroup(schema.sequences, change); - break; - case "enum": - addToChangeGroup(schema.types.enums, change); - break; - case "composite_type": - addToChangeGroup(schema.types.composites, change); - break; - case "range": - addToChangeGroup(schema.types.ranges, change); - break; - case "domain": - addToChangeGroup(schema.types.domains, change); - break; - case "collation": - addToChangeGroup(schema.collations, change); - break; - case "extension": - break; - case "index": - case "trigger": - case "rule": - case "rls_policy": - break; - case "event_trigger": - case "foreign_data_wrapper": - case "language": - case "publication": - case "role": - case "server": - case "subscription": - case "user_mapping": - break; - default: { - const _exhaustive: never = objectType; - throw new Error(`Unhandled object type: ${JSON.stringify(_exhaustive)}`); - } - } -} diff --git a/packages/pg-delta/src/core/plan/index.ts b/packages/pg-delta/src/core/plan/index.ts deleted file mode 100644 index 71a85c797..000000000 --- a/packages/pg-delta/src/core/plan/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Plan module - create and organize migration plans. - * - * @example - * ```ts - * import { createPlan, groupChangesHierarchically } from "./plan"; - * - * const planResult = await createPlan(fromUrl, toUrl); - * if (planResult) { - * const { plan, sortedChanges, ctx } = planResult; - * const hierarchy = groupChangesHierarchically(ctx, sortedChanges); - * console.log(renderPlanSql(plan)); - * } - * ``` - */ - -// Plan creation -export { createPlan } from "./create.ts"; -// Hierarchical grouping - -// Plan I/O -export { deserializePlan, serializePlan } from "./io.ts"; -// Types -export type { - ChangeEntry, - ChangeGroup, - HierarchicalPlan, - Plan, -} from "./types.ts"; diff --git a/packages/pg-delta/src/core/plan/io.ts b/packages/pg-delta/src/core/plan/io.ts deleted file mode 100644 index f058e3051..000000000 --- a/packages/pg-delta/src/core/plan/io.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Plan I/O utilities for serializing and deserializing plans to/from JSON. - */ - -import { normalizePlan } from "./normalize.ts"; -import { type Plan, PlanSchema } from "./types.ts"; - -/** - * Serialize a plan to JSON string. - */ -export function serializePlan(plan: Plan): string { - return JSON.stringify(plan, null, 2); -} - -/** - * Deserialize a plan from JSON string. Legacy v1 plans (flat `statements`) - * are normalized into migration units. - */ -export function deserializePlan(json: string): Plan { - const parsed = JSON.parse(json); - return normalizePlan(PlanSchema.parse(parsed)); -} diff --git a/packages/pg-delta/src/core/plan/normalize.test.ts b/packages/pg-delta/src/core/plan/normalize.test.ts deleted file mode 100644 index 8d5c1befb..000000000 --- a/packages/pg-delta/src/core/plan/normalize.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { normalizePlan } from "./normalize.ts"; -import type { SerializedPlan } from "./types.ts"; - -describe("normalizePlan", () => { - test("hydrates legacy v1 plans into a single transactional unit", () => { - const legacy: SerializedPlan = { - version: 1, - source: { fingerprint: "source" }, - target: { fingerprint: "target" }, - role: "app_owner", - statements: [ - 'SET ROLE "app_owner"', - "CREATE TABLE public.users (id integer)", - "CREATE INDEX users_id_idx ON public.users (id)", - ], - }; - - const plan = normalizePlan(legacy); - - expect(plan.sessionStatements).toEqual(['SET ROLE "app_owner"']); - expect(plan.units).toMatchInlineSnapshot(` - [ - { - "reason": "default", - "statements": [ - "CREATE TABLE public.users (id integer)", - "CREATE INDEX users_id_idx ON public.users (id)", - ], - "transactionMode": "transactional", - }, - ] - `); - expect("statements" in plan).toBe(false); - }); - - test("hydrates legacy v1 plans with only SET statements into zero units", () => { - const legacy: SerializedPlan = { - version: 1, - source: { fingerprint: "source" }, - target: { fingerprint: "target" }, - statements: ['SET ROLE "app_owner"'], - }; - - const plan = normalizePlan(legacy); - expect(plan.units).toEqual([]); - expect(plan.sessionStatements).toEqual(['SET ROLE "app_owner"']); - }); - - test("passes v2 plans through and defaults sessionStatements", () => { - const units = [ - { - transactionMode: "transactional" as const, - reason: "default" as const, - statements: ["CREATE TABLE public.users (id integer)"], - }, - ]; - const v2: SerializedPlan = { - version: 2, - source: { fingerprint: "source" }, - target: { fingerprint: "target" }, - units, - }; - - const plan = normalizePlan(v2); - expect(plan.units).toEqual(units); - expect(plan.sessionStatements).toEqual([]); - }); -}); diff --git a/packages/pg-delta/src/core/plan/normalize.ts b/packages/pg-delta/src/core/plan/normalize.ts deleted file mode 100644 index 672ab64c9..000000000 --- a/packages/pg-delta/src/core/plan/normalize.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { MigrationUnit, Plan, SerializedPlan } from "./types.ts"; - -/** - * Normalize a plan into the v2 shape: `units` + `sessionStatements`. - * - * Legacy v1 plans carry a flat `statements` array instead of units. Their - * leading SET statements become session statements, and the remaining - * statements become a single transactional unit — faithful to how the v1 - * applier executed them (one multi-statement query, i.e. one implicit - * transaction). - */ -export function normalizePlan(plan: SerializedPlan): Plan { - const { statements, ...rest } = plan; - return { - ...rest, - units: plan.units ?? legacyUnits(statements ?? []), - sessionStatements: - plan.sessionStatements ?? - (statements ?? []).filter((statement) => isSessionStatement(statement)), - }; -} - -function isSessionStatement(statement: string): boolean { - return /^SET\s+/i.test(statement.trim()); -} - -function legacyUnits(statements: string[]): MigrationUnit[] { - const schemaStatements = statements.filter( - (statement) => !isSessionStatement(statement), - ); - if (schemaStatements.length === 0) return []; - - return [ - { - transactionMode: "transactional", - reason: "default", - statements: schemaStatements, - }, - ]; -} diff --git a/packages/pg-delta/src/core/plan/render.test.ts b/packages/pg-delta/src/core/plan/render.test.ts deleted file mode 100644 index 7e823252b..000000000 --- a/packages/pg-delta/src/core/plan/render.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - flattenPlanStatements, - renderPlanFiles, - renderPlanSql, -} from "./render.ts"; -import type { Plan } from "./types.ts"; - -describe("plan rendering", () => { - test("renders single SQL scripts with unit boundary comments", () => { - expect(renderPlanSql(createPlan())).toMatchInlineSnapshot(` - "-- Migration unit 1: schema_changes - -- Transaction mode: transactional - -- Boundary reason: default - - SET ROLE app_owner; - - BEGIN; - - ALTER TYPE public.user_role ADD VALUE 'store'; - - COMMIT; - - -- Migration unit 2: after_enum_values - -- Transaction mode: transactional - -- Boundary reason: enum_value_visibility - - SET ROLE app_owner; - - BEGIN; - - ALTER TABLE public.profiles ALTER COLUMN role SET DEFAULT 'store'::public.user_role; - - COMMIT;" - `); - }); - - test("renders numbered migration files from units", () => { - const files = renderPlanFiles(createPlan()); - - expect(files.map((file) => file.path)).toMatchInlineSnapshot(` - [ - "001_schema_changes.sql", - "002_after_enum_values.sql", - ] - `); - - expect(files[0].sql).toMatchInlineSnapshot(` - "-- Migration unit 1: schema_changes - -- Transaction mode: transactional - -- Boundary reason: default - - SET ROLE app_owner; - - BEGIN; - - ALTER TYPE public.user_role ADD VALUE 'store'; - - COMMIT;" - `); - - expect(files[1].sql).toMatchInlineSnapshot(` - "-- Migration unit 2: after_enum_values - -- Transaction mode: transactional - -- Boundary reason: enum_value_visibility - - SET ROLE app_owner; - - BEGIN; - - ALTER TABLE public.profiles ALTER COLUMN role SET DEFAULT 'store'::public.user_role; - - COMMIT;" - `); - }); - - test("renders non-transactional units without transaction wrappers", () => { - const plan = createPlan(); - plan.units = [ - { - transactionMode: "none", - reason: "non_transactional", - statements: [ - "CREATE INDEX CONCURRENTLY users_email_idx ON public.users (email)", - ], - }, - ]; - - expect(renderPlanSql(plan)).toMatchInlineSnapshot(` - "-- Migration unit 1: non_transactional - -- Transaction mode: none - -- Boundary reason: non_transactional - -- Run statement-by-statement (psql does this; do not use psql -1 or - -- send this script as a single multi-statement query string). - - SET ROLE app_owner; - - CREATE INDEX CONCURRENTLY users_email_idx ON public.users (email);" - `); - }); - - test("flattenPlanStatements includes session statements", () => { - expect(flattenPlanStatements(createPlan())).toEqual([ - "SET ROLE app_owner", - "ALTER TYPE public.user_role ADD VALUE 'store'", - "ALTER TABLE public.profiles ALTER COLUMN role SET DEFAULT 'store'::public.user_role", - ]); - }); -}); - -function createPlan(): Plan { - return { - version: 2, - source: { fingerprint: "source" }, - target: { fingerprint: "target" }, - role: "app_owner", - sessionStatements: ["SET ROLE app_owner"], - units: [ - { - transactionMode: "transactional", - reason: "default", - statements: ["ALTER TYPE public.user_role ADD VALUE 'store'"], - }, - { - transactionMode: "transactional", - reason: "enum_value_visibility", - statements: [ - "ALTER TABLE public.profiles ALTER COLUMN role SET DEFAULT 'store'::public.user_role", - ], - }, - ], - risk: { level: "safe" }, - }; -} diff --git a/packages/pg-delta/src/core/plan/render.ts b/packages/pg-delta/src/core/plan/render.ts deleted file mode 100644 index 878972423..000000000 --- a/packages/pg-delta/src/core/plan/render.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Plan rendering - turn migration units into executable SQL scripts. - */ - -import { normalizePlan } from "./normalize.ts"; -import type { SqlFormatOptions } from "./sql-format.ts"; -import { formatSqlStatements } from "./sql-format.ts"; -import type { - ExecutionBoundaryReason, - MigrationUnit, - Plan, - SerializedPlan, -} from "./types.ts"; - -const STATEMENT_DELIMITER = ";\n\n"; - -export interface RenderPlanSqlOptions { - sqlFormatOptions?: SqlFormatOptions; - includeTransactions?: boolean; -} - -export interface RenderedPlanFile { - path: string; - sql: string; - unit: MigrationUnit; -} - -/** - * Render the whole plan as a single SQL script. Each migration unit is - * delimited with header comments and, for transactional units, wrapped in - * explicit BEGIN/COMMIT. - */ -export function renderPlanSql( - plan: SerializedPlan, - options: RenderPlanSqlOptions = {}, -): string { - const normalized = normalizePlan(plan); - return normalized.units - .map((unit, index) => renderUnitSql(normalized, unit, index, options)) - .join("\n\n"); -} - -/** - * Render the plan as one numbered SQL file per migration unit. Session - * statements are repeated in every file because each file may be executed - * in its own session. - */ -export function renderPlanFiles( - plan: SerializedPlan, - options: RenderPlanSqlOptions = {}, -): RenderedPlanFile[] { - const normalized = normalizePlan(plan); - return normalized.units.map((unit, index) => ({ - path: `${String(index + 1).padStart(3, "0")}_${unitName(unit.reason)}.sql`, - sql: renderUnitSql(normalized, unit, index, options), - unit, - })); -} - -/** - * Flatten a plan back into the ordered statement list, session statements - * included. Execution context (transaction boundaries) is lost — use - * `renderPlanSql`/`renderPlanFiles` or `plan.units` when it matters. - */ -export function flattenPlanStatements(plan: SerializedPlan): string[] { - const normalized = normalizePlan(plan); - return [ - ...normalized.sessionStatements, - ...normalized.units.flatMap((unit) => unit.statements), - ]; -} - -/** Display name for a migration unit, derived from its boundary reason. */ -function unitName(reason: ExecutionBoundaryReason): string { - switch (reason) { - case "default": - return "schema_changes"; - case "enum_value_visibility": - return "after_enum_values"; - case "non_transactional": - return "non_transactional"; - } -} - -function renderUnitSql( - plan: Plan, - unit: MigrationUnit, - index: number, - options: RenderPlanSqlOptions, -): string { - const includeTransactions = options.includeTransactions !== false; - const body = - options.sqlFormatOptions != null - ? formatSqlStatements(unit.statements, options.sqlFormatOptions) - : unit.statements; - - const lines: string[] = [ - `-- Migration unit ${index + 1}: ${unitName(unit.reason)}`, - `-- Transaction mode: ${unit.transactionMode}`, - `-- Boundary reason: ${unit.reason}`, - ]; - - if (unit.transactionMode === "none") { - // PostgreSQL runs every statement of a multi-command simple-query string - // in an implicit transaction block, so this unit can never execute as - // part of a single query string — no SET/COMMIT shuffling changes that. - lines.push( - "-- Run statement-by-statement (psql does this; do not use psql -1 or", - "-- send this script as a single multi-statement query string).", - ); - } - - lines.push(""); - - if (plan.sessionStatements.length > 0) { - lines.push(renderStatements(plan.sessionStatements)); - lines.push(""); - } - - if (includeTransactions && unit.transactionMode === "transactional") { - lines.push("BEGIN;"); - lines.push(""); - } - - lines.push(renderStatements(body)); - - if (includeTransactions && unit.transactionMode === "transactional") { - lines.push(""); - lines.push("COMMIT;"); - } - - return lines.join("\n").trimEnd(); -} - -function renderStatements(statements: string[]): string { - if (statements.length === 0) return ""; - return `${statements.map(trimTerminator).join(STATEMENT_DELIMITER)};`; -} - -/** - * Strip trailing semicolons so every rendered statement ends with exactly - * one. pg-delta's own serializers emit no terminator, but plan JSON is a - * persisted artifact — legacy v1 files, hand-built units, or user-edited - * plans may already carry one, and joining those blindly would render ";;". - */ -function trimTerminator(statement: string): string { - const trimmed = statement.trim(); - let end = trimmed.length; - while (end > 0 && trimmed.charCodeAt(end - 1) === 59) { - end--; - } - return trimmed.slice(0, end); -} diff --git a/packages/pg-delta/src/core/plan/risk.ts b/packages/pg-delta/src/core/plan/risk.ts deleted file mode 100644 index d308559c5..000000000 --- a/packages/pg-delta/src/core/plan/risk.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Risk classification for migration plans. - * Identifies data-loss operations that require explicit confirmation. - */ - -import type { Change } from "../change.types.ts"; -import { DropSequence } from "../objects/sequence/changes/sequence.drop.ts"; -import { AlterTableDropColumn } from "../objects/table/changes/table.alter.ts"; -import { DropTable } from "../objects/table/changes/table.drop.ts"; -import type { PlanRisk } from "./types.ts"; - -/** - * Classify a single change for data-loss risk. - */ -function classifyChangeRisk(change: Change): string | null { - if (change instanceof DropTable) { - return `drop table ${change.table.schema}.${change.table.name}`; - } - - if (change instanceof AlterTableDropColumn) { - return `drop column ${change.column.name} on ${change.table.schema}.${change.table.name}`; - } - - if (change instanceof DropSequence) { - return `drop sequence ${change.sequence.schema}.${change.sequence.name}`; - } - - // Extend here if TRUNCATE or other data-loss operations are added. - return null; -} - -/** - * Classify all changes for data-loss risk. - */ -export function classifyChangesRisk(changes: Change[]): PlanRisk { - const statements: string[] = []; - - for (const change of changes) { - const reason = classifyChangeRisk(change); - if (reason) statements.push(reason); - } - - if (statements.length > 0) { - return { level: "data_loss", statements }; - } - - return { level: "safe" }; -} diff --git a/packages/pg-delta/src/core/plan/serialize.test.ts b/packages/pg-delta/src/core/plan/serialize.test.ts deleted file mode 100644 index 4469f516d..000000000 --- a/packages/pg-delta/src/core/plan/serialize.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../change.types.ts"; -import { getObjectName, getObjectSchema, getParentInfo } from "./serialize.ts"; - -describe("getObjectName", () => { - const cases: [string, unknown, string][] = [ - [ - "aggregate", - { objectType: "aggregate", aggregate: { name: "my_agg" } }, - "my_agg", - ], - [ - "collation", - { objectType: "collation", collation: { name: "my_coll" } }, - "my_coll", - ], - [ - "composite_type", - { objectType: "composite_type", compositeType: { name: "my_comp" } }, - "my_comp", - ], - [ - "domain", - { objectType: "domain", domain: { name: "my_domain" } }, - "my_domain", - ], - ["enum", { objectType: "enum", enum: { name: "my_enum" } }, "my_enum"], - [ - "event_trigger", - { objectType: "event_trigger", eventTrigger: { name: "my_evt" } }, - "my_evt", - ], - [ - "extension", - { objectType: "extension", extension: { name: "my_ext" } }, - "my_ext", - ], - [ - "foreign_data_wrapper", - { - objectType: "foreign_data_wrapper", - foreignDataWrapper: { name: "my_fdw" }, - }, - "my_fdw", - ], - [ - "foreign_table", - { objectType: "foreign_table", foreignTable: { name: "my_ft" } }, - "my_ft", - ], - ["index", { objectType: "index", index: { name: "my_idx" } }, "my_idx"], - [ - "language", - { objectType: "language", language: { name: "plpgsql" } }, - "plpgsql", - ], - [ - "materialized_view", - { objectType: "materialized_view", materializedView: { name: "my_mv" } }, - "my_mv", - ], - [ - "procedure", - { objectType: "procedure", procedure: { name: "my_proc" } }, - "my_proc", - ], - [ - "publication", - { objectType: "publication", publication: { name: "my_pub" } }, - "my_pub", - ], - ["range", { objectType: "range", range: { name: "my_range" } }, "my_range"], - [ - "rls_policy", - { objectType: "rls_policy", policy: { name: "my_policy" } }, - "my_policy", - ], - ["role", { objectType: "role", role: { name: "my_role" } }, "my_role"], - ["rule", { objectType: "rule", rule: { name: "my_rule" } }, "my_rule"], - [ - "schema", - { objectType: "schema", schema: { name: "my_schema" } }, - "my_schema", - ], - [ - "sequence", - { objectType: "sequence", sequence: { name: "my_seq" } }, - "my_seq", - ], - [ - "server", - { objectType: "server", server: { name: "my_server" } }, - "my_server", - ], - [ - "subscription", - { objectType: "subscription", subscription: { name: "my_sub" } }, - "my_sub", - ], - ["table", { objectType: "table", table: { name: "my_table" } }, "my_table"], - [ - "trigger", - { objectType: "trigger", trigger: { name: "my_trigger" } }, - "my_trigger", - ], - [ - "user_mapping", - { - objectType: "user_mapping", - userMapping: { user: "alice", server: "remote" }, - }, - "alice@remote", - ], - ["view", { objectType: "view", view: { name: "my_view" } }, "my_view"], - ]; - - for (const [label, stub, expected] of cases) { - test(label, () => { - expect(getObjectName(stub as unknown as Change)).toBe(expected); - }); - } -}); - -describe("getObjectSchema", () => { - const withSchema: [string, unknown, string][] = [ - [ - "aggregate", - { objectType: "aggregate", aggregate: { schema: "public" } }, - "public", - ], - [ - "collation", - { objectType: "collation", collation: { schema: "pg_catalog" } }, - "pg_catalog", - ], - [ - "composite_type", - { objectType: "composite_type", compositeType: { schema: "public" } }, - "public", - ], - [ - "domain", - { objectType: "domain", domain: { schema: "public" } }, - "public", - ], - ["enum", { objectType: "enum", enum: { schema: "public" } }, "public"], - [ - "extension", - { objectType: "extension", extension: { schema: "public" } }, - "public", - ], - [ - "foreign_table", - { objectType: "foreign_table", foreignTable: { schema: "public" } }, - "public", - ], - ["index", { objectType: "index", index: { schema: "public" } }, "public"], - [ - "materialized_view", - { - objectType: "materialized_view", - materializedView: { schema: "public" }, - }, - "public", - ], - [ - "procedure", - { objectType: "procedure", procedure: { schema: "public" } }, - "public", - ], - ["range", { objectType: "range", range: { schema: "public" } }, "public"], - [ - "rls_policy", - { objectType: "rls_policy", policy: { schema: "public" } }, - "public", - ], - ["rule", { objectType: "rule", rule: { schema: "public" } }, "public"], - [ - "schema", - { objectType: "schema", schema: { name: "my_schema" } }, - "my_schema", - ], - [ - "sequence", - { objectType: "sequence", sequence: { schema: "public" } }, - "public", - ], - ["table", { objectType: "table", table: { schema: "public" } }, "public"], - [ - "trigger", - { objectType: "trigger", trigger: { schema: "public" } }, - "public", - ], - ["view", { objectType: "view", view: { schema: "public" } }, "public"], - ]; - - for (const [label, stub, expected] of withSchema) { - test(`${label} returns schema`, () => { - expect(getObjectSchema(stub as unknown as Change)).toBe(expected); - }); - } - - const withoutSchema: [string, unknown][] = [ - ["event_trigger", { objectType: "event_trigger" }], - ["foreign_data_wrapper", { objectType: "foreign_data_wrapper" }], - ["language", { objectType: "language" }], - ["publication", { objectType: "publication" }], - ["role", { objectType: "role" }], - ["server", { objectType: "server" }], - ["subscription", { objectType: "subscription" }], - ["user_mapping", { objectType: "user_mapping" }], - ]; - - for (const [label, stub] of withoutSchema) { - test(`${label} returns null`, () => { - expect(getObjectSchema(stub as unknown as Change)).toBeNull(); - }); - } -}); - -describe("getParentInfo", () => { - test("index on table", () => { - const change = { - objectType: "index", - index: { table_name: "users", table_relkind: "r" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ type: "table", name: "users" }); - }); - - test("index on materialized view", () => { - const change = { - objectType: "index", - index: { table_name: "user_stats", table_relkind: "m" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ - type: "materialized_view", - name: "user_stats", - }); - }); - - test("trigger on table", () => { - const change = { - objectType: "trigger", - trigger: { table_name: "orders", table_relkind: "r" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ type: "table", name: "orders" }); - }); - - test("trigger on view", () => { - const change = { - objectType: "trigger", - trigger: { table_name: "order_view", table_relkind: "v" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ type: "view", name: "order_view" }); - }); - - test("trigger on materialized view", () => { - const change = { - objectType: "trigger", - trigger: { table_name: "order_mv", table_relkind: "m" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ - type: "materialized_view", - name: "order_mv", - }); - }); - - test("rule on table", () => { - const change = { - objectType: "rule", - rule: { table_name: "items", relation_kind: "r" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ type: "table", name: "items" }); - }); - - test("rule on view", () => { - const change = { - objectType: "rule", - rule: { table_name: "item_view", relation_kind: "v" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ type: "view", name: "item_view" }); - }); - - test("rule on materialized view", () => { - const change = { - objectType: "rule", - rule: { table_name: "item_mv", relation_kind: "m" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ - type: "materialized_view", - name: "item_mv", - }); - }); - - test("rls_policy", () => { - const change = { - objectType: "rls_policy", - policy: { table_name: "secrets" }, - } as unknown as Change; - expect(getParentInfo(change)).toEqual({ type: "table", name: "secrets" }); - }); - - const nullCases = [ - "table", - "view", - "schema", - "sequence", - "role", - "extension", - ] as const; - for (const objectType of nullCases) { - test(`${objectType} returns null`, () => { - const change = { objectType } as unknown as Change; - expect(getParentInfo(change)).toBeNull(); - }); - } -}); diff --git a/packages/pg-delta/src/core/plan/serialize.ts b/packages/pg-delta/src/core/plan/serialize.ts deleted file mode 100644 index b6131d4b8..000000000 --- a/packages/pg-delta/src/core/plan/serialize.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Helpers for extracting object info from Change objects. - */ - -import type { Change } from "../change.types.ts"; -import type { ParentType } from "./types.ts"; - -// ============================================================================ -// Type-safe Change Accessors (with exhaustive checking) -// ============================================================================ - -/** - * Parent info for child objects (indexes, triggers, etc.) - */ -type ParentInfo = { - type: ParentType; - name: string; -}; - -/** - * Get the object name from a change (exhaustive). - */ -export function getObjectName(change: Change): string { - switch (change.objectType) { - case "aggregate": - return change.aggregate.name; - case "collation": - return change.collation.name; - case "composite_type": - return change.compositeType.name; - case "domain": - return change.domain.name; - case "enum": - return change.enum.name; - case "event_trigger": - return change.eventTrigger.name; - case "extension": - return change.extension.name; - case "foreign_data_wrapper": - return change.foreignDataWrapper.name; - case "foreign_table": - return change.foreignTable.name; - case "index": - return change.index.name; - case "language": - return change.language.name; - case "materialized_view": - return change.materializedView.name; - case "procedure": - return change.procedure.name; - case "publication": - return change.publication.name; - case "range": - return change.range.name; - case "rls_policy": - return change.policy.name; - case "role": - return change.role.name; - case "rule": - return change.rule.name; - case "schema": - return change.schema.name; - case "sequence": - return change.sequence.name; - case "server": - return change.server.name; - case "subscription": - return change.subscription.name; - case "table": - return change.table.name; - case "trigger": - return change.trigger.name; - case "user_mapping": - return `${change.userMapping.user}@${change.userMapping.server}`; - case "view": - return change.view.name; - default: { - const _exhaustive: never = change; - return _exhaustive; - } - } -} - -/** - * Get the schema from a change, or null for cluster-wide objects (exhaustive). - */ -export function getObjectSchema(change: Change): string | null { - switch (change.objectType) { - case "aggregate": - return change.aggregate.schema; - case "collation": - return change.collation.schema; - case "composite_type": - return change.compositeType.schema; - case "domain": - return change.domain.schema; - case "enum": - return change.enum.schema; - case "event_trigger": - return null; - case "extension": - return change.extension.schema; - case "foreign_data_wrapper": - return null; - case "foreign_table": - return change.foreignTable.schema; - case "index": - return change.index.schema; - case "language": - return null; - case "materialized_view": - return change.materializedView.schema; - case "procedure": - return change.procedure.schema; - case "publication": - return null; - case "range": - return change.range.schema; - case "rls_policy": - return change.policy.schema; - case "role": - return null; - case "rule": - return change.rule.schema; - case "schema": - return change.schema.name; - case "sequence": - return change.sequence.schema; - case "server": - return null; - case "subscription": - return null; - case "table": - return change.table.schema; - case "trigger": - return change.trigger.schema; - case "user_mapping": - return null; - case "view": - return change.view.schema; - default: { - const _exhaustive: never = change; - return _exhaustive; - } - } -} - -/** - * Get parent info for child objects (indexes, triggers, policies, rules). - * Returns null for top-level objects (exhaustive). - */ -export function getParentInfo(change: Change): ParentInfo | null { - switch (change.objectType) { - case "index": { - // Indexes can belong to tables or materialized views - // Use table_relkind to determine the parent type: 'r' = table, 'm' = materialized view - const parentType = - change.index.table_relkind === "m" ? "materialized_view" : "table"; - return { type: parentType, name: change.index.table_name }; - } - case "trigger": { - const parentType = - change.trigger.table_relkind === "v" - ? "view" - : change.trigger.table_relkind === "m" - ? "materialized_view" - : "table"; - return { type: parentType, name: change.trigger.table_name }; - } - case "rule": { - const parentType = - change.rule.relation_kind === "v" - ? "view" - : change.rule.relation_kind === "m" - ? "materialized_view" - : "table"; - return { type: parentType, name: change.rule.table_name }; - } - case "rls_policy": - return { type: "table", name: change.policy.table_name }; - case "aggregate": - case "collation": - case "composite_type": - case "domain": - case "enum": - case "event_trigger": - case "extension": - case "foreign_data_wrapper": - case "foreign_table": - case "language": - case "materialized_view": - case "procedure": - case "publication": - case "range": - case "role": - case "schema": - case "sequence": - case "server": - case "subscription": - case "table": - case "user_mapping": - case "view": - return null; - default: { - const _exhaustive: never = change; - return _exhaustive; - } - } -} diff --git a/packages/pg-delta/src/core/plan/sql-format.ts b/packages/pg-delta/src/core/plan/sql-format.ts deleted file mode 100644 index 584bde14b..000000000 --- a/packages/pg-delta/src/core/plan/sql-format.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { formatSqlStatements } from "./sql-format/index.ts"; -export type { SqlFormatOptions } from "./sql-format/types.ts"; diff --git a/packages/pg-delta/src/core/plan/sql-format/fixtures.ts b/packages/pg-delta/src/core/plan/sql-format/fixtures.ts deleted file mode 100644 index da2f31592..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/fixtures.ts +++ /dev/null @@ -1,2812 +0,0 @@ -import { Aggregate } from "../../objects/aggregate/aggregate.model.ts"; -import { AlterAggregateChangeOwner } from "../../objects/aggregate/changes/aggregate.alter.ts"; -import { - CreateCommentOnAggregate, - DropCommentOnAggregate, -} from "../../objects/aggregate/changes/aggregate.comment.ts"; -// ── Aggregate changes ─────────────────────────────────────────────────────── -import { CreateAggregate } from "../../objects/aggregate/changes/aggregate.create.ts"; -import { DropAggregate } from "../../objects/aggregate/changes/aggregate.drop.ts"; -import { - GrantAggregatePrivileges, - RevokeAggregatePrivileges, - RevokeGrantOptionAggregatePrivileges, -} from "../../objects/aggregate/changes/aggregate.privilege.ts"; -import type { ColumnProps } from "../../objects/base.model.ts"; -import { - AlterCollationChangeOwner, - AlterCollationRefreshVersion, -} from "../../objects/collation/changes/collation.alter.ts"; -import { - CreateCommentOnCollation, - DropCommentOnCollation, -} from "../../objects/collation/changes/collation.comment.ts"; -// ── Collation changes ─────────────────────────────────────────────────────── -import { CreateCollation } from "../../objects/collation/changes/collation.create.ts"; -import { DropCollation } from "../../objects/collation/changes/collation.drop.ts"; -// ── Models ────────────────────────────────────────────────────────────────── -import { Collation } from "../../objects/collation/collation.model.ts"; -import { - AlterDomainAddConstraint, - AlterDomainChangeOwner, - AlterDomainDropConstraint, - AlterDomainDropDefault, - AlterDomainDropNotNull, - AlterDomainSetDefault, - AlterDomainSetNotNull, - AlterDomainValidateConstraint, -} from "../../objects/domain/changes/domain.alter.ts"; -import { - CreateCommentOnDomain, - DropCommentOnDomain, -} from "../../objects/domain/changes/domain.comment.ts"; -// ── Domain changes ────────────────────────────────────────────────────────── -import { CreateDomain } from "../../objects/domain/changes/domain.create.ts"; -import { DropDomain } from "../../objects/domain/changes/domain.drop.ts"; -import { - GrantDomainPrivileges, - RevokeDomainPrivileges, - RevokeGrantOptionDomainPrivileges, -} from "../../objects/domain/changes/domain.privilege.ts"; -import { - Domain, - type DomainConstraintProps, -} from "../../objects/domain/domain.model.ts"; -import { - AlterEventTriggerChangeOwner, - AlterEventTriggerSetEnabled, -} from "../../objects/event-trigger/changes/event-trigger.alter.ts"; -import { - CreateCommentOnEventTrigger, - DropCommentOnEventTrigger, -} from "../../objects/event-trigger/changes/event-trigger.comment.ts"; -// ── Event Trigger changes ─────────────────────────────────────────────────── -import { CreateEventTrigger } from "../../objects/event-trigger/changes/event-trigger.create.ts"; -import { DropEventTrigger } from "../../objects/event-trigger/changes/event-trigger.drop.ts"; -import { EventTrigger } from "../../objects/event-trigger/event-trigger.model.ts"; -import { - AlterExtensionSetSchema, - AlterExtensionUpdateVersion, -} from "../../objects/extension/changes/extension.alter.ts"; -import { - CreateCommentOnExtension, - DropCommentOnExtension, -} from "../../objects/extension/changes/extension.comment.ts"; -// ── Extension changes ─────────────────────────────────────────────────────── -import { CreateExtension } from "../../objects/extension/changes/extension.create.ts"; -import { DropExtension } from "../../objects/extension/changes/extension.drop.ts"; -import { Extension } from "../../objects/extension/extension.model.ts"; -import { - AlterForeignDataWrapperChangeOwner, - AlterForeignDataWrapperSetOptions, -} from "../../objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts"; -import { - CreateCommentOnForeignDataWrapper, - DropCommentOnForeignDataWrapper, -} from "../../objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.comment.ts"; -// ── Foreign Data Wrapper changes ──────────────────────────────────────────── -import { CreateForeignDataWrapper } from "../../objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts"; -import { DropForeignDataWrapper } from "../../objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.drop.ts"; -import { - GrantForeignDataWrapperPrivileges, - RevokeForeignDataWrapperPrivileges, - RevokeGrantOptionForeignDataWrapperPrivileges, -} from "../../objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.privilege.ts"; -import { ForeignDataWrapper } from "../../objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts"; -import { - AlterForeignTableAddColumn, - AlterForeignTableAlterColumnDropDefault, - AlterForeignTableAlterColumnDropNotNull, - AlterForeignTableAlterColumnSetDefault, - AlterForeignTableAlterColumnSetNotNull, - AlterForeignTableAlterColumnType, - AlterForeignTableChangeOwner, - AlterForeignTableDropColumn, - AlterForeignTableSetOptions, -} from "../../objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts"; -import { - CreateCommentOnForeignTable, - DropCommentOnForeignTable, -} from "../../objects/foreign-data-wrapper/foreign-table/changes/foreign-table.comment.ts"; -// ── Foreign Table changes ─────────────────────────────────────────────────── -import { CreateForeignTable } from "../../objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts"; -import { DropForeignTable } from "../../objects/foreign-data-wrapper/foreign-table/changes/foreign-table.drop.ts"; -import { - GrantForeignTablePrivileges, - RevokeForeignTablePrivileges, - RevokeGrantOptionForeignTablePrivileges, -} from "../../objects/foreign-data-wrapper/foreign-table/changes/foreign-table.privilege.ts"; -import { ForeignTable } from "../../objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts"; -import { - AlterServerChangeOwner, - AlterServerSetOptions, - AlterServerSetVersion, -} from "../../objects/foreign-data-wrapper/server/changes/server.alter.ts"; -import { - CreateCommentOnServer, - DropCommentOnServer, -} from "../../objects/foreign-data-wrapper/server/changes/server.comment.ts"; -// ── Server changes ────────────────────────────────────────────────────────── -import { CreateServer } from "../../objects/foreign-data-wrapper/server/changes/server.create.ts"; -import { DropServer } from "../../objects/foreign-data-wrapper/server/changes/server.drop.ts"; -import { - GrantServerPrivileges, - RevokeGrantOptionServerPrivileges, - RevokeServerPrivileges, -} from "../../objects/foreign-data-wrapper/server/changes/server.privilege.ts"; -import { Server } from "../../objects/foreign-data-wrapper/server/server.model.ts"; -import { AlterUserMappingSetOptions } from "../../objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts"; -// ── User Mapping changes ──────────────────────────────────────────────────── -import { CreateUserMapping } from "../../objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts"; -import { DropUserMapping } from "../../objects/foreign-data-wrapper/user-mapping/changes/user-mapping.drop.ts"; -import { UserMapping } from "../../objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts"; -import { - AlterIndexSetStatistics, - AlterIndexSetStorageParams, -} from "../../objects/index/changes/index.alter.ts"; -import { - CreateCommentOnIndex, - DropCommentOnIndex, -} from "../../objects/index/changes/index.comment.ts"; -// ── Index changes ─────────────────────────────────────────────────────────── -import { CreateIndex } from "../../objects/index/changes/index.create.ts"; -import { DropIndex } from "../../objects/index/changes/index.drop.ts"; -import { Index } from "../../objects/index/index.model.ts"; -import { AlterLanguageChangeOwner } from "../../objects/language/changes/language.alter.ts"; -import { - CreateCommentOnLanguage, - DropCommentOnLanguage, -} from "../../objects/language/changes/language.comment.ts"; -// ── Language changes ──────────────────────────────────────────────────────── -import { CreateLanguage } from "../../objects/language/changes/language.create.ts"; -import { DropLanguage } from "../../objects/language/changes/language.drop.ts"; -import { - GrantLanguagePrivileges, - RevokeGrantOptionLanguagePrivileges, - RevokeLanguagePrivileges, -} from "../../objects/language/changes/language.privilege.ts"; -import { Language } from "../../objects/language/language.model.ts"; -import { - AlterMaterializedViewChangeOwner, - AlterMaterializedViewSetStorageParams, -} from "../../objects/materialized-view/changes/materialized-view.alter.ts"; -import { - CreateCommentOnMaterializedView, - CreateCommentOnMaterializedViewColumn, - DropCommentOnMaterializedView, - DropCommentOnMaterializedViewColumn, -} from "../../objects/materialized-view/changes/materialized-view.comment.ts"; -// ── Materialized View changes ─────────────────────────────────────────────── -import { CreateMaterializedView } from "../../objects/materialized-view/changes/materialized-view.create.ts"; -import { DropMaterializedView } from "../../objects/materialized-view/changes/materialized-view.drop.ts"; -import { - GrantMaterializedViewPrivileges, - RevokeGrantOptionMaterializedViewPrivileges, - RevokeMaterializedViewPrivileges, -} from "../../objects/materialized-view/changes/materialized-view.privilege.ts"; -import { MaterializedView } from "../../objects/materialized-view/materialized-view.model.ts"; -import { - AlterProcedureChangeOwner, - AlterProcedureSetConfig, - AlterProcedureSetLeakproof, - AlterProcedureSetParallel, - AlterProcedureSetSecurity, - AlterProcedureSetStrictness, - AlterProcedureSetVolatility, -} from "../../objects/procedure/changes/procedure.alter.ts"; -import { - CreateCommentOnProcedure, - DropCommentOnProcedure, -} from "../../objects/procedure/changes/procedure.comment.ts"; - -// ── Procedure / Function changes ──────────────────────────────────────────── -import { CreateProcedure } from "../../objects/procedure/changes/procedure.create.ts"; -import { DropProcedure } from "../../objects/procedure/changes/procedure.drop.ts"; -import { - GrantProcedurePrivileges, - RevokeGrantOptionProcedurePrivileges, - RevokeProcedurePrivileges, -} from "../../objects/procedure/changes/procedure.privilege.ts"; -import { Procedure } from "../../objects/procedure/procedure.model.ts"; -import { - AlterPublicationAddSchemas, - AlterPublicationAddTables, - AlterPublicationDropSchemas, - AlterPublicationDropTables, - AlterPublicationSetList, - AlterPublicationSetOptions, - AlterPublicationSetOwner, -} from "../../objects/publication/changes/publication.alter.ts"; -import { - CreateCommentOnPublication, - DropCommentOnPublication, -} from "../../objects/publication/changes/publication.comment.ts"; -// ── Publication changes ───────────────────────────────────────────────────── -import { CreatePublication } from "../../objects/publication/changes/publication.create.ts"; -import { DropPublication } from "../../objects/publication/changes/publication.drop.ts"; -import { Publication } from "../../objects/publication/publication.model.ts"; -import { - AlterRlsPolicySetRoles, - AlterRlsPolicySetUsingExpression, - AlterRlsPolicySetWithCheckExpression, -} from "../../objects/rls-policy/changes/rls-policy.alter.ts"; -import { - CreateCommentOnRlsPolicy, - DropCommentOnRlsPolicy, -} from "../../objects/rls-policy/changes/rls-policy.comment.ts"; -// ── RLS Policy changes ────────────────────────────────────────────────────── -import { CreateRlsPolicy } from "../../objects/rls-policy/changes/rls-policy.create.ts"; -import { DropRlsPolicy } from "../../objects/rls-policy/changes/rls-policy.drop.ts"; -import { RlsPolicy } from "../../objects/rls-policy/rls-policy.model.ts"; -import { - AlterRoleSetConfig, - AlterRoleSetOptions, -} from "../../objects/role/changes/role.alter.ts"; -import { - CreateCommentOnRole, - DropCommentOnRole, -} from "../../objects/role/changes/role.comment.ts"; -// ── Role changes ──────────────────────────────────────────────────────────── -import { CreateRole } from "../../objects/role/changes/role.create.ts"; -import { DropRole } from "../../objects/role/changes/role.drop.ts"; -import { - GrantRoleDefaultPrivileges, - GrantRoleMembership, - RevokeRoleDefaultPrivileges, - RevokeRoleMembership, - RevokeRoleMembershipOptions, -} from "../../objects/role/changes/role.privilege.ts"; -import { Role } from "../../objects/role/role.model.ts"; -import { - ReplaceRule, - SetRuleEnabledState, -} from "../../objects/rule/changes/rule.alter.ts"; -import { - CreateCommentOnRule, - DropCommentOnRule, -} from "../../objects/rule/changes/rule.comment.ts"; -// ── Rule changes ──────────────────────────────────────────────────────────── -import { CreateRule } from "../../objects/rule/changes/rule.create.ts"; -import { DropRule } from "../../objects/rule/changes/rule.drop.ts"; -import { Rule } from "../../objects/rule/rule.model.ts"; -import { AlterSchemaChangeOwner } from "../../objects/schema/changes/schema.alter.ts"; -import { - CreateCommentOnSchema, - DropCommentOnSchema, -} from "../../objects/schema/changes/schema.comment.ts"; -// ── Schema changes ────────────────────────────────────────────────────────── -import { CreateSchema } from "../../objects/schema/changes/schema.create.ts"; -import { DropSchema } from "../../objects/schema/changes/schema.drop.ts"; -import { - GrantSchemaPrivileges, - RevokeGrantOptionSchemaPrivileges, - RevokeSchemaPrivileges, -} from "../../objects/schema/changes/schema.privilege.ts"; -import { Schema } from "../../objects/schema/schema.model.ts"; -import { - AlterSequenceSetOptions, - AlterSequenceSetOwnedBy, -} from "../../objects/sequence/changes/sequence.alter.ts"; -import { - CreateCommentOnSequence, - DropCommentOnSequence, -} from "../../objects/sequence/changes/sequence.comment.ts"; -// ── Sequence changes ──────────────────────────────────────────────────────── -import { CreateSequence } from "../../objects/sequence/changes/sequence.create.ts"; -import { DropSequence } from "../../objects/sequence/changes/sequence.drop.ts"; -import { - GrantSequencePrivileges, - RevokeGrantOptionSequencePrivileges, - RevokeSequencePrivileges, -} from "../../objects/sequence/changes/sequence.privilege.ts"; -import { Sequence } from "../../objects/sequence/sequence.model.ts"; -import { - AlterSubscriptionDisable, - AlterSubscriptionEnable, - AlterSubscriptionSetConnection, - AlterSubscriptionSetOptions, - AlterSubscriptionSetOwner, - AlterSubscriptionSetPublication, -} from "../../objects/subscription/changes/subscription.alter.ts"; -import { - CreateCommentOnSubscription, - DropCommentOnSubscription, -} from "../../objects/subscription/changes/subscription.comment.ts"; -// ── Subscription changes ──────────────────────────────────────────────────── -import { CreateSubscription } from "../../objects/subscription/changes/subscription.create.ts"; -import { DropSubscription } from "../../objects/subscription/changes/subscription.drop.ts"; -import { Subscription } from "../../objects/subscription/subscription.model.ts"; -import { - AlterTableAddColumn, - AlterTableAddConstraint, - AlterTableAlterColumnDropDefault, - AlterTableAlterColumnDropNotNull, - AlterTableAlterColumnSetDefault, - AlterTableAlterColumnSetNotNull, - AlterTableAlterColumnType, - AlterTableAttachPartition, - AlterTableChangeOwner, - AlterTableDetachPartition, - AlterTableDisableRowLevelSecurity, - AlterTableDropColumn, - AlterTableDropConstraint, - AlterTableEnableRowLevelSecurity, - AlterTableForceRowLevelSecurity, - AlterTableNoForceRowLevelSecurity, - AlterTableResetStorageParams, - AlterTableSetLogged, - AlterTableSetReplicaIdentity, - AlterTableSetStorageParams, - AlterTableSetUnlogged, - AlterTableValidateConstraint, -} from "../../objects/table/changes/table.alter.ts"; -import { - CreateCommentOnColumn, - CreateCommentOnConstraint, - CreateCommentOnTable, - DropCommentOnColumn, - DropCommentOnConstraint, - DropCommentOnTable, -} from "../../objects/table/changes/table.comment.ts"; -// ── Table changes ─────────────────────────────────────────────────────────── -import { CreateTable } from "../../objects/table/changes/table.create.ts"; -import { DropTable } from "../../objects/table/changes/table.drop.ts"; -import { - GrantTablePrivileges, - RevokeGrantOptionTablePrivileges, - RevokeTablePrivileges, -} from "../../objects/table/changes/table.privilege.ts"; -import { Table } from "../../objects/table/table.model.ts"; -import { ReplaceTrigger } from "../../objects/trigger/changes/trigger.alter.ts"; -import { - CreateCommentOnTrigger, - DropCommentOnTrigger, -} from "../../objects/trigger/changes/trigger.comment.ts"; -// ── Trigger changes ───────────────────────────────────────────────────────── -import { CreateTrigger } from "../../objects/trigger/changes/trigger.create.ts"; -import { DropTrigger } from "../../objects/trigger/changes/trigger.drop.ts"; -import { Trigger } from "../../objects/trigger/trigger.model.ts"; -import { - AlterCompositeTypeAddAttribute, - AlterCompositeTypeAlterAttributeType, - AlterCompositeTypeChangeOwner, - AlterCompositeTypeDropAttribute, -} from "../../objects/type/composite-type/changes/composite-type.alter.ts"; -import { - CreateCommentOnCompositeType, - CreateCommentOnCompositeTypeAttribute, - DropCommentOnCompositeType, - DropCommentOnCompositeTypeAttribute, -} from "../../objects/type/composite-type/changes/composite-type.comment.ts"; -// ── Composite type changes ────────────────────────────────────────────────── -import { CreateCompositeType } from "../../objects/type/composite-type/changes/composite-type.create.ts"; -import { DropCompositeType } from "../../objects/type/composite-type/changes/composite-type.drop.ts"; -import { - GrantCompositeTypePrivileges, - RevokeCompositeTypePrivileges, - RevokeGrantOptionCompositeTypePrivileges, -} from "../../objects/type/composite-type/changes/composite-type.privilege.ts"; -import { CompositeType } from "../../objects/type/composite-type/composite-type.model.ts"; -import { - AlterEnumAddValue, - AlterEnumChangeOwner, -} from "../../objects/type/enum/changes/enum.alter.ts"; -import { - CreateCommentOnEnum, - DropCommentOnEnum, -} from "../../objects/type/enum/changes/enum.comment.ts"; -// ── Enum changes ──────────────────────────────────────────────────────────── -import { CreateEnum } from "../../objects/type/enum/changes/enum.create.ts"; -import { DropEnum } from "../../objects/type/enum/changes/enum.drop.ts"; -import { - GrantEnumPrivileges, - RevokeEnumPrivileges, - RevokeGrantOptionEnumPrivileges, -} from "../../objects/type/enum/changes/enum.privilege.ts"; -import { Enum } from "../../objects/type/enum/enum.model.ts"; -import { AlterRangeChangeOwner } from "../../objects/type/range/changes/range.alter.ts"; -import { - CreateCommentOnRange, - DropCommentOnRange, -} from "../../objects/type/range/changes/range.comment.ts"; -// ── Range changes ─────────────────────────────────────────────────────────── -import { CreateRange } from "../../objects/type/range/changes/range.create.ts"; -import { DropRange } from "../../objects/type/range/changes/range.drop.ts"; -import { - GrantRangePrivileges, - RevokeGrantOptionRangePrivileges, - RevokeRangePrivileges, -} from "../../objects/type/range/changes/range.privilege.ts"; -import { Range } from "../../objects/type/range/range.model.ts"; -import { - AlterViewChangeOwner, - AlterViewResetOptions, - AlterViewSetOptions, -} from "../../objects/view/changes/view.alter.ts"; -import { - CreateCommentOnView, - DropCommentOnView, -} from "../../objects/view/changes/view.comment.ts"; -// ── View changes ──────────────────────────────────────────────────────────── -import { CreateView } from "../../objects/view/changes/view.create.ts"; -import { DropView } from "../../objects/view/changes/view.drop.ts"; -import { - GrantViewPrivileges, - RevokeGrantOptionViewPrivileges, - RevokeViewPrivileges, -} from "../../objects/view/changes/view.privilege.ts"; -import { View } from "../../objects/view/view.model.ts"; -import type { SqlFormatOptions } from "../sql-format.ts"; -import { formatSqlScript } from "../statements.ts"; - -// ── Helpers ───────────────────────────────────────────────────────────────── - -type ChangeCase = { - label: string; - change: { serialize: () => string }; -}; - -const column = (overrides: Partial = {}): ColumnProps => ({ - name: "id", - position: 1, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - ...overrides, -}); - -// ── Model objects ─────────────────────────────────────────────────────────── - -const domainConstraint: DomainConstraintProps = { - name: "domain_chk", - validated: true, - is_local: true, - no_inherit: false, - check_expression: "VALUE <> ''", -}; - -const domainConstraint2: DomainConstraintProps = { - name: "domain_len_chk", - validated: false, - is_local: true, - no_inherit: true, - check_expression: "char_length(VALUE) <= 255", -}; - -const domain = new Domain({ - schema: "public", - name: "test_domain_all", - base_type: "text", - base_type_schema: "custom", - base_type_str: "text", - not_null: true, - type_modifier: null, - array_dimensions: 2, - collation: "mycoll", - default_bin: null, - default_value: "'hello'", - owner: "test", - comment: "domain comment", - constraints: [domainConstraint], - privileges: [], -}); - -const enumType = new Enum({ - schema: "public", - name: "test_enum", - owner: "test", - labels: [ - { sort_order: 1, label: "value1" }, - { sort_order: 2, label: "value2" }, - { sort_order: 3, label: "value3" }, - ], - comment: "enum comment", - privileges: [], -}); - -const compositeType = new CompositeType({ - schema: "public", - name: "test_type", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "test", - comment: "composite comment", - columns: [ - column({ name: "id", data_type_str: "integer", comment: "attr comment" }), - column({ name: "name", data_type_str: "text", collation: '"en_US"' }), - ], - privileges: [], -}); - -const rangeType = new Range({ - schema: "public", - name: "daterange_custom", - owner: "owner1", - subtype_schema: "pg_catalog", - subtype_str: "date", - collation: '"en_US"', - canonical_function_schema: "public", - canonical_function_name: "canon_fn", - subtype_diff_schema: "public", - subtype_diff_name: "diff_fn", - subtype_opclass_schema: "public", - subtype_opclass_name: "date_ops", - comment: "range comment", - privileges: [], -}); - -const collation = new Collation({ - schema: "public", - name: "test", - provider: "i", - is_deterministic: false, - encoding: 1, - collate: "en_US", - locale: "en_US", - version: "1.0", - ctype: "en_US", - icu_rules: "& A < a <<< à", - owner: "owner", - comment: "collation comment", -}); - -const pkConstraint = { - name: "pk_t_fmt", - constraint_type: "p" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["id"], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "owner1", - definition: "PRIMARY KEY (id)", - comment: "primary key", -}; - -const uniqueConstraint = { - name: "uq_t_fmt_status", - constraint_type: "u" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["status"], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "owner1", - definition: "UNIQUE (status)", -}; - -const fkConstraint = { - name: "fk_t_fmt_ref", - constraint_type: "f" as const, - deferrable: true, - initially_deferred: true, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["ref_id"], - foreign_key_columns: ["id"], - foreign_key_table: "other_table", - foreign_key_schema: "public", - foreign_key_table_is_partition: false, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: "public", - foreign_key_effective_table: "other_table", - on_update: "n" as const, - on_delete: "c" as const, - match_type: "f" as const, - check_expression: null, - owner: "owner1", - definition: - "FOREIGN KEY (ref_id) REFERENCES public.other_table(id) MATCH FULL ON UPDATE SET NULL ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED", -}; - -const checkConstraint = { - name: "chk_t_fmt_status", - constraint_type: "c" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: true, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: [] as string[], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: "status <> '' AND created_at > '2020-01-01'::timestamptz", - owner: "owner1", - definition: - "CHECK (status <> '' AND created_at > '2020-01-01'::timestamptz) NO INHERIT", - comment: "check constraint comment", -}; - -const table = new Table({ - schema: "public", - name: "table_with_very_long_name_for_formatting_and_wrapping_test", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: ["fillfactor=70", "autovacuum_enabled=false"], - partition_bound: null, - partition_by: null, - owner: "owner1", - comment: "table comment", - parent_schema: null, - parent_name: null, - columns: [ - column({ - name: "id", - data_type: "bigint", - data_type_str: "bigint", - not_null: true, - is_identity: true, - is_identity_always: true, - comment: "id column", - }), - column({ - name: "status", - data_type: "text", - data_type_str: "text", - default: "'pending'", - collation: '"en_US"', - comment: "status column", - }), - column({ - name: "created_at", - data_type: "timestamptz", - data_type_str: "timestamptz", - default: "now()", - }), - column({ - name: "ref_id", - data_type: "bigint", - data_type_str: "bigint", - }), - column({ - name: "computed", - data_type: "bigint", - data_type_str: "bigint", - is_generated: true, - default: "id * 2", - }), - ], - constraints: [pkConstraint, uniqueConstraint, fkConstraint, checkConstraint], - privileges: [], -}); - -const partitionedTable = new Table({ - schema: "public", - name: "events", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: true, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - partition_by: "RANGE (created_at)", - owner: "owner1", - comment: null, - parent_schema: null, - parent_name: null, - columns: [ - column({ - name: "id", - data_type: "bigint", - data_type_str: "bigint", - not_null: true, - }), - column({ - name: "created_at", - data_type: "timestamptz", - data_type_str: "timestamptz", - not_null: true, - }), - ], - constraints: [], - privileges: [], -}); - -const partitionChild = new Table({ - schema: "public", - name: "events_2024", - persistence: "p", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: true, - options: null, - partition_bound: "FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')", - partition_by: null, - owner: "owner1", - comment: null, - parent_schema: "public", - parent_name: "events", - columns: [ - column({ - name: "id", - data_type: "bigint", - data_type_str: "bigint", - not_null: true, - }), - column({ - name: "created_at", - data_type: "timestamptz", - data_type_str: "timestamptz", - not_null: true, - }), - ], - constraints: [], - privileges: [], -}); - -const publication = new Publication({ - name: "pub_custom", - owner: "owner1", - comment: "publication comment", - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [ - { - schema: "public", - name: "articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit", - columns: ["id", "title"], - row_filter: "(published = true)", - }, - { - schema: "public", - name: "comments_a_little_smaller_name_than_the_previous_one", - columns: null, - row_filter: null, - }, - ], - schemas: ["analytics"], -}); - -const view = new View({ - schema: "public", - name: "test_view", - definition: "SELECT *\nFROM test_table", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: false, - replica_identity: "d", - is_partition: false, - options: ["security_barrier=true", "check_option=local"], - partition_bound: null, - owner: "test", - comment: "view comment", - columns: [], - privileges: [], -}); - -const rule = new Rule({ - schema: "public", - table_name: "test_table", - name: "test_rule", - relation_kind: "r", - event: "INSERT", - enabled: "O", - is_instead: true, - definition: - "CREATE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING", - columns: [], - comment: "rule comment", - owner: "test", -}); - -const procedure = new Procedure({ - schema: "public", - name: "test_procedure", - kind: "p", - return_type: "void", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v", - parallel_safety: "u", - execution_cost: 0, - result_rows: 0, - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 0, - argument_default_count: 0, - argument_names: null, - argument_types: null, - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "BEGIN RETURN; END;", - binary_path: null, - sql_body: null, - owner: "test", - comment: "procedure comment", - privileges: [], - definition: - "CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$ begin null; end; $$", - config: null, -}); - -const complexFunction = new Procedure({ - schema: "public", - name: "calculate_metrics_for_analytics_dashboard_with_extended_name", - kind: "f", - return_type: "TABLE(total bigint, average numeric)", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: true, - volatility: "s", - parallel_safety: "s", - execution_cost: 100, - result_rows: 10, - is_strict: true, - leakproof: false, - returns_set: true, - argument_count: 3, - argument_default_count: 1, - argument_names: [ - '"p_schema_name_for_analytics"', - '"p_table_name_for_metrics"', - '"p_limit_count_default"', - ], - argument_types: ["text", "text", "integer"], - all_argument_types: ["text", "text", "integer"], - argument_modes: ["i", "i", "i"], - argument_defaults: "100", - source_code: - "BEGIN\n RETURN QUERY SELECT count(*)::bigint, avg(value)::numeric FROM ...\nEND;", - binary_path: null, - sql_body: null, - owner: "admin", - comment: "Calculate metrics for a given table", - privileges: [], - definition: - 'CREATE OR REPLACE FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name("p_schema_name_for_analytics" text, "p_table_name_for_metrics" text, "p_limit_count_default" integer DEFAULT 100) RETURNS TABLE(total bigint, average numeric) LANGUAGE plpgsql STABLE SECURITY DEFINER PARALLEL SAFE COST 100 ROWS 10 STRICT SET search_path TO \'pg_catalog\', \'public\' AS $function$ BEGIN RETURN QUERY SELECT count(*)::bigint, avg(value)::numeric FROM generate_series(1, p_limit_count_default); END; $function$', - config: ["search_path TO 'pg_catalog', 'public'"], -}); - -const sequence = new Sequence({ - schema: "public", - name: "table_with_very_long_name_for_formatting_and_wrapping_test_id_seq", - data_type: "bigint", - start_value: 1, - minimum_value: BigInt(1), - maximum_value: BigInt("9223372036854775807"), - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: "public", - owned_by_table: "table_with_very_long_name_for_formatting_and_wrapping_test", - owned_by_column: "id", - comment: - "sequence for table_with_very_long_name_for_formatting_and_wrapping_test.id", - privileges: [], - owner: "owner1", -}); - -const rlsPolicy = new RlsPolicy({ - schema: "public", - name: "allow_select_own", - table_name: "table_with_very_long_name_for_formatting_and_wrapping_test", - command: "r", - permissive: true, - roles: ["authenticated"], - using_expression: "auth.uid() = user_id", - with_check_expression: null, - owner: "owner1", - comment: "rls policy comment", - referenced_relations: [], - referenced_procedures: [], -}); - -const rlsPolicyRestrictive = new RlsPolicy({ - schema: "public", - name: "restrict_delete", - table_name: "table_with_very_long_name_for_formatting_and_wrapping_test", - command: "d", - permissive: false, - roles: ["authenticated", "service_role"], - using_expression: "auth.uid() = owner_id", - with_check_expression: "status <> 'locked'", - owner: "owner1", - comment: null, - referenced_relations: [], - referenced_procedures: [], -}); - -const index = new Index({ - schema: "public", - table_name: "table_with_very_long_name_for_formatting_and_wrapping_test", - name: "idx_t_fmt_status", - storage_params: ["fillfactor=90"], - statistics_target: [100], - index_type: "btree", - tablespace: null, - is_unique: true, - is_primary: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [2], - column_collations: [null], - operator_classes: ["default"], - column_options: [0], - index_expressions: null, - partial_predicate: "status <> 'archived'", - is_owned_by_constraint: false, - table_relkind: "r", - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: - "CREATE UNIQUE INDEX idx_t_fmt_status ON public.table_with_very_long_name_for_formatting_and_wrapping_test USING btree (status) WITH (fillfactor='90') WHERE (status <> 'archived'::text)", - comment: "index comment", - owner: "owner1", -}); - -const ginIndex = new Index({ - schema: "public", - table_name: "table_with_very_long_name_for_formatting_and_wrapping_test", - name: "idx_t_fmt_search", - storage_params: [], - statistics_target: [], - index_type: "gin", - tablespace: null, - is_unique: false, - is_primary: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: false, - key_columns: [], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: "to_tsvector('english', status)", - partial_predicate: null, - is_owned_by_constraint: false, - table_relkind: "r", - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: - "CREATE INDEX idx_t_fmt_search ON public.table_with_very_long_name_for_formatting_and_wrapping_test USING gin (to_tsvector('english'::regconfig, status))", - comment: null, - owner: "owner1", -}); - -const trigger = new Trigger({ - schema: "public", - name: "trg_audit", - table_name: "table_with_very_long_name_for_formatting_and_wrapping_test", - table_relkind: "r", - function_schema: "public", - function_name: "audit_trigger_fn", - trigger_type: 7, - enabled: "O", - is_internal: false, - deferrable: true, - initially_deferred: true, - argument_count: 2, - column_numbers: null, - arguments: ["arg1", "arg2"], - when_condition: "(NEW.status IS DISTINCT FROM OLD.status)", - old_table: "old_rows", - new_table: "new_rows", - is_partition_clone: false, - parent_trigger_name: null, - parent_table_schema: null, - parent_table_name: null, - is_on_partitioned_table: false, - owner: "owner1", - definition: - "CREATE TRIGGER trg_audit AFTER INSERT OR UPDATE ON public.table_with_very_long_name_for_formatting_and_wrapping_test REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ((NEW.status IS DISTINCT FROM OLD.status)) EXECUTE FUNCTION public.audit_trigger_fn('arg1', 'arg2')", - comment: "trigger comment", -}); - -// ── New object models ─────────────────────────────────────────────────────── - -const schema = new Schema({ - name: "application_schema_with_very_long_name_for_wrapping_tests", - owner: "admin", - comment: "application schema", - privileges: [], - security_labels: [], -}); - -const extension = new Extension({ - name: "pgcrypto", - schema: "extensions", - relocatable: true, - version: "1.3", - owner: "postgres", - comment: "cryptographic functions", - members: [], -}); - -const materializedView = new MaterializedView({ - schema: "analytics", - name: "daily_stats", - definition: - "SELECT date_trunc('day', created_at) AS day, count(*) AS total\nFROM public.events\nGROUP BY 1", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: ["fillfactor=70"], - partition_bound: null, - owner: "admin", - comment: "daily aggregation", - columns: [ - column({ - name: "day", - data_type: "timestamptz", - data_type_str: "timestamptz", - comment: "day bucket", - }), - column({ - name: "total", - position: 2, - data_type: "bigint", - data_type_str: "bigint", - }), - ], - privileges: [], -}); - -const aggregate = new Aggregate({ - schema: "public", - name: "array_cat_agg", - identity_arguments: "anycompatiblearray", - kind: "a", - aggkind: "n", - num_direct_args: 0, - return_type: "anycompatiblearray", - return_type_schema: "pg_catalog", - parallel_safety: "s", - is_strict: true, - transition_function: "array_cat(anycompatiblearray,anycompatiblearray)", - state_data_type: "anycompatiblearray", - state_data_type_schema: "pg_catalog", - state_data_space: 0, - final_function: null, - final_function_extra_args: false, - final_function_modify: null, - combine_function: "array_cat(anycompatiblearray,anycompatiblearray)", - serial_function: null, - deserial_function: null, - initial_condition: "{}", - moving_transition_function: null, - moving_inverse_function: null, - moving_state_data_type: null, - moving_state_data_type_schema: null, - moving_state_data_space: null, - moving_final_function: null, - moving_final_function_extra_args: false, - moving_final_function_modify: null, - moving_initial_condition: null, - sort_operator: null, - argument_count: 1, - argument_default_count: 0, - argument_names: null, - argument_types: ["anycompatiblearray"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - owner: "postgres", - comment: "concatenate arrays aggregate", - privileges: [], -}); - -const eventTrigger = new EventTrigger({ - name: "prevent_drop", - event: "sql_drop", - function_schema: "public", - function_name: "prevent_drop_fn", - enabled: "O", - tags: ["DROP TABLE", "DROP SCHEMA"], - owner: "postgres", - comment: "prevent accidental drops", -}); - -const language = new Language({ - name: "plv8", - is_trusted: true, - is_procedural: true, - call_handler: "plv8_call_handler", - inline_handler: "plv8_inline_handler", - validator: "plv8_call_validator", - owner: "postgres", - comment: "PL/V8 trusted procedural language", - privileges: [], -}); - -const role = new Role({ - name: "app_user", - is_superuser: false, - can_inherit: true, - can_create_roles: false, - can_create_databases: false, - can_login: true, - can_replicate: false, - connection_limit: 100, - can_bypass_rls: false, - config: ["statement_timeout=30000", "search_path=public,app_schema"], - comment: "application user role", - members: [ - { - member: "dev_user", - grantor: "postgres", - admin_option: true, - inherit_option: true, - set_option: true, - }, - ], - default_privileges: [ - { - in_schema: "public", - objtype: "r", - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - is_implicit: false, - }, - ], -}); - -const subscription = new Subscription({ - name: "sub_replica", - raw_name: "sub_replica", - owner: "postgres", - comment: "replication subscription", - enabled: true, - binary: true, - streaming: "parallel", - two_phase: false, - disable_on_error: true, - password_required: true, - run_as_owner: false, - failover: true, - conninfo: "host=primary.db port=5432 dbname=mydb", - slot_name: "sub_replica_slot", - slot_is_none: false, - replication_slot_created: true, - synchronous_commit: "remote_apply", - publications: ["pub_custom"], - origin: "any", -}); - -const foreignDataWrapper = new ForeignDataWrapper({ - name: "postgres_fdw", - owner: "postgres", - handler: "postgres_fdw_handler", - validator: "postgres_fdw_validator", - options: ["debug", "true"], - comment: "PostgreSQL foreign data wrapper", - privileges: [], -}); - -const foreignTable = new ForeignTable({ - schema: "public", - name: "remote_users", - owner: "postgres", - server: "remote_server", - options: ["schema_name", "public", "table_name", "users"], - comment: "remote users table", - columns: [ - column({ - name: "id", - data_type: "integer", - data_type_str: "integer", - not_null: true, - }), - column({ - name: "email", - data_type: "text", - data_type_str: "text", - position: 2, - }), - ], - privileges: [], -}); - -const server = new Server({ - name: "remote_server", - owner: "postgres", - foreign_data_wrapper: "postgres_fdw", - type: "postgresql", - version: "16.0", - options: ["host", "remote.host", "port", "5432", "dbname", "remote_db"], - comment: "remote PostgreSQL server", - privileges: [], -}); - -const userMapping = new UserMapping({ - user: "app_user", - server: "remote_server", - options: ["user", "remote_app", "password", "secret123"], -}); - -// ── Change cases ──────────────────────────────────────────────────────────── - -const changeCases: ChangeCase[] = [ - // ── Schema ── - { label: "schema.create", change: new CreateSchema({ schema }) }, - { label: "schema.drop", change: new DropSchema({ schema }) }, - { - label: "schema.alter.change_owner", - change: new AlterSchemaChangeOwner({ schema, owner: "new_admin" }), - }, - { label: "schema.comment", change: new CreateCommentOnSchema({ schema }) }, - { label: "schema.drop_comment", change: new DropCommentOnSchema({ schema }) }, - { - label: "schema.grant", - change: new GrantSchemaPrivileges({ - schema, - grantee: "app_user", - privileges: [ - { privilege: "USAGE", grantable: true }, - { privilege: "CREATE", grantable: true }, - ], - }), - }, - { - label: "schema.revoke", - change: new RevokeSchemaPrivileges({ - schema, - grantee: "app_user", - privileges: [{ privilege: "CREATE", grantable: false }], - }), - }, - { - label: "schema.revoke_grant_option", - change: new RevokeGrantOptionSchemaPrivileges({ - schema, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── Extension ── - { label: "extension.create", change: new CreateExtension({ extension }) }, - { label: "extension.drop", change: new DropExtension({ extension }) }, - { - label: "extension.alter.update_version", - change: new AlterExtensionUpdateVersion({ extension, version: "1.4" }), - }, - { - label: "extension.alter.set_schema", - change: new AlterExtensionSetSchema({ extension, schema: "public" }), - }, - { - label: "extension.comment", - change: new CreateCommentOnExtension({ extension }), - }, - { - label: "extension.drop_comment", - change: new DropCommentOnExtension({ extension }), - }, - - // ── Domain ── - { label: "domain.create", change: new CreateDomain({ domain }) }, - { label: "domain.drop", change: new DropDomain({ domain }) }, - { - label: "domain.alter.set_default", - change: new AlterDomainSetDefault({ domain, defaultValue: "'world'" }), - }, - { - label: "domain.alter.drop_default", - change: new AlterDomainDropDefault({ domain }), - }, - { - label: "domain.alter.set_not_null", - change: new AlterDomainSetNotNull({ domain }), - }, - { - label: "domain.alter.drop_not_null", - change: new AlterDomainDropNotNull({ domain }), - }, - { - label: "domain.alter.change_owner", - change: new AlterDomainChangeOwner({ domain, owner: "new_owner" }), - }, - { - label: "domain.alter.add_constraint", - change: new AlterDomainAddConstraint({ - domain, - constraint: domainConstraint2, - }), - }, - { - label: "domain.alter.drop_constraint", - change: new AlterDomainDropConstraint({ - domain, - constraint: domainConstraint, - }), - }, - { - label: "domain.alter.validate_constraint", - change: new AlterDomainValidateConstraint({ - domain, - constraint: domainConstraint2, - }), - }, - { label: "domain.comment", change: new CreateCommentOnDomain({ domain }) }, - { label: "domain.drop_comment", change: new DropCommentOnDomain({ domain }) }, - { - label: "domain.grant", - change: new GrantDomainPrivileges({ - domain, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "domain.revoke", - change: new RevokeDomainPrivileges({ - domain, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "domain.revoke_grant_option", - change: new RevokeGrantOptionDomainPrivileges({ - domain, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── Enum ── - { label: "type.enum.create", change: new CreateEnum({ enum: enumType }) }, - { label: "type.enum.drop", change: new DropEnum({ enum: enumType }) }, - { - label: "type.enum.alter.change_owner", - change: new AlterEnumChangeOwner({ enum: enumType, owner: "new_owner" }), - }, - { - label: "type.enum.alter.add_value", - change: new AlterEnumAddValue({ - enum: enumType, - newValue: "value4", - position: { after: "value2" }, - }), - }, - { - label: "type.enum.comment", - change: new CreateCommentOnEnum({ enum: enumType }), - }, - { - label: "type.enum.drop_comment", - change: new DropCommentOnEnum({ enum: enumType }), - }, - { - label: "type.enum.grant", - change: new GrantEnumPrivileges({ - enum: enumType, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "type.enum.revoke", - change: new RevokeEnumPrivileges({ - enum: enumType, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "type.enum.revoke_grant_option", - change: new RevokeGrantOptionEnumPrivileges({ - enum: enumType, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── Composite type ── - { - label: "type.composite.create", - change: new CreateCompositeType({ compositeType }), - }, - { - label: "type.composite.drop", - change: new DropCompositeType({ compositeType }), - }, - { - label: "type.composite.alter.change_owner", - change: new AlterCompositeTypeChangeOwner({ - compositeType, - owner: "new_owner", - }), - }, - { - label: "type.composite.alter.add_attribute", - change: new AlterCompositeTypeAddAttribute({ - compositeType, - attribute: column({ name: "age", data_type_str: "integer" }), - }), - }, - { - label: "type.composite.alter.drop_attribute", - change: new AlterCompositeTypeDropAttribute({ - compositeType, - attribute: column({ name: "name", data_type_str: "text" }), - }), - }, - { - label: "type.composite.alter.alter_attr_type", - change: new AlterCompositeTypeAlterAttributeType({ - compositeType, - attribute: column({ - name: "name", - data_type_str: "varchar(255)", - collation: '"C"', - }), - }), - }, - { - label: "type.composite.comment", - change: new CreateCommentOnCompositeType({ compositeType }), - }, - { - label: "type.composite.drop_comment", - change: new DropCommentOnCompositeType({ compositeType }), - }, - { - label: "type.composite.attr_comment", - change: new CreateCommentOnCompositeTypeAttribute({ - compositeType, - attribute: column({ - name: "id", - data_type_str: "integer", - comment: "attr comment", - }), - }), - }, - { - label: "type.composite.drop_attr_comment", - change: new DropCommentOnCompositeTypeAttribute({ - compositeType, - attribute: column({ name: "id", data_type_str: "integer" }), - }), - }, - { - label: "type.composite.grant", - change: new GrantCompositeTypePrivileges({ - compositeType, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "type.composite.revoke", - change: new RevokeCompositeTypePrivileges({ - compositeType, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "type.composite.revoke_grant_option", - change: new RevokeGrantOptionCompositeTypePrivileges({ - compositeType, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── Range ── - { label: "type.range.create", change: new CreateRange({ range: rangeType }) }, - { label: "type.range.drop", change: new DropRange({ range: rangeType }) }, - { - label: "type.range.alter.change_owner", - change: new AlterRangeChangeOwner({ range: rangeType, owner: "new_owner" }), - }, - { - label: "type.range.comment", - change: new CreateCommentOnRange({ range: rangeType }), - }, - { - label: "type.range.drop_comment", - change: new DropCommentOnRange({ range: rangeType }), - }, - { - label: "type.range.grant", - change: new GrantRangePrivileges({ - range: rangeType, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "type.range.revoke", - change: new RevokeRangePrivileges({ - range: rangeType, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "type.range.revoke_grant_option", - change: new RevokeGrantOptionRangePrivileges({ - range: rangeType, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── Collation ── - { label: "collation.create", change: new CreateCollation({ collation }) }, - { label: "collation.drop", change: new DropCollation({ collation }) }, - { - label: "collation.alter.change_owner", - change: new AlterCollationChangeOwner({ collation, owner: "new_owner" }), - }, - { - label: "collation.alter.refresh_version", - change: new AlterCollationRefreshVersion({ collation }), - }, - { - label: "collation.comment", - change: new CreateCommentOnCollation({ collation }), - }, - { - label: "collation.drop_comment", - change: new DropCommentOnCollation({ collation }), - }, - - // ── Table ── - { label: "table.create", change: new CreateTable({ table }) }, - { label: "table.drop", change: new DropTable({ table }) }, - { - label: "table.alter.add_column", - change: new AlterTableAddColumn({ - table, - column: column({ - name: "email", - data_type: "text", - data_type_str: "text", - not_null: true, - default: "'user@example.com'", - collation: '"en_US"', - }), - }), - }, - { - label: "table.alter.drop_column", - change: new AlterTableDropColumn({ - table, - column: column({ - name: "computed", - data_type: "bigint", - data_type_str: "bigint", - }), - }), - }, - { - label: "table.alter.column_type", - change: new AlterTableAlterColumnType({ - table, - column: column({ - name: "status", - data_type: "varchar", - data_type_str: "character varying(255)", - collation: '"C"', - }), - }), - }, - { - label: "table.alter.column_set_default", - change: new AlterTableAlterColumnSetDefault({ - table, - column: column({ - name: "status", - data_type: "text", - data_type_str: "text", - default: "'active'", - }), - }), - }, - { - label: "table.alter.column_drop_default", - change: new AlterTableAlterColumnDropDefault({ - table, - column: column({ - name: "status", - data_type: "text", - data_type_str: "text", - }), - }), - }, - { - label: "table.alter.column_set_not_null", - change: new AlterTableAlterColumnSetNotNull({ - table, - column: column({ - name: "status", - data_type: "text", - data_type_str: "text", - }), - }), - }, - { - label: "table.alter.column_drop_not_null", - change: new AlterTableAlterColumnDropNotNull({ - table, - column: column({ - name: "status", - data_type: "text", - data_type_str: "text", - }), - }), - }, - { - label: "table.alter.add_constraint", - change: new AlterTableAddConstraint({ - table, - constraint: uniqueConstraint, - }), - }, - { - label: "table.alter.add_fk_constraint", - change: new AlterTableAddConstraint({ table, constraint: fkConstraint }), - }, - { - label: "table.alter.drop_constraint", - change: new AlterTableDropConstraint({ - table, - constraint: uniqueConstraint, - }), - }, - { - label: "table.alter.validate_constraint", - change: new AlterTableValidateConstraint({ - table, - constraint: checkConstraint, - }), - }, - { - label: "table.alter.change_owner", - change: new AlterTableChangeOwner({ table, owner: "new_owner" }), - }, - { - label: "table.alter.set_logged", - change: new AlterTableSetLogged({ table }), - }, - { - label: "table.alter.set_unlogged", - change: new AlterTableSetUnlogged({ table }), - }, - { - label: "table.alter.enable_rls", - change: new AlterTableEnableRowLevelSecurity({ table }), - }, - { - label: "table.alter.disable_rls", - change: new AlterTableDisableRowLevelSecurity({ table }), - }, - { - label: "table.alter.force_rls", - change: new AlterTableForceRowLevelSecurity({ table }), - }, - { - label: "table.alter.no_force_rls", - change: new AlterTableNoForceRowLevelSecurity({ table }), - }, - { - label: "table.alter.set_storage_params", - change: new AlterTableSetStorageParams({ - table, - options: ["fillfactor=80", "autovacuum_enabled=true"], - }), - }, - { - label: "table.alter.reset_storage_params", - change: new AlterTableResetStorageParams({ - table, - params: ["fillfactor", "autovacuum_enabled"], - }), - }, - { - label: "table.alter.replica_identity", - change: new AlterTableSetReplicaIdentity({ table, mode: "f" }), - }, - { - label: "table.alter.attach_partition", - change: new AlterTableAttachPartition({ - table: partitionedTable, - partition: partitionChild, - }), - }, - { - label: "table.alter.detach_partition", - change: new AlterTableDetachPartition({ - table: partitionedTable, - partition: partitionChild, - }), - }, - { label: "table.comment", change: new CreateCommentOnTable({ table }) }, - { label: "table.drop_comment", change: new DropCommentOnTable({ table }) }, - { - label: "table.column_comment", - change: new CreateCommentOnColumn({ - table, - column: column({ - name: "id", - data_type: "bigint", - data_type_str: "bigint", - comment: "id column", - }), - }), - }, - { - label: "table.drop_column_comment", - change: new DropCommentOnColumn({ - table, - column: column({ - name: "id", - data_type: "bigint", - data_type_str: "bigint", - }), - }), - }, - { - label: "table.constraint_comment", - change: new CreateCommentOnConstraint({ table, constraint: pkConstraint }), - }, - { - label: "table.drop_constraint_comment", - change: new DropCommentOnConstraint({ table, constraint: checkConstraint }), - }, - { - label: "table.grant", - change: new GrantTablePrivileges({ - table, - grantee: "app_reader", - privileges: [ - { privilege: "SELECT", grantable: false }, - { privilege: "INSERT", grantable: false }, - ], - }), - }, - { - label: "table.revoke", - change: new RevokeTablePrivileges({ - table, - grantee: "app_reader", - privileges: [ - { privilege: "DELETE", grantable: false }, - { privilege: "UPDATE", grantable: false }, - ], - }), - }, - { - label: "table.revoke_grant_option", - change: new RevokeGrantOptionTablePrivileges({ - table, - grantee: "app_reader", - privilegeNames: ["SELECT", "INSERT"], - }), - }, - - // ── Publication ── - { - label: "publication.create", - change: new CreatePublication({ publication }), - }, - { label: "publication.drop", change: new DropPublication({ publication }) }, - { - label: "publication.alter.set_options", - change: new AlterPublicationSetOptions({ - publication, - setPublish: true, - setPublishViaPartitionRoot: true, - }), - }, - { - label: "publication.alter.set_list", - change: new AlterPublicationSetList({ publication }), - }, - { - label: "publication.alter.add_tables", - change: new AlterPublicationAddTables({ - publication, - tables: [ - { - schema: "public", - name: "new_table_with_very_long_name_for_formatting_and_wrapping_test", - columns: null, - row_filter: null, - }, - ], - }), - }, - { - label: "publication.alter.drop_tables", - change: new AlterPublicationDropTables({ - publication, - tables: [ - { - schema: "public", - name: "comments_a_little_smaller_name_than_the_previous_one", - columns: null, - row_filter: null, - }, - ], - }), - }, - { - label: "publication.alter.add_schemas", - change: new AlterPublicationAddSchemas({ - publication, - schemas: ["staging"], - }), - }, - { - label: "publication.alter.drop_schemas", - change: new AlterPublicationDropSchemas({ - publication, - schemas: ["analytics"], - }), - }, - { - label: "publication.alter.set_owner", - change: new AlterPublicationSetOwner({ publication, owner: "new_owner" }), - }, - { - label: "publication.comment", - change: new CreateCommentOnPublication({ publication }), - }, - { - label: "publication.drop_comment", - change: new DropCommentOnPublication({ publication }), - }, - - // ── View ── - { label: "view.create", change: new CreateView({ view }) }, - { label: "view.drop", change: new DropView({ view }) }, - { - label: "view.alter.change_owner", - change: new AlterViewChangeOwner({ view, owner: "new_owner" }), - }, - { - label: "view.alter.set_options", - change: new AlterViewSetOptions({ - view, - options: ["security_barrier=true", "check_option=cascaded"], - }), - }, - { - label: "view.alter.reset_options", - change: new AlterViewResetOptions({ view, params: ["security_barrier"] }), - }, - { label: "view.comment", change: new CreateCommentOnView({ view }) }, - { label: "view.drop_comment", change: new DropCommentOnView({ view }) }, - { - label: "view.grant", - change: new GrantViewPrivileges({ - view, - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: true }], - }), - }, - { - label: "view.revoke", - change: new RevokeViewPrivileges({ - view, - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - }), - }, - { - label: "view.revoke_grant_option", - change: new RevokeGrantOptionViewPrivileges({ - view, - grantee: "app_reader", - privilegeNames: ["SELECT"], - }), - }, - - // ── Rule ── - { label: "rule.create", change: new CreateRule({ rule }) }, - { label: "rule.drop", change: new DropRule({ rule }) }, - { label: "rule.replace", change: new ReplaceRule({ rule }) }, - { - label: "rule.alter.set_enabled", - change: new SetRuleEnabledState({ rule, enabled: "D" }), - }, - { label: "rule.comment", change: new CreateCommentOnRule({ rule }) }, - { label: "rule.drop_comment", change: new DropCommentOnRule({ rule }) }, - - // ── Procedure ── - { label: "procedure.create", change: new CreateProcedure({ procedure }) }, - { label: "procedure.drop", change: new DropProcedure({ procedure }) }, - - // ── Function ── - { - label: "function.create", - change: new CreateProcedure({ procedure: complexFunction }), - }, - { - label: "function.drop", - change: new DropProcedure({ procedure: complexFunction }), - }, - { - label: "function.alter.change_owner", - change: new AlterProcedureChangeOwner({ - procedure: complexFunction, - owner: "new_admin", - }), - }, - { - label: "function.alter.set_security", - change: new AlterProcedureSetSecurity({ - procedure: complexFunction, - securityDefiner: false, - }), - }, - { - label: "function.alter.set_config", - change: new AlterProcedureSetConfig({ - procedure: complexFunction, - action: "set", - key: "work_mem", - value: "'256MB'", - }), - }, - { - label: "function.alter.set_volatility", - change: new AlterProcedureSetVolatility({ - procedure: complexFunction, - volatility: "i", - }), - }, - { - label: "function.alter.set_strictness", - change: new AlterProcedureSetStrictness({ - procedure: complexFunction, - isStrict: false, - }), - }, - { - label: "function.alter.set_leakproof", - change: new AlterProcedureSetLeakproof({ - procedure: complexFunction, - leakproof: true, - }), - }, - { - label: "function.alter.set_parallel", - change: new AlterProcedureSetParallel({ - procedure: complexFunction, - parallelSafety: "r", - }), - }, - { - label: "function.comment", - change: new CreateCommentOnProcedure({ procedure: complexFunction }), - }, - { - label: "function.drop_comment", - change: new DropCommentOnProcedure({ procedure: complexFunction }), - }, - { - label: "function.grant", - change: new GrantProcedurePrivileges({ - procedure: complexFunction, - grantee: "app_user", - privileges: [{ privilege: "EXECUTE", grantable: true }], - }), - }, - { - label: "function.revoke", - change: new RevokeProcedurePrivileges({ - procedure: complexFunction, - grantee: "app_user", - privileges: [{ privilege: "EXECUTE", grantable: false }], - }), - }, - { - label: "function.revoke_grant_option", - change: new RevokeGrantOptionProcedurePrivileges({ - procedure: complexFunction, - grantee: "app_user", - privilegeNames: ["EXECUTE"], - }), - }, - - // ── Sequence ── - { label: "sequence.create", change: new CreateSequence({ sequence }) }, - { label: "sequence.drop", change: new DropSequence({ sequence }) }, - { - label: "sequence.alter.set_owned_by", - change: new AlterSequenceSetOwnedBy({ - sequence, - ownedBy: { - schema: "public", - table: "table_with_very_long_name_for_formatting_and_wrapping_test", - column: "id", - }, - }), - }, - { - label: "sequence.alter.set_options", - change: new AlterSequenceSetOptions({ - sequence, - options: [ - "INCREMENT BY 10", - "MINVALUE 1", - "MAXVALUE 1000000", - "CACHE 5", - "CYCLE", - ], - }), - }, - { - label: "sequence.comment", - change: new CreateCommentOnSequence({ sequence }), - }, - { - label: "sequence.drop_comment", - change: new DropCommentOnSequence({ sequence }), - }, - { - label: "sequence.grant", - change: new GrantSequencePrivileges({ - sequence, - grantee: "app_user", - privileges: [ - { privilege: "USAGE", grantable: false }, - { privilege: "SELECT", grantable: false }, - ], - }), - }, - { - label: "sequence.revoke", - change: new RevokeSequencePrivileges({ - sequence, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "sequence.revoke_grant_option", - change: new RevokeGrantOptionSequencePrivileges({ - sequence, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── RLS Policy ── - { - label: "policy.create", - change: new CreateRlsPolicy({ policy: rlsPolicy }), - }, - { - label: "policy.create_restrictive", - change: new CreateRlsPolicy({ policy: rlsPolicyRestrictive }), - }, - { label: "policy.drop", change: new DropRlsPolicy({ policy: rlsPolicy }) }, - { - label: "policy.alter.set_roles", - change: new AlterRlsPolicySetRoles({ - policy: rlsPolicy, - roles: ["authenticated", "anon"], - }), - }, - { - label: "policy.alter.set_using", - change: new AlterRlsPolicySetUsingExpression({ - policy: rlsPolicy, - usingExpression: "auth.uid() = user_id AND status = 'active'", - }), - }, - { - label: "policy.alter.set_with_check", - change: new AlterRlsPolicySetWithCheckExpression({ - policy: rlsPolicy, - withCheckExpression: "auth.uid() = user_id", - }), - }, - { - label: "policy.comment", - change: new CreateCommentOnRlsPolicy({ policy: rlsPolicy }), - }, - { - label: "policy.drop_comment", - change: new DropCommentOnRlsPolicy({ policy: rlsPolicy }), - }, - - // ── Index ── - { - label: "index.create", - change: new CreateIndex({ index, indexableObject: table }), - }, - { - label: "index.create_gin", - change: new CreateIndex({ index: ginIndex, indexableObject: table }), - }, - { label: "index.drop", change: new DropIndex({ index }) }, - { - label: "index.alter.set_storage_params", - change: new AlterIndexSetStorageParams({ - index, - paramsToSet: ["fillfactor=80"], - keysToReset: ["deduplicate_items"], - }), - }, - { - label: "index.alter.set_statistics", - change: new AlterIndexSetStatistics({ - index, - columnTargets: [{ columnNumber: 1, statistics: 500 }], - }), - }, - { label: "index.comment", change: new CreateCommentOnIndex({ index }) }, - { label: "index.drop_comment", change: new DropCommentOnIndex({ index }) }, - - // ── Trigger ── - { label: "trigger.create", change: new CreateTrigger({ trigger }) }, - { label: "trigger.drop", change: new DropTrigger({ trigger }) }, - { label: "trigger.replace", change: new ReplaceTrigger({ trigger }) }, - { label: "trigger.comment", change: new CreateCommentOnTrigger({ trigger }) }, - { - label: "trigger.drop_comment", - change: new DropCommentOnTrigger({ trigger }), - }, - - // ── Materialized View ── - { - label: "matview.create", - change: new CreateMaterializedView({ materializedView }), - }, - { - label: "matview.drop", - change: new DropMaterializedView({ materializedView }), - }, - { - label: "matview.alter.change_owner", - change: new AlterMaterializedViewChangeOwner({ - materializedView, - owner: "new_owner", - }), - }, - { - label: "matview.alter.set_storage", - change: new AlterMaterializedViewSetStorageParams({ - materializedView, - paramsToSet: ["fillfactor=80"], - keysToReset: ["autovacuum_enabled"], - }), - }, - { - label: "matview.comment", - change: new CreateCommentOnMaterializedView({ materializedView }), - }, - { - label: "matview.drop_comment", - change: new DropCommentOnMaterializedView({ materializedView }), - }, - { - label: "matview.column_comment", - change: new CreateCommentOnMaterializedViewColumn({ - materializedView, - column: column({ - name: "day", - data_type: "timestamptz", - data_type_str: "timestamptz", - comment: "day bucket", - }), - }), - }, - { - label: "matview.drop_column_comment", - change: new DropCommentOnMaterializedViewColumn({ - materializedView, - column: column({ - name: "day", - data_type: "timestamptz", - data_type_str: "timestamptz", - }), - }), - }, - { - label: "matview.grant", - change: new GrantMaterializedViewPrivileges({ - materializedView, - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - }), - }, - { - label: "matview.revoke", - change: new RevokeMaterializedViewPrivileges({ - materializedView, - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - }), - }, - { - label: "matview.revoke_grant_option", - change: new RevokeGrantOptionMaterializedViewPrivileges({ - materializedView, - grantee: "app_reader", - privilegeNames: ["SELECT"], - }), - }, - - // ── Aggregate ── - { label: "aggregate.create", change: new CreateAggregate({ aggregate }) }, - { label: "aggregate.drop", change: new DropAggregate({ aggregate }) }, - { - label: "aggregate.alter.change_owner", - change: new AlterAggregateChangeOwner({ aggregate, owner: "new_owner" }), - }, - { - label: "aggregate.comment", - change: new CreateCommentOnAggregate({ aggregate }), - }, - { - label: "aggregate.drop_comment", - change: new DropCommentOnAggregate({ aggregate }), - }, - { - label: "aggregate.grant", - change: new GrantAggregatePrivileges({ - aggregate, - grantee: "app_user", - privileges: [{ privilege: "EXECUTE", grantable: false }], - }), - }, - { - label: "aggregate.revoke", - change: new RevokeAggregatePrivileges({ - aggregate, - grantee: "app_user", - privileges: [{ privilege: "EXECUTE", grantable: false }], - }), - }, - { - label: "aggregate.revoke_grant_option", - change: new RevokeGrantOptionAggregatePrivileges({ - aggregate, - grantee: "app_user", - privilegeNames: ["EXECUTE"], - }), - }, - - // ── Event Trigger ── - { - label: "event_trigger.create", - change: new CreateEventTrigger({ eventTrigger }), - }, - { - label: "event_trigger.drop", - change: new DropEventTrigger({ eventTrigger }), - }, - { - label: "event_trigger.alter.change_owner", - change: new AlterEventTriggerChangeOwner({ - eventTrigger, - owner: "new_owner", - }), - }, - { - label: "event_trigger.alter.set_enabled", - change: new AlterEventTriggerSetEnabled({ eventTrigger, enabled: "D" }), - }, - { - label: "event_trigger.comment", - change: new CreateCommentOnEventTrigger({ eventTrigger }), - }, - { - label: "event_trigger.drop_comment", - change: new DropCommentOnEventTrigger({ eventTrigger }), - }, - - // ── Language ── - { label: "language.create", change: new CreateLanguage({ language }) }, - { label: "language.drop", change: new DropLanguage({ language }) }, - { - label: "language.alter.change_owner", - change: new AlterLanguageChangeOwner({ language, owner: "new_owner" }), - }, - { - label: "language.comment", - change: new CreateCommentOnLanguage({ language }), - }, - { - label: "language.drop_comment", - change: new DropCommentOnLanguage({ language }), - }, - { - label: "language.grant", - change: new GrantLanguagePrivileges({ - language, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: true }], - }), - }, - { - label: "language.revoke", - change: new RevokeLanguagePrivileges({ - language, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "language.revoke_grant_option", - change: new RevokeGrantOptionLanguagePrivileges({ - language, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── Role ── - { label: "role.create", change: new CreateRole({ role }) }, - { label: "role.drop", change: new DropRole({ role }) }, - { - label: "role.alter.set_options", - change: new AlterRoleSetOptions({ - role, - options: ["NOSUPERUSER", "CREATEDB"], - }), - }, - { - label: "role.alter.set_config", - change: new AlterRoleSetConfig({ - role, - action: "set", - key: "statement_timeout", - value: "'60000'", - }), - }, - { label: "role.comment", change: new CreateCommentOnRole({ role }) }, - { label: "role.drop_comment", change: new DropCommentOnRole({ role }) }, - { - label: "role.grant_membership", - change: new GrantRoleMembership({ - role, - member: "dev_user", - options: { admin: true, inherit: true, set: true }, - }), - }, - { - label: "role.revoke_membership", - change: new RevokeRoleMembership({ role, member: "dev_user" }), - }, - { - label: "role.revoke_membership_options", - change: new RevokeRoleMembershipOptions({ - role, - member: "dev_user", - admin: true, - }), - }, - { - label: "role.grant_default_privileges", - change: new GrantRoleDefaultPrivileges({ - role, - inSchema: "public", - objtype: "r", - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - version: 1, - }), - }, - { - label: "role.revoke_default_privileges", - change: new RevokeRoleDefaultPrivileges({ - role, - inSchema: "public", - objtype: "r", - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - version: 1, - }), - }, - - // ── Subscription ── - { - label: "subscription.create", - change: new CreateSubscription({ subscription }), - }, - { - label: "subscription.drop", - change: new DropSubscription({ subscription }), - }, - { - label: "subscription.alter.set_connection", - change: new AlterSubscriptionSetConnection({ subscription }), - }, - { - label: "subscription.alter.set_publication", - change: new AlterSubscriptionSetPublication({ subscription }), - }, - { - label: "subscription.alter.enable", - change: new AlterSubscriptionEnable({ subscription }), - }, - { - label: "subscription.alter.disable", - change: new AlterSubscriptionDisable({ subscription }), - }, - { - label: "subscription.alter.set_options", - change: new AlterSubscriptionSetOptions({ - subscription, - options: ["binary", "streaming", "synchronous_commit"], - }), - }, - { - label: "subscription.alter.set_owner", - change: new AlterSubscriptionSetOwner({ subscription, owner: "new_owner" }), - }, - { - label: "subscription.comment", - change: new CreateCommentOnSubscription({ subscription }), - }, - { - label: "subscription.drop_comment", - change: new DropCommentOnSubscription({ subscription }), - }, - - // ── Foreign Data Wrapper ── - { - label: "fdw.create", - change: new CreateForeignDataWrapper({ foreignDataWrapper }), - }, - { - label: "fdw.drop", - change: new DropForeignDataWrapper({ foreignDataWrapper }), - }, - { - label: "fdw.alter.change_owner", - change: new AlterForeignDataWrapperChangeOwner({ - foreignDataWrapper, - owner: "new_owner", - }), - }, - { - label: "fdw.alter.set_options", - change: new AlterForeignDataWrapperSetOptions({ - foreignDataWrapper, - options: [ - { action: "SET", option: "debug", value: "false" }, - { action: "ADD", option: "use_remote_estimate" }, - ], - }), - }, - { - label: "fdw.comment", - change: new CreateCommentOnForeignDataWrapper({ foreignDataWrapper }), - }, - { - label: "fdw.drop_comment", - change: new DropCommentOnForeignDataWrapper({ foreignDataWrapper }), - }, - { - label: "fdw.grant", - change: new GrantForeignDataWrapperPrivileges({ - foreignDataWrapper, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "fdw.revoke", - change: new RevokeForeignDataWrapperPrivileges({ - foreignDataWrapper, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "fdw.revoke_grant_option", - change: new RevokeGrantOptionForeignDataWrapperPrivileges({ - foreignDataWrapper, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── Foreign Table ── - { - label: "foreign_table.create", - change: new CreateForeignTable({ foreignTable }), - }, - { - label: "foreign_table.drop", - change: new DropForeignTable({ foreignTable }), - }, - { - label: "foreign_table.alter.change_owner", - change: new AlterForeignTableChangeOwner({ - foreignTable, - owner: "new_owner", - }), - }, - { - label: "foreign_table.alter.add_column", - change: new AlterForeignTableAddColumn({ - foreignTable, - column: column({ - name: "name", - data_type: "text", - data_type_str: "text", - not_null: true, - default: "'unknown'", - }), - }), - }, - { - label: "foreign_table.alter.drop_column", - change: new AlterForeignTableDropColumn({ - foreignTable, - columnName: "email", - }), - }, - { - label: "foreign_table.alter.column_type", - change: new AlterForeignTableAlterColumnType({ - foreignTable, - columnName: "id", - dataType: "bigint", - }), - }, - { - label: "foreign_table.alter.column_set_default", - change: new AlterForeignTableAlterColumnSetDefault({ - foreignTable, - columnName: "email", - defaultValue: "'nobody@example.com'", - }), - }, - { - label: "foreign_table.alter.column_drop_default", - change: new AlterForeignTableAlterColumnDropDefault({ - foreignTable, - columnName: "email", - }), - }, - { - label: "foreign_table.alter.column_set_not_null", - change: new AlterForeignTableAlterColumnSetNotNull({ - foreignTable, - columnName: "email", - }), - }, - { - label: "foreign_table.alter.column_drop_not_null", - change: new AlterForeignTableAlterColumnDropNotNull({ - foreignTable, - columnName: "email", - }), - }, - { - label: "foreign_table.alter.set_options", - change: new AlterForeignTableSetOptions({ - foreignTable, - options: [{ action: "SET", option: "fetch_size", value: "1000" }], - }), - }, - { - label: "foreign_table.comment", - change: new CreateCommentOnForeignTable({ foreignTable }), - }, - { - label: "foreign_table.drop_comment", - change: new DropCommentOnForeignTable({ foreignTable }), - }, - { - label: "foreign_table.grant", - change: new GrantForeignTablePrivileges({ - foreignTable, - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - }), - }, - { - label: "foreign_table.revoke", - change: new RevokeForeignTablePrivileges({ - foreignTable, - grantee: "app_reader", - privileges: [{ privilege: "SELECT", grantable: false }], - }), - }, - { - label: "foreign_table.revoke_grant_option", - change: new RevokeGrantOptionForeignTablePrivileges({ - foreignTable, - grantee: "app_reader", - privilegeNames: ["SELECT"], - }), - }, - - // ── Server ── - { label: "server.create", change: new CreateServer({ server }) }, - { label: "server.drop", change: new DropServer({ server }) }, - { - label: "server.alter.change_owner", - change: new AlterServerChangeOwner({ server, owner: "new_owner" }), - }, - { - label: "server.alter.set_version", - change: new AlterServerSetVersion({ server, version: "17.0" }), - }, - { - label: "server.alter.set_options", - change: new AlterServerSetOptions({ - server, - options: [ - { action: "SET", option: "host", value: "new.host" }, - { action: "DROP", option: "port" }, - ], - }), - }, - { label: "server.comment", change: new CreateCommentOnServer({ server }) }, - { label: "server.drop_comment", change: new DropCommentOnServer({ server }) }, - { - label: "server.grant", - change: new GrantServerPrivileges({ - server, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "server.revoke", - change: new RevokeServerPrivileges({ - server, - grantee: "app_user", - privileges: [{ privilege: "USAGE", grantable: false }], - }), - }, - { - label: "server.revoke_grant_option", - change: new RevokeGrantOptionServerPrivileges({ - server, - grantee: "app_user", - privilegeNames: ["USAGE"], - }), - }, - - // ── User Mapping ── - { - label: "user_mapping.create", - change: new CreateUserMapping({ userMapping }), - }, - { label: "user_mapping.drop", change: new DropUserMapping({ userMapping }) }, - { - label: "user_mapping.alter.set_options", - change: new AlterUserMappingSetOptions({ - userMapping, - options: [{ action: "SET", option: "password", value: "new_secret" }], - }), - }, -]; - -const renderChanges = (changes: ChangeCase[]): string[] => - changes.map(({ label, change }) => `-- ${label}\n${change.serialize()}`); - -export function renderScript(options?: SqlFormatOptions): string { - return formatSqlScript(renderChanges(changeCases), options); -} diff --git a/packages/pg-delta/src/core/plan/sql-format/format-lowercase-coverage.test.ts b/packages/pg-delta/src/core/plan/sql-format/format-lowercase-coverage.test.ts deleted file mode 100644 index 45c75b60c..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/format-lowercase-coverage.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { formatSqlStatements } from "../sql-format.ts"; - -describe("lowercase coverage formatting", () => { - test("normalizes contextual keywords while preserving protected payloads", () => { - const statements = [ - "CREATE EVENT TRIGGER prevent_drop ON sql_drop WHEN TAG IN ('DROP TABLE', 'DROP SCHEMA') EXECUTE FUNCTION public.prevent_drop_fn();", - "CREATE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql STABLE AS $function$SELECT coalesce(nullif(current_setting('request.jwt.claim.sub', true), ''), (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub'))::uuid$function$;", - "COMMENT ON FUNCTION public.fn() IS E'line 1 \\' still quoted\\nline 2';", - "CREATE COLLATION public.test (LOCALE = 'en_US', DETERMINISTIC = false, provider = icu);", - ]; - - const formatted = formatSqlStatements(statements, { - keywordCase: "lower", - maxWidth: 140, - }); - - const normalized = [formatted[0], formatted[1], formatted[3]].map((value) => - value.replace(/\s+/g, " ").trim(), - ); - expect(normalized).toMatchInlineSnapshot(` - [ - "create event trigger prevent_drop on sql_drop when tag in ('DROP TABLE', 'DROP SCHEMA') execute function public.prevent_drop_fn()", - "create function auth.uid() returns uuid language sql stable AS $function$SELECT coalesce(nullif(current_setting('request.jwt.claim.sub', true), ''), (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub'))::uuid$function$", - "create collation public.test ( locale = 'en_US', deterministic = false, provider = icu )", - ] - `); - - expect(formatted[2]).toMatchInlineSnapshot( - `"comment on function public.fn() is E'line 1 \\' still quoted\\nline 2'"`, - ); - }); - - test("fails safe: malformed protected literals skip casing but still wrap", () => { - const statements = [ - "COMMENT ON FUNCTION public.fn() IS E'unterminated \\'", - "ALTER TABLE auth.audit_log_entries ENABLE ROW LEVEL SECURITY;", - ]; - - const formatted = formatSqlStatements(statements, { - keywordCase: "lower", - maxWidth: 40, - }); - - // Malformed statement: casing skipped (stays uppercase) but wrapping still applies - expect(formatted[0].replace(/\s+/g, " ").trim()).toMatchInlineSnapshot( - `"COMMENT ON FUNCTION public.fn() IS E'unterminated \\'"`, - ); - - expect(formatted[1].replace(/\s+/g, " ").trim()).toMatchInlineSnapshot( - `"alter table auth.audit_log_entries enable row level security"`, - ); - }); - - test("lowercases all ALTER DEFAULT PRIVILEGES object-type keywords", () => { - const statements = [ - "ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public GRANT ALL ON TABLES TO app_reader;", - "ALTER DEFAULT PRIVILEGES FOR ROLE app_user GRANT ALL ON SEQUENCES TO app_reader;", - "ALTER DEFAULT PRIVILEGES FOR ROLE app_user GRANT ALL ON ROUTINES TO PUBLIC;", - "ALTER DEFAULT PRIVILEGES FOR ROLE app_user GRANT ALL ON TYPES TO PUBLIC;", - "ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA api GRANT ALL ON SCHEMAS TO app_admin;", - "ALTER DEFAULT PRIVILEGES FOR ROLE app_user REVOKE ALL ON SEQUENCES FROM app_reader;", - "ALTER DEFAULT PRIVILEGES FOR ROLE app_user REVOKE ALL ON TYPES FROM PUBLIC;", - ]; - - const formatted = formatSqlStatements(statements, { - keywordCase: "lower", - }); - - const normalized = formatted.map((v) => v.replace(/\s+/g, " ").trim()); - expect(normalized).toMatchInlineSnapshot(` - [ - "alter default privileges for role app_user in schema public grant all on tables to app_reader", - "alter default privileges for role app_user grant all on sequences to app_reader", - "alter default privileges for role app_user grant all on routines to public", - "alter default privileges for role app_user grant all on types to public", - "alter default privileges for role app_user in schema api grant all on schemas to app_admin", - "alter default privileges for role app_user revoke all on sequences from app_reader", - "alter default privileges for role app_user revoke all on types from public", - ] - `); - }); - - test("lowercases PUBLIC in standalone GRANT/REVOKE statements", () => { - const statements = [ - "GRANT ALL ON SCHEMA public TO PUBLIC;", - "GRANT EXECUTE ON FUNCTION public.my_fn() TO PUBLIC;", - "REVOKE ALL ON SCHEMA public FROM PUBLIC;", - "GRANT USAGE ON TYPE public.my_type TO PUBLIC;", - ]; - - const formatted = formatSqlStatements(statements, { - keywordCase: "lower", - }); - - const normalized = formatted.map((v) => v.replace(/\s+/g, " ").trim()); - expect(normalized).toMatchInlineSnapshot(` - [ - "grant all on schema public to public", - "grant execute on function public.my_fn() to public", - "revoke all on schema public from public", - "grant usage on type public.my_type to public", - ] - `); - }); - - test("preserves full CHECK clause text while casing surrounding structure", () => { - const [formatted] = formatSqlStatements( - [ - "ALTER TABLE public.t ADD CONSTRAINT c CHECK (State IN ('ON','OFF')) NO INHERIT;", - ], - { keywordCase: "lower" }, - ); - - expect(formatted.replace(/\s+/g, " ").trim()).toMatchInlineSnapshot( - `"alter table public.t add constraint c check (State IN ('ON','OFF')) no inherit"`, - ); - }); -}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-off.test.ts b/packages/pg-delta/src/core/plan/sql-format/format-off.test.ts deleted file mode 100644 index f5d481562..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/format-off.test.ts +++ /dev/null @@ -1,806 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { renderScript } from "./fixtures.ts"; - -describe("sql formatting snapshots", () => { - test("format-off", () => { - const output = ["-- format: off", renderScript(undefined)] - .filter(Boolean) - .join("\n"); - expect(output).toMatchInlineSnapshot(` - "-- format: off - -- schema.create - CREATE SCHEMA application_schema_with_very_long_name_for_wrapping_tests AUTHORIZATION admin; - - -- schema.drop - DROP SCHEMA application_schema_with_very_long_name_for_wrapping_tests; - - -- schema.alter.change_owner - ALTER SCHEMA application_schema_with_very_long_name_for_wrapping_tests OWNER TO new_admin; - - -- schema.comment - COMMENT ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests IS 'application schema'; - - -- schema.drop_comment - COMMENT ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests IS NULL; - - -- schema.grant - GRANT ALL ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests TO app_user WITH GRANT OPTION; - - -- schema.revoke - REVOKE CREATE ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests FROM app_user; - - -- schema.revoke_grant_option - REVOKE GRANT OPTION FOR USAGE ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests FROM app_user; - - -- extension.create - CREATE EXTENSION pgcrypto WITH SCHEMA extensions; - - -- extension.drop - DROP EXTENSION pgcrypto; - - -- extension.alter.update_version - ALTER EXTENSION pgcrypto UPDATE TO '1.4'; - - -- extension.alter.set_schema - ALTER EXTENSION pgcrypto SET SCHEMA public; - - -- extension.comment - COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; - - -- extension.drop_comment - COMMENT ON EXTENSION pgcrypto IS NULL; - - -- domain.create - CREATE DOMAIN public.test_domain_all AS custom.text[][] COLLATE mycoll DEFAULT 'hello' NOT NULL CHECK (VALUE <> ''); - - -- domain.drop - DROP DOMAIN public.test_domain_all; - - -- domain.alter.set_default - ALTER DOMAIN public.test_domain_all SET DEFAULT 'world'; - - -- domain.alter.drop_default - ALTER DOMAIN public.test_domain_all DROP DEFAULT; - - -- domain.alter.set_not_null - ALTER DOMAIN public.test_domain_all SET NOT NULL; - - -- domain.alter.drop_not_null - ALTER DOMAIN public.test_domain_all DROP NOT NULL; - - -- domain.alter.change_owner - ALTER DOMAIN public.test_domain_all OWNER TO new_owner; - - -- domain.alter.add_constraint - ALTER DOMAIN public.test_domain_all ADD CONSTRAINT domain_len_chk CHECK (char_length(VALUE) <= 255) NOT VALID; - - -- domain.alter.drop_constraint - ALTER DOMAIN public.test_domain_all DROP CONSTRAINT domain_chk; - - -- domain.alter.validate_constraint - ALTER DOMAIN public.test_domain_all VALIDATE CONSTRAINT domain_len_chk; - - -- domain.comment - COMMENT ON DOMAIN public.test_domain_all IS 'domain comment'; - - -- domain.drop_comment - COMMENT ON DOMAIN public.test_domain_all IS NULL; - - -- domain.grant - GRANT ALL ON DOMAIN public.test_domain_all TO app_user; - - -- domain.revoke - REVOKE ALL ON DOMAIN public.test_domain_all FROM app_user; - - -- domain.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON DOMAIN public.test_domain_all FROM app_user; - - -- type.enum.create - CREATE TYPE public.test_enum AS ENUM ('value1', 'value2', 'value3'); - - -- type.enum.drop - DROP TYPE public.test_enum; - - -- type.enum.alter.change_owner - ALTER TYPE public.test_enum OWNER TO new_owner; - - -- type.enum.alter.add_value - ALTER TYPE public.test_enum ADD VALUE 'value4' AFTER 'value2'; - - -- type.enum.comment - COMMENT ON TYPE public.test_enum IS 'enum comment'; - - -- type.enum.drop_comment - COMMENT ON TYPE public.test_enum IS NULL; - - -- type.enum.grant - GRANT ALL ON TYPE public.test_enum TO app_user; - - -- type.enum.revoke - REVOKE ALL ON TYPE public.test_enum FROM app_user; - - -- type.enum.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.test_enum FROM app_user; - - -- type.composite.create - CREATE TYPE public.test_type AS (id integer, name text COLLATE "en_US"); - - -- type.composite.drop - DROP TYPE public.test_type; - - -- type.composite.alter.change_owner - ALTER TYPE public.test_type OWNER TO new_owner; - - -- type.composite.alter.add_attribute - ALTER TYPE public.test_type ADD ATTRIBUTE age integer; - - -- type.composite.alter.drop_attribute - ALTER TYPE public.test_type DROP ATTRIBUTE name; - - -- type.composite.alter.alter_attr_type - ALTER TYPE public.test_type ALTER ATTRIBUTE name TYPE varchar(255) COLLATE "C"; - - -- type.composite.comment - COMMENT ON TYPE public.test_type IS 'composite comment'; - - -- type.composite.drop_comment - COMMENT ON TYPE public.test_type IS NULL; - - -- type.composite.attr_comment - COMMENT ON COLUMN public.test_type.id IS 'attr comment'; - - -- type.composite.drop_attr_comment - COMMENT ON COLUMN public.test_type.id IS NULL; - - -- type.composite.grant - GRANT ALL ON TYPE public.test_type TO app_user; - - -- type.composite.revoke - REVOKE ALL ON TYPE public.test_type FROM app_user; - - -- type.composite.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.test_type FROM app_user; - - -- type.range.create - CREATE TYPE public.daterange_custom AS RANGE (SUBTYPE = date, SUBTYPE_OPCLASS = public.date_ops, COLLATION = "en_US", CANONICAL = public.canon_fn, SUBTYPE_DIFF = public.diff_fn); - - -- type.range.drop - DROP TYPE public.daterange_custom; - - -- type.range.alter.change_owner - ALTER TYPE public.daterange_custom OWNER TO new_owner; - - -- type.range.comment - COMMENT ON TYPE public.daterange_custom IS 'range comment'; - - -- type.range.drop_comment - COMMENT ON TYPE public.daterange_custom IS NULL; - - -- type.range.grant - GRANT ALL ON TYPE public.daterange_custom TO app_user; - - -- type.range.revoke - REVOKE ALL ON TYPE public.daterange_custom FROM app_user; - - -- type.range.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.daterange_custom FROM app_user; - - -- collation.create - CREATE COLLATION public.test (LOCALE = 'en_US', LC_COLLATE = 'en_US', LC_CTYPE = 'en_US', PROVIDER = icu, DETERMINISTIC = false, RULES = '& A < a <<< à', VERSION = '1.0'); - - -- collation.drop - DROP COLLATION public.test; - - -- collation.alter.change_owner - ALTER COLLATION public.test OWNER TO new_owner; - - -- collation.alter.refresh_version - ALTER COLLATION public.test REFRESH VERSION; - - -- collation.comment - COMMENT ON COLLATION public.test IS 'collation comment'; - - -- collation.drop_comment - COMMENT ON COLLATION public.test IS NULL; - - -- table.create - CREATE TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test (id bigint GENERATED ALWAYS AS IDENTITY NOT NULL, status text COLLATE "en_US" DEFAULT 'pending', created_at timestamptz DEFAULT now(), ref_id bigint, computed bigint GENERATED ALWAYS AS (id * 2) STORED) WITH (fillfactor=70, autovacuum_enabled=false); - - -- table.drop - DROP TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- table.alter.add_column - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ADD COLUMN email text COLLATE "en_US" DEFAULT 'user@example.com' NOT NULL; - - -- table.alter.drop_column - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test DROP COLUMN computed; - - -- table.alter.column_type - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ALTER COLUMN status TYPE character varying(255) COLLATE "C"; - - -- table.alter.column_set_default - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ALTER COLUMN status SET DEFAULT 'active'; - - -- table.alter.column_drop_default - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ALTER COLUMN status DROP DEFAULT; - - -- table.alter.column_set_not_null - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ALTER COLUMN status SET NOT NULL; - - -- table.alter.column_drop_not_null - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ALTER COLUMN status DROP NOT NULL; - - -- table.alter.add_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ADD CONSTRAINT uq_t_fmt_status UNIQUE (status); - - -- table.alter.add_fk_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ADD CONSTRAINT fk_t_fmt_ref FOREIGN KEY (ref_id) REFERENCES public.other_table(id) MATCH FULL ON UPDATE SET NULL ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; - - -- table.alter.drop_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test DROP CONSTRAINT uq_t_fmt_status; - - -- table.alter.validate_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test VALIDATE CONSTRAINT chk_t_fmt_status; - - -- table.alter.change_owner - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test OWNER TO new_owner; - - -- table.alter.set_logged - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test SET LOGGED; - - -- table.alter.set_unlogged - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test SET UNLOGGED; - - -- table.alter.enable_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ENABLE ROW LEVEL SECURITY; - - -- table.alter.disable_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test DISABLE ROW LEVEL SECURITY; - - -- table.alter.force_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test FORCE ROW LEVEL SECURITY; - - -- table.alter.no_force_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test NO FORCE ROW LEVEL SECURITY; - - -- table.alter.set_storage_params - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test SET (fillfactor=80, autovacuum_enabled=true); - - -- table.alter.reset_storage_params - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test RESET (fillfactor, autovacuum_enabled); - - -- table.alter.replica_identity - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test REPLICA IDENTITY FULL; - - -- table.alter.attach_partition - ALTER TABLE public.events ATTACH PARTITION public.events_2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - -- table.alter.detach_partition - ALTER TABLE public.events DETACH PARTITION public.events_2024; - - -- table.comment - COMMENT ON TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'table comment'; - - -- table.drop_comment - COMMENT ON TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- table.column_comment - COMMENT ON COLUMN public.table_with_very_long_name_for_formatting_and_wrapping_test.id IS 'id column'; - - -- table.drop_column_comment - COMMENT ON COLUMN public.table_with_very_long_name_for_formatting_and_wrapping_test.id IS NULL; - - -- table.constraint_comment - COMMENT ON CONSTRAINT pk_t_fmt ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'primary key'; - - -- table.drop_constraint_comment - COMMENT ON CONSTRAINT chk_t_fmt_status ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- table.grant - GRANT INSERT, SELECT ON public.table_with_very_long_name_for_formatting_and_wrapping_test TO app_reader; - - -- table.revoke - REVOKE DELETE, UPDATE ON public.table_with_very_long_name_for_formatting_and_wrapping_test FROM app_reader; - - -- table.revoke_grant_option - REVOKE GRANT OPTION FOR INSERT, SELECT ON public.table_with_very_long_name_for_formatting_and_wrapping_test FROM app_reader; - - -- publication.create - CREATE PUBLICATION pub_custom FOR TABLE public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit (id, title) WHERE (published = true), TABLE public.comments_a_little_smaller_name_than_the_previous_one, TABLES IN SCHEMA analytics; - - -- publication.drop - DROP PUBLICATION pub_custom; - - -- publication.alter.set_options - ALTER PUBLICATION pub_custom SET (publish = 'insert, update, delete, truncate', publish_via_partition_root = false); - - -- publication.alter.set_list - ALTER PUBLICATION pub_custom SET TABLE public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit (id, title) WHERE (published = true), TABLE public.comments_a_little_smaller_name_than_the_previous_one, TABLES IN SCHEMA analytics; - - -- publication.alter.add_tables - ALTER PUBLICATION pub_custom ADD TABLE public.new_table_with_very_long_name_for_formatting_and_wrapping_test; - - -- publication.alter.drop_tables - ALTER PUBLICATION pub_custom DROP TABLE public.comments_a_little_smaller_name_than_the_previous_one; - - -- publication.alter.add_schemas - ALTER PUBLICATION pub_custom ADD TABLES IN SCHEMA staging; - - -- publication.alter.drop_schemas - ALTER PUBLICATION pub_custom DROP TABLES IN SCHEMA analytics; - - -- publication.alter.set_owner - ALTER PUBLICATION pub_custom OWNER TO new_owner; - - -- publication.comment - COMMENT ON PUBLICATION pub_custom IS 'publication comment'; - - -- publication.drop_comment - COMMENT ON PUBLICATION pub_custom IS NULL; - - -- view.create - CREATE VIEW public.test_view WITH (security_barrier=true, check_option=local) AS SELECT * - FROM test_table; - - -- view.drop - DROP VIEW public.test_view; - - -- view.alter.change_owner - ALTER VIEW public.test_view OWNER TO new_owner; - - -- view.alter.set_options - ALTER VIEW public.test_view SET (security_barrier=true, check_option=cascaded); - - -- view.alter.reset_options - ALTER VIEW public.test_view RESET (security_barrier); - - -- view.comment - COMMENT ON VIEW public.test_view IS 'view comment'; - - -- view.drop_comment - COMMENT ON VIEW public.test_view IS NULL; - - -- view.grant - GRANT SELECT ON public.test_view TO app_reader WITH GRANT OPTION; - - -- view.revoke - REVOKE SELECT ON public.test_view FROM app_reader; - - -- view.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON public.test_view FROM app_reader; - - -- rule.create - CREATE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.drop - DROP RULE test_rule ON public.test_table; - - -- rule.replace - CREATE OR REPLACE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.alter.set_enabled - ALTER TABLE public.test_table DISABLE RULE test_rule; - - -- rule.comment - COMMENT ON RULE test_rule ON public.test_table IS 'rule comment'; - - -- rule.drop_comment - COMMENT ON RULE test_rule ON public.test_table IS NULL; - - -- procedure.create - CREATE PROCEDURE public.test_procedure() LANGUAGE plpgsql AS $$ begin null; end; $$; - - -- procedure.drop - DROP PROCEDURE public.test_procedure(); - - -- function.create - CREATE FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name("p_schema_name_for_analytics" text, "p_table_name_for_metrics" text, "p_limit_count_default" integer DEFAULT 100) RETURNS TABLE(total bigint, average numeric) LANGUAGE plpgsql STABLE SECURITY DEFINER PARALLEL SAFE COST 100 ROWS 10 STRICT SET search_path TO 'pg_catalog', 'public' AS $function$ BEGIN RETURN QUERY SELECT count(*)::bigint, avg(value)::numeric FROM generate_series(1, p_limit_count_default); END; $function$; - - -- function.drop - DROP FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(IN "p_schema_name_for_analytics" text, IN "p_table_name_for_metrics" text, IN "p_limit_count_default" integer); - - -- function.alter.change_owner - ALTER FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) OWNER TO new_admin; - - -- function.alter.set_security - ALTER FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) SECURITY INVOKER; - - -- function.alter.set_config - ALTER FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) SET work_mem TO '256MB'; - - -- function.alter.set_volatility - ALTER FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) IMMUTABLE; - - -- function.alter.set_strictness - ALTER FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) CALLED ON NULL INPUT; - - -- function.alter.set_leakproof - ALTER FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) LEAKPROOF; - - -- function.alter.set_parallel - ALTER FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) PARALLEL RESTRICTED; - - -- function.comment - COMMENT ON FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) IS 'Calculate metrics for a given table'; - - -- function.drop_comment - COMMENT ON FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) IS NULL; - - -- function.grant - GRANT ALL ON FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) TO app_user WITH GRANT OPTION; - - -- function.revoke - REVOKE ALL ON FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) FROM app_user; - - -- function.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) FROM app_user; - - -- sequence.create - CREATE SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq; - - -- sequence.drop - DROP SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq CASCADE; - - -- sequence.alter.set_owned_by - ALTER SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq OWNED BY public.table_with_very_long_name_for_formatting_and_wrapping_test.id; - - -- sequence.alter.set_options - ALTER SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq INCREMENT BY 10 MINVALUE 1 MAXVALUE 1000000 CACHE 5 CYCLE; - - -- sequence.comment - COMMENT ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq IS 'sequence for table_with_very_long_name_for_formatting_and_wrapping_test.id'; - - -- sequence.drop_comment - COMMENT ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq IS NULL; - - -- sequence.grant - GRANT SELECT, USAGE ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq TO app_user; - - -- sequence.revoke - REVOKE USAGE ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq FROM app_user; - - -- sequence.revoke_grant_option - REVOKE GRANT OPTION FOR USAGE ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq FROM app_user; - - -- policy.create - CREATE POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test FOR SELECT TO authenticated USING (auth.uid() = user_id); - - -- policy.create_restrictive - CREATE POLICY restrict_delete ON public.table_with_very_long_name_for_formatting_and_wrapping_test AS RESTRICTIVE FOR DELETE TO authenticated, service_role USING (auth.uid() = owner_id) WITH CHECK (status <> 'locked'); - - -- policy.drop - DROP POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- policy.alter.set_roles - ALTER POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test TO authenticated, anon; - - -- policy.alter.set_using - ALTER POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test USING (auth.uid() = user_id AND status = 'active'); - - -- policy.alter.set_with_check - ALTER POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test WITH CHECK (auth.uid() = user_id); - - -- policy.comment - COMMENT ON POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'rls policy comment'; - - -- policy.drop_comment - COMMENT ON POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- index.create - CREATE UNIQUE INDEX idx_t_fmt_status ON public.table_with_very_long_name_for_formatting_and_wrapping_test (status) WITH (fillfactor='90') WHERE (status <> 'archived'::text); - - -- index.create_gin - CREATE INDEX idx_t_fmt_search ON public.table_with_very_long_name_for_formatting_and_wrapping_test USING gin (to_tsvector('english'::regconfig, status)); - - -- index.drop - DROP INDEX public.idx_t_fmt_status; - - -- index.alter.set_storage_params - ALTER INDEX public.idx_t_fmt_status RESET (deduplicate_items); - ALTER INDEX public.idx_t_fmt_status SET (fillfactor=80); - - -- index.alter.set_statistics - ALTER INDEX public.idx_t_fmt_status ALTER COLUMN 1 SET STATISTICS 500; - - -- index.comment - COMMENT ON INDEX public.idx_t_fmt_status IS 'index comment'; - - -- index.drop_comment - COMMENT ON INDEX public.idx_t_fmt_status IS NULL; - - -- trigger.create - CREATE TRIGGER trg_audit AFTER INSERT OR UPDATE ON public.table_with_very_long_name_for_formatting_and_wrapping_test REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ((NEW.status IS DISTINCT FROM OLD.status)) EXECUTE FUNCTION public.audit_trigger_fn('arg1', 'arg2'); - - -- trigger.drop - DROP TRIGGER trg_audit ON public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- trigger.replace - CREATE OR REPLACE TRIGGER trg_audit AFTER INSERT OR UPDATE ON public.table_with_very_long_name_for_formatting_and_wrapping_test REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ((NEW.status IS DISTINCT FROM OLD.status)) EXECUTE FUNCTION public.audit_trigger_fn('arg1', 'arg2'); - - -- trigger.comment - COMMENT ON TRIGGER trg_audit ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'trigger comment'; - - -- trigger.drop_comment - COMMENT ON TRIGGER trg_audit ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- matview.create - CREATE MATERIALIZED VIEW analytics.daily_stats WITH (fillfactor=70) AS SELECT date_trunc('day', created_at) AS day, count(*) AS total - FROM public.events - GROUP BY 1 WITH DATA; - - -- matview.drop - DROP MATERIALIZED VIEW analytics.daily_stats; - - -- matview.alter.change_owner - ALTER MATERIALIZED VIEW analytics.daily_stats OWNER TO new_owner; - - -- matview.alter.set_storage - ALTER MATERIALIZED VIEW analytics.daily_stats RESET (autovacuum_enabled); - ALTER MATERIALIZED VIEW analytics.daily_stats SET (fillfactor=80); - - -- matview.comment - COMMENT ON MATERIALIZED VIEW analytics.daily_stats IS 'daily aggregation'; - - -- matview.drop_comment - COMMENT ON MATERIALIZED VIEW analytics.daily_stats IS NULL; - - -- matview.column_comment - COMMENT ON COLUMN analytics.daily_stats.day IS 'day bucket'; - - -- matview.drop_column_comment - COMMENT ON COLUMN analytics.daily_stats.day IS NULL; - - -- matview.grant - GRANT SELECT ON analytics.daily_stats TO app_reader; - - -- matview.revoke - REVOKE SELECT ON analytics.daily_stats FROM app_reader; - - -- matview.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON analytics.daily_stats FROM app_reader; - - -- aggregate.create - CREATE AGGREGATE public.array_cat_agg(anycompatiblearray) (SFUNC = array_cat, STYPE = anycompatiblearray, COMBINEFUNC = array_cat, INITCOND = '{}', PARALLEL = SAFE, STRICT); - - -- aggregate.drop - DROP AGGREGATE public.array_cat_agg(anycompatiblearray); - - -- aggregate.alter.change_owner - ALTER AGGREGATE public.array_cat_agg(anycompatiblearray) OWNER TO new_owner; - - -- aggregate.comment - COMMENT ON AGGREGATE public.array_cat_agg(anycompatiblearray) IS 'concatenate arrays aggregate'; - - -- aggregate.drop_comment - COMMENT ON AGGREGATE public.array_cat_agg(anycompatiblearray) IS NULL; - - -- aggregate.grant - GRANT ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) TO app_user; - - -- aggregate.revoke - REVOKE ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) FROM app_user; - - -- aggregate.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) FROM app_user; - - -- event_trigger.create - CREATE EVENT TRIGGER prevent_drop ON sql_drop WHEN TAG IN ('DROP TABLE', 'DROP SCHEMA') EXECUTE FUNCTION public.prevent_drop_fn(); - - -- event_trigger.drop - DROP EVENT TRIGGER prevent_drop; - - -- event_trigger.alter.change_owner - ALTER EVENT TRIGGER prevent_drop OWNER TO new_owner; - - -- event_trigger.alter.set_enabled - ALTER EVENT TRIGGER prevent_drop DISABLE; - - -- event_trigger.comment - COMMENT ON EVENT TRIGGER prevent_drop IS 'prevent accidental drops'; - - -- event_trigger.drop_comment - COMMENT ON EVENT TRIGGER prevent_drop IS NULL; - - -- language.create - CREATE TRUSTED LANGUAGE plv8 HANDLER plv8_call_handler INLINE plv8_inline_handler VALIDATOR plv8_call_validator; - - -- language.drop - DROP LANGUAGE plv8; - - -- language.alter.change_owner - ALTER LANGUAGE plv8 OWNER TO new_owner; - - -- language.comment - COMMENT ON LANGUAGE plv8 IS 'PL/V8 trusted procedural language'; - - -- language.drop_comment - COMMENT ON LANGUAGE plv8 IS NULL; - - -- language.grant - GRANT ALL ON LANGUAGE plv8 TO app_user WITH GRANT OPTION; - - -- language.revoke - REVOKE ALL ON LANGUAGE plv8 FROM app_user; - - -- language.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON LANGUAGE plv8 FROM app_user; - - -- role.create - CREATE ROLE app_user WITH LOGIN CONNECTION LIMIT 100; - - -- role.drop - DROP ROLE app_user; - - -- role.alter.set_options - ALTER ROLE app_user WITH NOSUPERUSER CREATEDB; - - -- role.alter.set_config - ALTER ROLE app_user SET statement_timeout TO '60000'; - - -- role.comment - COMMENT ON ROLE app_user IS 'application user role'; - - -- role.drop_comment - COMMENT ON ROLE app_user IS NULL; - - -- role.grant_membership - GRANT app_user TO dev_user WITH ADMIN OPTION; - - -- role.revoke_membership - REVOKE app_user FROM dev_user; - - -- role.revoke_membership_options - REVOKE ADMIN OPTION FOR app_user FROM dev_user; - - -- role.grant_default_privileges - ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public GRANT SELECT ON TABLES TO app_reader; - - -- role.revoke_default_privileges - ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public REVOKE SELECT ON TABLES FROM app_reader; - - -- subscription.create - CREATE SUBSCRIPTION sub_replica CONNECTION 'host=primary.db port=5432 dbname=mydb' PUBLICATION pub_custom WITH (slot_name = 'sub_replica_slot', binary = true, streaming = 'parallel', synchronous_commit = 'remote_apply', disable_on_error = true, failover = true, create_slot = false); - - -- subscription.drop - DROP SUBSCRIPTION sub_replica; - - -- subscription.alter.set_connection - ALTER SUBSCRIPTION sub_replica CONNECTION 'host=primary.db port=5432 dbname=mydb'; - - -- subscription.alter.set_publication - ALTER SUBSCRIPTION sub_replica SET PUBLICATION pub_custom; - - -- subscription.alter.enable - ALTER SUBSCRIPTION sub_replica ENABLE; - - -- subscription.alter.disable - ALTER SUBSCRIPTION sub_replica DISABLE; - - -- subscription.alter.set_options - ALTER SUBSCRIPTION sub_replica SET (binary = true, streaming = 'parallel', synchronous_commit = 'remote_apply'); - - -- subscription.alter.set_owner - ALTER SUBSCRIPTION sub_replica OWNER TO new_owner; - - -- subscription.comment - COMMENT ON SUBSCRIPTION sub_replica IS 'replication subscription'; - - -- subscription.drop_comment - COMMENT ON SUBSCRIPTION sub_replica IS NULL; - - -- fdw.create - CREATE FOREIGN DATA WRAPPER postgres_fdw HANDLER postgres_fdw_handler VALIDATOR postgres_fdw_validator OPTIONS (debug '__OPTION_DEBUG__'); - - -- fdw.drop - DROP FOREIGN DATA WRAPPER postgres_fdw; - - -- fdw.alter.change_owner - ALTER FOREIGN DATA WRAPPER postgres_fdw OWNER TO new_owner; - - -- fdw.alter.set_options - ALTER FOREIGN DATA WRAPPER postgres_fdw OPTIONS (SET debug '__OPTION_DEBUG__', ADD use_remote_estimate ''); - - -- fdw.comment - COMMENT ON FOREIGN DATA WRAPPER postgres_fdw IS 'PostgreSQL foreign data wrapper'; - - -- fdw.drop_comment - COMMENT ON FOREIGN DATA WRAPPER postgres_fdw IS NULL; - - -- fdw.grant - GRANT ALL ON FOREIGN DATA WRAPPER postgres_fdw TO app_user; - - -- fdw.revoke - REVOKE ALL ON FOREIGN DATA WRAPPER postgres_fdw FROM app_user; - - -- fdw.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FOREIGN DATA WRAPPER postgres_fdw FROM app_user; - - -- foreign_table.create - CREATE FOREIGN TABLE public.remote_users (id integer, email text) SERVER remote_server OPTIONS (schema_name 'public', table_name 'users'); - - -- foreign_table.drop - DROP FOREIGN TABLE public.remote_users; - - -- foreign_table.alter.change_owner - ALTER FOREIGN TABLE public.remote_users OWNER TO new_owner; - - -- foreign_table.alter.add_column - ALTER FOREIGN TABLE public.remote_users ADD COLUMN name text NOT NULL DEFAULT 'unknown'; - - -- foreign_table.alter.drop_column - ALTER FOREIGN TABLE public.remote_users DROP COLUMN email; - - -- foreign_table.alter.column_type - ALTER FOREIGN TABLE public.remote_users ALTER COLUMN id TYPE bigint; - - -- foreign_table.alter.column_set_default - ALTER FOREIGN TABLE public.remote_users ALTER COLUMN email SET DEFAULT 'nobody@example.com'; - - -- foreign_table.alter.column_drop_default - ALTER FOREIGN TABLE public.remote_users ALTER COLUMN email DROP DEFAULT; - - -- foreign_table.alter.column_set_not_null - ALTER FOREIGN TABLE public.remote_users ALTER COLUMN email SET NOT NULL; - - -- foreign_table.alter.column_drop_not_null - ALTER FOREIGN TABLE public.remote_users ALTER COLUMN email DROP NOT NULL; - - -- foreign_table.alter.set_options - ALTER FOREIGN TABLE public.remote_users OPTIONS (SET fetch_size '1000'); - - -- foreign_table.comment - COMMENT ON FOREIGN TABLE public.remote_users IS 'remote users table'; - - -- foreign_table.drop_comment - COMMENT ON FOREIGN TABLE public.remote_users IS NULL; - - -- foreign_table.grant - GRANT SELECT ON TABLE public.remote_users TO app_reader; - - -- foreign_table.revoke - REVOKE SELECT ON TABLE public.remote_users FROM app_reader; - - -- foreign_table.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON TABLE public.remote_users FROM app_reader; - - -- server.create - CREATE SERVER remote_server TYPE 'postgresql' VERSION '16.0' FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host 'remote.host', port '5432', dbname 'remote_db'); - - -- server.drop - DROP SERVER remote_server; - - -- server.alter.change_owner - ALTER SERVER remote_server OWNER TO new_owner; - - -- server.alter.set_version - ALTER SERVER remote_server VERSION '17.0'; - - -- server.alter.set_options - ALTER SERVER remote_server OPTIONS (SET host 'new.host', DROP port); - - -- server.comment - COMMENT ON SERVER remote_server IS 'remote PostgreSQL server'; - - -- server.drop_comment - COMMENT ON SERVER remote_server IS NULL; - - -- server.grant - GRANT ALL ON SERVER remote_server TO app_user; - - -- server.revoke - REVOKE ALL ON SERVER remote_server FROM app_user; - - -- server.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON SERVER remote_server FROM app_user; - - -- user_mapping.create - CREATE USER MAPPING FOR app_user SERVER remote_server OPTIONS (user 'remote_app', password '__OPTION_PASSWORD__'); - - -- user_mapping.drop - DROP USER MAPPING FOR app_user SERVER remote_server; - - -- user_mapping.alter.set_options - ALTER USER MAPPING FOR app_user SERVER remote_server OPTIONS (SET password '__OPTION_PASSWORD__');" - `); - }); -}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-pretty-lower-leading.test.ts b/packages/pg-delta/src/core/plan/sql-format/format-pretty-lower-leading.test.ts deleted file mode 100644 index 7a4d018a4..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/format-pretty-lower-leading.test.ts +++ /dev/null @@ -1,1062 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { renderScript } from "./fixtures.ts"; - -describe("sql formatting snapshots", () => { - test("format-pretty-lower-leading", () => { - const output = [ - "-- format: { keywordCase: 'lower', commaStyle: 'leading', alignColumns: true, indent: 4 }", - renderScript({ - keywordCase: "lower", - commaStyle: "leading", - alignColumns: true, - indent: 4, - }), - ] - .filter(Boolean) - .join("\n"); - expect(output).toMatchInlineSnapshot(` - "-- format: { keywordCase: 'lower', commaStyle: 'leading', alignColumns: true, indent: 4 } - -- schema.create - create schema application_schema_with_very_long_name_for_wrapping_tests authorization admin; - - -- schema.drop - drop schema application_schema_with_very_long_name_for_wrapping_tests; - - -- schema.alter.change_owner - alter schema application_schema_with_very_long_name_for_wrapping_tests owner to new_admin; - - -- schema.comment - comment on schema application_schema_with_very_long_name_for_wrapping_tests is - 'application schema'; - - -- schema.drop_comment - comment on schema application_schema_with_very_long_name_for_wrapping_tests is null; - - -- schema.grant - grant all on schema application_schema_with_very_long_name_for_wrapping_tests to app_user - with grant option; - - -- schema.revoke - revoke create on schema application_schema_with_very_long_name_for_wrapping_tests from app_user; - - -- schema.revoke_grant_option - revoke grant option for usage - on schema application_schema_with_very_long_name_for_wrapping_tests from app_user; - - -- extension.create - create extension pgcrypto with schema extensions; - - -- extension.drop - drop extension pgcrypto; - - -- extension.alter.update_version - alter extension pgcrypto update to '1.4'; - - -- extension.alter.set_schema - alter extension pgcrypto set schema public; - - -- extension.comment - comment on extension pgcrypto is 'cryptographic functions'; - - -- extension.drop_comment - comment on extension pgcrypto is null; - - -- domain.create - create domain public.test_domain_all as custom.text[][] - collate mycoll - default 'hello' - not null - check (VALUE <> ''); - - -- domain.drop - drop domain public.test_domain_all; - - -- domain.alter.set_default - alter domain public.test_domain_all - set default 'world'; - - -- domain.alter.drop_default - alter domain public.test_domain_all - drop default; - - -- domain.alter.set_not_null - alter domain public.test_domain_all - set not null; - - -- domain.alter.drop_not_null - alter domain public.test_domain_all - drop not null; - - -- domain.alter.change_owner - alter domain public.test_domain_all - owner to new_owner; - - -- domain.alter.add_constraint - alter domain public.test_domain_all - add constraint domain_len_chk check (char_length(VALUE) <= 255) not valid; - - -- domain.alter.drop_constraint - alter domain public.test_domain_all - drop constraint domain_chk; - - -- domain.alter.validate_constraint - alter domain public.test_domain_all - validate constraint domain_len_chk; - - -- domain.comment - comment on domain public.test_domain_all is 'domain comment'; - - -- domain.drop_comment - comment on domain public.test_domain_all is null; - - -- domain.grant - grant all on domain public.test_domain_all to app_user; - - -- domain.revoke - revoke all on domain public.test_domain_all from app_user; - - -- domain.revoke_grant_option - revoke grant option for all on domain public.test_domain_all from app_user; - - -- type.enum.create - create type public.test_enum as enum ( - 'value1' - , 'value2' - , 'value3' - ); - - -- type.enum.drop - drop type public.test_enum; - - -- type.enum.alter.change_owner - alter type public.test_enum owner to new_owner; - - -- type.enum.alter.add_value - alter type public.test_enum add value 'value4' after 'value2'; - - -- type.enum.comment - comment on type public.test_enum is 'enum comment'; - - -- type.enum.drop_comment - comment on type public.test_enum is null; - - -- type.enum.grant - grant all on type public.test_enum to app_user; - - -- type.enum.revoke - revoke all on type public.test_enum from app_user; - - -- type.enum.revoke_grant_option - revoke grant option for all on type public.test_enum from app_user; - - -- type.composite.create - create type public.test_type as ( - id integer - , name text collate "en_US" - ); - - -- type.composite.drop - drop type public.test_type; - - -- type.composite.alter.change_owner - alter type public.test_type owner to new_owner; - - -- type.composite.alter.add_attribute - alter type public.test_type add attribute age integer; - - -- type.composite.alter.drop_attribute - alter type public.test_type drop attribute name; - - -- type.composite.alter.alter_attr_type - alter type public.test_type alter attribute name type varchar(255) collate "C"; - - -- type.composite.comment - comment on type public.test_type is 'composite comment'; - - -- type.composite.drop_comment - comment on type public.test_type is null; - - -- type.composite.attr_comment - comment on column public.test_type.id is 'attr comment'; - - -- type.composite.drop_attr_comment - comment on column public.test_type.id is null; - - -- type.composite.grant - grant all on type public.test_type to app_user; - - -- type.composite.revoke - revoke all on type public.test_type from app_user; - - -- type.composite.revoke_grant_option - revoke grant option for all on type public.test_type from app_user; - - -- type.range.create - create type public.daterange_custom as range ( - subtype = date - , subtype_opclass = public.date_ops - , collation = "en_US" - , canonical = public.canon_fn - , subtype_diff = public.diff_fn - ); - - -- type.range.drop - drop type public.daterange_custom; - - -- type.range.alter.change_owner - alter type public.daterange_custom owner to new_owner; - - -- type.range.comment - comment on type public.daterange_custom is 'range comment'; - - -- type.range.drop_comment - comment on type public.daterange_custom is null; - - -- type.range.grant - grant all on type public.daterange_custom to app_user; - - -- type.range.revoke - revoke all on type public.daterange_custom from app_user; - - -- type.range.revoke_grant_option - revoke grant option for all on type public.daterange_custom from app_user; - - -- collation.create - create collation public.test ( - locale = 'en_US' - , lc_collate = 'en_US' - , lc_ctype = 'en_US' - , provider = icu - , deterministic = false - , rules = '& A < a <<< à' - , version = '1.0' - ); - - -- collation.drop - drop collation public.test; - - -- collation.alter.change_owner - alter collation public.test owner to new_owner; - - -- collation.alter.refresh_version - alter collation public.test refresh version; - - -- collation.comment - comment on collation public.test is 'collation comment'; - - -- collation.drop_comment - comment on collation public.test is null; - - -- table.create - create table public.table_with_very_long_name_for_formatting_and_wrapping_test ( - id bigint generated always as identity not null - , status text collate "en_US" default 'pending' - , created_at timestamptz default now() - , ref_id bigint - , computed bigint generated always as (id * 2) stored - ) with (fillfactor=70, autovacuum_enabled=false); - - -- table.drop - drop table public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- table.alter.add_column - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - add column email text collate "en_US" default 'user@example.com' not null; - - -- table.alter.drop_column - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - drop column computed; - - -- table.alter.column_type - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - alter column status type character varying(255) collate "C"; - - -- table.alter.column_set_default - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - alter column status set default 'active'; - - -- table.alter.column_drop_default - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - alter column status drop default; - - -- table.alter.column_set_not_null - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - alter column status set not null; - - -- table.alter.column_drop_not_null - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - alter column status drop not null; - - -- table.alter.add_constraint - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - add constraint uq_t_fmt_status unique (status); - - -- table.alter.add_fk_constraint - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - add constraint fk_t_fmt_ref foreign key (ref_id) references public.other_table(id) match full - on update set null on delete cascade deferrable initially deferred; - - -- table.alter.drop_constraint - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - drop constraint uq_t_fmt_status; - - -- table.alter.validate_constraint - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - validate constraint chk_t_fmt_status; - - -- table.alter.change_owner - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - owner to new_owner; - - -- table.alter.set_logged - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - set logged; - - -- table.alter.set_unlogged - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - set unlogged; - - -- table.alter.enable_rls - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - enable row level security; - - -- table.alter.disable_rls - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - disable row level security; - - -- table.alter.force_rls - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - force row level security; - - -- table.alter.no_force_rls - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - no force row level security; - - -- table.alter.set_storage_params - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - set (fillfactor=80, autovacuum_enabled=true); - - -- table.alter.reset_storage_params - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - reset (fillfactor, autovacuum_enabled); - - -- table.alter.replica_identity - alter table public.table_with_very_long_name_for_formatting_and_wrapping_test - replica identity full; - - -- table.alter.attach_partition - alter table public.events - attach partition public.events_2024 for values from ('2024-01-01') to ('2025-01-01'); - - -- table.alter.detach_partition - alter table public.events - detach partition public.events_2024; - - -- table.comment - comment on table public.table_with_very_long_name_for_formatting_and_wrapping_test is - 'table comment'; - - -- table.drop_comment - comment on table public.table_with_very_long_name_for_formatting_and_wrapping_test is null; - - -- table.column_comment - comment on column public.table_with_very_long_name_for_formatting_and_wrapping_test.id is - 'id column'; - - -- table.drop_column_comment - comment on column public.table_with_very_long_name_for_formatting_and_wrapping_test.id is null; - - -- table.constraint_comment - comment on constraint pk_t_fmt - on public.table_with_very_long_name_for_formatting_and_wrapping_test is - 'primary key'; - - -- table.drop_constraint_comment - comment on constraint chk_t_fmt_status - on public.table_with_very_long_name_for_formatting_and_wrapping_test is null; - - -- table.grant - grant insert, - select on public.table_with_very_long_name_for_formatting_and_wrapping_test to app_reader; - - -- table.revoke - revoke delete, - update on public.table_with_very_long_name_for_formatting_and_wrapping_test from app_reader; - - -- table.revoke_grant_option - revoke grant option for insert, - select on public.table_with_very_long_name_for_formatting_and_wrapping_test from app_reader; - - -- publication.create - create publication pub_custom for table - public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit - ( - id - , title - ) where (published = true), - table public.comments_a_little_smaller_name_than_the_previous_one, tables in schema analytics; - - -- publication.drop - drop publication pub_custom; - - -- publication.alter.set_options - alter publication pub_custom - set (publish = 'insert, update, delete, truncate', publish_via_partition_root = false); - - -- publication.alter.set_list - alter publication pub_custom - set table - public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit - (id, title) where (published = true), - table public.comments_a_little_smaller_name_than_the_previous_one, tables in schema analytics; - - -- publication.alter.add_tables - alter publication pub_custom - add table public.new_table_with_very_long_name_for_formatting_and_wrapping_test; - - -- publication.alter.drop_tables - alter publication pub_custom drop table public.comments_a_little_smaller_name_than_the_previous_one; - - -- publication.alter.add_schemas - alter publication pub_custom add tables in schema staging; - - -- publication.alter.drop_schemas - alter publication pub_custom drop tables in schema analytics; - - -- publication.alter.set_owner - alter publication pub_custom owner to new_owner; - - -- publication.comment - comment on publication pub_custom is 'publication comment'; - - -- publication.drop_comment - comment on publication pub_custom is null; - - -- view.create - create view public.test_view with (security_barrier=true, check_option=local) AS SELECT * - FROM test_table; - - -- view.drop - drop view public.test_view; - - -- view.alter.change_owner - alter view public.test_view owner to new_owner; - - -- view.alter.set_options - alter view public.test_view set (security_barrier=true, check_option=cascaded); - - -- view.alter.reset_options - alter view public.test_view reset (security_barrier); - - -- view.comment - comment on view public.test_view is 'view comment'; - - -- view.drop_comment - comment on view public.test_view is null; - - -- view.grant - grant select on public.test_view to app_reader with grant option; - - -- view.revoke - revoke select on public.test_view from app_reader; - - -- view.revoke_grant_option - revoke grant option for select on public.test_view from app_reader; - - -- rule.create - create rule test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.drop - drop rule test_rule on public.test_table; - - -- rule.replace - create or replace rule test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.alter.set_enabled - alter table public.test_table - disable rule test_rule; - - -- rule.comment - comment on rule test_rule on public.test_table is 'rule comment'; - - -- rule.drop_comment - comment on rule test_rule on public.test_table is null; - - -- procedure.create - create procedure public.test_procedure() - language plpgsql - AS $$ begin null; end; $$; - - -- procedure.drop - drop procedure public.test_procedure(); - - -- function.create - create function public.calculate_metrics_for_analytics_dashboard_with_extended_name ( - "p_schema_name_for_analytics" text - , "p_table_name_for_metrics" text - , "p_limit_count_default" integer default 100 - ) - returns table ( - total bigint - , average numeric - ) - language plpgsql - stable - security definer - parallel safe - cost 100 - rows 10 - strict - set search_path to 'pg_catalog', 'public' - AS $function$ BEGIN RETURN QUERY SELECT count(*)::bigint, avg(value)::numeric FROM generate_series(1, p_limit_count_default); END; $function$; - - -- function.drop - drop function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(in - "p_schema_name_for_analytics" text, - in "p_table_name_for_metrics" text, in "p_limit_count_default" integer); - - -- function.alter.change_owner - alter function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) owner - to new_admin; - - -- function.alter.set_security - alter function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - security invoker; - - -- function.alter.set_config - alter function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - set work_mem to '256MB'; - - -- function.alter.set_volatility - alter function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - immutable; - - -- function.alter.set_strictness - alter function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) called - on null input; - - -- function.alter.set_leakproof - alter function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - leakproof; - - -- function.alter.set_parallel - alter function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - parallel restricted; - - -- function.comment - comment on function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) is - 'Calculate metrics for a given table'; - - -- function.drop_comment - comment on function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) is null; - - -- function.grant - grant all on function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) to - app_user with grant option; - - -- function.revoke - revoke all on function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) from - app_user; - - -- function.revoke_grant_option - revoke grant option for all on function - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) from - app_user; - - -- sequence.create - create sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq; - - -- sequence.drop - drop sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq cascade; - - -- sequence.alter.set_owned_by - alter sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq owned by - public.table_with_very_long_name_for_formatting_and_wrapping_test.id; - - -- sequence.alter.set_options - alter sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq increment by - 10 minvalue 1 maxvalue 1000000 cache 5 cycle; - - -- sequence.comment - comment on sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq is - 'sequence for table_with_very_long_name_for_formatting_and_wrapping_test.id'; - - -- sequence.drop_comment - comment on sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq is null; - - -- sequence.grant - grant select, - usage - on sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq to app_user; - - -- sequence.revoke - revoke usage - on sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq from - app_user; - - -- sequence.revoke_grant_option - revoke grant option for usage - on sequence public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq from - app_user; - - -- policy.create - create policy allow_select_own on public.table_with_very_long_name_for_formatting_and_wrapping_test - for select - to authenticated - using (auth.uid() = user_id); - - -- policy.create_restrictive - create policy restrict_delete on public.table_with_very_long_name_for_formatting_and_wrapping_test - as restrictive - for delete - to authenticated, service_role - using (auth.uid() = owner_id) - with check (status <> 'locked'); - - -- policy.drop - drop policy allow_select_own on public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- policy.alter.set_roles - alter policy allow_select_own - on public.table_with_very_long_name_for_formatting_and_wrapping_test to authenticated, anon; - - -- policy.alter.set_using - alter policy allow_select_own on public.table_with_very_long_name_for_formatting_and_wrapping_test - using (auth.uid() = user_id AND status = 'active'); - - -- policy.alter.set_with_check - alter policy allow_select_own on public.table_with_very_long_name_for_formatting_and_wrapping_test - with check (auth.uid() = user_id); - - -- policy.comment - comment on policy allow_select_own - on public.table_with_very_long_name_for_formatting_and_wrapping_test is - 'rls policy comment'; - - -- policy.drop_comment - comment on policy allow_select_own - on public.table_with_very_long_name_for_formatting_and_wrapping_test is null; - - -- index.create - create unique index idx_t_fmt_status - on public.table_with_very_long_name_for_formatting_and_wrapping_test (status) - with (fillfactor='90') - where (status <> 'archived'::text); - - -- index.create_gin - create index idx_t_fmt_search on public.table_with_very_long_name_for_formatting_and_wrapping_test - using gin (to_tsvector('english'::regconfig, status)); - - -- index.drop - drop index public.idx_t_fmt_status; - - -- index.alter.set_storage_params - alter index public.idx_t_fmt_status reset (deduplicate_items); - - alter index public.idx_t_fmt_status set (fillfactor=80); - - -- index.alter.set_statistics - alter index public.idx_t_fmt_status alter column 1 set statistics 500; - - -- index.comment - comment on index public.idx_t_fmt_status is 'index comment'; - - -- index.drop_comment - comment on index public.idx_t_fmt_status is null; - - -- trigger.create - create trigger trg_audit after insert or update - on public.table_with_very_long_name_for_formatting_and_wrapping_test - referencing OLD table as old_rows NEW table as new_rows for each row when ( - (NEW.status IS DISTINCT FROM OLD.status) - ) execute function public.audit_trigger_fn('arg1', 'arg2'); - - -- trigger.drop - drop trigger trg_audit on public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- trigger.replace - create or replace trigger trg_audit after insert or update - on public.table_with_very_long_name_for_formatting_and_wrapping_test - referencing OLD table as old_rows NEW table as new_rows for each row when ( - (NEW.status IS DISTINCT FROM OLD.status) - ) execute function public.audit_trigger_fn('arg1', 'arg2'); - - -- trigger.comment - comment on trigger trg_audit - on public.table_with_very_long_name_for_formatting_and_wrapping_test is - 'trigger comment'; - - -- trigger.drop_comment - comment on trigger trg_audit - on public.table_with_very_long_name_for_formatting_and_wrapping_test is null; - - -- matview.create - create materialized view analytics.daily_stats - with (fillfactor=70) - AS SELECT date_trunc('day', created_at) AS day, count(*) AS total - FROM public.events - GROUP BY 1 WITH DATA; - - -- matview.drop - drop materialized view analytics.daily_stats; - - -- matview.alter.change_owner - alter materialized view analytics.daily_stats - owner to new_owner; - - -- matview.alter.set_storage - alter materialized view analytics.daily_stats - reset (autovacuum_enabled); - - alter materialized view analytics.daily_stats - set (fillfactor=80); - - -- matview.comment - comment on materialized view analytics.daily_stats is 'daily aggregation'; - - -- matview.drop_comment - comment on materialized view analytics.daily_stats is null; - - -- matview.column_comment - comment on column analytics.daily_stats.day is 'day bucket'; - - -- matview.drop_column_comment - comment on column analytics.daily_stats.day is null; - - -- matview.grant - grant select on analytics.daily_stats to app_reader; - - -- matview.revoke - revoke select on analytics.daily_stats from app_reader; - - -- matview.revoke_grant_option - revoke grant option for select on analytics.daily_stats from app_reader; - - -- aggregate.create - create aggregate public.array_cat_agg(anycompatiblearray) ( - sfunc = array_cat - , stype = anycompatiblearray - , combinefunc = array_cat - , initcond = '{}' - , parallel = SAFE - , strict - ); - - -- aggregate.drop - drop aggregate public.array_cat_agg(anycompatiblearray); - - -- aggregate.alter.change_owner - alter aggregate public.array_cat_agg(anycompatiblearray) owner to new_owner; - - -- aggregate.comment - comment on aggregate public.array_cat_agg(anycompatiblearray) is 'concatenate arrays aggregate'; - - -- aggregate.drop_comment - comment on aggregate public.array_cat_agg(anycompatiblearray) is null; - - -- aggregate.grant - grant all on function public.array_cat_agg(anycompatiblearray) to app_user; - - -- aggregate.revoke - revoke all on function public.array_cat_agg(anycompatiblearray) from app_user; - - -- aggregate.revoke_grant_option - revoke grant option for all on function public.array_cat_agg(anycompatiblearray) from app_user; - - -- event_trigger.create - create event trigger prevent_drop - on sql_drop - when tag in ('DROP TABLE', 'DROP SCHEMA') - execute function public.prevent_drop_fn(); - - -- event_trigger.drop - drop event trigger prevent_drop; - - -- event_trigger.alter.change_owner - alter event trigger prevent_drop - owner to new_owner; - - -- event_trigger.alter.set_enabled - alter event trigger prevent_drop - disable; - - -- event_trigger.comment - comment on event trigger prevent_drop is 'prevent accidental drops'; - - -- event_trigger.drop_comment - comment on event trigger prevent_drop is null; - - -- language.create - create trusted language plv8 - handler plv8_call_handler - inline plv8_inline_handler - validator plv8_call_validator; - - -- language.drop - drop language plv8; - - -- language.alter.change_owner - alter language plv8 owner to new_owner; - - -- language.comment - comment on language plv8 is 'PL/V8 trusted procedural language'; - - -- language.drop_comment - comment on language plv8 is null; - - -- language.grant - grant all on language plv8 to app_user with grant option; - - -- language.revoke - revoke all on language plv8 from app_user; - - -- language.revoke_grant_option - revoke grant option for all on language plv8 from app_user; - - -- role.create - create role app_user with login connection limit 100; - - -- role.drop - drop role app_user; - - -- role.alter.set_options - alter role app_user with nosuperuser createdb; - - -- role.alter.set_config - alter role app_user set statement_timeout to '60000'; - - -- role.comment - comment on role app_user is 'application user role'; - - -- role.drop_comment - comment on role app_user is null; - - -- role.grant_membership - grant app_user to dev_user with admin option; - - -- role.revoke_membership - revoke app_user from dev_user; - - -- role.revoke_membership_options - revoke admin option for app_user from dev_user; - - -- role.grant_default_privileges - alter default privileges for role app_user in schema public grant select on tables to app_reader; - - -- role.revoke_default_privileges - alter default privileges for role app_user in schema public revoke select on tables from app_reader; - - -- subscription.create - create subscription sub_replica - connection 'host=primary.db port=5432 dbname=mydb' - publication pub_custom - with ( - slot_name = 'sub_replica_slot' - , binary = true - , streaming = 'parallel' - , synchronous_commit = 'remote_apply' - , disable_on_error = true - , failover = true - , create_slot = false - ); - - -- subscription.drop - drop subscription sub_replica; - - -- subscription.alter.set_connection - alter subscription sub_replica - connection 'host=primary.db port=5432 dbname=mydb'; - - -- subscription.alter.set_publication - alter subscription sub_replica - set publication pub_custom; - - -- subscription.alter.enable - alter subscription sub_replica - enable; - - -- subscription.alter.disable - alter subscription sub_replica - disable; - - -- subscription.alter.set_options - alter subscription sub_replica - set ( - binary = true - , streaming = 'parallel' - , synchronous_commit = 'remote_apply' - ); - - -- subscription.alter.set_owner - alter subscription sub_replica - owner to new_owner; - - -- subscription.comment - comment on subscription sub_replica is 'replication subscription'; - - -- subscription.drop_comment - comment on subscription sub_replica is null; - - -- fdw.create - create foreign data wrapper postgres_fdw - handler postgres_fdw_handler - validator postgres_fdw_validator - options (debug '__OPTION_DEBUG__'); - - -- fdw.drop - drop foreign data wrapper postgres_fdw; - - -- fdw.alter.change_owner - alter foreign data wrapper postgres_fdw - owner to new_owner; - - -- fdw.alter.set_options - alter foreign data wrapper postgres_fdw - options ( - SET debug '__OPTION_DEBUG__' - , ADD use_remote_estimate '' - ); - - -- fdw.comment - comment on foreign data wrapper postgres_fdw is 'PostgreSQL foreign data wrapper'; - - -- fdw.drop_comment - comment on foreign data wrapper postgres_fdw is null; - - -- fdw.grant - grant all on foreign data wrapper postgres_fdw to app_user; - - -- fdw.revoke - revoke all on foreign data wrapper postgres_fdw from app_user; - - -- fdw.revoke_grant_option - revoke grant option for all on foreign data wrapper postgres_fdw from app_user; - - -- foreign_table.create - create foreign table public.remote_users ( - id integer - , email text - ) server remote_server options (schema_name 'public', table_name 'users'); - - -- foreign_table.drop - drop foreign table public.remote_users; - - -- foreign_table.alter.change_owner - alter foreign table public.remote_users - owner to new_owner; - - -- foreign_table.alter.add_column - alter foreign table public.remote_users - add column name text not null default 'unknown'; - - -- foreign_table.alter.drop_column - alter foreign table public.remote_users - drop column email; - - -- foreign_table.alter.column_type - alter foreign table public.remote_users - alter column id type bigint; - - -- foreign_table.alter.column_set_default - alter foreign table public.remote_users - alter column email set default 'nobody@example.com'; - - -- foreign_table.alter.column_drop_default - alter foreign table public.remote_users - alter column email drop default; - - -- foreign_table.alter.column_set_not_null - alter foreign table public.remote_users - alter column email set not null; - - -- foreign_table.alter.column_drop_not_null - alter foreign table public.remote_users - alter column email drop not null; - - -- foreign_table.alter.set_options - alter foreign table public.remote_users - options (SET fetch_size '1000'); - - -- foreign_table.comment - comment on foreign table public.remote_users is 'remote users table'; - - -- foreign_table.drop_comment - comment on foreign table public.remote_users is null; - - -- foreign_table.grant - grant select on table public.remote_users to app_reader; - - -- foreign_table.revoke - revoke select on table public.remote_users from app_reader; - - -- foreign_table.revoke_grant_option - revoke grant option for select on table public.remote_users from app_reader; - - -- server.create - create server remote_server - type 'postgresql' - version '16.0' - foreign data wrapper postgres_fdw - options ( - host 'remote.host' - , port '5432' - , dbname 'remote_db' - ); - - -- server.drop - drop server remote_server; - - -- server.alter.change_owner - alter server remote_server - owner to new_owner; - - -- server.alter.set_version - alter server remote_server - version '17.0'; - - -- server.alter.set_options - alter server remote_server - options ( - SET host 'new.host' - , DROP port - ); - - -- server.comment - comment on server remote_server is 'remote PostgreSQL server'; - - -- server.drop_comment - comment on server remote_server is null; - - -- server.grant - grant all on server remote_server to app_user; - - -- server.revoke - revoke all on server remote_server from app_user; - - -- server.revoke_grant_option - revoke grant option for all on server remote_server from app_user; - - -- user_mapping.create - create user mapping for app_user server remote_server - options (user 'remote_app', password '__OPTION_PASSWORD__'); - - -- user_mapping.drop - drop user mapping for app_user server remote_server; - - -- user_mapping.alter.set_options - alter user mapping for app_user server remote_server options (SET password '__OPTION_PASSWORD__');" - `); - }); -}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-pretty-narrow.test.ts b/packages/pg-delta/src/core/plan/sql-format/format-pretty-narrow.test.ts deleted file mode 100644 index e0aca1517..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/format-pretty-narrow.test.ts +++ /dev/null @@ -1,1281 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { renderScript } from "./fixtures.ts"; - -describe("sql formatting snapshots", () => { - test("format-pretty-narrow", () => { - const output = [ - "-- format: { maxWidth: 40 }", - renderScript({ maxWidth: 40 }), - ] - .filter(Boolean) - .join("\n"); - expect(output).toMatchInlineSnapshot(` - "-- format: { maxWidth: 40 } - -- schema.create - CREATE SCHEMA - application_schema_with_very_long_name_for_wrapping_tests AUTHORIZATION admin; - - -- schema.drop - DROP SCHEMA - application_schema_with_very_long_name_for_wrapping_tests; - - -- schema.alter.change_owner - ALTER SCHEMA - application_schema_with_very_long_name_for_wrapping_tests OWNER TO new_admin; - - -- schema.comment - COMMENT ON SCHEMA - application_schema_with_very_long_name_for_wrapping_tests IS 'application schema'; - - -- schema.drop_comment - COMMENT ON SCHEMA - application_schema_with_very_long_name_for_wrapping_tests IS NULL; - - -- schema.grant - GRANT ALL ON SCHEMA - application_schema_with_very_long_name_for_wrapping_tests TO app_user WITH GRANT OPTION; - - -- schema.revoke - REVOKE CREATE ON SCHEMA - application_schema_with_very_long_name_for_wrapping_tests FROM app_user; - - -- schema.revoke_grant_option - REVOKE GRANT OPTION FOR USAGE - ON SCHEMA - application_schema_with_very_long_name_for_wrapping_tests FROM app_user; - - -- extension.create - CREATE EXTENSION pgcrypto - WITH SCHEMA extensions; - - -- extension.drop - DROP EXTENSION pgcrypto; - - -- extension.alter.update_version - ALTER EXTENSION pgcrypto UPDATE TO '1.4'; - - -- extension.alter.set_schema - ALTER EXTENSION pgcrypto - SET SCHEMA public; - - -- extension.comment - COMMENT ON EXTENSION pgcrypto IS - 'cryptographic functions'; - - -- extension.drop_comment - COMMENT ON EXTENSION pgcrypto IS NULL; - - -- domain.create - CREATE DOMAIN public.test_domain_all AS - custom.text[][] - COLLATE mycoll - DEFAULT 'hello' - NOT NULL - CHECK (VALUE <> ''); - - -- domain.drop - DROP DOMAIN public.test_domain_all; - - -- domain.alter.set_default - ALTER DOMAIN public.test_domain_all - SET DEFAULT 'world'; - - -- domain.alter.drop_default - ALTER DOMAIN public.test_domain_all - DROP DEFAULT; - - -- domain.alter.set_not_null - ALTER DOMAIN public.test_domain_all - SET NOT NULL; - - -- domain.alter.drop_not_null - ALTER DOMAIN public.test_domain_all - DROP NOT NULL; - - -- domain.alter.change_owner - ALTER DOMAIN public.test_domain_all - OWNER TO new_owner; - - -- domain.alter.add_constraint - ALTER DOMAIN public.test_domain_all - ADD CONSTRAINT domain_len_chk - CHECK (char_length(VALUE) <= 255) - NOT VALID; - - -- domain.alter.drop_constraint - ALTER DOMAIN public.test_domain_all - DROP CONSTRAINT domain_chk; - - -- domain.alter.validate_constraint - ALTER DOMAIN public.test_domain_all - VALIDATE CONSTRAINT domain_len_chk; - - -- domain.comment - COMMENT ON DOMAIN public.test_domain_all - IS 'domain comment'; - - -- domain.drop_comment - COMMENT ON DOMAIN public.test_domain_all - IS NULL; - - -- domain.grant - GRANT ALL ON DOMAIN - public.test_domain_all TO app_user; - - -- domain.revoke - REVOKE ALL ON DOMAIN - public.test_domain_all FROM app_user; - - -- domain.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON DOMAIN - public.test_domain_all FROM app_user; - - -- type.enum.create - CREATE TYPE public.test_enum AS ENUM ( - 'value1', - 'value2', - 'value3' - ); - - -- type.enum.drop - DROP TYPE public.test_enum; - - -- type.enum.alter.change_owner - ALTER TYPE public.test_enum OWNER TO - new_owner; - - -- type.enum.alter.add_value - ALTER TYPE public.test_enum - ADD VALUE 'value4' AFTER 'value2'; - - -- type.enum.comment - COMMENT ON TYPE public.test_enum IS - 'enum comment'; - - -- type.enum.drop_comment - COMMENT ON TYPE public.test_enum IS NULL; - - -- type.enum.grant - GRANT ALL ON TYPE public.test_enum TO - app_user; - - -- type.enum.revoke - REVOKE ALL ON TYPE public.test_enum FROM - app_user; - - -- type.enum.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE - public.test_enum FROM app_user; - - -- type.composite.create - CREATE TYPE public.test_type AS ( - id integer, - name text COLLATE "en_US" - ); - - -- type.composite.drop - DROP TYPE public.test_type; - - -- type.composite.alter.change_owner - ALTER TYPE public.test_type OWNER TO - new_owner; - - -- type.composite.alter.add_attribute - ALTER TYPE public.test_type - ADD ATTRIBUTE age integer; - - -- type.composite.alter.drop_attribute - ALTER TYPE public.test_type DROP - ATTRIBUTE name; - - -- type.composite.alter.alter_attr_type - ALTER TYPE public.test_type ALTER - ATTRIBUTE name TYPE varchar(255) - COLLATE "C"; - - -- type.composite.comment - COMMENT ON TYPE public.test_type IS - 'composite comment'; - - -- type.composite.drop_comment - COMMENT ON TYPE public.test_type IS NULL; - - -- type.composite.attr_comment - COMMENT ON COLUMN public.test_type.id IS - 'attr comment'; - - -- type.composite.drop_attr_comment - COMMENT ON COLUMN public.test_type.id IS - NULL; - - -- type.composite.grant - GRANT ALL ON TYPE public.test_type TO - app_user; - - -- type.composite.revoke - REVOKE ALL ON TYPE public.test_type FROM - app_user; - - -- type.composite.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE - public.test_type FROM app_user; - - -- type.range.create - CREATE TYPE public.daterange_custom AS - RANGE ( - SUBTYPE = date, - SUBTYPE_OPCLASS = public.date_ops, - COLLATION = "en_US", - CANONICAL = public.canon_fn, - SUBTYPE_DIFF = public.diff_fn - ); - - -- type.range.drop - DROP TYPE public.daterange_custom; - - -- type.range.alter.change_owner - ALTER TYPE public.daterange_custom OWNER - TO new_owner; - - -- type.range.comment - COMMENT ON TYPE public.daterange_custom - IS 'range comment'; - - -- type.range.drop_comment - COMMENT ON TYPE public.daterange_custom - IS NULL; - - -- type.range.grant - GRANT ALL ON TYPE - public.daterange_custom TO app_user; - - -- type.range.revoke - REVOKE ALL ON TYPE - public.daterange_custom FROM app_user; - - -- type.range.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE - public.daterange_custom FROM app_user; - - -- collation.create - CREATE COLLATION public.test ( - LOCALE = 'en_US', - LC_COLLATE = 'en_US', - LC_CTYPE = 'en_US', - PROVIDER = icu, - DETERMINISTIC = false, - RULES = '& A < a <<< à', - VERSION = '1.0' - ); - - -- collation.drop - DROP COLLATION public.test; - - -- collation.alter.change_owner - ALTER COLLATION public.test OWNER TO - new_owner; - - -- collation.alter.refresh_version - ALTER COLLATION public.test REFRESH - VERSION; - - -- collation.comment - COMMENT ON COLLATION public.test IS - 'collation comment'; - - -- collation.drop_comment - COMMENT ON COLLATION public.test IS NULL; - - -- table.create - CREATE TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test ( - id bigint GENERATED - ALWAYS AS IDENTITY NOT NULL, - status text COLLATE "en_US" - DEFAULT 'pending', - created_at timestamptz DEFAULT now(), - ref_id bigint, - computed bigint GENERATED - ALWAYS AS (id * 2) STORED - ) - WITH (fillfactor=70, autovacuum_enabled=false); - - -- table.drop - DROP TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- table.alter.add_column - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD COLUMN email text COLLATE "en_US" - DEFAULT 'user@example.com' NOT NULL; - - -- table.alter.drop_column - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - DROP COLUMN computed; - - -- table.alter.column_type - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status TYPE character - varying(255) COLLATE "C"; - - -- table.alter.column_set_default - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status - SET DEFAULT 'active'; - - -- table.alter.column_drop_default - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status DROP DEFAULT; - - -- table.alter.column_set_not_null - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status SET NOT NULL; - - -- table.alter.column_drop_not_null - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status DROP NOT NULL; - - -- table.alter.add_constraint - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD - CONSTRAINT uq_t_fmt_status UNIQUE - (status); - - -- table.alter.add_fk_constraint - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD CONSTRAINT fk_t_fmt_ref - FOREIGN KEY (ref_id) - REFERENCES public.other_table(id) - MATCH FULL ON UPDATE SET NULL - ON DELETE CASCADE DEFERRABLE - INITIALLY DEFERRED; - - -- table.alter.drop_constraint - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - DROP CONSTRAINT uq_t_fmt_status; - - -- table.alter.validate_constraint - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - VALIDATE CONSTRAINT chk_t_fmt_status; - - -- table.alter.change_owner - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - OWNER TO new_owner; - - -- table.alter.set_logged - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - SET LOGGED; - - -- table.alter.set_unlogged - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - SET UNLOGGED; - - -- table.alter.enable_rls - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - ENABLE ROW LEVEL SECURITY; - - -- table.alter.disable_rls - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - DISABLE ROW LEVEL SECURITY; - - -- table.alter.force_rls - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - FORCE ROW LEVEL SECURITY; - - -- table.alter.no_force_rls - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - NO FORCE ROW LEVEL SECURITY; - - -- table.alter.set_storage_params - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - SET - (fillfactor=80, - autovacuum_enabled=true); - - -- table.alter.reset_storage_params - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - RESET (fillfactor, autovacuum_enabled); - - -- table.alter.replica_identity - ALTER TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test - REPLICA IDENTITY FULL; - - -- table.alter.attach_partition - ALTER TABLE public.events - ATTACH PARTITION public.events_2024 - FOR VALUES FROM ('2024-01-01') TO - ('2025-01-01'); - - -- table.alter.detach_partition - ALTER TABLE public.events - DETACH PARTITION public.events_2024; - - -- table.comment - COMMENT ON TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'table comment'; - - -- table.drop_comment - COMMENT ON TABLE - public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- table.column_comment - COMMENT ON COLUMN - public.table_with_very_long_name_for_formatting_and_wrapping_test.id IS 'id column'; - - -- table.drop_column_comment - COMMENT ON COLUMN - public.table_with_very_long_name_for_formatting_and_wrapping_test.id IS NULL; - - -- table.constraint_comment - COMMENT ON CONSTRAINT pk_t_fmt - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'primary key'; - - -- table.drop_constraint_comment - COMMENT ON CONSTRAINT chk_t_fmt_status - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- table.grant - GRANT INSERT, - SELECT - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test TO app_reader; - - -- table.revoke - REVOKE DELETE, - UPDATE - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test FROM app_reader; - - -- table.revoke_grant_option - REVOKE GRANT OPTION FOR INSERT, - SELECT - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test FROM app_reader; - - -- publication.create - CREATE PUBLICATION pub_custom FOR TABLE - public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit ( - id, - title - ) WHERE (published = true), - TABLE - public.comments_a_little_smaller_name_than_the_previous_one, TABLES IN SCHEMA analytics; - - -- publication.drop - DROP PUBLICATION pub_custom; - - -- publication.alter.set_options - ALTER PUBLICATION pub_custom - SET - (publish = - 'insert, update, delete, truncate', - publish_via_partition_root = false); - - -- publication.alter.set_list - ALTER PUBLICATION pub_custom - SET TABLE - public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit (id, title) WHERE (published = true), TABLE public.comments_a_little_smaller_name_than_the_previous_one, TABLES IN SCHEMA analytics; - - -- publication.alter.add_tables - ALTER PUBLICATION pub_custom - ADD TABLE - public.new_table_with_very_long_name_for_formatting_and_wrapping_test; - - -- publication.alter.drop_tables - ALTER - PUBLICATION pub_custom DROP TABLE - public.comments_a_little_smaller_name_than_the_previous_one; - - -- publication.alter.add_schemas - ALTER PUBLICATION pub_custom - ADD TABLES IN SCHEMA staging; - - -- publication.alter.drop_schemas - ALTER - PUBLICATION pub_custom DROP TABLES IN - SCHEMA analytics; - - -- publication.alter.set_owner - ALTER - PUBLICATION pub_custom OWNER TO - new_owner; - - -- publication.comment - COMMENT ON - PUBLICATION pub_custom IS - 'publication comment'; - - -- publication.drop_comment - COMMENT ON - PUBLICATION pub_custom IS NULL; - - -- view.create - CREATE VIEW public.test_view WITH (security_barrier=true, check_option=local) AS SELECT * - FROM test_table; - - -- view.drop - DROP VIEW public.test_view; - - -- view.alter.change_owner - ALTER VIEW public.test_view OWNER TO - new_owner; - - -- view.alter.set_options - ALTER VIEW public.test_view - SET - (security_barrier=true, - check_option=cascaded); - - -- view.alter.reset_options - ALTER VIEW public.test_view RESET - (security_barrier); - - -- view.comment - COMMENT ON VIEW public.test_view IS - 'view comment'; - - -- view.drop_comment - COMMENT ON VIEW public.test_view IS NULL; - - -- view.grant - GRANT SELECT - ON public.test_view TO app_reader - WITH GRANT OPTION; - - -- view.revoke - REVOKE SELECT - ON public.test_view FROM app_reader; - - -- view.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT - ON public.test_view FROM app_reader; - - -- rule.create - CREATE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.drop - DROP RULE test_rule ON public.test_table; - - -- rule.replace - CREATE OR REPLACE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.alter.set_enabled - ALTER TABLE public.test_table - DISABLE RULE test_rule; - - -- rule.comment - COMMENT ON RULE test_rule - ON public.test_table IS - 'rule comment'; - - -- rule.drop_comment - COMMENT ON RULE test_rule - ON public.test_table IS NULL; - - -- procedure.create - CREATE PROCEDURE public.test_procedure() - LANGUAGE plpgsql - AS $$ begin null; end; $$; - - -- procedure.drop - DROP PROCEDURE public.test_procedure(); - - -- function.create - CREATE FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name ( - "p_schema_name_for_analytics" text, - "p_table_name_for_metrics" text, - "p_limit_count_default" integer - DEFAULT 100 - ) - RETURNS TABLE ( - total bigint, - average numeric - ) - LANGUAGE plpgsql - STABLE - SECURITY DEFINER - PARALLEL SAFE - COST 100 - ROWS 10 - STRICT - SET search_path TO 'pg_catalog', - 'public' - AS $function$ BEGIN RETURN QUERY SELECT count(*)::bigint, avg(value)::numeric FROM generate_series(1, p_limit_count_default); END; $function$; - - -- function.drop - DROP FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(IN "p_schema_name_for_analytics" text, IN "p_table_name_for_metrics" text, IN "p_limit_count_default" integer); - - -- function.alter.change_owner - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) OWNER TO new_admin; - - -- function.alter.set_security - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) SECURITY INVOKER; - - -- function.alter.set_config - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) SET work_mem TO '256MB'; - - -- function.alter.set_volatility - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) IMMUTABLE; - - -- function.alter.set_strictness - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) CALLED ON NULL INPUT; - - -- function.alter.set_leakproof - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) LEAKPROOF; - - -- function.alter.set_parallel - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) PARALLEL RESTRICTED; - - -- function.comment - COMMENT ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) IS 'Calculate metrics for a given table'; - - -- function.drop_comment - COMMENT ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) IS NULL; - - -- function.grant - GRANT ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) TO app_user WITH GRANT OPTION; - - -- function.revoke - REVOKE ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) FROM app_user; - - -- function.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) FROM app_user; - - -- sequence.create - CREATE SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq; - - -- sequence.drop - DROP SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq CASCADE; - - -- sequence.alter.set_owned_by - ALTER SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq OWNED BY public.table_with_very_long_name_for_formatting_and_wrapping_test.id; - - -- sequence.alter.set_options - ALTER SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq INCREMENT BY 10 MINVALUE 1 MAXVALUE 1000000 CACHE 5 CYCLE; - - -- sequence.comment - COMMENT ON SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq IS 'sequence for table_with_very_long_name_for_formatting_and_wrapping_test.id'; - - -- sequence.drop_comment - COMMENT ON SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq IS NULL; - - -- sequence.grant - GRANT SELECT, - USAGE - ON SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq TO app_user; - - -- sequence.revoke - REVOKE USAGE - ON SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq FROM app_user; - - -- sequence.revoke_grant_option - REVOKE GRANT OPTION FOR USAGE - ON SEQUENCE - public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq FROM app_user; - - -- policy.create - CREATE POLICY allow_select_own - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test - FOR SELECT - TO authenticated - USING (auth.uid() = user_id); - - -- policy.create_restrictive - CREATE POLICY restrict_delete - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test - AS RESTRICTIVE - FOR DELETE - TO authenticated, service_role - USING (auth.uid() = owner_id) - WITH CHECK (status <> 'locked'); - - -- policy.drop - DROP POLICY allow_select_own - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- policy.alter.set_roles - ALTER POLICY allow_select_own - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test TO authenticated, anon; - - -- policy.alter.set_using - ALTER POLICY allow_select_own - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test USING (auth.uid() = user_id AND status = 'active'); - - -- policy.alter.set_with_check - ALTER POLICY allow_select_own - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test WITH CHECK (auth.uid() = user_id); - - -- policy.comment - COMMENT ON POLICY allow_select_own - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'rls policy comment'; - - -- policy.drop_comment - COMMENT ON POLICY allow_select_own - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- index.create - CREATE UNIQUE INDEX idx_t_fmt_status - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test (status) - WITH (fillfactor='90') - WHERE (status <> 'archived'::text); - - -- index.create_gin - CREATE INDEX idx_t_fmt_search - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test USING gin (to_tsvector('english'::regconfig, status)); - - -- index.drop - DROP INDEX public.idx_t_fmt_status; - - -- index.alter.set_storage_params - ALTER INDEX public.idx_t_fmt_status - RESET (deduplicate_items); - - ALTER INDEX public.idx_t_fmt_status - SET (fillfactor=80); - - -- index.alter.set_statistics - ALTER INDEX public.idx_t_fmt_status - ALTER COLUMN 1 SET STATISTICS 500; - - -- index.comment - COMMENT ON INDEX public.idx_t_fmt_status - IS 'index comment'; - - -- index.drop_comment - COMMENT ON INDEX public.idx_t_fmt_status - IS NULL; - - -- trigger.create - CREATE TRIGGER trg_audit AFTER INSERT OR - UPDATE - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ( - (NEW.status IS DISTINCT FROM - OLD.status) - ) EXECUTE FUNCTION - public.audit_trigger_fn('arg1', - 'arg2'); - - -- trigger.drop - DROP TRIGGER trg_audit - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- trigger.replace - CREATE OR REPLACE TRIGGER trg_audit - AFTER INSERT OR UPDATE - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ( - (NEW.status IS DISTINCT FROM - OLD.status) - ) EXECUTE FUNCTION - public.audit_trigger_fn('arg1', - 'arg2'); - - -- trigger.comment - COMMENT ON TRIGGER trg_audit - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'trigger comment'; - - -- trigger.drop_comment - COMMENT ON TRIGGER trg_audit - ON - public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- matview.create - CREATE MATERIALIZED VIEW - analytics.daily_stats - WITH (fillfactor=70) - AS SELECT date_trunc('day', created_at) AS day, count(*) AS total - FROM public.events - GROUP BY 1 WITH DATA; - - -- matview.drop - DROP MATERIALIZED VIEW - analytics.daily_stats; - - -- matview.alter.change_owner - ALTER MATERIALIZED VIEW - analytics.daily_stats - OWNER TO new_owner; - - -- matview.alter.set_storage - ALTER MATERIALIZED VIEW - analytics.daily_stats - RESET (autovacuum_enabled); - - ALTER MATERIALIZED VIEW - analytics.daily_stats - SET (fillfactor=80); - - -- matview.comment - COMMENT ON MATERIALIZED VIEW - analytics.daily_stats IS - 'daily aggregation'; - - -- matview.drop_comment - COMMENT ON MATERIALIZED VIEW - analytics.daily_stats IS NULL; - - -- matview.column_comment - COMMENT ON COLUMN - analytics.daily_stats.day IS - 'day bucket'; - - -- matview.drop_column_comment - COMMENT ON COLUMN - analytics.daily_stats.day IS NULL; - - -- matview.grant - GRANT SELECT - ON analytics.daily_stats TO app_reader; - - -- matview.revoke - REVOKE SELECT - ON analytics.daily_stats FROM - app_reader; - - -- matview.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT - ON analytics.daily_stats FROM - app_reader; - - -- aggregate.create - CREATE AGGREGATE - public.array_cat_agg(anycompatiblearray) ( - SFUNC = array_cat, - STYPE = anycompatiblearray, - COMBINEFUNC = array_cat, - INITCOND = '{}', - PARALLEL = SAFE, - STRICT - ); - - -- aggregate.drop - DROP AGGREGATE - public.array_cat_agg(anycompatiblearray); - - -- aggregate.alter.change_owner - ALTER AGGREGATE - public.array_cat_agg(anycompatiblearray) OWNER TO new_owner; - - -- aggregate.comment - COMMENT ON AGGREGATE - public.array_cat_agg(anycompatiblearray) IS 'concatenate arrays aggregate'; - - -- aggregate.drop_comment - COMMENT ON AGGREGATE - public.array_cat_agg(anycompatiblearray) IS NULL; - - -- aggregate.grant - GRANT ALL ON FUNCTION - public.array_cat_agg(anycompatiblearray) TO app_user; - - -- aggregate.revoke - REVOKE ALL ON FUNCTION - public.array_cat_agg(anycompatiblearray) FROM app_user; - - -- aggregate.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FUNCTION - public.array_cat_agg(anycompatiblearray) FROM app_user; - - -- event_trigger.create - CREATE EVENT TRIGGER prevent_drop - ON sql_drop - WHEN TAG IN - ('DROP TABLE', 'DROP SCHEMA') - EXECUTE FUNCTION - public.prevent_drop_fn(); - - -- event_trigger.drop - DROP EVENT TRIGGER prevent_drop; - - -- event_trigger.alter.change_owner - ALTER EVENT TRIGGER prevent_drop - OWNER TO new_owner; - - -- event_trigger.alter.set_enabled - ALTER EVENT TRIGGER prevent_drop - DISABLE; - - -- event_trigger.comment - COMMENT ON EVENT TRIGGER prevent_drop IS - 'prevent accidental drops'; - - -- event_trigger.drop_comment - COMMENT ON EVENT TRIGGER prevent_drop IS - NULL; - - -- language.create - CREATE TRUSTED LANGUAGE plv8 - HANDLER plv8_call_handler - INLINE plv8_inline_handler - VALIDATOR plv8_call_validator; - - -- language.drop - DROP LANGUAGE plv8; - - -- language.alter.change_owner - ALTER LANGUAGE plv8 OWNER TO new_owner; - - -- language.comment - COMMENT ON LANGUAGE plv8 IS - 'PL/V8 trusted procedural language'; - - -- language.drop_comment - COMMENT ON LANGUAGE plv8 IS NULL; - - -- language.grant - GRANT ALL ON LANGUAGE plv8 TO app_user - WITH GRANT OPTION; - - -- language.revoke - REVOKE ALL ON LANGUAGE plv8 FROM - app_user; - - -- language.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON LANGUAGE - plv8 FROM app_user; - - -- role.create - CREATE ROLE app_user WITH LOGIN - CONNECTION LIMIT 100; - - -- role.drop - DROP ROLE app_user; - - -- role.alter.set_options - ALTER ROLE app_user - WITH NOSUPERUSER CREATEDB; - - -- role.alter.set_config - ALTER ROLE app_user - SET statement_timeout TO '60000'; - - -- role.comment - COMMENT ON ROLE app_user IS - 'application user role'; - - -- role.drop_comment - COMMENT ON ROLE app_user IS NULL; - - -- role.grant_membership - GRANT app_user TO dev_user - WITH ADMIN OPTION; - - -- role.revoke_membership - REVOKE app_user FROM dev_user; - - -- role.revoke_membership_options - REVOKE ADMIN OPTION FOR app_user FROM - dev_user; - - -- role.grant_default_privileges - ALTER DEFAULT PRIVILEGES FOR ROLE - app_user IN SCHEMA public GRANT SELECT - ON TABLES TO app_reader; - - -- role.revoke_default_privileges - ALTER DEFAULT PRIVILEGES FOR ROLE - app_user IN SCHEMA public REVOKE - SELECT ON TABLES FROM app_reader; - - -- subscription.create - CREATE SUBSCRIPTION sub_replica - CONNECTION - 'host=primary.db port=5432 dbname=mydb' - PUBLICATION pub_custom - WITH ( - slot_name = - 'sub_replica_slot', - binary = true, - streaming = 'parallel', - synchronous_commit = 'remote_apply', - disable_on_error = true, - failover = true, - create_slot = false - ); - - -- subscription.drop - DROP SUBSCRIPTION sub_replica; - - -- subscription.alter.set_connection - ALTER SUBSCRIPTION sub_replica - CONNECTION - 'host=primary.db port=5432 dbname=mydb'; - - -- subscription.alter.set_publication - ALTER SUBSCRIPTION sub_replica - SET PUBLICATION pub_custom; - - -- subscription.alter.enable - ALTER SUBSCRIPTION sub_replica - ENABLE; - - -- subscription.alter.disable - ALTER SUBSCRIPTION sub_replica - DISABLE; - - -- subscription.alter.set_options - ALTER SUBSCRIPTION sub_replica - SET ( - binary = true, - streaming = 'parallel', - synchronous_commit = 'remote_apply' - ); - - -- subscription.alter.set_owner - ALTER SUBSCRIPTION sub_replica - OWNER TO new_owner; - - -- subscription.comment - COMMENT ON SUBSCRIPTION sub_replica IS - 'replication subscription'; - - -- subscription.drop_comment - COMMENT ON SUBSCRIPTION sub_replica IS - NULL; - - -- fdw.create - CREATE FOREIGN DATA WRAPPER postgres_fdw - HANDLER postgres_fdw_handler - VALIDATOR postgres_fdw_validator - OPTIONS (debug '__OPTION_DEBUG__'); - - -- fdw.drop - DROP FOREIGN DATA WRAPPER postgres_fdw; - - -- fdw.alter.change_owner - ALTER FOREIGN DATA WRAPPER postgres_fdw - OWNER TO new_owner; - - -- fdw.alter.set_options - ALTER FOREIGN DATA WRAPPER postgres_fdw - OPTIONS ( - SET debug '__OPTION_DEBUG__', - ADD use_remote_estimate '' - ); - - -- fdw.comment - COMMENT ON - FOREIGN DATA WRAPPER postgres_fdw IS - 'PostgreSQL foreign data wrapper'; - - -- fdw.drop_comment - COMMENT ON - FOREIGN DATA WRAPPER postgres_fdw IS - NULL; - - -- fdw.grant - GRANT ALL ON - FOREIGN DATA WRAPPER postgres_fdw TO - app_user; - - -- fdw.revoke - REVOKE ALL ON - FOREIGN DATA WRAPPER postgres_fdw FROM - app_user; - - -- fdw.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON - FOREIGN DATA WRAPPER postgres_fdw FROM - app_user; - - -- foreign_table.create - CREATE - FOREIGN TABLE public.remote_users ( - id integer, - email text - ) SERVER remote_server - OPTIONS - (schema_name 'public', table_name - 'users'); - - -- foreign_table.drop - DROP FOREIGN TABLE public.remote_users; - - -- foreign_table.alter.change_owner - ALTER FOREIGN TABLE public.remote_users - OWNER TO new_owner; - - -- foreign_table.alter.add_column - ALTER FOREIGN TABLE public.remote_users - ADD COLUMN name text - NOT NULL DEFAULT 'unknown'; - - -- foreign_table.alter.drop_column - ALTER FOREIGN TABLE public.remote_users - DROP COLUMN email; - - -- foreign_table.alter.column_type - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN id TYPE bigint; - - -- foreign_table.alter.column_set_default - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email - SET DEFAULT 'nobody@example.com'; - - -- foreign_table.alter.column_drop_default - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email DROP DEFAULT; - - -- foreign_table.alter.column_set_not_null - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email SET NOT NULL; - - -- foreign_table.alter.column_drop_not_null - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email DROP NOT NULL; - - -- foreign_table.alter.set_options - ALTER FOREIGN TABLE public.remote_users - OPTIONS (SET fetch_size '1000'); - - -- foreign_table.comment - COMMENT ON - FOREIGN TABLE public.remote_users IS - 'remote users table'; - - -- foreign_table.drop_comment - COMMENT ON - FOREIGN TABLE public.remote_users IS - NULL; - - -- foreign_table.grant - GRANT SELECT - ON TABLE public.remote_users TO - app_reader; - - -- foreign_table.revoke - REVOKE SELECT - ON TABLE public.remote_users FROM - app_reader; - - -- foreign_table.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT - ON TABLE public.remote_users FROM - app_reader; - - -- server.create - CREATE SERVER remote_server - TYPE 'postgresql' - VERSION '16.0' - FOREIGN DATA WRAPPER postgres_fdw - OPTIONS ( - host 'remote.host', - port '5432', - dbname 'remote_db' - ); - - -- server.drop - DROP SERVER remote_server; - - -- server.alter.change_owner - ALTER SERVER remote_server - OWNER TO new_owner; - - -- server.alter.set_version - ALTER SERVER remote_server - VERSION '17.0'; - - -- server.alter.set_options - ALTER SERVER remote_server - OPTIONS ( - SET host 'new.host', - DROP port - ); - - -- server.comment - COMMENT ON SERVER remote_server IS - 'remote PostgreSQL server'; - - -- server.drop_comment - COMMENT ON SERVER remote_server IS NULL; - - -- server.grant - GRANT ALL ON SERVER remote_server TO - app_user; - - -- server.revoke - REVOKE ALL ON SERVER remote_server FROM - app_user; - - -- server.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON SERVER - remote_server FROM app_user; - - -- user_mapping.create - CREATE USER MAPPING FOR app_user SERVER - remote_server - OPTIONS - (user 'remote_app', password - '__OPTION_PASSWORD__'); - - -- user_mapping.drop - DROP USER MAPPING FOR app_user SERVER - remote_server; - - -- user_mapping.alter.set_options - ALTER USER MAPPING FOR app_user SERVER - remote_server - OPTIONS - (SET password '__OPTION_PASSWORD__');" - `); - }); -}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-pretty-preserve.test.ts b/packages/pg-delta/src/core/plan/sql-format/format-pretty-preserve.test.ts deleted file mode 100644 index 002f137a1..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/format-pretty-preserve.test.ts +++ /dev/null @@ -1,1058 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { renderScript } from "./fixtures.ts"; - -describe("sql formatting snapshots", () => { - test("format-pretty-preserve", () => { - const output = [ - "-- format: { keywordCase: 'preserve', alignColumns: false, alignKeyValues: false, indent: 3 }", - renderScript({ - keywordCase: "preserve", - alignColumns: false, - alignKeyValues: false, - indent: 3, - }), - ] - .filter(Boolean) - .join("\n"); - expect(output).toMatchInlineSnapshot(` - "-- format: { keywordCase: 'preserve', alignColumns: false, alignKeyValues: false, indent: 3 } - -- schema.create - CREATE SCHEMA application_schema_with_very_long_name_for_wrapping_tests AUTHORIZATION admin; - - -- schema.drop - DROP SCHEMA application_schema_with_very_long_name_for_wrapping_tests; - - -- schema.alter.change_owner - ALTER SCHEMA application_schema_with_very_long_name_for_wrapping_tests OWNER TO new_admin; - - -- schema.comment - COMMENT ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests IS - 'application schema'; - - -- schema.drop_comment - COMMENT ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests IS NULL; - - -- schema.grant - GRANT ALL ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests TO app_user - WITH GRANT OPTION; - - -- schema.revoke - REVOKE CREATE ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests FROM app_user; - - -- schema.revoke_grant_option - REVOKE GRANT OPTION FOR USAGE - ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests FROM app_user; - - -- extension.create - CREATE EXTENSION pgcrypto WITH SCHEMA extensions; - - -- extension.drop - DROP EXTENSION pgcrypto; - - -- extension.alter.update_version - ALTER EXTENSION pgcrypto UPDATE TO '1.4'; - - -- extension.alter.set_schema - ALTER EXTENSION pgcrypto SET SCHEMA public; - - -- extension.comment - COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; - - -- extension.drop_comment - COMMENT ON EXTENSION pgcrypto IS NULL; - - -- domain.create - CREATE DOMAIN public.test_domain_all AS custom.text[][] - COLLATE mycoll - DEFAULT 'hello' - NOT NULL - CHECK (VALUE <> ''); - - -- domain.drop - DROP DOMAIN public.test_domain_all; - - -- domain.alter.set_default - ALTER DOMAIN public.test_domain_all - SET DEFAULT 'world'; - - -- domain.alter.drop_default - ALTER DOMAIN public.test_domain_all - DROP DEFAULT; - - -- domain.alter.set_not_null - ALTER DOMAIN public.test_domain_all - SET NOT NULL; - - -- domain.alter.drop_not_null - ALTER DOMAIN public.test_domain_all - DROP NOT NULL; - - -- domain.alter.change_owner - ALTER DOMAIN public.test_domain_all - OWNER TO new_owner; - - -- domain.alter.add_constraint - ALTER DOMAIN public.test_domain_all - ADD CONSTRAINT domain_len_chk CHECK (char_length(VALUE) <= 255) NOT VALID; - - -- domain.alter.drop_constraint - ALTER DOMAIN public.test_domain_all - DROP CONSTRAINT domain_chk; - - -- domain.alter.validate_constraint - ALTER DOMAIN public.test_domain_all - VALIDATE CONSTRAINT domain_len_chk; - - -- domain.comment - COMMENT ON DOMAIN public.test_domain_all IS 'domain comment'; - - -- domain.drop_comment - COMMENT ON DOMAIN public.test_domain_all IS NULL; - - -- domain.grant - GRANT ALL ON DOMAIN public.test_domain_all TO app_user; - - -- domain.revoke - REVOKE ALL ON DOMAIN public.test_domain_all FROM app_user; - - -- domain.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON DOMAIN public.test_domain_all FROM app_user; - - -- type.enum.create - CREATE TYPE public.test_enum AS ENUM ( - 'value1', - 'value2', - 'value3' - ); - - -- type.enum.drop - DROP TYPE public.test_enum; - - -- type.enum.alter.change_owner - ALTER TYPE public.test_enum OWNER TO new_owner; - - -- type.enum.alter.add_value - ALTER TYPE public.test_enum ADD VALUE 'value4' AFTER 'value2'; - - -- type.enum.comment - COMMENT ON TYPE public.test_enum IS 'enum comment'; - - -- type.enum.drop_comment - COMMENT ON TYPE public.test_enum IS NULL; - - -- type.enum.grant - GRANT ALL ON TYPE public.test_enum TO app_user; - - -- type.enum.revoke - REVOKE ALL ON TYPE public.test_enum FROM app_user; - - -- type.enum.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.test_enum FROM app_user; - - -- type.composite.create - CREATE TYPE public.test_type AS ( - id integer, - name text COLLATE "en_US" - ); - - -- type.composite.drop - DROP TYPE public.test_type; - - -- type.composite.alter.change_owner - ALTER TYPE public.test_type OWNER TO new_owner; - - -- type.composite.alter.add_attribute - ALTER TYPE public.test_type ADD ATTRIBUTE age integer; - - -- type.composite.alter.drop_attribute - ALTER TYPE public.test_type DROP ATTRIBUTE name; - - -- type.composite.alter.alter_attr_type - ALTER TYPE public.test_type ALTER ATTRIBUTE name TYPE varchar(255) COLLATE "C"; - - -- type.composite.comment - COMMENT ON TYPE public.test_type IS 'composite comment'; - - -- type.composite.drop_comment - COMMENT ON TYPE public.test_type IS NULL; - - -- type.composite.attr_comment - COMMENT ON COLUMN public.test_type.id IS 'attr comment'; - - -- type.composite.drop_attr_comment - COMMENT ON COLUMN public.test_type.id IS NULL; - - -- type.composite.grant - GRANT ALL ON TYPE public.test_type TO app_user; - - -- type.composite.revoke - REVOKE ALL ON TYPE public.test_type FROM app_user; - - -- type.composite.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.test_type FROM app_user; - - -- type.range.create - CREATE TYPE public.daterange_custom AS RANGE ( - SUBTYPE = date, - SUBTYPE_OPCLASS = public.date_ops, - COLLATION = "en_US", - CANONICAL = public.canon_fn, - SUBTYPE_DIFF = public.diff_fn - ); - - -- type.range.drop - DROP TYPE public.daterange_custom; - - -- type.range.alter.change_owner - ALTER TYPE public.daterange_custom OWNER TO new_owner; - - -- type.range.comment - COMMENT ON TYPE public.daterange_custom IS 'range comment'; - - -- type.range.drop_comment - COMMENT ON TYPE public.daterange_custom IS NULL; - - -- type.range.grant - GRANT ALL ON TYPE public.daterange_custom TO app_user; - - -- type.range.revoke - REVOKE ALL ON TYPE public.daterange_custom FROM app_user; - - -- type.range.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.daterange_custom FROM app_user; - - -- collation.create - CREATE COLLATION public.test ( - LOCALE = 'en_US', - LC_COLLATE = 'en_US', - LC_CTYPE = 'en_US', - PROVIDER = icu, - DETERMINISTIC = false, - RULES = '& A < a <<< à', - VERSION = '1.0' - ); - - -- collation.drop - DROP COLLATION public.test; - - -- collation.alter.change_owner - ALTER COLLATION public.test OWNER TO new_owner; - - -- collation.alter.refresh_version - ALTER COLLATION public.test REFRESH VERSION; - - -- collation.comment - COMMENT ON COLLATION public.test IS 'collation comment'; - - -- collation.drop_comment - COMMENT ON COLLATION public.test IS NULL; - - -- table.create - CREATE TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ( - id bigint GENERATED ALWAYS AS IDENTITY NOT NULL, - status text COLLATE "en_US" DEFAULT 'pending', - created_at timestamptz DEFAULT now(), - ref_id bigint, - computed bigint GENERATED ALWAYS AS (id * 2) STORED - ) WITH (fillfactor=70, autovacuum_enabled=false); - - -- table.drop - DROP TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- table.alter.add_column - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD COLUMN email text COLLATE "en_US" DEFAULT 'user@example.com' NOT NULL; - - -- table.alter.drop_column - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - DROP COLUMN computed; - - -- table.alter.column_type - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status TYPE character varying(255) COLLATE "C"; - - -- table.alter.column_set_default - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status SET DEFAULT 'active'; - - -- table.alter.column_drop_default - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status DROP DEFAULT; - - -- table.alter.column_set_not_null - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status SET NOT NULL; - - -- table.alter.column_drop_not_null - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status DROP NOT NULL; - - -- table.alter.add_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD CONSTRAINT uq_t_fmt_status UNIQUE (status); - - -- table.alter.add_fk_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD CONSTRAINT fk_t_fmt_ref FOREIGN KEY (ref_id) REFERENCES public.other_table(id) MATCH FULL - ON UPDATE SET NULL ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; - - -- table.alter.drop_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - DROP CONSTRAINT uq_t_fmt_status; - - -- table.alter.validate_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - VALIDATE CONSTRAINT chk_t_fmt_status; - - -- table.alter.change_owner - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - OWNER TO new_owner; - - -- table.alter.set_logged - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - SET LOGGED; - - -- table.alter.set_unlogged - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - SET UNLOGGED; - - -- table.alter.enable_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ENABLE ROW LEVEL SECURITY; - - -- table.alter.disable_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - DISABLE ROW LEVEL SECURITY; - - -- table.alter.force_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - FORCE ROW LEVEL SECURITY; - - -- table.alter.no_force_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - NO FORCE ROW LEVEL SECURITY; - - -- table.alter.set_storage_params - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - SET (fillfactor=80, autovacuum_enabled=true); - - -- table.alter.reset_storage_params - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - RESET (fillfactor, autovacuum_enabled); - - -- table.alter.replica_identity - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - REPLICA IDENTITY FULL; - - -- table.alter.attach_partition - ALTER TABLE public.events - ATTACH PARTITION public.events_2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - -- table.alter.detach_partition - ALTER TABLE public.events - DETACH PARTITION public.events_2024; - - -- table.comment - COMMENT ON TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test IS - 'table comment'; - - -- table.drop_comment - COMMENT ON TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- table.column_comment - COMMENT ON COLUMN public.table_with_very_long_name_for_formatting_and_wrapping_test.id IS - 'id column'; - - -- table.drop_column_comment - COMMENT ON COLUMN public.table_with_very_long_name_for_formatting_and_wrapping_test.id IS NULL; - - -- table.constraint_comment - COMMENT ON CONSTRAINT pk_t_fmt - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'primary key'; - - -- table.drop_constraint_comment - COMMENT ON CONSTRAINT chk_t_fmt_status - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- table.grant - GRANT INSERT, - SELECT ON public.table_with_very_long_name_for_formatting_and_wrapping_test TO app_reader; - - -- table.revoke - REVOKE DELETE, - UPDATE ON public.table_with_very_long_name_for_formatting_and_wrapping_test FROM app_reader; - - -- table.revoke_grant_option - REVOKE GRANT OPTION FOR INSERT, - SELECT ON public.table_with_very_long_name_for_formatting_and_wrapping_test FROM app_reader; - - -- publication.create - CREATE PUBLICATION pub_custom FOR TABLE - public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit ( - id, - title - ) WHERE (published = true), - TABLE public.comments_a_little_smaller_name_than_the_previous_one, TABLES IN SCHEMA analytics; - - -- publication.drop - DROP PUBLICATION pub_custom; - - -- publication.alter.set_options - ALTER PUBLICATION pub_custom - SET (publish = 'insert, update, delete, truncate', publish_via_partition_root = false); - - -- publication.alter.set_list - ALTER PUBLICATION pub_custom - SET TABLE - public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit - (id, title) WHERE (published = true), - TABLE public.comments_a_little_smaller_name_than_the_previous_one, TABLES IN SCHEMA analytics; - - -- publication.alter.add_tables - ALTER PUBLICATION pub_custom - ADD TABLE public.new_table_with_very_long_name_for_formatting_and_wrapping_test; - - -- publication.alter.drop_tables - ALTER PUBLICATION pub_custom DROP TABLE public.comments_a_little_smaller_name_than_the_previous_one; - - -- publication.alter.add_schemas - ALTER PUBLICATION pub_custom ADD TABLES IN SCHEMA staging; - - -- publication.alter.drop_schemas - ALTER PUBLICATION pub_custom DROP TABLES IN SCHEMA analytics; - - -- publication.alter.set_owner - ALTER PUBLICATION pub_custom OWNER TO new_owner; - - -- publication.comment - COMMENT ON PUBLICATION pub_custom IS 'publication comment'; - - -- publication.drop_comment - COMMENT ON PUBLICATION pub_custom IS NULL; - - -- view.create - CREATE VIEW public.test_view WITH (security_barrier=true, check_option=local) AS SELECT * - FROM test_table; - - -- view.drop - DROP VIEW public.test_view; - - -- view.alter.change_owner - ALTER VIEW public.test_view OWNER TO new_owner; - - -- view.alter.set_options - ALTER VIEW public.test_view SET (security_barrier=true, check_option=cascaded); - - -- view.alter.reset_options - ALTER VIEW public.test_view RESET (security_barrier); - - -- view.comment - COMMENT ON VIEW public.test_view IS 'view comment'; - - -- view.drop_comment - COMMENT ON VIEW public.test_view IS NULL; - - -- view.grant - GRANT SELECT ON public.test_view TO app_reader WITH GRANT OPTION; - - -- view.revoke - REVOKE SELECT ON public.test_view FROM app_reader; - - -- view.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON public.test_view FROM app_reader; - - -- rule.create - CREATE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.drop - DROP RULE test_rule ON public.test_table; - - -- rule.replace - CREATE OR REPLACE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.alter.set_enabled - ALTER TABLE public.test_table - DISABLE RULE test_rule; - - -- rule.comment - COMMENT ON RULE test_rule ON public.test_table IS 'rule comment'; - - -- rule.drop_comment - COMMENT ON RULE test_rule ON public.test_table IS NULL; - - -- procedure.create - CREATE PROCEDURE public.test_procedure() - LANGUAGE plpgsql - AS $$ begin null; end; $$; - - -- procedure.drop - DROP PROCEDURE public.test_procedure(); - - -- function.create - CREATE FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name ( - "p_schema_name_for_analytics" text, - "p_table_name_for_metrics" text, - "p_limit_count_default" integer DEFAULT 100 - ) - RETURNS TABLE ( - total bigint, - average numeric - ) - LANGUAGE plpgsql - STABLE - SECURITY DEFINER - PARALLEL SAFE - COST 100 - ROWS 10 - STRICT - SET search_path TO 'pg_catalog', 'public' - AS $function$ BEGIN RETURN QUERY SELECT count(*)::bigint, avg(value)::numeric FROM generate_series(1, p_limit_count_default); END; $function$; - - -- function.drop - DROP FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(IN - "p_schema_name_for_analytics" text, - IN "p_table_name_for_metrics" text, IN "p_limit_count_default" integer); - - -- function.alter.change_owner - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) OWNER TO - new_admin; - - -- function.alter.set_security - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) SECURITY - INVOKER; - - -- function.alter.set_config - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - SET work_mem TO '256MB'; - - -- function.alter.set_volatility - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - IMMUTABLE; - - -- function.alter.set_strictness - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) CALLED - ON NULL INPUT; - - -- function.alter.set_leakproof - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - LEAKPROOF; - - -- function.alter.set_parallel - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) PARALLEL - RESTRICTED; - - -- function.comment - COMMENT ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) IS - 'Calculate metrics for a given table'; - - -- function.drop_comment - COMMENT ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) IS NULL; - - -- function.grant - GRANT ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) TO - app_user WITH GRANT OPTION; - - -- function.revoke - REVOKE ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) FROM - app_user; - - -- function.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) FROM - app_user; - - -- sequence.create - CREATE SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq; - - -- sequence.drop - DROP SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq CASCADE; - - -- sequence.alter.set_owned_by - ALTER SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq OWNED BY - public.table_with_very_long_name_for_formatting_and_wrapping_test.id; - - -- sequence.alter.set_options - ALTER SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq INCREMENT BY - 10 MINVALUE 1 MAXVALUE 1000000 CACHE 5 CYCLE; - - -- sequence.comment - COMMENT ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq IS - 'sequence for table_with_very_long_name_for_formatting_and_wrapping_test.id'; - - -- sequence.drop_comment - COMMENT ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq IS NULL; - - -- sequence.grant - GRANT SELECT, - USAGE - ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq TO app_user; - - -- sequence.revoke - REVOKE USAGE - ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq FROM - app_user; - - -- sequence.revoke_grant_option - REVOKE GRANT OPTION FOR USAGE - ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq FROM - app_user; - - -- policy.create - CREATE POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test - FOR SELECT - TO authenticated - USING (auth.uid() = user_id); - - -- policy.create_restrictive - CREATE POLICY restrict_delete ON public.table_with_very_long_name_for_formatting_and_wrapping_test - AS RESTRICTIVE - FOR DELETE - TO authenticated, service_role - USING (auth.uid() = owner_id) - WITH CHECK (status <> 'locked'); - - -- policy.drop - DROP POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- policy.alter.set_roles - ALTER POLICY allow_select_own - ON public.table_with_very_long_name_for_formatting_and_wrapping_test TO authenticated, anon; - - -- policy.alter.set_using - ALTER POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test - USING (auth.uid() = user_id AND status = 'active'); - - -- policy.alter.set_with_check - ALTER POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test - WITH CHECK (auth.uid() = user_id); - - -- policy.comment - COMMENT ON POLICY allow_select_own - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'rls policy comment'; - - -- policy.drop_comment - COMMENT ON POLICY allow_select_own - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- index.create - CREATE UNIQUE INDEX idx_t_fmt_status - ON public.table_with_very_long_name_for_formatting_and_wrapping_test (status) - WITH (fillfactor='90') - WHERE (status <> 'archived'::text); - - -- index.create_gin - CREATE INDEX idx_t_fmt_search ON public.table_with_very_long_name_for_formatting_and_wrapping_test - USING gin (to_tsvector('english'::regconfig, status)); - - -- index.drop - DROP INDEX public.idx_t_fmt_status; - - -- index.alter.set_storage_params - ALTER INDEX public.idx_t_fmt_status RESET (deduplicate_items); - - ALTER INDEX public.idx_t_fmt_status SET (fillfactor=80); - - -- index.alter.set_statistics - ALTER INDEX public.idx_t_fmt_status ALTER COLUMN 1 SET STATISTICS 500; - - -- index.comment - COMMENT ON INDEX public.idx_t_fmt_status IS 'index comment'; - - -- index.drop_comment - COMMENT ON INDEX public.idx_t_fmt_status IS NULL; - - -- trigger.create - CREATE TRIGGER trg_audit AFTER INSERT OR UPDATE - ON public.table_with_very_long_name_for_formatting_and_wrapping_test - REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ( - (NEW.status IS DISTINCT FROM OLD.status) - ) EXECUTE FUNCTION public.audit_trigger_fn('arg1', 'arg2'); - - -- trigger.drop - DROP TRIGGER trg_audit ON public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- trigger.replace - CREATE OR REPLACE TRIGGER trg_audit AFTER INSERT OR UPDATE - ON public.table_with_very_long_name_for_formatting_and_wrapping_test - REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ( - (NEW.status IS DISTINCT FROM OLD.status) - ) EXECUTE FUNCTION public.audit_trigger_fn('arg1', 'arg2'); - - -- trigger.comment - COMMENT ON TRIGGER trg_audit - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'trigger comment'; - - -- trigger.drop_comment - COMMENT ON TRIGGER trg_audit - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- matview.create - CREATE MATERIALIZED VIEW analytics.daily_stats - WITH (fillfactor=70) - AS SELECT date_trunc('day', created_at) AS day, count(*) AS total - FROM public.events - GROUP BY 1 WITH DATA; - - -- matview.drop - DROP MATERIALIZED VIEW analytics.daily_stats; - - -- matview.alter.change_owner - ALTER MATERIALIZED VIEW analytics.daily_stats - OWNER TO new_owner; - - -- matview.alter.set_storage - ALTER MATERIALIZED VIEW analytics.daily_stats - RESET (autovacuum_enabled); - - ALTER MATERIALIZED VIEW analytics.daily_stats - SET (fillfactor=80); - - -- matview.comment - COMMENT ON MATERIALIZED VIEW analytics.daily_stats IS 'daily aggregation'; - - -- matview.drop_comment - COMMENT ON MATERIALIZED VIEW analytics.daily_stats IS NULL; - - -- matview.column_comment - COMMENT ON COLUMN analytics.daily_stats.day IS 'day bucket'; - - -- matview.drop_column_comment - COMMENT ON COLUMN analytics.daily_stats.day IS NULL; - - -- matview.grant - GRANT SELECT ON analytics.daily_stats TO app_reader; - - -- matview.revoke - REVOKE SELECT ON analytics.daily_stats FROM app_reader; - - -- matview.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON analytics.daily_stats FROM app_reader; - - -- aggregate.create - CREATE AGGREGATE public.array_cat_agg(anycompatiblearray) ( - SFUNC = array_cat, - STYPE = anycompatiblearray, - COMBINEFUNC = array_cat, - INITCOND = '{}', - PARALLEL = SAFE, - STRICT - ); - - -- aggregate.drop - DROP AGGREGATE public.array_cat_agg(anycompatiblearray); - - -- aggregate.alter.change_owner - ALTER AGGREGATE public.array_cat_agg(anycompatiblearray) OWNER TO new_owner; - - -- aggregate.comment - COMMENT ON AGGREGATE public.array_cat_agg(anycompatiblearray) IS 'concatenate arrays aggregate'; - - -- aggregate.drop_comment - COMMENT ON AGGREGATE public.array_cat_agg(anycompatiblearray) IS NULL; - - -- aggregate.grant - GRANT ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) TO app_user; - - -- aggregate.revoke - REVOKE ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) FROM app_user; - - -- aggregate.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) FROM app_user; - - -- event_trigger.create - CREATE EVENT TRIGGER prevent_drop - ON sql_drop - WHEN TAG IN ('DROP TABLE', 'DROP SCHEMA') - EXECUTE FUNCTION public.prevent_drop_fn(); - - -- event_trigger.drop - DROP EVENT TRIGGER prevent_drop; - - -- event_trigger.alter.change_owner - ALTER EVENT TRIGGER prevent_drop - OWNER TO new_owner; - - -- event_trigger.alter.set_enabled - ALTER EVENT TRIGGER prevent_drop - DISABLE; - - -- event_trigger.comment - COMMENT ON EVENT TRIGGER prevent_drop IS 'prevent accidental drops'; - - -- event_trigger.drop_comment - COMMENT ON EVENT TRIGGER prevent_drop IS NULL; - - -- language.create - CREATE TRUSTED LANGUAGE plv8 - HANDLER plv8_call_handler - INLINE plv8_inline_handler - VALIDATOR plv8_call_validator; - - -- language.drop - DROP LANGUAGE plv8; - - -- language.alter.change_owner - ALTER LANGUAGE plv8 OWNER TO new_owner; - - -- language.comment - COMMENT ON LANGUAGE plv8 IS 'PL/V8 trusted procedural language'; - - -- language.drop_comment - COMMENT ON LANGUAGE plv8 IS NULL; - - -- language.grant - GRANT ALL ON LANGUAGE plv8 TO app_user WITH GRANT OPTION; - - -- language.revoke - REVOKE ALL ON LANGUAGE plv8 FROM app_user; - - -- language.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON LANGUAGE plv8 FROM app_user; - - -- role.create - CREATE ROLE app_user WITH LOGIN CONNECTION LIMIT 100; - - -- role.drop - DROP ROLE app_user; - - -- role.alter.set_options - ALTER ROLE app_user WITH NOSUPERUSER CREATEDB; - - -- role.alter.set_config - ALTER ROLE app_user SET statement_timeout TO '60000'; - - -- role.comment - COMMENT ON ROLE app_user IS 'application user role'; - - -- role.drop_comment - COMMENT ON ROLE app_user IS NULL; - - -- role.grant_membership - GRANT app_user TO dev_user WITH ADMIN OPTION; - - -- role.revoke_membership - REVOKE app_user FROM dev_user; - - -- role.revoke_membership_options - REVOKE ADMIN OPTION FOR app_user FROM dev_user; - - -- role.grant_default_privileges - ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public GRANT SELECT ON TABLES TO app_reader; - - -- role.revoke_default_privileges - ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public REVOKE SELECT ON TABLES FROM app_reader; - - -- subscription.create - CREATE SUBSCRIPTION sub_replica - CONNECTION 'host=primary.db port=5432 dbname=mydb' - PUBLICATION pub_custom - WITH ( - slot_name = 'sub_replica_slot', - binary = true, - streaming = 'parallel', - synchronous_commit = 'remote_apply', - disable_on_error = true, - failover = true, - create_slot = false - ); - - -- subscription.drop - DROP SUBSCRIPTION sub_replica; - - -- subscription.alter.set_connection - ALTER SUBSCRIPTION sub_replica - CONNECTION 'host=primary.db port=5432 dbname=mydb'; - - -- subscription.alter.set_publication - ALTER SUBSCRIPTION sub_replica - SET PUBLICATION pub_custom; - - -- subscription.alter.enable - ALTER SUBSCRIPTION sub_replica - ENABLE; - - -- subscription.alter.disable - ALTER SUBSCRIPTION sub_replica - DISABLE; - - -- subscription.alter.set_options - ALTER SUBSCRIPTION sub_replica - SET ( - binary = true, - streaming = 'parallel', - synchronous_commit = 'remote_apply' - ); - - -- subscription.alter.set_owner - ALTER SUBSCRIPTION sub_replica - OWNER TO new_owner; - - -- subscription.comment - COMMENT ON SUBSCRIPTION sub_replica IS 'replication subscription'; - - -- subscription.drop_comment - COMMENT ON SUBSCRIPTION sub_replica IS NULL; - - -- fdw.create - CREATE FOREIGN DATA WRAPPER postgres_fdw - HANDLER postgres_fdw_handler - VALIDATOR postgres_fdw_validator - OPTIONS (debug '__OPTION_DEBUG__'); - - -- fdw.drop - DROP FOREIGN DATA WRAPPER postgres_fdw; - - -- fdw.alter.change_owner - ALTER FOREIGN DATA WRAPPER postgres_fdw - OWNER TO new_owner; - - -- fdw.alter.set_options - ALTER FOREIGN DATA WRAPPER postgres_fdw - OPTIONS ( - SET debug '__OPTION_DEBUG__', - ADD use_remote_estimate '' - ); - - -- fdw.comment - COMMENT ON FOREIGN DATA WRAPPER postgres_fdw IS 'PostgreSQL foreign data wrapper'; - - -- fdw.drop_comment - COMMENT ON FOREIGN DATA WRAPPER postgres_fdw IS NULL; - - -- fdw.grant - GRANT ALL ON FOREIGN DATA WRAPPER postgres_fdw TO app_user; - - -- fdw.revoke - REVOKE ALL ON FOREIGN DATA WRAPPER postgres_fdw FROM app_user; - - -- fdw.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FOREIGN DATA WRAPPER postgres_fdw FROM app_user; - - -- foreign_table.create - CREATE FOREIGN TABLE public.remote_users ( - id integer, - email text - ) SERVER remote_server OPTIONS (schema_name 'public', table_name 'users'); - - -- foreign_table.drop - DROP FOREIGN TABLE public.remote_users; - - -- foreign_table.alter.change_owner - ALTER FOREIGN TABLE public.remote_users - OWNER TO new_owner; - - -- foreign_table.alter.add_column - ALTER FOREIGN TABLE public.remote_users - ADD COLUMN name text NOT NULL DEFAULT 'unknown'; - - -- foreign_table.alter.drop_column - ALTER FOREIGN TABLE public.remote_users - DROP COLUMN email; - - -- foreign_table.alter.column_type - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN id TYPE bigint; - - -- foreign_table.alter.column_set_default - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email SET DEFAULT 'nobody@example.com'; - - -- foreign_table.alter.column_drop_default - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email DROP DEFAULT; - - -- foreign_table.alter.column_set_not_null - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email SET NOT NULL; - - -- foreign_table.alter.column_drop_not_null - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email DROP NOT NULL; - - -- foreign_table.alter.set_options - ALTER FOREIGN TABLE public.remote_users - OPTIONS (SET fetch_size '1000'); - - -- foreign_table.comment - COMMENT ON FOREIGN TABLE public.remote_users IS 'remote users table'; - - -- foreign_table.drop_comment - COMMENT ON FOREIGN TABLE public.remote_users IS NULL; - - -- foreign_table.grant - GRANT SELECT ON TABLE public.remote_users TO app_reader; - - -- foreign_table.revoke - REVOKE SELECT ON TABLE public.remote_users FROM app_reader; - - -- foreign_table.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON TABLE public.remote_users FROM app_reader; - - -- server.create - CREATE SERVER remote_server - TYPE 'postgresql' - VERSION '16.0' - FOREIGN DATA WRAPPER postgres_fdw - OPTIONS ( - host 'remote.host', - port '5432', - dbname 'remote_db' - ); - - -- server.drop - DROP SERVER remote_server; - - -- server.alter.change_owner - ALTER SERVER remote_server - OWNER TO new_owner; - - -- server.alter.set_version - ALTER SERVER remote_server - VERSION '17.0'; - - -- server.alter.set_options - ALTER SERVER remote_server - OPTIONS ( - SET host 'new.host', - DROP port - ); - - -- server.comment - COMMENT ON SERVER remote_server IS 'remote PostgreSQL server'; - - -- server.drop_comment - COMMENT ON SERVER remote_server IS NULL; - - -- server.grant - GRANT ALL ON SERVER remote_server TO app_user; - - -- server.revoke - REVOKE ALL ON SERVER remote_server FROM app_user; - - -- server.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON SERVER remote_server FROM app_user; - - -- user_mapping.create - CREATE USER MAPPING FOR app_user SERVER remote_server - OPTIONS (user 'remote_app', password '__OPTION_PASSWORD__'); - - -- user_mapping.drop - DROP USER MAPPING FOR app_user SERVER remote_server; - - -- user_mapping.alter.set_options - ALTER USER MAPPING FOR app_user SERVER remote_server OPTIONS (SET password '__OPTION_PASSWORD__');" - `); - }); -}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-pretty-upper.test.ts b/packages/pg-delta/src/core/plan/sql-format/format-pretty-upper.test.ts deleted file mode 100644 index 50827ed01..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/format-pretty-upper.test.ts +++ /dev/null @@ -1,1049 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { renderScript } from "./fixtures.ts"; - -describe("sql formatting snapshots", () => { - test("format-pretty-upper", () => { - const output = [ - "-- format: { keywordCase: 'upper' }", - renderScript({ keywordCase: "upper" }), - ] - .filter(Boolean) - .join("\n"); - expect(output).toMatchInlineSnapshot(` - "-- format: { keywordCase: 'upper' } - -- schema.create - CREATE SCHEMA application_schema_with_very_long_name_for_wrapping_tests AUTHORIZATION admin; - - -- schema.drop - DROP SCHEMA application_schema_with_very_long_name_for_wrapping_tests; - - -- schema.alter.change_owner - ALTER SCHEMA application_schema_with_very_long_name_for_wrapping_tests OWNER TO new_admin; - - -- schema.comment - COMMENT ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests IS - 'application schema'; - - -- schema.drop_comment - COMMENT ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests IS NULL; - - -- schema.grant - GRANT ALL ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests TO app_user - WITH GRANT OPTION; - - -- schema.revoke - REVOKE CREATE ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests FROM app_user; - - -- schema.revoke_grant_option - REVOKE GRANT OPTION FOR USAGE - ON SCHEMA application_schema_with_very_long_name_for_wrapping_tests FROM app_user; - - -- extension.create - CREATE EXTENSION pgcrypto WITH SCHEMA extensions; - - -- extension.drop - DROP EXTENSION pgcrypto; - - -- extension.alter.update_version - ALTER EXTENSION pgcrypto UPDATE TO '1.4'; - - -- extension.alter.set_schema - ALTER EXTENSION pgcrypto SET SCHEMA public; - - -- extension.comment - COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; - - -- extension.drop_comment - COMMENT ON EXTENSION pgcrypto IS NULL; - - -- domain.create - CREATE DOMAIN public.test_domain_all AS custom.text[][] - COLLATE mycoll - DEFAULT 'hello' - NOT NULL - CHECK (VALUE <> ''); - - -- domain.drop - DROP DOMAIN public.test_domain_all; - - -- domain.alter.set_default - ALTER DOMAIN public.test_domain_all - SET DEFAULT 'world'; - - -- domain.alter.drop_default - ALTER DOMAIN public.test_domain_all - DROP DEFAULT; - - -- domain.alter.set_not_null - ALTER DOMAIN public.test_domain_all - SET NOT NULL; - - -- domain.alter.drop_not_null - ALTER DOMAIN public.test_domain_all - DROP NOT NULL; - - -- domain.alter.change_owner - ALTER DOMAIN public.test_domain_all - OWNER TO new_owner; - - -- domain.alter.add_constraint - ALTER DOMAIN public.test_domain_all - ADD CONSTRAINT domain_len_chk CHECK (char_length(VALUE) <= 255) NOT VALID; - - -- domain.alter.drop_constraint - ALTER DOMAIN public.test_domain_all - DROP CONSTRAINT domain_chk; - - -- domain.alter.validate_constraint - ALTER DOMAIN public.test_domain_all - VALIDATE CONSTRAINT domain_len_chk; - - -- domain.comment - COMMENT ON DOMAIN public.test_domain_all IS 'domain comment'; - - -- domain.drop_comment - COMMENT ON DOMAIN public.test_domain_all IS NULL; - - -- domain.grant - GRANT ALL ON DOMAIN public.test_domain_all TO app_user; - - -- domain.revoke - REVOKE ALL ON DOMAIN public.test_domain_all FROM app_user; - - -- domain.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON DOMAIN public.test_domain_all FROM app_user; - - -- type.enum.create - CREATE TYPE public.test_enum AS ENUM ( - 'value1', - 'value2', - 'value3' - ); - - -- type.enum.drop - DROP TYPE public.test_enum; - - -- type.enum.alter.change_owner - ALTER TYPE public.test_enum OWNER TO new_owner; - - -- type.enum.alter.add_value - ALTER TYPE public.test_enum ADD VALUE 'value4' AFTER 'value2'; - - -- type.enum.comment - COMMENT ON TYPE public.test_enum IS 'enum comment'; - - -- type.enum.drop_comment - COMMENT ON TYPE public.test_enum IS NULL; - - -- type.enum.grant - GRANT ALL ON TYPE public.test_enum TO app_user; - - -- type.enum.revoke - REVOKE ALL ON TYPE public.test_enum FROM app_user; - - -- type.enum.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.test_enum FROM app_user; - - -- type.composite.create - CREATE TYPE public.test_type AS ( - id integer, - name text COLLATE "en_US" - ); - - -- type.composite.drop - DROP TYPE public.test_type; - - -- type.composite.alter.change_owner - ALTER TYPE public.test_type OWNER TO new_owner; - - -- type.composite.alter.add_attribute - ALTER TYPE public.test_type ADD ATTRIBUTE age integer; - - -- type.composite.alter.drop_attribute - ALTER TYPE public.test_type DROP ATTRIBUTE name; - - -- type.composite.alter.alter_attr_type - ALTER TYPE public.test_type ALTER ATTRIBUTE name TYPE varchar(255) COLLATE "C"; - - -- type.composite.comment - COMMENT ON TYPE public.test_type IS 'composite comment'; - - -- type.composite.drop_comment - COMMENT ON TYPE public.test_type IS NULL; - - -- type.composite.attr_comment - COMMENT ON COLUMN public.test_type.id IS 'attr comment'; - - -- type.composite.drop_attr_comment - COMMENT ON COLUMN public.test_type.id IS NULL; - - -- type.composite.grant - GRANT ALL ON TYPE public.test_type TO app_user; - - -- type.composite.revoke - REVOKE ALL ON TYPE public.test_type FROM app_user; - - -- type.composite.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.test_type FROM app_user; - - -- type.range.create - CREATE TYPE public.daterange_custom AS RANGE ( - SUBTYPE = date, - SUBTYPE_OPCLASS = public.date_ops, - COLLATION = "en_US", - CANONICAL = public.canon_fn, - SUBTYPE_DIFF = public.diff_fn - ); - - -- type.range.drop - DROP TYPE public.daterange_custom; - - -- type.range.alter.change_owner - ALTER TYPE public.daterange_custom OWNER TO new_owner; - - -- type.range.comment - COMMENT ON TYPE public.daterange_custom IS 'range comment'; - - -- type.range.drop_comment - COMMENT ON TYPE public.daterange_custom IS NULL; - - -- type.range.grant - GRANT ALL ON TYPE public.daterange_custom TO app_user; - - -- type.range.revoke - REVOKE ALL ON TYPE public.daterange_custom FROM app_user; - - -- type.range.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON TYPE public.daterange_custom FROM app_user; - - -- collation.create - CREATE COLLATION public.test ( - LOCALE = 'en_US', - LC_COLLATE = 'en_US', - LC_CTYPE = 'en_US', - PROVIDER = icu, - DETERMINISTIC = false, - RULES = '& A < a <<< à', - VERSION = '1.0' - ); - - -- collation.drop - DROP COLLATION public.test; - - -- collation.alter.change_owner - ALTER COLLATION public.test OWNER TO new_owner; - - -- collation.alter.refresh_version - ALTER COLLATION public.test REFRESH VERSION; - - -- collation.comment - COMMENT ON COLLATION public.test IS 'collation comment'; - - -- collation.drop_comment - COMMENT ON COLLATION public.test IS NULL; - - -- table.create - CREATE TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test ( - id bigint GENERATED ALWAYS AS IDENTITY NOT NULL, - status text COLLATE "en_US" DEFAULT 'pending', - created_at timestamptz DEFAULT now(), - ref_id bigint, - computed bigint GENERATED ALWAYS AS (id * 2) STORED - ) WITH (fillfactor=70, autovacuum_enabled=false); - - -- table.drop - DROP TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- table.alter.add_column - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD COLUMN email text COLLATE "en_US" DEFAULT 'user@example.com' NOT NULL; - - -- table.alter.drop_column - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - DROP COLUMN computed; - - -- table.alter.column_type - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status TYPE character varying(255) COLLATE "C"; - - -- table.alter.column_set_default - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status SET DEFAULT 'active'; - - -- table.alter.column_drop_default - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status DROP DEFAULT; - - -- table.alter.column_set_not_null - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status SET NOT NULL; - - -- table.alter.column_drop_not_null - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ALTER COLUMN status DROP NOT NULL; - - -- table.alter.add_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD CONSTRAINT uq_t_fmt_status UNIQUE (status); - - -- table.alter.add_fk_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ADD CONSTRAINT fk_t_fmt_ref FOREIGN KEY (ref_id) REFERENCES public.other_table(id) MATCH FULL - ON UPDATE SET NULL ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; - - -- table.alter.drop_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - DROP CONSTRAINT uq_t_fmt_status; - - -- table.alter.validate_constraint - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - VALIDATE CONSTRAINT chk_t_fmt_status; - - -- table.alter.change_owner - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - OWNER TO new_owner; - - -- table.alter.set_logged - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - SET LOGGED; - - -- table.alter.set_unlogged - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - SET UNLOGGED; - - -- table.alter.enable_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - ENABLE ROW LEVEL SECURITY; - - -- table.alter.disable_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - DISABLE ROW LEVEL SECURITY; - - -- table.alter.force_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - FORCE ROW LEVEL SECURITY; - - -- table.alter.no_force_rls - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - NO FORCE ROW LEVEL SECURITY; - - -- table.alter.set_storage_params - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - SET (fillfactor=80, autovacuum_enabled=true); - - -- table.alter.reset_storage_params - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - RESET (fillfactor, autovacuum_enabled); - - -- table.alter.replica_identity - ALTER TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test - REPLICA IDENTITY FULL; - - -- table.alter.attach_partition - ALTER TABLE public.events - ATTACH PARTITION public.events_2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - -- table.alter.detach_partition - ALTER TABLE public.events - DETACH PARTITION public.events_2024; - - -- table.comment - COMMENT ON TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test IS - 'table comment'; - - -- table.drop_comment - COMMENT ON TABLE public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- table.column_comment - COMMENT ON COLUMN public.table_with_very_long_name_for_formatting_and_wrapping_test.id IS - 'id column'; - - -- table.drop_column_comment - COMMENT ON COLUMN public.table_with_very_long_name_for_formatting_and_wrapping_test.id IS NULL; - - -- table.constraint_comment - COMMENT ON CONSTRAINT pk_t_fmt - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'primary key'; - - -- table.drop_constraint_comment - COMMENT ON CONSTRAINT chk_t_fmt_status - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- table.grant - GRANT INSERT, - SELECT ON public.table_with_very_long_name_for_formatting_and_wrapping_test TO app_reader; - - -- table.revoke - REVOKE DELETE, - UPDATE ON public.table_with_very_long_name_for_formatting_and_wrapping_test FROM app_reader; - - -- table.revoke_grant_option - REVOKE GRANT OPTION FOR INSERT, - SELECT ON public.table_with_very_long_name_for_formatting_and_wrapping_test FROM app_reader; - - -- publication.create - CREATE PUBLICATION pub_custom FOR TABLE - public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit ( - id, - title - ) WHERE (published = true), - TABLE public.comments_a_little_smaller_name_than_the_previous_one, TABLES IN SCHEMA analytics; - - -- publication.drop - DROP PUBLICATION pub_custom; - - -- publication.alter.set_options - ALTER PUBLICATION pub_custom - SET (publish = 'insert, update, delete, truncate', publish_via_partition_root = false); - - -- publication.alter.set_list - ALTER PUBLICATION pub_custom - SET TABLE - public.articles_with_a_very_long_name_very_very_long_name_that_will_go_above_the_wrapping_limit - (id, title) WHERE (published = true), - TABLE public.comments_a_little_smaller_name_than_the_previous_one, TABLES IN SCHEMA analytics; - - -- publication.alter.add_tables - ALTER PUBLICATION pub_custom - ADD TABLE public.new_table_with_very_long_name_for_formatting_and_wrapping_test; - - -- publication.alter.drop_tables - ALTER PUBLICATION pub_custom DROP TABLE public.comments_a_little_smaller_name_than_the_previous_one; - - -- publication.alter.add_schemas - ALTER PUBLICATION pub_custom ADD TABLES IN SCHEMA staging; - - -- publication.alter.drop_schemas - ALTER PUBLICATION pub_custom DROP TABLES IN SCHEMA analytics; - - -- publication.alter.set_owner - ALTER PUBLICATION pub_custom OWNER TO new_owner; - - -- publication.comment - COMMENT ON PUBLICATION pub_custom IS 'publication comment'; - - -- publication.drop_comment - COMMENT ON PUBLICATION pub_custom IS NULL; - - -- view.create - CREATE VIEW public.test_view WITH (security_barrier=true, check_option=local) AS SELECT * - FROM test_table; - - -- view.drop - DROP VIEW public.test_view; - - -- view.alter.change_owner - ALTER VIEW public.test_view OWNER TO new_owner; - - -- view.alter.set_options - ALTER VIEW public.test_view SET (security_barrier=true, check_option=cascaded); - - -- view.alter.reset_options - ALTER VIEW public.test_view RESET (security_barrier); - - -- view.comment - COMMENT ON VIEW public.test_view IS 'view comment'; - - -- view.drop_comment - COMMENT ON VIEW public.test_view IS NULL; - - -- view.grant - GRANT SELECT ON public.test_view TO app_reader WITH GRANT OPTION; - - -- view.revoke - REVOKE SELECT ON public.test_view FROM app_reader; - - -- view.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON public.test_view FROM app_reader; - - -- rule.create - CREATE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.drop - DROP RULE test_rule ON public.test_table; - - -- rule.replace - CREATE OR REPLACE RULE test_rule AS ON INSERT TO public.test_table DO INSTEAD NOTHING; - - -- rule.alter.set_enabled - ALTER TABLE public.test_table - DISABLE RULE test_rule; - - -- rule.comment - COMMENT ON RULE test_rule ON public.test_table IS 'rule comment'; - - -- rule.drop_comment - COMMENT ON RULE test_rule ON public.test_table IS NULL; - - -- procedure.create - CREATE PROCEDURE public.test_procedure() - LANGUAGE plpgsql - AS $$ begin null; end; $$; - - -- procedure.drop - DROP PROCEDURE public.test_procedure(); - - -- function.create - CREATE FUNCTION public.calculate_metrics_for_analytics_dashboard_with_extended_name ( - "p_schema_name_for_analytics" text, - "p_table_name_for_metrics" text, - "p_limit_count_default" integer DEFAULT 100 - ) - RETURNS TABLE ( - total bigint, - average numeric - ) - LANGUAGE plpgsql - STABLE - SECURITY DEFINER - PARALLEL SAFE - COST 100 - ROWS 10 - STRICT - SET search_path TO 'pg_catalog', 'public' - AS $function$ BEGIN RETURN QUERY SELECT count(*)::bigint, avg(value)::numeric FROM generate_series(1, p_limit_count_default); END; $function$; - - -- function.drop - DROP FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(IN - "p_schema_name_for_analytics" text, - IN "p_table_name_for_metrics" text, IN "p_limit_count_default" integer); - - -- function.alter.change_owner - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) OWNER TO - new_admin; - - -- function.alter.set_security - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) SECURITY - INVOKER; - - -- function.alter.set_config - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) - SET work_mem TO '256MB'; - - -- function.alter.set_volatility - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) IMMUTABLE; - - -- function.alter.set_strictness - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) CALLED - ON NULL INPUT; - - -- function.alter.set_leakproof - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) LEAKPROOF; - - -- function.alter.set_parallel - ALTER FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) PARALLEL - RESTRICTED; - - -- function.comment - COMMENT ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) IS - 'Calculate metrics for a given table'; - - -- function.drop_comment - COMMENT ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text,text,integer) IS NULL; - - -- function.grant - GRANT ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) TO - app_user WITH GRANT OPTION; - - -- function.revoke - REVOKE ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) FROM - app_user; - - -- function.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FUNCTION - public.calculate_metrics_for_analytics_dashboard_with_extended_name(text, text, integer) FROM - app_user; - - -- sequence.create - CREATE SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq; - - -- sequence.drop - DROP SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq CASCADE; - - -- sequence.alter.set_owned_by - ALTER SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq OWNED BY - public.table_with_very_long_name_for_formatting_and_wrapping_test.id; - - -- sequence.alter.set_options - ALTER SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq INCREMENT BY - 10 MINVALUE 1 MAXVALUE 1000000 CACHE 5 CYCLE; - - -- sequence.comment - COMMENT ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq IS - 'sequence for table_with_very_long_name_for_formatting_and_wrapping_test.id'; - - -- sequence.drop_comment - COMMENT ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq IS NULL; - - -- sequence.grant - GRANT SELECT, - USAGE - ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq TO app_user; - - -- sequence.revoke - REVOKE USAGE - ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq FROM app_user; - - -- sequence.revoke_grant_option - REVOKE GRANT OPTION FOR USAGE - ON SEQUENCE public.table_with_very_long_name_for_formatting_and_wrapping_test_id_seq FROM app_user; - - -- policy.create - CREATE POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test - FOR SELECT - TO authenticated - USING (auth.uid() = user_id); - - -- policy.create_restrictive - CREATE POLICY restrict_delete ON public.table_with_very_long_name_for_formatting_and_wrapping_test - AS RESTRICTIVE - FOR DELETE - TO authenticated, service_role - USING (auth.uid() = owner_id) - WITH CHECK (status <> 'locked'); - - -- policy.drop - DROP POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- policy.alter.set_roles - ALTER POLICY allow_select_own - ON public.table_with_very_long_name_for_formatting_and_wrapping_test TO authenticated, anon; - - -- policy.alter.set_using - ALTER POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test - USING (auth.uid() = user_id AND status = 'active'); - - -- policy.alter.set_with_check - ALTER POLICY allow_select_own ON public.table_with_very_long_name_for_formatting_and_wrapping_test - WITH CHECK (auth.uid() = user_id); - - -- policy.comment - COMMENT ON POLICY allow_select_own - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'rls policy comment'; - - -- policy.drop_comment - COMMENT ON POLICY allow_select_own - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- index.create - CREATE UNIQUE INDEX idx_t_fmt_status - ON public.table_with_very_long_name_for_formatting_and_wrapping_test (status) - WITH (fillfactor='90') - WHERE (status <> 'archived'::text); - - -- index.create_gin - CREATE INDEX idx_t_fmt_search ON public.table_with_very_long_name_for_formatting_and_wrapping_test - USING gin (to_tsvector('english'::regconfig, status)); - - -- index.drop - DROP INDEX public.idx_t_fmt_status; - - -- index.alter.set_storage_params - ALTER INDEX public.idx_t_fmt_status RESET (deduplicate_items); - - ALTER INDEX public.idx_t_fmt_status SET (fillfactor=80); - - -- index.alter.set_statistics - ALTER INDEX public.idx_t_fmt_status ALTER COLUMN 1 SET STATISTICS 500; - - -- index.comment - COMMENT ON INDEX public.idx_t_fmt_status IS 'index comment'; - - -- index.drop_comment - COMMENT ON INDEX public.idx_t_fmt_status IS NULL; - - -- trigger.create - CREATE TRIGGER trg_audit AFTER INSERT OR UPDATE - ON public.table_with_very_long_name_for_formatting_and_wrapping_test - REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ( - (NEW.status IS DISTINCT FROM OLD.status) - ) EXECUTE FUNCTION public.audit_trigger_fn('arg1', 'arg2'); - - -- trigger.drop - DROP TRIGGER trg_audit ON public.table_with_very_long_name_for_formatting_and_wrapping_test; - - -- trigger.replace - CREATE OR REPLACE TRIGGER trg_audit AFTER INSERT OR UPDATE - ON public.table_with_very_long_name_for_formatting_and_wrapping_test - REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH ROW WHEN ( - (NEW.status IS DISTINCT FROM OLD.status) - ) EXECUTE FUNCTION public.audit_trigger_fn('arg1', 'arg2'); - - -- trigger.comment - COMMENT ON TRIGGER trg_audit - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS 'trigger comment'; - - -- trigger.drop_comment - COMMENT ON TRIGGER trg_audit - ON public.table_with_very_long_name_for_formatting_and_wrapping_test IS NULL; - - -- matview.create - CREATE MATERIALIZED VIEW analytics.daily_stats - WITH (fillfactor=70) - AS SELECT date_trunc('day', created_at) AS day, count(*) AS total - FROM public.events - GROUP BY 1 WITH DATA; - - -- matview.drop - DROP MATERIALIZED VIEW analytics.daily_stats; - - -- matview.alter.change_owner - ALTER MATERIALIZED VIEW analytics.daily_stats - OWNER TO new_owner; - - -- matview.alter.set_storage - ALTER MATERIALIZED VIEW analytics.daily_stats - RESET (autovacuum_enabled); - - ALTER MATERIALIZED VIEW analytics.daily_stats - SET (fillfactor=80); - - -- matview.comment - COMMENT ON MATERIALIZED VIEW analytics.daily_stats IS 'daily aggregation'; - - -- matview.drop_comment - COMMENT ON MATERIALIZED VIEW analytics.daily_stats IS NULL; - - -- matview.column_comment - COMMENT ON COLUMN analytics.daily_stats.day IS 'day bucket'; - - -- matview.drop_column_comment - COMMENT ON COLUMN analytics.daily_stats.day IS NULL; - - -- matview.grant - GRANT SELECT ON analytics.daily_stats TO app_reader; - - -- matview.revoke - REVOKE SELECT ON analytics.daily_stats FROM app_reader; - - -- matview.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON analytics.daily_stats FROM app_reader; - - -- aggregate.create - CREATE AGGREGATE public.array_cat_agg(anycompatiblearray) ( - SFUNC = array_cat, - STYPE = anycompatiblearray, - COMBINEFUNC = array_cat, - INITCOND = '{}', - PARALLEL = SAFE, - STRICT - ); - - -- aggregate.drop - DROP AGGREGATE public.array_cat_agg(anycompatiblearray); - - -- aggregate.alter.change_owner - ALTER AGGREGATE public.array_cat_agg(anycompatiblearray) OWNER TO new_owner; - - -- aggregate.comment - COMMENT ON AGGREGATE public.array_cat_agg(anycompatiblearray) IS 'concatenate arrays aggregate'; - - -- aggregate.drop_comment - COMMENT ON AGGREGATE public.array_cat_agg(anycompatiblearray) IS NULL; - - -- aggregate.grant - GRANT ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) TO app_user; - - -- aggregate.revoke - REVOKE ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) FROM app_user; - - -- aggregate.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FUNCTION public.array_cat_agg(anycompatiblearray) FROM app_user; - - -- event_trigger.create - CREATE EVENT TRIGGER prevent_drop - ON sql_drop - WHEN TAG IN ('DROP TABLE', 'DROP SCHEMA') - EXECUTE FUNCTION public.prevent_drop_fn(); - - -- event_trigger.drop - DROP EVENT TRIGGER prevent_drop; - - -- event_trigger.alter.change_owner - ALTER EVENT TRIGGER prevent_drop - OWNER TO new_owner; - - -- event_trigger.alter.set_enabled - ALTER EVENT TRIGGER prevent_drop - DISABLE; - - -- event_trigger.comment - COMMENT ON EVENT TRIGGER prevent_drop IS 'prevent accidental drops'; - - -- event_trigger.drop_comment - COMMENT ON EVENT TRIGGER prevent_drop IS NULL; - - -- language.create - CREATE TRUSTED LANGUAGE plv8 - HANDLER plv8_call_handler - INLINE plv8_inline_handler - VALIDATOR plv8_call_validator; - - -- language.drop - DROP LANGUAGE plv8; - - -- language.alter.change_owner - ALTER LANGUAGE plv8 OWNER TO new_owner; - - -- language.comment - COMMENT ON LANGUAGE plv8 IS 'PL/V8 trusted procedural language'; - - -- language.drop_comment - COMMENT ON LANGUAGE plv8 IS NULL; - - -- language.grant - GRANT ALL ON LANGUAGE plv8 TO app_user WITH GRANT OPTION; - - -- language.revoke - REVOKE ALL ON LANGUAGE plv8 FROM app_user; - - -- language.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON LANGUAGE plv8 FROM app_user; - - -- role.create - CREATE ROLE app_user WITH LOGIN CONNECTION LIMIT 100; - - -- role.drop - DROP ROLE app_user; - - -- role.alter.set_options - ALTER ROLE app_user WITH NOSUPERUSER CREATEDB; - - -- role.alter.set_config - ALTER ROLE app_user SET statement_timeout TO '60000'; - - -- role.comment - COMMENT ON ROLE app_user IS 'application user role'; - - -- role.drop_comment - COMMENT ON ROLE app_user IS NULL; - - -- role.grant_membership - GRANT app_user TO dev_user WITH ADMIN OPTION; - - -- role.revoke_membership - REVOKE app_user FROM dev_user; - - -- role.revoke_membership_options - REVOKE ADMIN OPTION FOR app_user FROM dev_user; - - -- role.grant_default_privileges - ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public GRANT SELECT ON TABLES TO app_reader; - - -- role.revoke_default_privileges - ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public REVOKE SELECT ON TABLES FROM app_reader; - - -- subscription.create - CREATE SUBSCRIPTION sub_replica - CONNECTION 'host=primary.db port=5432 dbname=mydb' - PUBLICATION pub_custom - WITH ( - slot_name = 'sub_replica_slot', - binary = true, - streaming = 'parallel', - synchronous_commit = 'remote_apply', - disable_on_error = true, - failover = true, - create_slot = false - ); - - -- subscription.drop - DROP SUBSCRIPTION sub_replica; - - -- subscription.alter.set_connection - ALTER SUBSCRIPTION sub_replica - CONNECTION 'host=primary.db port=5432 dbname=mydb'; - - -- subscription.alter.set_publication - ALTER SUBSCRIPTION sub_replica - SET PUBLICATION pub_custom; - - -- subscription.alter.enable - ALTER SUBSCRIPTION sub_replica - ENABLE; - - -- subscription.alter.disable - ALTER SUBSCRIPTION sub_replica - DISABLE; - - -- subscription.alter.set_options - ALTER SUBSCRIPTION sub_replica - SET ( - binary = true, - streaming = 'parallel', - synchronous_commit = 'remote_apply' - ); - - -- subscription.alter.set_owner - ALTER SUBSCRIPTION sub_replica - OWNER TO new_owner; - - -- subscription.comment - COMMENT ON SUBSCRIPTION sub_replica IS 'replication subscription'; - - -- subscription.drop_comment - COMMENT ON SUBSCRIPTION sub_replica IS NULL; - - -- fdw.create - CREATE FOREIGN DATA WRAPPER postgres_fdw - HANDLER postgres_fdw_handler - VALIDATOR postgres_fdw_validator - OPTIONS (debug '__OPTION_DEBUG__'); - - -- fdw.drop - DROP FOREIGN DATA WRAPPER postgres_fdw; - - -- fdw.alter.change_owner - ALTER FOREIGN DATA WRAPPER postgres_fdw - OWNER TO new_owner; - - -- fdw.alter.set_options - ALTER FOREIGN DATA WRAPPER postgres_fdw - OPTIONS ( - SET debug '__OPTION_DEBUG__', - ADD use_remote_estimate '' - ); - - -- fdw.comment - COMMENT ON FOREIGN DATA WRAPPER postgres_fdw IS 'PostgreSQL foreign data wrapper'; - - -- fdw.drop_comment - COMMENT ON FOREIGN DATA WRAPPER postgres_fdw IS NULL; - - -- fdw.grant - GRANT ALL ON FOREIGN DATA WRAPPER postgres_fdw TO app_user; - - -- fdw.revoke - REVOKE ALL ON FOREIGN DATA WRAPPER postgres_fdw FROM app_user; - - -- fdw.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON FOREIGN DATA WRAPPER postgres_fdw FROM app_user; - - -- foreign_table.create - CREATE FOREIGN TABLE public.remote_users ( - id integer, - email text - ) SERVER remote_server OPTIONS (schema_name 'public', table_name 'users'); - - -- foreign_table.drop - DROP FOREIGN TABLE public.remote_users; - - -- foreign_table.alter.change_owner - ALTER FOREIGN TABLE public.remote_users - OWNER TO new_owner; - - -- foreign_table.alter.add_column - ALTER FOREIGN TABLE public.remote_users - ADD COLUMN name text NOT NULL DEFAULT 'unknown'; - - -- foreign_table.alter.drop_column - ALTER FOREIGN TABLE public.remote_users - DROP COLUMN email; - - -- foreign_table.alter.column_type - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN id TYPE bigint; - - -- foreign_table.alter.column_set_default - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email SET DEFAULT 'nobody@example.com'; - - -- foreign_table.alter.column_drop_default - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email DROP DEFAULT; - - -- foreign_table.alter.column_set_not_null - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email SET NOT NULL; - - -- foreign_table.alter.column_drop_not_null - ALTER FOREIGN TABLE public.remote_users - ALTER COLUMN email DROP NOT NULL; - - -- foreign_table.alter.set_options - ALTER FOREIGN TABLE public.remote_users - OPTIONS (SET fetch_size '1000'); - - -- foreign_table.comment - COMMENT ON FOREIGN TABLE public.remote_users IS 'remote users table'; - - -- foreign_table.drop_comment - COMMENT ON FOREIGN TABLE public.remote_users IS NULL; - - -- foreign_table.grant - GRANT SELECT ON TABLE public.remote_users TO app_reader; - - -- foreign_table.revoke - REVOKE SELECT ON TABLE public.remote_users FROM app_reader; - - -- foreign_table.revoke_grant_option - REVOKE GRANT OPTION FOR SELECT ON TABLE public.remote_users FROM app_reader; - - -- server.create - CREATE SERVER remote_server - TYPE 'postgresql' - VERSION '16.0' - FOREIGN DATA WRAPPER postgres_fdw - OPTIONS ( - host 'remote.host', - port '5432', - dbname 'remote_db' - ); - - -- server.drop - DROP SERVER remote_server; - - -- server.alter.change_owner - ALTER SERVER remote_server - OWNER TO new_owner; - - -- server.alter.set_version - ALTER SERVER remote_server - VERSION '17.0'; - - -- server.alter.set_options - ALTER SERVER remote_server - OPTIONS ( - SET host 'new.host', - DROP port - ); - - -- server.comment - COMMENT ON SERVER remote_server IS 'remote PostgreSQL server'; - - -- server.drop_comment - COMMENT ON SERVER remote_server IS NULL; - - -- server.grant - GRANT ALL ON SERVER remote_server TO app_user; - - -- server.revoke - REVOKE ALL ON SERVER remote_server FROM app_user; - - -- server.revoke_grant_option - REVOKE GRANT OPTION FOR ALL ON SERVER remote_server FROM app_user; - - -- user_mapping.create - CREATE USER MAPPING FOR app_user SERVER remote_server - OPTIONS (user 'remote_app', password '__OPTION_PASSWORD__'); - - -- user_mapping.drop - DROP USER MAPPING FOR app_user SERVER remote_server; - - -- user_mapping.alter.set_options - ALTER USER MAPPING FOR app_user SERVER remote_server OPTIONS (SET password '__OPTION_PASSWORD__');" - `); - }); -}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-trigger-quoted-name.test.ts b/packages/pg-delta/src/core/plan/sql-format/format-trigger-quoted-name.test.ts deleted file mode 100644 index 003f30204..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/format-trigger-quoted-name.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { formatSqlStatements } from "./index.ts"; - -describe("formatCreateTrigger with quoted (dashed) names", () => { - // Regression: CLI-1820. A trigger whose name contains characters that force - // PostgreSQL to double-quote it (e.g. a dash) used to lose its event/table - // clause ("AFTER INSERT ON public.t1"). The tokenizer emitted no token for - // the quoted identifier, so the formatter mistook the next keyword for the - // name and sliced away everything before the first recognized clause. - it("preserves the event/table clause for a dashed trigger name", () => { - const sql = `CREATE TRIGGER "new-webhook-with-dashed-name" AFTER INSERT ON public.t1 FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://example.com/x', 'POST', '{}', '{}', '5000')`; - - const [formatted] = formatSqlStatements([sql], { keywordCase: "upper" }); - - expect(formatted).toContain("AFTER INSERT ON public.t1"); - expect(formatted).toMatchInlineSnapshot(` - "CREATE TRIGGER "new-webhook-with-dashed-name" - AFTER INSERT ON public.t1 - FOR EACH ROW - EXECUTE FUNCTION - supabase_functions.http_request('https://example.com/x', 'POST', '{}', '{}', '5000')" - `); - }); - - it("formats a dashed trigger name the same way as an unquoted one", () => { - const dashed = `CREATE TRIGGER "send-chat-push" AFTER INSERT ON public.chat_message FOR EACH ROW EXECUTE FUNCTION public.notify()`; - const plain = `CREATE TRIGGER send_chat_push AFTER INSERT ON public.chat_message FOR EACH ROW EXECUTE FUNCTION public.notify()`; - - const [dashedOut] = formatSqlStatements([dashed], { keywordCase: "upper" }); - const [plainOut] = formatSqlStatements([plain], { keywordCase: "upper" }); - - expect(dashedOut.replace('"send-chat-push"', "send_chat_push")).toBe( - plainOut, - ); - }); -}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-utils.test.ts b/packages/pg-delta/src/core/plan/sql-format/format-utils.test.ts deleted file mode 100644 index e8dd01653..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/format-utils.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { DEFAULT_OPTIONS } from "./constants.ts"; -import { - formatColumnList, - formatKeyValueItems, - formatListItems, - indentString, - splitLeadingComments, - splitSqlStatements, -} from "./format-utils.ts"; - -describe("splitSqlStatements", () => { - it("splits by semicolons", () => { - const result = splitSqlStatements("SELECT 1;SELECT 2"); - expect(result).toEqual(["SELECT 1", "SELECT 2"]); - }); - - it("ignores semicolons inside quotes", () => { - const result = splitSqlStatements("SELECT ';' FROM foo"); - expect(result).toEqual(["SELECT ';' FROM foo"]); - }); - - it("ignores semicolons inside comments", () => { - const result = splitSqlStatements("SELECT 1 -- semi; here\nFROM foo"); - expect(result).toEqual(["SELECT 1 -- semi; here\nFROM foo"]); - }); -}); - -describe("splitLeadingComments", () => { - it("separates leading comment lines from body", () => { - const input = "-- comment\n-- another\nSELECT 1"; - const result = splitLeadingComments(input); - expect(result.commentLines).toEqual(["-- comment", "-- another"]); - expect(result.body).toBe("SELECT 1"); - }); - - it("returns empty commentLines when no comments", () => { - const result = splitLeadingComments("SELECT 1"); - expect(result.commentLines).toEqual([]); - expect(result.body).toBe("SELECT 1"); - }); -}); - -describe("formatColumnList", () => { - it("formats column definitions with alignment", () => { - const content = "id integer, name text, description varchar(255)"; - const result = formatColumnList(content, DEFAULT_OPTIONS); - expect(result).not.toBeNull(); - expect(result?.length).toBe(3); - // Each line should be indented - for (const line of result ?? []) { - expect(line).toMatch(/^\s+/); - } - }); - - it("returns null for empty content", () => { - expect(formatColumnList("", DEFAULT_OPTIONS)).toBeNull(); - }); -}); - -describe("formatKeyValueItems", () => { - it("formats key=value items with alignment", () => { - const items = ["a = 1", "long_key = 2"]; - const result = formatKeyValueItems(items, DEFAULT_OPTIONS); - expect(result.length).toBe(2); - // Both should be indented - for (const line of result) { - expect(line).toMatch(/^\s+/); - } - }); -}); - -describe("formatListItems", () => { - it("applies trailing comma style", () => { - const result = formatListItems(["a", "b", "c"], " ", "trailing"); - expect(result).toEqual([" a,", " b,", " c"]); - }); - - it("applies leading comma style", () => { - const result = formatListItems(["a", "b", "c"], " ", "leading"); - expect(result).toEqual([" a", " , b", " , c"]); - }); -}); - -describe("indentString", () => { - it("returns correct number of spaces", () => { - expect(indentString(0)).toBe(""); - expect(indentString(2)).toBe(" "); - expect(indentString(4)).toBe(" "); - }); -}); diff --git a/packages/pg-delta/src/core/plan/sql-format/formatters.ts b/packages/pg-delta/src/core/plan/sql-format/formatters.ts deleted file mode 100644 index 892dd0c26..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/formatters.ts +++ /dev/null @@ -1,921 +0,0 @@ -import { - formatColumnList, - formatKeyValueItems, - formatListItems, - formatMixedItems, - indentString, - joinHeaderAndClauses, -} from "./format-utils.ts"; -import { - findClausePositions, - findTopLevelParen, - scanTokens, - skipQualifiedName, - sliceClauses, - splitByCommas, -} from "./tokenizer.ts"; -import type { NormalizedOptions, Token } from "./types.ts"; - -// ── Module-level keyword sets (hoisted to avoid per-call allocations) ──────── - -const DOMAIN_CLAUSE_KEYWORDS = new Set(["COLLATE", "DEFAULT", "CHECK"]); - -const FUNCTION_CLAUSE_KEYWORDS = new Set([ - "RETURNS", - "LANGUAGE", - "TRANSFORM", - "WINDOW", - "IMMUTABLE", - "STABLE", - "VOLATILE", - "LEAKPROOF", - "CALLED", - "STRICT", - "SECURITY", - "PARALLEL", - "COST", - "ROWS", - "SUPPORT", - "SET", - "AS", -]); - -const POLICY_CLAUSE_KEYWORDS = new Set(["FOR", "TO", "USING", "WITH"]); - -const TRIGGER_CLAUSE_KEYWORDS = new Set([ - "BEFORE", - "AFTER", - "INSTEAD", - "FOR", - "WHEN", - "EXECUTE", -]); -const EVENT_TRIGGER_CLAUSE_KEYWORDS = new Set(["ON", "WHEN", "EXECUTE"]); - -const INDEX_CLAUSE_KEYWORDS = new Set(["WHERE", "WITH", "TABLESPACE"]); - -const LANGUAGE_CLAUSE_KEYWORDS = new Set(["HANDLER", "INLINE", "VALIDATOR"]); - -const MATVIEW_CLAUSE_KEYWORDS = new Set(["WITH", "AS"]); - -const SUBSCRIPTION_CLAUSE_KEYWORDS = new Set([ - "CONNECTION", - "PUBLICATION", - "WITH", -]); - -const FDW_CLAUSE_KEYWORDS = new Set(["HANDLER", "VALIDATOR", "OPTIONS"]); - -const EXPANDABLE_KEYWORDS = new Set(["OPTIONS", "WITH", "SET"]); - -// ── Formatters ─────────────────────────────────────────────────────────────── - -export function formatCreateDomain( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 2) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "DOMAIN") { - return null; - } - - // Domain has a special NOT NULL compound clause that findClausePositions - // can't handle generically, so we keep a custom scan here. - const clauseStarts: number[] = []; - for (let i = 0; i < tokens.length; i += 1) { - if (tokens[i].depth !== 0) continue; - - const upper = tokens[i].upper; - if (DOMAIN_CLAUSE_KEYWORDS.has(upper)) { - clauseStarts.push(tokens[i].start); - continue; - } - if ( - upper === "NOT" && - tokens[i + 1]?.upper === "NULL" && - tokens[i + 1]?.depth === 0 - ) { - clauseStarts.push(tokens[i].start); - i += 1; - } - } - - if (clauseStarts.length === 0) return null; - clauseStarts.sort((a, b) => a - b); - - const prefix = statement.slice(0, clauseStarts[0]).trim(); - const clauses = sliceClauses(statement, clauseStarts); - - return joinHeaderAndClauses(prefix, clauses, options); -} - -export function formatCreateEnum( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 4) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "TYPE") { - return null; - } - - const enumToken = tokens.find( - (token, index) => - token.upper === "ENUM" && tokens[index - 1]?.upper === "AS", - ); - if (!enumToken) return null; - - const parens = findTopLevelParen(statement, enumToken.end); - if (!parens) return null; - const { open, close } = parens; - - const header = statement.slice(0, open).trim(); - const content = statement.slice(open + 1, close).trim(); - const suffix = statement.slice(close + 1).trim(); - - const items = splitByCommas(content); - const indent = indentString(options.indent); - const listLines = formatListItems(items, indent, options.commaStyle); - - const lines = [`${header} (`, ...listLines, `)${suffix ? ` ${suffix}` : ""}`]; - return lines.join("\n"); -} - -export function formatCreateCompositeType( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "TYPE") { - return null; - } - - const asToken = tokens.find((token) => token.upper === "AS"); - if (!asToken) return null; - const asIndex = tokens.indexOf(asToken); - const nextToken = tokens[asIndex + 1]; - if (nextToken?.upper === "ENUM" || nextToken?.upper === "RANGE") { - return null; - } - const parens = findTopLevelParen(statement, asToken.end); - if (!parens) return null; - - const { open, close } = parens; - const header = statement.slice(0, open).trim(); - const content = statement.slice(open + 1, close).trim(); - const suffix = statement.slice(close + 1).trim(); - - const formattedColumns = formatColumnList(content, options); - if (!formattedColumns) return null; - - const lines = [ - `${header} (`, - ...formattedColumns, - `)${suffix ? ` ${suffix}` : ""}`, - ]; - return lines.join("\n"); -} - -export function formatCreateTable( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE") return null; - - const tableToken = tokens.find((token, index) => { - if (token.upper !== "TABLE") return false; - if (index > 0 && tokens[index - 1].upper === "RETURNS") return false; - return true; - }); - if (!tableToken) return null; - - const parens = findTopLevelParen(statement, tableToken.end); - if (!parens) return null; - - const { open, close } = parens; - const hasPartitionBeforeColumns = tokens.some( - (token) => - token.depth === 0 && token.upper === "PARTITION" && token.start < open, - ); - if (hasPartitionBeforeColumns) return null; - const header = statement.slice(0, open).trim(); - const content = statement.slice(open + 1, close).trim(); - const suffix = statement.slice(close + 1).trim(); - - const formattedColumns = formatColumnList(content, options); - if (!formattedColumns) return null; - - const lines = [ - `${header} (`, - ...formattedColumns, - `)${suffix ? ` ${suffix}` : ""}`, - ]; - return lines.join("\n"); -} - -export function formatCreateRange( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 4) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "TYPE") { - return null; - } - - const rangeToken = tokens.find( - (token, index) => - token.upper === "RANGE" && tokens[index - 1]?.upper === "AS", - ); - if (!rangeToken) return null; - - const parens = findTopLevelParen(statement, rangeToken.end); - if (!parens) return null; - const { open, close } = parens; - - const header = statement.slice(0, open).trim(); - const content = statement.slice(open + 1, close).trim(); - const suffix = statement.slice(close + 1).trim(); - - const items = splitByCommas(content); - if (items.length === 0) return null; - - const formattedItems = formatKeyValueItems(items, options); - const lines = [ - `${header} (`, - ...formattedItems, - `)${suffix ? ` ${suffix}` : ""}`, - ]; - return lines.join("\n"); -} - -export function formatCreateCollation( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "COLLATION") { - return null; - } - - const parens = findTopLevelParen(statement, tokens[1].end); - if (!parens) return null; - const { open, close } = parens; - - const header = statement.slice(0, open).trim(); - const content = statement.slice(open + 1, close).trim(); - const suffix = statement.slice(close + 1).trim(); - - const items = splitByCommas(content); - if (items.length === 0) return null; - - const formattedItems = formatKeyValueItems(items, options); - const lines = [ - `${header} (`, - ...formattedItems, - `)${suffix ? ` ${suffix}` : ""}`, - ]; - return lines.join("\n"); -} - -export function formatCreateFunction( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE") return null; - - let cursor = 1; - if ( - tokens[cursor]?.upper === "OR" && - tokens[cursor + 1]?.upper === "REPLACE" - ) { - cursor += 2; - } - const objectToken = tokens[cursor]; - if ( - !objectToken || - (objectToken.upper !== "FUNCTION" && objectToken.upper !== "PROCEDURE") - ) { - return null; - } - - const parens = findTopLevelParen(statement, objectToken.end); - if (!parens) return null; - const { open, close } = parens; - - const header = statement.slice(0, open).trim(); - const argContent = statement.slice(open + 1, close).trim(); - const postArgs = statement.slice(close + 1).trim(); - - const indent = indentString(options.indent); - const lines: string[] = []; - - if (argContent.length === 0) { - lines.push(`${header}()`); - } else { - const formattedArgs = formatColumnList(argContent, options); - if (formattedArgs) { - lines.push(`${header} (`, ...formattedArgs, `)`); - } else { - lines.push(`${header}(${argContent})`); - } - } - - if (postArgs.length === 0) { - return lines.join("\n"); - } - - // Function/procedure has special compound clauses (NOT LEAKPROOF, placeholders) - // that require a custom scan rather than the generic findClausePositions. - const postTokens = scanTokens(postArgs); - const clauseStarts: number[] = []; - - for (let i = 0; i < postTokens.length; i += 1) { - const tok = postTokens[i]; - if (tok.depth !== 0) continue; - - if (tok.upper === "NOT" && postTokens[i + 1]?.upper === "LEAKPROOF") { - clauseStarts.push(tok.start); - i += 1; - continue; - } - - if (FUNCTION_CLAUSE_KEYWORDS.has(tok.upper)) { - clauseStarts.push(tok.start); - continue; - } - if (tok.value.startsWith("__PGDELTA_PLACEHOLDER_")) { - clauseStarts.push(tok.start); - } - } - - if (clauseStarts.length === 0) { - lines[lines.length - 1] += ` ${postArgs}`; - return lines.join("\n"); - } - - clauseStarts.sort((a, b) => a - b); - - const beforeFirstClause = postArgs.slice(0, clauseStarts[0]).trim(); - if (beforeFirstClause.length > 0) { - lines[lines.length - 1] += ` ${beforeFirstClause}`; - } - - const clauses = sliceClauses(postArgs, clauseStarts); - for (const clause of clauses) { - const clauseTokens = scanTokens(clause); - if ( - clauseTokens.length >= 2 && - clauseTokens[0].upper === "RETURNS" && - clauseTokens[1].upper === "TABLE" - ) { - const tableParens = findTopLevelParen(clause, clauseTokens[1].end); - if (tableParens) { - const innerContent = clause - .slice(tableParens.open + 1, tableParens.close) - .trim(); - const afterTable = clause.slice(tableParens.close + 1).trim(); - - if (innerContent.length > 0) { - const formattedCols = formatColumnList(innerContent, { - ...options, - indent: options.indent * 2, - }); - if (formattedCols) { - lines.push( - `${indent}RETURNS TABLE (`, - ...formattedCols, - `${indent})`, - ); - } else { - lines.push(`${indent}RETURNS TABLE (${innerContent})`); - } - } else { - lines.push(`${indent}RETURNS TABLE ()`); - } - if (afterTable.length > 0) { - lines[lines.length - 1] += ` ${afterTable}`; - } - continue; - } - } - - lines.push(`${indent}${clause}`); - } - - return lines.join("\n"); -} - -export function formatCreatePolicy( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "POLICY") { - return null; - } - - // Policy has special WITH CHECK handling that requires a custom scan. - const clauseStarts: number[] = []; - for (let i = 2; i < tokens.length; i += 1) { - if (tokens[i].depth !== 0) continue; - const upper = tokens[i].upper; - - if ( - upper === "AS" && - (tokens[i + 1]?.upper === "PERMISSIVE" || - tokens[i + 1]?.upper === "RESTRICTIVE") - ) { - clauseStarts.push(tokens[i].start); - continue; - } - if (upper === "WITH" && tokens[i + 1]?.upper === "CHECK") { - clauseStarts.push(tokens[i].start); - continue; - } - if (upper === "WITH") continue; - if (POLICY_CLAUSE_KEYWORDS.has(upper)) { - clauseStarts.push(tokens[i].start); - } - } - - if (clauseStarts.length === 0) return null; - clauseStarts.sort((a, b) => a - b); - - const header = statement.slice(0, clauseStarts[0]).trim(); - const clauses = sliceClauses(statement, clauseStarts); - - return joinHeaderAndClauses(header, clauses, options); -} - -export function formatCreateTrigger( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE") return null; - - let triggerIndex = -1; - for (let i = 1; i < Math.min(5, tokens.length); i += 1) { - if (tokens[i].upper === "TRIGGER") { - triggerIndex = i; - break; - } - } - if (triggerIndex === -1) return null; - - const nameToken = tokens[triggerIndex + 1]; - if (!nameToken) return null; - - const headerEnd = nameToken.end; - const rest = statement.slice(headerEnd).trim(); - const header = statement.slice(0, headerEnd).trim(); - - if (rest.length === 0) return null; - - const restTokens = scanTokens(rest); - const clauseKeywords = - tokens[triggerIndex - 1]?.upper === "EVENT" - ? EVENT_TRIGGER_CLAUSE_KEYWORDS - : TRIGGER_CLAUSE_KEYWORDS; - const positions = findClausePositions(restTokens, clauseKeywords); - if (positions.length === 0) return null; - - const clauses = sliceClauses(rest, positions); - return joinHeaderAndClauses(header, clauses, options); -} - -export function formatCreateIndex( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE") return null; - - let indexIndex = -1; - for (let i = 1; i < Math.min(4, tokens.length); i += 1) { - if (tokens[i].upper === "INDEX") { - indexIndex = i; - break; - } - } - if (indexIndex === -1) return null; - - const parens = findTopLevelParen(statement, tokens[indexIndex].end); - if (!parens) return null; - - let headerEnd = parens.close + 1; - - const afterParens = statement.slice(headerEnd).trim(); - const afterTokens = scanTokens(afterParens); - if (afterTokens.length > 0 && afterTokens[0].upper === "INCLUDE") { - const includeParens = findTopLevelParen(afterParens, afterTokens[0].end); - if (includeParens) { - headerEnd = - headerEnd + afterParens.slice(0, includeParens.close + 1).length; - } - } - - const restText = statement.slice(headerEnd).trim(); - if (restText.length === 0) return null; - - const restTokens = scanTokens(restText); - const positions = findClausePositions(restTokens, INDEX_CLAUSE_KEYWORDS); - if (positions.length === 0) return null; - - const header = statement.slice(0, headerEnd).trim(); - const clauses = sliceClauses(restText, positions); - return joinHeaderAndClauses(header, clauses, options); -} - -export function formatAlterTable( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "ALTER" || tokens[1].upper !== "TABLE") { - return null; - } - - let cursor = 2; - if ( - tokens[cursor]?.upper === "IF" && - tokens[cursor + 1]?.upper === "EXISTS" - ) { - cursor += 2; - } - if (tokens[cursor]?.upper === "ONLY") { - cursor += 1; - } - - if (cursor >= tokens.length) return null; - - cursor = skipQualifiedName(statement, tokens, cursor); - if (cursor >= tokens.length) return null; - - const headerEnd = tokens[cursor].start; - const header = statement.slice(0, headerEnd).trim(); - const action = statement.slice(headerEnd).trim(); - - if (action.length === 0) return null; - - const indent = indentString(options.indent); - return `${header}\n${indent}${action}`; -} - -export function formatCreateAggregate( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "AGGREGATE") { - return null; - } - - // Find the argument list parentheses first (e.g. array_cat_agg(anycompatiblearray)) - const argParens = findTopLevelParen(statement, tokens[1].end); - if (!argParens) return null; - - // Find the options parentheses after the argument list - const optParens = findTopLevelParen(statement, argParens.close + 1); - if (!optParens) return null; - - const { open, close } = optParens; - const header = statement.slice(0, open).trim(); - const content = statement.slice(open + 1, close).trim(); - const suffix = statement.slice(close + 1).trim(); - - const items = splitByCommas(content); - if (items.length === 0) return null; - - const formattedItems = formatMixedItems(items, options); - const lines = [ - `${header} (`, - ...formattedItems, - `)${suffix ? ` ${suffix}` : ""}`, - ]; - return lines.join("\n"); -} - -export function formatCreateLanguage( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE") return null; - - // Find LANGUAGE token (may be preceded by TRUSTED) - let langIndex = -1; - for (let i = 1; i < Math.min(4, tokens.length); i += 1) { - if (tokens[i].upper === "LANGUAGE") { - langIndex = i; - break; - } - } - if (langIndex === -1) return null; - - // Must have a name token after LANGUAGE - const nameToken = tokens[langIndex + 1]; - if (!nameToken) return null; - - const headerEnd = nameToken.end; - const rest = statement.slice(headerEnd).trim(); - const header = statement.slice(0, headerEnd).trim(); - - if (rest.length === 0) return null; - - const restTokens = scanTokens(rest); - const positions = findClausePositions(restTokens, LANGUAGE_CLAUSE_KEYWORDS); - if (positions.length === 0) return null; - - const clauses = sliceClauses(rest, positions); - return joinHeaderAndClauses(header, clauses, options); -} - -export function formatCreateMaterializedView( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 4) return null; - if (tokens[0].upper !== "CREATE") return null; - - // Find MATERIALIZED VIEW sequence - let viewIndex = -1; - for (let i = 1; i < Math.min(5, tokens.length); i += 1) { - if (tokens[i].upper === "MATERIALIZED" && tokens[i + 1]?.upper === "VIEW") { - viewIndex = i + 1; // point to VIEW - break; - } - } - if (viewIndex === -1) return null; - - // Find schema-qualified name after VIEW - let cursor = viewIndex + 1; - if (cursor >= tokens.length) return null; - cursor = skipQualifiedName(statement, tokens, cursor); - - const nameEnd = tokens[cursor - 1].end; - const rest = statement.slice(nameEnd).trim(); - const header = statement.slice(0, nameEnd).trim(); - - if (rest.length === 0) return null; - - // Materialized view has special placeholder handling for protected view bodies - const restTokens = scanTokens(rest); - const clauseStarts: number[] = []; - - for (let i = 0; i < restTokens.length; i += 1) { - if (restTokens[i].depth !== 0) continue; - if (MATVIEW_CLAUSE_KEYWORDS.has(restTokens[i].upper)) { - clauseStarts.push(restTokens[i].start); - } - // Handle placeholder for protected view body - if (restTokens[i].value.startsWith("__PGDELTA_PLACEHOLDER_")) { - clauseStarts.push(restTokens[i].start); - } - } - - if (clauseStarts.length === 0) return null; - clauseStarts.sort((a, b) => a - b); - - const clauses = sliceClauses(rest, clauseStarts); - return joinHeaderAndClauses(header, clauses, options); -} - -export function formatCreateSubscription( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "SUBSCRIPTION") { - return null; - } - - // Name is the token after SUBSCRIPTION - const nameToken = tokens[2]; - if (!nameToken) return null; - - const headerEnd = nameToken.end; - const rest = statement.slice(headerEnd).trim(); - const header = statement.slice(0, headerEnd).trim(); - - if (rest.length === 0) return null; - - const restTokens = scanTokens(rest); - const positions = findClausePositions( - restTokens, - SUBSCRIPTION_CLAUSE_KEYWORDS, - ); - if (positions.length === 0) return null; - - const clauses = sliceClauses(rest, positions); - return joinHeaderAndClauses(header, clauses, options, expandOptionsClause); -} - -export function formatCreateFDW( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 5) return null; - if (tokens[0].upper !== "CREATE") return null; - - // Must be CREATE FOREIGN DATA WRAPPER (not CREATE SERVER ... FOREIGN DATA WRAPPER) - if ( - tokens[1].upper !== "FOREIGN" || - tokens[2]?.upper !== "DATA" || - tokens[3]?.upper !== "WRAPPER" - ) { - return null; - } - - // Name is the token after WRAPPER - const nameToken = tokens[4]; - if (!nameToken) return null; - - const headerEnd = nameToken.end; - const rest = statement.slice(headerEnd).trim(); - const header = statement.slice(0, headerEnd).trim(); - - if (rest.length === 0) return null; - - const restTokens = scanTokens(rest); - const positions = findClausePositions(restTokens, FDW_CLAUSE_KEYWORDS); - if (positions.length === 0) return null; - - const clauses = sliceClauses(rest, positions); - return joinHeaderAndClauses(header, clauses, options, expandOptionsClause); -} - -export function formatCreateServer( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "CREATE" || tokens[1].upper !== "SERVER") { - return null; - } - - // Name is the token after SERVER - const nameToken = tokens[2]; - if (!nameToken) return null; - - const headerEnd = nameToken.end; - const rest = statement.slice(headerEnd).trim(); - const header = statement.slice(0, headerEnd).trim(); - - if (rest.length === 0) return null; - - // Server has a multi-keyword clause (FOREIGN DATA WRAPPER) requiring custom scan - const restTokens = scanTokens(rest); - const clauseStarts: number[] = []; - - for (let i = 0; i < restTokens.length; i += 1) { - if (restTokens[i].depth !== 0) continue; - const upper = restTokens[i].upper; - if (upper === "TYPE" || upper === "VERSION" || upper === "OPTIONS") { - clauseStarts.push(restTokens[i].start); - continue; - } - // Handle FOREIGN DATA WRAPPER as a clause start - if ( - upper === "FOREIGN" && - restTokens[i + 1]?.upper === "DATA" && - restTokens[i + 2]?.upper === "WRAPPER" - ) { - clauseStarts.push(restTokens[i].start); - } - } - - if (clauseStarts.length === 0) return null; - clauseStarts.sort((a, b) => a - b); - - const clauses = sliceClauses(rest, clauseStarts); - return joinHeaderAndClauses(header, clauses, options, expandOptionsClause); -} - -export function formatAlterGeneric( - statement: string, - tokens: Token[], - options: NormalizedOptions, -): string | null { - if (tokens.length < 3) return null; - if (tokens[0].upper !== "ALTER") return null; - - // Already handled by formatAlterTable - if (tokens[1].upper === "TABLE") return null; - - // Map of ALTER types to the number of type-keyword tokens - // e.g. ALTER DOMAIN = 1, ALTER FOREIGN DATA WRAPPER = 3, ALTER MATERIALIZED VIEW = 2 - let typeTokenCount = 0; - const t1 = tokens[1]?.upper; - - if (t1 === "DOMAIN" || t1 === "SUBSCRIPTION" || t1 === "SERVER") { - typeTokenCount = 1; - } else if (t1 === "MATERIALIZED" && tokens[2]?.upper === "VIEW") { - typeTokenCount = 2; - } else if (t1 === "FOREIGN") { - if (tokens[2]?.upper === "TABLE") { - typeTokenCount = 2; - } else if (tokens[2]?.upper === "DATA" && tokens[3]?.upper === "WRAPPER") { - typeTokenCount = 3; - } else { - return null; - } - } else if (t1 === "EVENT" && tokens[2]?.upper === "TRIGGER") { - typeTokenCount = 2; - } else { - return null; - } - - // cursor now points to the first token after the type keywords - let cursor = 1 + typeTokenCount; - - // Skip IF EXISTS - if ( - tokens[cursor]?.upper === "IF" && - tokens[cursor + 1]?.upper === "EXISTS" - ) { - cursor += 2; - } - - if (cursor >= tokens.length) return null; - - // Skip the name (may be schema-qualified) - cursor = skipQualifiedName(statement, tokens, cursor); - if (cursor >= tokens.length) return null; - - const headerEnd = tokens[cursor].start; - const header = statement.slice(0, headerEnd).trim(); - const action = statement.slice(headerEnd).trim(); - - if (action.length === 0) return null; - - const indent = indentString(options.indent); - const expandedLines = expandOptionsClause(action, indent, options); - return [header, ...expandedLines].join("\n"); -} - -/** - * If a clause contains a parenthesized options list (e.g. OPTIONS(...), WITH(...), SET(...)) - * and it has multiple comma-separated items, expand them one per line. - * Returns an array of properly indented lines that should be pushed directly into the output. - * - * Also used as a `clauseTransform` callback for `joinHeaderAndClauses`. - */ -function expandOptionsClause( - clause: string, - baseIndent: string, - options: NormalizedOptions, -): string[] { - const clauseTokens = scanTokens(clause); - if (clauseTokens.length === 0) return [`${baseIndent}${clause}`]; - - const firstUpper = clauseTokens[0].upper; - if (!EXPANDABLE_KEYWORDS.has(firstUpper)) { - return [`${baseIndent}${clause}`]; - } - - const parens = findTopLevelParen(clause, clauseTokens[0].end); - if (!parens) return [`${baseIndent}${clause}`]; - - const { open, close } = parens; - const content = clause.slice(open + 1, close).trim(); - const suffix = clause.slice(close + 1).trim(); - const keyword = clause.slice(0, open).trim(); - - const items = splitByCommas(content); - if (items.length <= 1) return [`${baseIndent}${clause}`]; - - const innerIndent = `${baseIndent}${indentString(options.indent)}`; - const formattedItems = formatMixedItems(items, options, innerIndent); - return [ - `${baseIndent}${keyword} (`, - ...formattedItems, - `${baseIndent})${suffix ? ` ${suffix}` : ""}`, - ]; -} - -export function formatGeneric( - statement: string, - _tokens: Token[], - _options: NormalizedOptions, -): string { - return statement.trim(); -} diff --git a/packages/pg-delta/src/core/plan/sql-format/index.ts b/packages/pg-delta/src/core/plan/sql-format/index.ts deleted file mode 100644 index 517cc30f0..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/index.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { DEFAULT_OPTIONS } from "./constants.ts"; -import { splitLeadingComments, splitSqlStatements } from "./format-utils.ts"; -import { - formatAlterGeneric, - formatAlterTable, - formatCreateAggregate, - formatCreateCollation, - formatCreateCompositeType, - formatCreateDomain, - formatCreateEnum, - formatCreateFDW, - formatCreateFunction, - formatCreateIndex, - formatCreateLanguage, - formatCreateMaterializedView, - formatCreatePolicy, - formatCreateRange, - formatCreateServer, - formatCreateSubscription, - formatCreateTable, - formatCreateTrigger, - formatGeneric, -} from "./formatters.ts"; -import { applyKeywordCase } from "./keyword-case.ts"; -import { protectSegments, restorePlaceholders } from "./protect.ts"; -import { scanTokens } from "./tokenizer.ts"; -import type { NormalizedOptions, SqlFormatOptions } from "./types.ts"; -import { wrapStatement } from "./wrap.ts"; - -export function formatSqlStatements( - statements: string[], - options: SqlFormatOptions = {}, -): string[] { - const resolved = normalizeOptions(options); - const flattened = flattenStatements(statements); - return flattened - .map((statement) => formatStatement(statement, resolved)) - .filter((statement) => statement.length > 0); -} - -function normalizeOptions(options: SqlFormatOptions): NormalizedOptions { - const indent = - typeof options.indent === "number" && Number.isFinite(options.indent) - ? Math.max(0, Math.floor(options.indent)) - : DEFAULT_OPTIONS.indent; - const maxWidth = - typeof options.maxWidth === "number" && Number.isFinite(options.maxWidth) - ? Math.max(20, Math.floor(options.maxWidth)) - : DEFAULT_OPTIONS.maxWidth; - const keywordCase = - options.keywordCase === "upper" || - options.keywordCase === "lower" || - options.keywordCase === "preserve" - ? options.keywordCase - : DEFAULT_OPTIONS.keywordCase; - const commaStyle = - options.commaStyle === "leading" || options.commaStyle === "trailing" - ? options.commaStyle - : DEFAULT_OPTIONS.commaStyle; - - return { - keywordCase, - indent, - maxWidth, - commaStyle, - alignColumns: - typeof options.alignColumns === "boolean" - ? options.alignColumns - : DEFAULT_OPTIONS.alignColumns, - alignKeyValues: - typeof options.alignKeyValues === "boolean" - ? options.alignKeyValues - : DEFAULT_OPTIONS.alignKeyValues, - preserveRoutineBodies: - typeof options.preserveRoutineBodies === "boolean" - ? options.preserveRoutineBodies - : DEFAULT_OPTIONS.preserveRoutineBodies, - preserveViewBodies: - typeof options.preserveViewBodies === "boolean" - ? options.preserveViewBodies - : DEFAULT_OPTIONS.preserveViewBodies, - preserveRuleBodies: - typeof options.preserveRuleBodies === "boolean" - ? options.preserveRuleBodies - : DEFAULT_OPTIONS.preserveRuleBodies, - }; -} - -function flattenStatements(statements: string[]): string[] { - const output: string[] = []; - for (const statement of statements) { - for (const split of splitSqlStatements(statement)) { - if (split.trim().length > 0) { - output.push(split); - } - } - } - return output; -} - -function formatStatement( - statement: string, - options: NormalizedOptions, -): string { - const { commentLines, body } = splitLeadingComments(statement); - if (body.trim().length === 0) { - return commentLines.join("\n"); - } - - const protectedSegments = protectSegments(body, options); - const tokens = scanTokens(protectedSegments.text); - let formatted = - formatCreateDomain(protectedSegments.text, tokens, options) ?? - formatCreateEnum(protectedSegments.text, tokens, options) ?? - formatCreateCompositeType(protectedSegments.text, tokens, options) ?? - formatCreateTable(protectedSegments.text, tokens, options) ?? - formatCreateRange(protectedSegments.text, tokens, options) ?? - formatCreateCollation(protectedSegments.text, tokens, options) ?? - formatCreateFunction(protectedSegments.text, tokens, options) ?? - formatCreatePolicy(protectedSegments.text, tokens, options) ?? - formatCreateTrigger(protectedSegments.text, tokens, options) ?? - formatCreateIndex(protectedSegments.text, tokens, options) ?? - formatCreateAggregate(protectedSegments.text, tokens, options) ?? - formatCreateLanguage(protectedSegments.text, tokens, options) ?? - formatCreateMaterializedView(protectedSegments.text, tokens, options) ?? - formatCreateSubscription(protectedSegments.text, tokens, options) ?? - formatCreateFDW(protectedSegments.text, tokens, options) ?? - formatCreateServer(protectedSegments.text, tokens, options) ?? - formatAlterTable(protectedSegments.text, tokens, options) ?? - formatAlterGeneric(protectedSegments.text, tokens, options) ?? - formatGeneric(protectedSegments.text, tokens, options); - - if (!protectedSegments.skipCasing && options.keywordCase !== "preserve") { - formatted = applyKeywordCase(formatted, options); - } - - formatted = wrapStatement( - formatted, - options, - protectedSegments.noWrapPlaceholders, - ); - formatted = restorePlaceholders(formatted, protectedSegments.placeholders); - - if (commentLines.length > 0) { - return [...commentLines, formatted].join("\n"); - } - - return formatted; -} diff --git a/packages/pg-delta/src/core/plan/sql-format/tokenizer.ts b/packages/pg-delta/src/core/plan/sql-format/tokenizer.ts deleted file mode 100644 index de58644ca..000000000 --- a/packages/pg-delta/src/core/plan/sql-format/tokenizer.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { isWordChar, walkSql } from "./sql-scanner.ts"; -import type { Token } from "./types.ts"; - -export function scanTokens(statement: string): Token[] { - const tokens: Token[] = []; - let skipUntil = -1; - - walkSql( - statement, - (index, char, depth) => { - if (index < skipUntil) return true; - if (char === "(" || char === ")") return true; - if (isWordChar(char)) { - let end = index + 1; - while (end < statement.length && isWordChar(statement[end])) { - end += 1; - } - const value = statement.slice(index, end); - tokens.push({ - value, - upper: value.toUpperCase(), - start: index, - end, - depth, - }); - skipUntil = end; - } - return true; - }, - { - trackDepth: true, - // Double-quoted identifiers (e.g. `"my-trigger"`) are atomic tokens too. - // Without this, the walker skips their interior entirely and positional - // token logic (e.g. "the name follows the TRIGGER keyword") lands on the - // wrong token. The value keeps the surrounding quotes; a quoted - // identifier never matches a keyword, which is correct. - onQuotedIdentifier: (start, end, depth) => { - const value = statement.slice(start, end); - tokens.push({ - value, - upper: value.toUpperCase(), - start, - end, - depth, - }); - skipUntil = end; - return true; - }, - }, - ); - - return tokens; -} - -export function findTopLevelParen( - statement: string, - startIndex: number, -): { open: number; close: number } | null { - let result: { open: number; close: number } | null = null; - let openIndex: number | null = null; - - walkSql( - statement, - (index, char, depth) => { - if (char === "(") { - if (depth === 0) { - openIndex = index; - } - return true; - } - if (char === ")") { - if (depth === 0 && openIndex !== null) { - result = { open: openIndex, close: index }; - return false; - } - } - return true; - }, - { trackDepth: true, startIndex }, - ); - - return result; -} - -/** - * Collect the starting positions of top-level clause keywords in a token list. - * Returns a sorted array of character offsets (Token.start values). - */ -export function findClausePositions( - tokens: Token[], - keywords: Set, -): number[] { - const positions: number[] = []; - for (let i = 0; i < tokens.length; i += 1) { - if (tokens[i].depth !== 0) continue; - if (keywords.has(tokens[i].upper)) { - positions.push(tokens[i].start); - } - } - positions.sort((a, b) => a - b); - return positions; -} - -/** - * Advance a cursor past a possibly schema-qualified name (e.g. `public.my_table`). - * Returns the new cursor position (pointing to the first token after the name). - */ -export function skipQualifiedName( - statement: string, - tokens: Token[], - cursor: number, -): number { - let c = cursor + 1; - while ( - c < tokens.length && - tokens[c].start === tokens[c - 1].end + 1 && - statement[tokens[c - 1].end] === "." - ) { - c += 1; - } - return c; -} - -/** - * Slice a text into clause strings given sorted clause-start positions. - * Returns trimmed, non-empty clause strings. - */ -export function sliceClauses(text: string, positions: number[]): string[] { - const clauses: string[] = []; - for (let i = 0; i < positions.length; i += 1) { - const start = positions[i]; - const end = positions[i + 1] ?? text.length; - const clause = text.slice(start, end).trim(); - if (clause.length > 0) clauses.push(clause); - } - return clauses; -} - -export function splitByCommas(content: string): string[] { - const items: string[] = []; - let buffer = ""; - - walkSql( - content, - (_index, char, depth) => { - if (char === "(" || char === ")") { - buffer += char; - return true; - } - if (char === "," && depth === 0) { - items.push(buffer); - buffer = ""; - return true; - } - buffer += char; - return true; - }, - { - trackDepth: true, - onSkipped: (chunk) => { - buffer += chunk; - }, - }, - ); - - if (buffer.length > 0) { - items.push(buffer); - } - - return items.map((item) => item.trim()).filter((item) => item.length > 0); -} diff --git a/packages/pg-delta/src/core/plan/ssl-config.ts b/packages/pg-delta/src/core/plan/ssl-config.ts deleted file mode 100644 index 4aac1fb18..000000000 --- a/packages/pg-delta/src/core/plan/ssl-config.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * SSL configuration parsing for PostgreSQL connection URLs. - * - * Supports sslmode and certificate paths (URL params or env). Used by plan, - * apply, and catalog-export when connecting to source/target databases. - */ - -import { readFile } from "node:fs/promises"; - -/** Parsed SSL options for the pg client plus URL with SSL params stripped (internal). */ -type SslConfig = { - ssl?: - | boolean - | { - rejectUnauthorized: boolean; - ca?: string; - cert?: string; - key?: string; - /** - * Custom server identity check function. - * Used to skip hostname verification for verify-ca mode. - * Returns undefined to indicate success (no error). - */ - checkServerIdentity?: () => undefined; - }; - cleanedUrl: string; -}; - -/** - * Parse SSL configuration from a PostgreSQL connection URL. - * Supports sslmode (require, verify-ca, verify-full, prefer, disable). - * Certificates can be provided via: - * - Query string parameters (file paths): sslrootcert, sslcert, sslkey (preferred) - * - Environment variables (content): PGDELTA_SOURCE_SSLROOTCERT/SSLCERT/SSLKEY or PGDELTA_TARGET_SSLROOTCERT/SSLCERT/SSLKEY - * Returns SSL options for the postgres.js library and a cleaned URL without SSL-related query parameters. - */ -export async function parseSslConfig( - url: string, - connectionType: "source" | "target", -): Promise { - const urlObj = new URL(url); - const sslmode = urlObj.searchParams.get("sslmode"); - const sslrootcert = urlObj.searchParams.get("sslrootcert"); - const sslcert = urlObj.searchParams.get("sslcert"); - const sslkey = urlObj.searchParams.get("sslkey"); - - // Remove SSL-related query parameters since we parse them ourselves - urlObj.searchParams.delete("sslmode"); - urlObj.searchParams.delete("sslrootcert"); - urlObj.searchParams.delete("sslcert"); - urlObj.searchParams.delete("sslkey"); - const cleanedUrl = urlObj.toString(); - - // Handle different SSL modes - if (sslmode === "disable") { - return { cleanedUrl, ssl: false }; - } - - if ( - sslmode === "require" || - sslmode === "prefer" || - sslmode === "verify-ca" || - sslmode === "verify-full" - ) { - // Helper function to get certificate value: query param (file path) takes precedence over env var (content) - const getCertValue = async ( - queryParam: string | null, - envVarName: string, - ): Promise => { - // Prefer query parameter (file path) - if (queryParam) { - try { - return await readFile(queryParam, "utf-8"); - } catch (error) { - throw new Error( - `Failed to read certificate file '${queryParam}': ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - // Fallback to environment variable (content) - const envValue = process.env[envVarName]; - return envValue || undefined; - }; - - const hasExplicitVerification = - sslmode === "verify-ca" || sslmode === "verify-full"; - - // Get CA certificate value. - // - verify-ca/verify-full: check query param first, then env var - // - require/prefer: only check query param (libpq backward compatibility - // requires an explicit root CA *file*, not a global env var) - const caEnvVar = - connectionType === "source" - ? "PGDELTA_SOURCE_SSLROOTCERT" - : "PGDELTA_TARGET_SSLROOTCERT"; - let caValue: string | undefined; - if (sslrootcert) { - // Explicit file path in query param — always honour it - caValue = await getCertValue(sslrootcert, caEnvVar); - } else if (hasExplicitVerification) { - // verify-ca / verify-full without file path — fall back to env var - caValue = await getCertValue(null, caEnvVar); - } - // require/prefer without sslrootcert: no CA cert, no verification - - // Determine if we should verify the CA chain - // From PostgreSQL docs: "if a root CA file exists, the behavior of sslmode=require - // will be the same as that of verify-ca" - const hasLibpqCompatibility = - (sslmode === "require" || sslmode === "prefer") && caValue !== undefined; - const shouldVerifyCa = hasExplicitVerification || hasLibpqCompatibility; - - // Determine if we should verify hostname - // - verify-full: verify both CA and hostname - // - verify-ca: verify CA only (skip hostname) - // - require/prefer with CA (libpq compat): behaves like verify-ca (skip hostname) - const shouldVerifyHostname = sslmode === "verify-full"; - - const ssl: { - rejectUnauthorized: boolean; - ca?: string; - cert?: string; - key?: string; - checkServerIdentity?: () => undefined; - } = { - rejectUnauthorized: shouldVerifyCa, - }; - - // Add CA certificate if verifying - if (shouldVerifyCa && caValue) { - ssl.ca = caValue; - } - - // For verify-ca and libpq compatibility mode: skip hostname verification - // This matches PostgreSQL semantics where verify-ca only checks the CA chain - if (shouldVerifyCa && !shouldVerifyHostname) { - ssl.checkServerIdentity = () => undefined; - } - - // Get client certificate (optional, for mutual TLS) - const certEnvVar = - connectionType === "source" - ? "PGDELTA_SOURCE_SSLCERT" - : "PGDELTA_TARGET_SSLCERT"; - const certValue = await getCertValue(sslcert, certEnvVar); - if (certValue) { - ssl.cert = certValue; - } - - // Get client key (optional, for mutual TLS, required if cert is provided) - const keyEnvVar = - connectionType === "source" - ? "PGDELTA_SOURCE_SSLKEY" - : "PGDELTA_TARGET_SSLKEY"; - const keyValue = await getCertValue(sslkey, keyEnvVar); - if (keyValue) { - ssl.key = keyValue; - } - - // Warn if cert is provided without key (or vice versa) - if ((ssl.cert && !ssl.key) || (!ssl.cert && ssl.key)) { - throw new Error( - "Both client certificate and key must be provided together for mutual TLS", - ); - } - - return { ssl, cleanedUrl }; - } - - // No sslmode specified or invalid value - explicitly disable SSL - return { cleanedUrl, ssl: false }; -} diff --git a/packages/pg-delta/src/core/plan/statements.ts b/packages/pg-delta/src/core/plan/statements.ts deleted file mode 100644 index 94b0d2185..000000000 --- a/packages/pg-delta/src/core/plan/statements.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * SQL script formatting utilities. - */ - -import { formatSqlStatements, type SqlFormatOptions } from "./sql-format.ts"; - -const STATEMENT_DELIMITER = ";\n\n"; - -/** - * Format an array of SQL statements into a single script string. - * Statements are joined with double newlines and the script ends with a semicolon. - */ -export function formatSqlScript( - statements: string[], - options?: SqlFormatOptions, -): string { - if (statements.length === 0) return ""; - const formatted = options - ? formatSqlStatements(statements, options) - : statements; - return `${formatted.join(STATEMENT_DELIMITER)};`; -} diff --git a/packages/pg-delta/src/core/plan/types.ts b/packages/pg-delta/src/core/plan/types.ts deleted file mode 100644 index 7ed0bacfc..000000000 --- a/packages/pg-delta/src/core/plan/types.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Type definitions for the Plan module. - */ - -import z from "zod"; -import type { Change } from "../change.types.ts"; -import type { Integration } from "../integrations/integration.types.ts"; -import type { CommitBoundaryReason } from "../objects/base.change.ts"; - -// ============================================================================ -// Core Types -// ============================================================================ - -export type PlanRisk = - | { level: "safe" } - | { level: "data_loss"; statements: string[] }; - -export type TransactionMode = "transactional" | "none"; - -/** - * Why a migration unit starts a new execution boundary. - * - * - `"default"` — the first (or only) unit of the plan. - * - `"non_transactional"` — the unit's statement cannot run inside a - * transaction block (see `BaseChange.nonTransactional`). - * - commit-visibility kinds (see `BaseChange.commitBoundary`) — the previous - * unit produced effects that are only usable after COMMIT. - */ -export type ExecutionBoundaryReason = - | "default" - | "non_transactional" - | CommitBoundaryReason; - -/** - * An ordered group of SQL statements that share one execution context. - * - * Transactional units are applied inside an explicit BEGIN/COMMIT; - * non-transactional units run their single statement without a wrapper. - */ -export interface MigrationUnit { - transactionMode: TransactionMode; - reason: ExecutionBoundaryReason; - statements: string[]; -} - -/** - * All supported object types in the system. - * Derived from the Change union type's objectType discriminant. - */ -type ObjectType = Change["objectType"]; - -/** - * Parent types for child objects. - */ -export type ParentType = Extract< - ObjectType, - "table" | "view" | "materialized_view" | "foreign_table" ->; - -/** - * A change entry storing both serialized and original change for instanceof checks. - */ -export interface ChangeEntry { - original: Change; -} - -/** - * A group of changes organized by operation. - */ -export interface ChangeGroup { - create: ChangeEntry[]; - alter: ChangeEntry[]; - drop: ChangeEntry[]; -} - -/** - * Children objects of a table/view (indexes, triggers, policies, etc.) - */ -export interface TableChildren { - changes: ChangeGroup; - columns: ChangeGroup; - indexes: ChangeGroup; - triggers: ChangeGroup; - rules: ChangeGroup; - policies: ChangeGroup; - /** Partition tables (only for partitioned tables) */ - partitions: Record; -} - -/** - * Children objects of a materialized view - */ -export interface MaterializedViewChildren { - changes: ChangeGroup; - indexes: ChangeGroup; -} - -/** - * Type grouping within a schema - */ -export interface TypeGroup { - enums: ChangeGroup; - composites: ChangeGroup; - ranges: ChangeGroup; - domains: ChangeGroup; -} - -/** - * Schema-level grouping of objects - */ -export interface SchemaGroup { - changes: ChangeGroup; - tables: Record; - views: Record; - materializedViews: Record; - functions: ChangeGroup; - procedures: ChangeGroup; - aggregates: ChangeGroup; - sequences: ChangeGroup; - types: TypeGroup; - collations: ChangeGroup; - foreignTables: Record; -} - -/** - * Cluster-wide objects (no schema) - */ -export interface ClusterGroup { - roles: ChangeGroup; - extensions: ChangeGroup; - eventTriggers: ChangeGroup; - publications: ChangeGroup; - subscriptions: ChangeGroup; - foreignDataWrappers: ChangeGroup; - servers: ChangeGroup; - userMappings: ChangeGroup; -} - -/** - * Fully hierarchical plan structure for tree display. - */ -export interface HierarchicalPlan { - cluster: ClusterGroup; - schemas: Record; -} - -/** - * Plan schema for serialization/deserialization. - */ -export const PlanSchema = z.object({ - version: z.number(), - toolVersion: z.string().optional(), - source: z.object({ - fingerprint: z.string(), - }), - target: z.object({ - fingerprint: z.string(), - }), - units: z - .array( - z.object({ - transactionMode: z.enum(["transactional", "none"]), - reason: z.enum([ - "default", - "non_transactional", - "enum_value_visibility", - ]), - statements: z.array(z.string()), - }), - ) - .optional(), - /** Session-level statements (SET ROLE, ...) applied once before the units. */ - sessionStatements: z.array(z.string()).optional(), - /** - * Legacy v1 plans only: the flat statement list. Converted to units by - * `normalizePlan`; never emitted by `createPlan`/`serializePlan`. - */ - statements: z.array(z.string()).optional(), - role: z.string().optional(), - filter: z.any().optional(), // FilterDSL - complex recursive type, validated at compile time - serialize: z.any().optional(), // SerializeDSL - complex recursive type, validated at compile time - risk: z - .discriminatedUnion("level", [ - z.object({ - level: z.literal("safe"), - }), - z.object({ - level: z.literal("data_loss"), - statements: z.array(z.string()), - }), - ]) - .optional(), -}); - -export type SerializedPlan = z.infer; - -/** - * A migration plan containing all changes to transform one database schema - * into another, as an ordered list of execution-aware migration units. - * - * `units` and `sessionStatements` are the single source of truth: render via - * `renderPlanSql`/`renderPlanFiles`, or flatten via `flattenPlanStatements`. - */ -export type Plan = Omit< - SerializedPlan, - "units" | "statements" | "sessionStatements" -> & { - units: MigrationUnit[]; - sessionStatements: string[]; -}; - -/** - * Options for creating a plan. - */ -export interface CreatePlanOptions { - /** Filter - either FilterDSL (stored in plan) or ChangeFilter function (not stored) */ - filter?: Integration["filter"]; - /** Serialize - either SerializeDSL (stored in plan) or ChangeSerializer function (not stored) */ - serialize?: Integration["serialize"]; - /** Role to use when executing the migration (SET ROLE will be added to statements) */ - role?: string; - /** - * When true, don't subtract privileges covered by ALTER DEFAULT PRIVILEGES - * from explicit GRANTs during diffing. Use this for declarative export where - * the output must be self-contained and not rely on statement execution order. - */ - skipDefaultPrivilegeSubtraction?: boolean; - /** - * Number of retry attempts for catalog extractors when `pg_get_*def()` - * returns NULL for at least one row (a transient race with concurrent DDL). - * Total attempts is `extractRetries + 1`. When undefined, the value is read - * from the `PGDELTA_EXTRACT_RETRIES` environment variable, falling back to - * a default of 1 (i.e. the first attempt plus one retry, 2 attempts total). - */ - extractRetries?: number; -} diff --git a/packages/pg-delta/src/core/post-diff-normalization.test.ts b/packages/pg-delta/src/core/post-diff-normalization.test.ts deleted file mode 100644 index 906c084fa..000000000 --- a/packages/pg-delta/src/core/post-diff-normalization.test.ts +++ /dev/null @@ -1,590 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "./change.types.ts"; -import { CreateIndex } from "./objects/index/changes/index.create.ts"; -import { DropIndex } from "./objects/index/changes/index.drop.ts"; -import { Index, type IndexProps } from "./objects/index/index.model.ts"; -import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts"; -import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; -import { - Sequence, - type SequenceProps, -} from "./objects/sequence/sequence.model.ts"; -import { - AlterTableAddConstraint, - AlterTableChangeOwner, - AlterTableDropColumn, - AlterTableDropConstraint, - AlterTableEnableRowLevelSecurity, - AlterTableSetReplicaIdentity, - AlterTableValidateConstraint, -} from "./objects/table/changes/table.alter.ts"; -import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts"; -import { CreateTable } from "./objects/table/changes/table.create.ts"; -import { DropTable } from "./objects/table/changes/table.drop.ts"; -import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts"; -import { Table } from "./objects/table/table.model.ts"; -import { normalizePostDiffChanges } from "./post-diff-normalization.ts"; - -const baseTableProps = { - schema: "public", - persistence: "p" as const, - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - privileges: [], -}; - -function integerColumn(name: string, position: number) { - return { - name, - position, - data_type: "integer" as const, - data_type_str: "integer", - is_custom_type: false as const, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }; -} - -describe("normalizePostDiffChanges", () => { - test("prunes same-table drop-column and drop-constraint ALTERs for replaced tables only", async () => { - const mainChildren = new Table({ - ...baseTableProps, - name: "children", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("parent_ref", 2), - integerColumn("status", 3), - ], - }); - const branchChildren = new Table({ - ...baseTableProps, - name: "children", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("status", 2), - ], - }); - - const droppedColumn = mainChildren.columns.find( - (column) => column.name === "parent_ref", - ); - if (!droppedColumn) throw new Error("test setup: parent_ref missing"); - - const preExistingDropColumn = new AlterTableDropColumn({ - table: mainChildren, - column: droppedColumn, - }); - const preExistingDropConstraint = new AlterTableDropConstraint({ - table: mainChildren, - constraint: { - name: "children_parent_ref_fkey", - constraint_type: "f", - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["parent_ref"], - foreign_key_columns: ["id"], - foreign_key_table: "parents", - foreign_key_schema: "public", - foreign_key_table_is_partition: false, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: "public", - foreign_key_effective_table: "parents", - on_update: "a", - on_delete: "a", - match_type: "s", - check_expression: null, - owner: "postgres", - definition: "FOREIGN KEY (parent_ref) REFERENCES public.parents(id)", - comment: null, - }, - }); - const preExistingChangeOwner = new AlterTableChangeOwner({ - table: branchChildren, - owner: "new_owner", - }); - const preExistingEnableRls = new AlterTableEnableRowLevelSecurity({ - table: branchChildren, - }); - const preExistingReplicaIdentity = new AlterTableSetReplicaIdentity({ - table: branchChildren, - mode: "f", - }); - const preExistingGrant = new GrantTablePrivileges({ - table: branchChildren, - grantee: "reader", - privileges: [{ privilege: "SELECT", grantable: false }], - }); - const changes: Change[] = [ - new DropTable({ table: mainChildren }), - new CreateTable({ table: branchChildren }), - preExistingDropColumn, - preExistingDropConstraint, - preExistingChangeOwner, - preExistingEnableRls, - preExistingReplicaIdentity, - preExistingGrant, - ]; - - const normalized = normalizePostDiffChanges({ - changes, - replacedTableIds: new Set([mainChildren.stableId]), - }); - - expect(normalized.some((change) => change instanceof DropTable)).toBe(true); - expect(normalized.some((change) => change instanceof CreateTable)).toBe( - true, - ); - expect(normalized).not.toContain(preExistingDropColumn); - expect(normalized).not.toContain(preExistingDropConstraint); - expect( - normalized.some((change) => change instanceof AlterTableDropColumn), - ).toBe(false); - expect( - normalized.some((change) => change instanceof AlterTableDropConstraint), - ).toBe(false); - expect(normalized).toContain(preExistingChangeOwner); - expect(normalized).toContain(preExistingEnableRls); - expect(normalized).toContain(preExistingReplicaIdentity); - expect(normalized).toContain(preExistingGrant); - }); - - test("dedupes duplicate constraint Add/Validate/Comment on replaced tables keeping last occurrence", async () => { - const branchChildren = new Table({ - ...baseTableProps, - name: "children", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("parent_ref", 2), - ], - }); - const otherTable = new Table({ - ...baseTableProps, - name: "other", - columns: [{ ...integerColumn("id", 1), not_null: true }], - }); - - const fkConstraint = { - name: "children_parent_ref_fkey", - constraint_type: "f" as const, - deferrable: false, - initially_deferred: false, - validated: false, - is_local: true, - no_inherit: false, - is_temporal: true, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: ["parent_ref"], - foreign_key_columns: ["id"], - foreign_key_table: "parents", - foreign_key_schema: "public", - foreign_key_table_is_partition: false, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: "public", - foreign_key_effective_table: "parents", - on_update: "a" as const, - on_delete: "a" as const, - match_type: "s" as const, - check_expression: null, - owner: "postgres", - definition: - "FOREIGN KEY (parent_ref, PERIOD valid_period) REFERENCES public.parents(id, PERIOD valid_period)", - comment: "fk comment", - }; - const otherConstraint = { - ...fkConstraint, - name: "other_unique", - constraint_type: "u" as const, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - foreign_key_columns: [], - key_columns: ["id"], - definition: "UNIQUE (id)", - }; - - const diffTablesAdd = new AlterTableAddConstraint({ - table: branchChildren, - constraint: fkConstraint, - }); - const diffTablesValidate = new AlterTableValidateConstraint({ - table: branchChildren, - constraint: fkConstraint, - }); - const diffTablesComment = new CreateCommentOnConstraint({ - table: branchChildren, - constraint: fkConstraint, - }); - const expansionAdd = new AlterTableAddConstraint({ - table: branchChildren, - constraint: fkConstraint, - }); - const expansionValidate = new AlterTableValidateConstraint({ - table: branchChildren, - constraint: fkConstraint, - }); - const expansionComment = new CreateCommentOnConstraint({ - table: branchChildren, - constraint: fkConstraint, - }); - const soloOtherTableAdd = new AlterTableAddConstraint({ - table: otherTable, - constraint: otherConstraint, - }); - - const changes: Change[] = [ - new DropTable({ table: branchChildren }), - new CreateTable({ table: branchChildren }), - diffTablesAdd, - diffTablesValidate, - diffTablesComment, - soloOtherTableAdd, - expansionAdd, - expansionValidate, - expansionComment, - ]; - - const normalized = normalizePostDiffChanges({ - changes, - replacedTableIds: new Set([branchChildren.stableId]), - }); - - expect(normalized).not.toContain(diffTablesAdd); - expect(normalized).not.toContain(diffTablesValidate); - expect(normalized).not.toContain(diffTablesComment); - expect(normalized).toContain(expansionAdd); - expect(normalized).toContain(expansionValidate); - expect(normalized).toContain(expansionComment); - expect(normalized).toContain(soloOtherTableAdd); - - expect( - normalized.filter((change) => change instanceof AlterTableAddConstraint), - ).toHaveLength(2); - expect( - normalized.filter( - (change) => change instanceof AlterTableValidateConstraint, - ), - ).toHaveLength(1); - expect( - normalized.filter( - (change) => change instanceof CreateCommentOnConstraint, - ), - ).toHaveLength(1); - }); - - describe("DropSequence pruning on replaced tables", () => { - const baseSequenceProps: SequenceProps = { - schema: "public", - name: "project_link_type_id_seq", - data_type: "integer", - start_value: 1, - minimum_value: 1n, - maximum_value: 2147483647n, - increment: 1, - cycle_option: false, - cache_size: 1, - persistence: "p", - owned_by_schema: "public", - owned_by_table: "project_link_type", - owned_by_column: "id", - comment: null, - privileges: [], - owner: "postgres", - }; - - test("prunes DropSequence when its OWNED BY table is in replacedTableIds", () => { - const replacedTable = new Table({ - ...baseTableProps, - name: "project_link_type", - columns: [{ ...integerColumn("id", 1), not_null: true }], - }); - const ownedSequence = new Sequence(baseSequenceProps); - - const dropSequence = new DropSequence({ sequence: ownedSequence }); - const dropTable = new DropTable({ table: replacedTable }); - const createTable = new CreateTable({ table: replacedTable }); - - const changes: Change[] = [dropSequence, dropTable, createTable]; - - const normalized = normalizePostDiffChanges({ - changes, - replacedTableIds: new Set([replacedTable.stableId]), - }); - - expect(normalized.some((change) => change instanceof DropSequence)).toBe( - false, - ); - expect(normalized).toContain(dropTable); - expect(normalized).toContain(createTable); - }); - - test("keeps DropSequence whose OWNED BY table is not in replacedTableIds", () => { - const survivingTable = new Table({ - ...baseTableProps, - name: "project_link_type", - columns: [{ ...integerColumn("id", 1), not_null: true }], - }); - const ownedSequence = new Sequence(baseSequenceProps); - - const dropSequence = new DropSequence({ sequence: ownedSequence }); - - const normalized = normalizePostDiffChanges({ - changes: [dropSequence], - // Different table is being replaced; the sequence's OWNED BY does - // not match, so DropSequence must survive. - replacedTableIds: new Set([ - `table:${survivingTable.schema}.unrelated_table` as const, - ]), - }); - - expect(normalized).toContain(dropSequence); - }); - - test("keeps DropSequence with no OWNED BY when replacedTableIds is non-empty", () => { - const orphanSequence = new Sequence({ - ...baseSequenceProps, - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - }); - - const dropSequence = new DropSequence({ sequence: orphanSequence }); - - const normalized = normalizePostDiffChanges({ - changes: [dropSequence], - replacedTableIds: new Set(["table:public.project_link_type" as const]), - }); - - expect(normalized).toContain(dropSequence); - }); - - test("keeps unrelated CreateSequence and DropSequence even when its non-owning table is replaced", () => { - const sequenceA = new Sequence(baseSequenceProps); - const sequenceB = new Sequence({ - ...baseSequenceProps, - name: "unrelated_seq", - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - }); - - const dropOwned = new DropSequence({ sequence: sequenceA }); - const createUnrelated = new CreateSequence({ sequence: sequenceB }); - - const replacedTable = new Table({ - ...baseTableProps, - name: "project_link_type", - columns: [{ ...integerColumn("id", 1), not_null: true }], - }); - - const normalized = normalizePostDiffChanges({ - changes: [dropOwned, createUnrelated], - replacedTableIds: new Set([replacedTable.stableId]), - }); - - expect(normalized.some((change) => change instanceof DropSequence)).toBe( - false, - ); - expect(normalized).toContain(createUnrelated); - }); - }); - - describe("restoreReplicaIdentityAfterIndexReplace", () => { - const baseIndexProps: IndexProps = { - schema: "public", - table_name: "replicated", - name: "tenant_idx", - storage_params: [], - statistics_target: [], - index_type: "btree", - tablespace: null, - is_unique: true, - is_primary: false, - is_exclusion: false, - nulls_not_distinct: false, - immediate: true, - is_clustered: false, - is_replica_identity: true, - key_columns: [], - column_collations: [], - operator_classes: [], - column_options: [], - index_expressions: null, - partial_predicate: null, - table_relkind: "r", - is_owned_by_constraint: false, - is_partitioned_index: false, - is_index_partition: false, - parent_index_name: null, - definition: "CREATE UNIQUE INDEX tenant_idx ON public.replicated (a)", - comment: null, - owner: "postgres", - }; - - function makeBranchTable(replicaIdentityIndex: string | null) { - return new Table({ - ...baseTableProps, - name: "replicated", - replica_identity: replicaIdentityIndex ? "i" : "d", - replica_identity_index: replicaIdentityIndex, - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("a", 2), - ], - }); - } - - test("re-emits ALTER TABLE … REPLICA IDENTITY USING INDEX after a DropIndex+CreateIndex pair", () => { - const branchTable = makeBranchTable("tenant_idx"); - const oldIndex = new Index(baseIndexProps); - const newIndex = new Index({ - ...baseIndexProps, - definition: - "CREATE UNIQUE INDEX tenant_idx ON public.replicated (a, id)", - }); - - const changes: Change[] = [ - new DropIndex({ index: oldIndex }), - new CreateIndex({ index: newIndex, indexableObject: branchTable }), - ]; - - const normalized = normalizePostDiffChanges({ - changes, - branchTables: { [branchTable.stableId]: branchTable }, - }); - - expect(normalized.map((c) => c.constructor.name)).toEqual([ - "DropIndex", - "CreateIndex", - "AlterTableSetReplicaIdentity", - ]); - - const inserted = normalized[2] as AlterTableSetReplicaIdentity; - expect(inserted.mode).toBe("i"); - expect(inserted.indexName).toBe("tenant_idx"); - expect(inserted.requires).toEqual([ - "table:public.replicated", - "index:public.replicated.tenant_idx", - ]); - }); - - test("does not double-emit when diffTables already produced an AlterTableSetReplicaIdentity for the same table", () => { - const branchTable = makeBranchTable("tenant_idx"); - const oldIndex = new Index(baseIndexProps); - const newIndex = new Index({ - ...baseIndexProps, - definition: - "CREATE UNIQUE INDEX tenant_idx ON public.replicated (a, id)", - }); - - const changes: Change[] = [ - new DropIndex({ index: oldIndex }), - new CreateIndex({ index: newIndex, indexableObject: branchTable }), - new AlterTableSetReplicaIdentity({ - table: branchTable, - mode: "i", - indexName: "tenant_idx", - }), - ]; - - const normalized = normalizePostDiffChanges({ - changes, - branchTables: { [branchTable.stableId]: branchTable }, - }); - - expect( - normalized.filter((c) => c instanceof AlterTableSetReplicaIdentity), - ).toHaveLength(1); - }); - - test("ignores DropIndex without a matching CreateIndex (pure drop)", () => { - // Pure drop: the user removed the index entirely. The table.diff path is - // responsible for emitting the corresponding REPLICA IDENTITY DEFAULT. - // The post-diff pass must not synthesize a USING INDEX setter for an - // index that no longer exists. - const branchTable = makeBranchTable(null); - const oldIndex = new Index(baseIndexProps); - - const changes: Change[] = [new DropIndex({ index: oldIndex })]; - - const normalized = normalizePostDiffChanges({ - changes, - branchTables: { [branchTable.stableId]: branchTable }, - }); - - expect( - normalized.filter((c) => c instanceof AlterTableSetReplicaIdentity), - ).toHaveLength(0); - }); - - test("ignores indexes that are not the table's replica identity", () => { - // The table has replica_identity = 'd', so even if some other index is - // being replaced, no setter should be injected. - const branchTable = makeBranchTable(null); - const otherIndex = new Index({ - ...baseIndexProps, - name: "some_other_idx", - is_replica_identity: false, - definition: "CREATE INDEX some_other_idx ON public.replicated (a)", - }); - const newOtherIndex = new Index({ - ...baseIndexProps, - name: "some_other_idx", - is_replica_identity: false, - definition: "CREATE INDEX some_other_idx ON public.replicated (a, id)", - }); - - const changes: Change[] = [ - new DropIndex({ index: otherIndex }), - new CreateIndex({ index: newOtherIndex, indexableObject: branchTable }), - ]; - - const normalized = normalizePostDiffChanges({ - changes, - branchTables: { [branchTable.stableId]: branchTable }, - }); - - expect( - normalized.filter((c) => c instanceof AlterTableSetReplicaIdentity), - ).toHaveLength(0); - }); - }); -}); diff --git a/packages/pg-delta/src/core/post-diff-normalization.ts b/packages/pg-delta/src/core/post-diff-normalization.ts deleted file mode 100644 index 5854041d6..000000000 --- a/packages/pg-delta/src/core/post-diff-normalization.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type { Change } from "./change.types.ts"; -import { CreateIndex } from "./objects/index/changes/index.create.ts"; -import { DropIndex } from "./objects/index/changes/index.drop.ts"; -import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; -import { - AlterTableAddConstraint, - AlterTableDropColumn, - AlterTableDropConstraint, - AlterTableSetReplicaIdentity, - AlterTableValidateConstraint, -} from "./objects/table/changes/table.alter.ts"; -import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts"; -import type { Table } from "./objects/table/table.model.ts"; -import { stableId } from "./objects/utils.ts"; - -function constraintStableId( - table: { schema: string; name: string }, - constraintName: string, -) { - return stableId.constraint(table.schema, table.name, constraintName); -} - -function isSupersededByTableReplacement( - change: Change, - replacedTableIds: ReadonlySet, -): boolean { - if ( - change instanceof AlterTableDropColumn || - change instanceof AlterTableDropConstraint - ) { - return replacedTableIds.has(change.table.stableId); - } - - // `DropSequence(S)` is superseded when S is OWNED BY a column on a table - // that `expandReplaceDependencies` has promoted to `DropTable + CreateTable` - // in the same plan. PostgreSQL cascade-drops the OWNED BY sequence as part - // of the DROP TABLE, so the explicit DROP SEQUENCE is redundant and — more - // importantly — closes an unbreakable `DropSequence ↔ DropTable` cycle in - // the drop phase via the bidirectional pg_depend edges between the - // sequence and its owning column (`column → sequence` for the DEFAULT - // nextval reference, `sequence → column` for the OWNED BY auto-dependency). - // The alpha.15 short-circuit in `diffSequences.dropped` only suppresses - // `DropSequence` when the owning table itself is gone from `branchTables`; - // here the table survives in branch and the replacement is added later by - // the expander, so this whole-plan rewrite has to happen post-diff. - if (change instanceof DropSequence) { - if ( - !change.sequence.owned_by_schema || - !change.sequence.owned_by_table || - !change.sequence.owned_by_column - ) { - return false; - } - const ownedByTableId = stableId.table( - change.sequence.owned_by_schema, - change.sequence.owned_by_table, - ); - return replacedTableIds.has(ownedByTableId); - } - - return false; -} - -/** - * Drop earlier duplicates of `AlterTableAddConstraint` / - * `AlterTableValidateConstraint` / `CreateCommentOnConstraint` targeting - * replaced tables, keeping only the last occurrence of each - * `(changeType, table.stableId, constraint.name)`. - * - * When `expandReplaceDependencies()` promotes a table to a full - * `DropTable + CreateTable` pair, it also emits one - * `AlterTableAddConstraint` (plus optional `VALIDATE CONSTRAINT` / - * `COMMENT ON CONSTRAINT`) per branch constraint. If `diffTables()` already - * emitted the same change for a shape flip or a new constraint on that - * table, the plan ends up with two identical `ALTER TABLE ... ADD - * CONSTRAINT ...` statements and PostgreSQL fails at apply time with - * `constraint "..." for relation "..." already exists`. Because - * `expandReplaceDependencies()` appends its additions after the original - * `diffTables()` output, the last occurrence is the expansion's emission — - * keeping it preserves correctness while removing the duplicate. - */ -function dropReplacedTableDuplicateConstraintChanges( - changes: Change[], - replacedTableIds: ReadonlySet, -): Change[] { - if (replacedTableIds.size === 0) return changes; - - const keyFor = (change: Change): string | null => { - if ( - !(change instanceof AlterTableAddConstraint) && - !(change instanceof AlterTableValidateConstraint) && - !(change instanceof CreateCommentOnConstraint) - ) { - return null; - } - if (!replacedTableIds.has(change.table.stableId)) return null; - const tag = - change instanceof AlterTableAddConstraint - ? "add" - : change instanceof AlterTableValidateConstraint - ? "validate" - : "comment"; - return `${tag}:${constraintStableId(change.table, change.constraint.name)}`; - }; - - const seen = new Set(); - const reversedKept: Change[] = []; - let mutated = false; - - // Walk backwards: the first encounter of each key corresponds to its LAST - // occurrence in the original order. `expandReplaceDependencies()` appends - // additions after the original changes, so "last wins" keeps the - // expansion's emission and drops the earlier diffTables duplicate. - for (let i = changes.length - 1; i >= 0; i--) { - const change = changes[i] as Change; - const key = keyFor(change); - if (key !== null) { - if (seen.has(key)) { - mutated = true; - continue; - } - seen.add(key); - } - reversedKept.push(change); - } - - return mutated ? reversedKept.reverse() : changes; -} - -/** - * Re-emit `ALTER TABLE ... REPLICA IDENTITY USING INDEX ` after any - * `DropIndex(idx) + CreateIndex(idx)` pair where `idx` is the replica-identity - * index of a branch table. - * - * Background: PostgreSQL silently flips a table's `relreplident` to `'d'` - * (DEFAULT) when the index it points to is dropped. `CREATE INDEX` cannot - * restore the marker — only `ALTER TABLE ... REPLICA IDENTITY USING INDEX` - * can. When both main and branch carry `replica_identity = 'i'` pointing at - * the same index name, `diffTables()` emits no replica-identity change of its - * own, so the marker would be lost on apply. - * - * This is a whole-plan interaction: `diffTables()` cannot detect it without - * also looking at index changes. Per the "whole-plan interactions belong in - * post-diff normalization" rule in the package CLAUDE.md, the restoration - * lives here. - * - * Insertion is idempotent: if `diffTables()` already emitted the same - * `AlterTableSetReplicaIdentity` for this table (e.g. when the user is also - * switching the replica-identity index name in the same migration), no - * duplicate is added. - */ -function restoreReplicaIdentityAfterIndexReplace( - changes: Change[], - branchTables: Record, -): Change[] { - // Build the index-stable-id → owning-table map from branch state. Only - // tables in 'i' mode contribute, and only those whose configured index name - // is non-null (the extractor returns null for any other mode). - const replicaIdentityIndexToTable = new Map(); - for (const table of Object.values(branchTables)) { - if (table.replica_identity !== "i" || !table.replica_identity_index) { - continue; - } - const indexId = stableId.index( - table.schema, - table.name, - table.replica_identity_index, - ); - replicaIdentityIndexToTable.set(indexId, table); - } - if (replicaIdentityIndexToTable.size === 0) return changes; - - // Find the indexes that are both dropped AND created in this plan. A pure - // drop or a pure create is handled by `diffTables()` directly (the table's - // replica_identity / replica_identity_index fields will have changed). The - // hole is specifically the drop+create pair that recreates the same name. - const droppedIndexIds = new Set(); - const createdIndexIds = new Set(); - for (const change of changes) { - if (change instanceof DropIndex) { - droppedIndexIds.add(change.index.stableId); - } else if (change instanceof CreateIndex) { - createdIndexIds.add(change.index.stableId); - } - } - const replacedIndexIds = new Set(); - for (const id of droppedIndexIds) { - if (createdIndexIds.has(id) && replicaIdentityIndexToTable.has(id)) { - replacedIndexIds.add(id); - } - } - if (replacedIndexIds.size === 0) return changes; - - // Skip tables for which `diffTables()` already emitted a replica-identity - // setter — re-emitting would produce a redundant ALTER TABLE (harmless on - // apply, but noisy in plan output). - const tablesWithExistingReplicaIdentitySetter = new Set(); - for (const change of changes) { - if (change instanceof AlterTableSetReplicaIdentity) { - tablesWithExistingReplicaIdentitySetter.add(change.table.stableId); - } - } - - // Insert one `AlterTableSetReplicaIdentity` per replaced index, immediately - // after the matching `CreateIndex`. The change's `requires` already names - // both the table and the recreated index, so the topo sort orders it - // correctly relative to the surrounding DDL. - const result: Change[] = []; - for (const change of changes) { - result.push(change); - if ( - !(change instanceof CreateIndex) || - !replacedIndexIds.has(change.index.stableId) - ) { - continue; - } - const table = replicaIdentityIndexToTable.get(change.index.stableId); - if (!table) continue; - if (tablesWithExistingReplicaIdentitySetter.has(table.stableId)) continue; - - result.push( - new AlterTableSetReplicaIdentity({ - table, - mode: "i", - indexName: table.replica_identity_index, - }), - ); - // Mark as emitted so a second replaced index on the same table — if that - // ever arises — doesn't double-emit. - tablesWithExistingReplicaIdentitySetter.add(table.stableId); - } - - return result; -} - -/** - * Apply structural rewrites to the change list that are only obvious once - * every object diff has been collected. This pass does NOT prevent dependency - * cycles — that responsibility now lives in the sort phase, where - * `sortPhaseChanges` invokes `tryBreakCycleByChangeInjection` lazily on cycles - * that edge filtering can't break (FK SCC of dropped tables, - * AlterPublicationDropTables ↔ AlterTableDropColumn, …). - * - * Concretely, this pass: - * - * - Prunes `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` - * changes that are made redundant by an expansion-emitted - * `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase - * would try to drop a column that no longer exists in the freshly - * recreated table. - * - Prunes `DropSequence(S)` changes when `S` is `OWNED BY` a column on a - * table promoted to `DropTable + CreateTable` by the expander. The - * `DROP TABLE` cascade drops the sequence at apply time; emitting an - * explicit `DROP SEQUENCE` in the same drop phase both duplicates the - * cascade and forms an unbreakable `DropSequence ↔ DropTable` cycle on - * the bidirectional pg_depend edges between the sequence and the - * owning column. - * - Dedupes duplicate `AlterTableAddConstraint` / - * `AlterTableValidateConstraint` / `CreateCommentOnConstraint` changes - * produced when `diffTables()` and `expandReplaceDependencies()` both - * emit the same constraint operation for a replaced table. Last write - * wins so the expansion's emission survives. - * - Re-emits `ALTER TABLE ... REPLICA IDENTITY USING INDEX ` after any - * `DropIndex(idx) + CreateIndex(idx)` pair where `idx` is the replica - * identity index of a branch table — Postgres silently clears the marker - * when the underlying index is dropped, and `CREATE INDEX` cannot restore - * it. - * - * Object-local PostgreSQL semantics (for example owned-sequence cascades) - * stay in the corresponding `diff*` function instead of this pass. - */ -export function normalizePostDiffChanges({ - changes, - replacedTableIds = new Set(), - branchTables = {}, -}: { - changes: Change[]; - replacedTableIds?: ReadonlySet; - branchTables?: Record; -}): Change[] { - const restoredChanges = restoreReplicaIdentityAfterIndexReplace( - changes, - branchTables, - ); - - const dedupedChanges = dropReplacedTableDuplicateConstraintChanges( - restoredChanges, - replacedTableIds, - ); - - if (replacedTableIds.size === 0) return dedupedChanges; - - return dedupedChanges.filter( - (change) => !isSupersededByTableReplacement(change, replacedTableIds), - ); -} diff --git a/packages/pg-delta/src/core/postgres-config.test.ts b/packages/pg-delta/src/core/postgres-config.test.ts deleted file mode 100644 index c6bafa009..000000000 --- a/packages/pg-delta/src/core/postgres-config.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { describe, expect, spyOn, test } from "bun:test"; -import { - connectWithRetry, - connectWithTimeout, - isRetryableConnectError, - poolConfigFromUrl, -} from "./postgres-config.ts"; - -function makeError(message: string, code?: string): Error { - const err = new Error(message) as Error & { code?: string }; - if (code !== undefined) err.code = code; - return err; -} - -describe("isRetryableConnectError", () => { - describe("non-retryable", () => { - test("PG auth code 28P01", () => { - expect( - isRetryableConnectError(makeError("password auth failed", "28P01")), - ).toBe(false); - }); - - test("PG auth code 28000", () => { - expect( - isRetryableConnectError( - makeError("invalid authorization specification", "28000"), - ), - ).toBe(false); - }); - - test("ENOTFOUND (permanent DNS failure)", () => { - expect( - isRetryableConnectError( - makeError("getaddrinfo ENOTFOUND bogus.host", "ENOTFOUND"), - ), - ).toBe(false); - }); - - test("TLS error via code (ERR_TLS_*)", () => { - expect( - isRetryableConnectError( - makeError("TLS failure", "ERR_TLS_CERT_ALTNAME_INVALID"), - ), - ).toBe(false); - }); - - test("TLS error via message marker", () => { - expect( - isRetryableConnectError( - makeError("self-signed certificate in certificate chain"), - ), - ).toBe(false); - }); - - test("SSL error via message marker", () => { - expect( - isRetryableConnectError( - makeError("SSL connection has been closed unexpectedly"), - ), - ).toBe(false); - }); - }); - - describe("retryable", () => { - test("ECONNRESET", () => { - expect( - isRetryableConnectError(makeError("socket hang up", "ECONNRESET")), - ).toBe(true); - }); - - test("ECONNREFUSED", () => { - expect( - isRetryableConnectError( - makeError("connect ECONNREFUSED", "ECONNREFUSED"), - ), - ).toBe(true); - }); - - test("ETIMEDOUT", () => { - expect( - isRetryableConnectError(makeError("connect ETIMEDOUT", "ETIMEDOUT")), - ).toBe(true); - }); - - test("EAI_AGAIN (transient DNS)", () => { - expect( - isRetryableConnectError( - makeError("getaddrinfo EAI_AGAIN db.host", "EAI_AGAIN"), - ), - ).toBe(true); - }); - - test("our own eager-connect timeout wrapper", () => { - expect( - isRetryableConnectError( - new Error( - "Connection to target database timed out after 2500ms. " + - "The server may require SSL, use an invalid certificate, or be unreachable.", - ), - ), - ).toBe(true); - }); - - test("unknown generic Error is transient-by-default", () => { - expect(isRetryableConnectError(new Error("something weird"))).toBe(true); - }); - - test("non-Error values are transient-by-default", () => { - expect(isRetryableConnectError("string error")).toBe(true); - expect(isRetryableConnectError({ reason: "x" })).toBe(true); - expect(isRetryableConnectError(undefined)).toBe(true); - }); - }); -}); - -describe("connectWithRetry", () => { - const noSleep = async (_ms: number) => { - // no-op sleep to keep tests fast - }; - - test("resolves on first attempt without retrying", async () => { - let attempts = 0; - const result = await connectWithRetry({ - connect: async () => { - attempts++; - return "ok" as const; - }, - sleep: noSleep, - }); - expect(result).toBe("ok"); - expect(attempts).toBe(1); - }); - - test("retries a retryable error until success", async () => { - let attempts = 0; - const result = await connectWithRetry({ - connect: async () => { - attempts++; - if (attempts < 3) { - throw makeError("connect ECONNREFUSED", "ECONNREFUSED"); - } - return "ok" as const; - }, - maxAttempts: 5, - sleep: noSleep, - }); - expect(result).toBe("ok"); - expect(attempts).toBe(3); - }); - - test("honours maxAttempts and throws the last error", async () => { - let attempts = 0; - const boom = makeError("connect ECONNREFUSED", "ECONNREFUSED"); - await expect( - connectWithRetry({ - connect: async () => { - attempts++; - throw boom; - }, - maxAttempts: 4, - sleep: noSleep, - }), - ).rejects.toBe(boom); - expect(attempts).toBe(4); - }); - - test("stops immediately on a non-retryable error", async () => { - let attempts = 0; - const authError = makeError("password authentication failed", "28P01"); - await expect( - connectWithRetry({ - connect: async () => { - attempts++; - throw authError; - }, - maxAttempts: 10, - sleep: noSleep, - }), - ).rejects.toBe(authError); - expect(attempts).toBe(1); - }); - - test("uses exponential backoff: 250ms, 500ms (3 attempts)", async () => { - const delays: number[] = []; - let attempts = 0; - await expect( - connectWithRetry({ - connect: async () => { - attempts++; - throw makeError("connect ECONNREFUSED", "ECONNREFUSED"); - }, - maxAttempts: 3, - baseBackoffMs: 250, - maxBackoffMs: 1000, - sleep: async (ms) => { - delays.push(ms); - }, - }), - ).rejects.toBeDefined(); - expect(attempts).toBe(3); - // Sleep is invoked once after attempt 1 and once after attempt 2; - // the final failure throws without sleeping. - expect(delays).toEqual([250, 500]); - }); - - test("caps backoff at maxBackoffMs", async () => { - const delays: number[] = []; - await expect( - connectWithRetry({ - connect: async () => { - throw makeError("connect ECONNREFUSED", "ECONNREFUSED"); - }, - maxAttempts: 5, - baseBackoffMs: 250, - maxBackoffMs: 600, - sleep: async (ms) => { - delays.push(ms); - }, - }), - ).rejects.toBeDefined(); - // Uncapped would be [250, 500, 1000, 2000]; the 1000/2000 values are - // both capped to 600. - expect(delays).toEqual([250, 500, 600, 600]); - }); - - test("injected isRetryable overrides the default predicate", async () => { - let attempts = 0; - const err = new Error("custom transient"); - const neverRetry = () => false; - await expect( - connectWithRetry({ - connect: async () => { - attempts++; - throw err; - }, - maxAttempts: 5, - isRetryable: neverRetry, - sleep: noSleep, - }), - ).rejects.toBe(err); - expect(attempts).toBe(1); - }); -}); - -describe("connectWithTimeout", () => { - test("clears the timer when connect resolves before it fires", async () => { - const clearSpy = spyOn(globalThis, "clearTimeout"); - try { - const sentinel = { client: true }; - const result = await connectWithTimeout( - () => Promise.resolve(sentinel), - 60_000, - "source", - ); - expect(result).toBe(sentinel); - expect(clearSpy).toHaveBeenCalled(); - } finally { - clearSpy.mockRestore(); - } - }); - - test("rejects with a timeout error when connect is too slow", async () => { - await expect( - connectWithTimeout(() => new Promise(() => {}), 5, "target"), - ).rejects.toThrow(/timed out after 5ms/); - }); - - test("clears the timer even when connect rejects", async () => { - const clearSpy = spyOn(globalThis, "clearTimeout"); - try { - const boom = new Error("connect ECONNREFUSED"); - await expect( - connectWithTimeout(() => Promise.reject(boom), 60_000, "target"), - ).rejects.toBe(boom); - expect(clearSpy).toHaveBeenCalled(); - } finally { - clearSpy.mockRestore(); - } - }); -}); - -describe("poolConfigFromUrl", () => { - describe("non-IPv6 URLs pass through as connectionString", () => { - test("DNS hostname", () => { - const url = "postgresql://user:pass@db.example.com:5432/mydb"; - expect(poolConfigFromUrl(url)).toEqual({ connectionString: url }); - }); - - test("IPv4 host", () => { - const url = "postgresql://user:pass@127.0.0.1:5432/mydb"; - expect(poolConfigFromUrl(url)).toEqual({ connectionString: url }); - }); - - test("DNS hostname with query params", () => { - const url = - "postgresql://user:pass@db.example.com:5432/mydb?application_name=test"; - expect(poolConfigFromUrl(url)).toEqual({ connectionString: url }); - }); - }); - - describe("bracketed IPv6 URLs expand to explicit fields with no brackets", () => { - test("full 8-group IPv6 — host has no brackets and no connectionString", () => { - const url = - "postgresql://user:pass@[2600:1f16:1cd0:3340:f92e:f4cb:7a52:10a1]:5432/mydb"; - const config = poolConfigFromUrl(url); - expect(config.connectionString).toBeUndefined(); - expect(config.host).toBe("2600:1f16:1cd0:3340:f92e:f4cb:7a52:10a1"); - expect(config.port).toBe(5432); - expect(config.user).toBe("user"); - expect(config.password).toBe("pass"); - expect(config.database).toBe("mydb"); - }); - - test("compressed ::1 form", () => { - const url = "postgresql://user:pass@[::1]:5432/mydb"; - const config = poolConfigFromUrl(url); - expect(config.host).toBe("::1"); - expect(config.port).toBe(5432); - }); - - test("host bracket strip survives percent-decoded username/password", () => { - const url = "postgresql://user:p%40ss%2Fword@[::1]:5432/mydb"; - const config = poolConfigFromUrl(url); - expect(config.host).toBe("::1"); - expect(config.user).toBe("user"); - expect(config.password).toBe("p@ss/word"); - }); - - test("works without port", () => { - const url = "postgresql://user:pass@[::1]/mydb"; - const config = poolConfigFromUrl(url); - expect(config.host).toBe("::1"); - expect(config.port).toBeUndefined(); - }); - - test("works without database (pathname='/')", () => { - const url = "postgresql://user:pass@[::1]:5432/"; - const config = poolConfigFromUrl(url); - expect(config.host).toBe("::1"); - expect(config.database).toBeUndefined(); - }); - - test("works without userinfo", () => { - const url = "postgresql://[::1]:5432/mydb"; - const config = poolConfigFromUrl(url); - expect(config.host).toBe("::1"); - expect(config.user).toBeUndefined(); - expect(config.password).toBeUndefined(); - }); - - test("query params are forwarded as top-level config keys", () => { - const url = - "postgresql://user:pass@[::1]:5432/mydb?application_name=pgdelta&connect_timeout=5"; - const config = poolConfigFromUrl(url) as unknown as Record< - string, - unknown - >; - expect(config.application_name).toBe("pgdelta"); - expect(config.connect_timeout).toBe("5"); - expect(config.host).toBe("::1"); - }); - - test("IPv4-mapped IPv6 is stripped of brackets (WHATWG canonicalisation is fine)", () => { - // WHATWG URL canonicalises `::ffff:192.0.2.1` to `::ffff:c000:201`; - // either form resolves to the same IPv6 address, and the point of this - // test is purely that no brackets escape to pg. - const url = "postgresql://user:pass@[::ffff:192.0.2.1]:5432/mydb"; - const config = poolConfigFromUrl(url); - expect(config.host).not.toContain("["); - expect(config.host).not.toContain("]"); - expect(config.host).toBe("::ffff:c000:201"); - }); - }); -}); diff --git a/packages/pg-delta/src/core/postgres-config.ts b/packages/pg-delta/src/core/postgres-config.ts deleted file mode 100644 index c68d739af..000000000 --- a/packages/pg-delta/src/core/postgres-config.ts +++ /dev/null @@ -1,474 +0,0 @@ -/** - * PostgreSQL connection configuration with custom type handlers. - */ - -import type { ClientBase, PoolClient, PoolConfig } from "pg"; -import { escapeIdentifier, Pool, types } from "pg"; -import { normalizeConnectionUrl } from "./connection-url.ts"; -import { parseSslConfig } from "./plan/ssl-config.ts"; - -// ============================================================================ -// Array Parser -// ============================================================================ - -/** - * Parse PostgreSQL array string into JavaScript array. - * Handles: {val1,val2}, {NULL,val}, {"quoted,val"}, nested arrays. - */ -function parseArray( - value: string, - parseElement: (val: string) => unknown = (v) => v, -): unknown[] { - if (!value || value === "{}") return []; - - // Remove outer braces - const inner = value.slice(1, -1); - if (inner === "") return []; - - const result: unknown[] = []; - let current = ""; - let inQuotes = false; - let depth = 0; - - for (let i = 0; i < inner.length; i++) { - const char = inner[i]; - - if (char === '"' && inner[i - 1] !== "\\") { - inQuotes = !inQuotes; - current += char; - } else if (char === "{" && !inQuotes) { - depth++; - current += char; - } else if (char === "}" && !inQuotes) { - depth--; - current += char; - } else if (char === "," && !inQuotes && depth === 0) { - result.push(parseElement(current)); - current = ""; - } else { - current += char; - } - } - - if (current !== "") { - result.push(parseElement(current)); - } - - return result; -} - -/** - * Parse element, handling NULL, quoted strings, and unquoted values. - */ -function parseStringElement(val: string): string | null { - if (val === "NULL") return null; - if (val.startsWith('"') && val.endsWith('"')) { - // Unescape quoted string - return val.slice(1, -1).replace(/\\(.)/g, "$1"); - } - return val; -} - -function parseIntElement(val: string): number | null { - if (val === "NULL") return null; - return Number.parseInt(val, 10); -} - -// ============================================================================ -// Type Parsers -// ============================================================================ - -// int2vector: "1 2 3" -> [1, 2, 3] -// @ts-expect-error - pg types expects TypeId but raw OID numbers work fine -types.setTypeParser(22, (val: string) => { - if (!val || val === "") return []; - return val - .split(" ") - .map(Number) - .filter((n: number) => !Number.isNaN(n)); -}); - -// bigint: string -> BigInt -types.setTypeParser(20, (val: string) => BigInt(val)); - -// PostgreSQL array types -// @ts-expect-error - pg types expects TypeId but raw OID numbers work fine -types.setTypeParser(1002, (val: string) => parseArray(val, parseStringElement)); // "char"[] -// @ts-expect-error - pg types expects TypeId but raw OID numbers work fine -types.setTypeParser(1009, (val: string) => parseArray(val, parseStringElement)); // text[] -// @ts-expect-error - pg types expects TypeId but raw OID numbers work fine -types.setTypeParser(1015, (val: string) => parseArray(val, parseStringElement)); // varchar[] -// @ts-expect-error - pg types expects TypeId but raw OID numbers work fine -types.setTypeParser(1005, (val: string) => parseArray(val, parseIntElement)); // int2[] -// @ts-expect-error - pg types expects TypeId but raw OID numbers work fine -types.setTypeParser(1007, (val: string) => parseArray(val, parseIntElement)); // int4[] -// @ts-expect-error - pg types expects TypeId but raw OID numbers work fine -types.setTypeParser(1016, (val: string) => parseArray(val, parseIntElement)); // int8[] - -const DEFAULT_POOL_MAX = Number(process.env.PGDELTA_POOL_MAX) || 5; -const DEFAULT_CONNECTION_TIMEOUT_MS = - Number(process.env.PGDELTA_CONNECTION_TIMEOUT_MS) || 3_000; -const DEFAULT_CONNECT_TIMEOUT_MS = - Number(process.env.PGDELTA_CONNECT_TIMEOUT_MS) || 2_500; -const DEFAULT_CONNECT_MAX_ATTEMPTS = - Number(process.env.PGDELTA_CONNECT_MAX_ATTEMPTS) || 3; -const DEFAULT_CONNECT_BASE_BACKOFF_MS = - Number(process.env.PGDELTA_CONNECT_BASE_BACKOFF_MS) || 250; -const DEFAULT_CONNECT_MAX_BACKOFF_MS = - Number(process.env.PGDELTA_CONNECT_MAX_BACKOFF_MS) || 1_000; - -// PostgreSQL auth-class SQLSTATE codes: not retryable. -const NON_RETRYABLE_PG_CODES = new Set([ - "28000", // invalid_authorization_specification - "28P01", // invalid_password - "28P02", // pgdelta: alias reserved here to future-proof against new auth codes -]); - -// Non-retryable TLS/SSL markers. The `pg` driver surfaces TLS failures as -// either plain Node `Error` instances with a code on `ERR_TLS_*` or error -// messages that include well-known cert/TLS terminology; we match both -// because node-pg normalises some of these. -const TLS_MESSAGE_MARKERS = [ - "self-signed certificate", - "self signed certificate", - "unable to verify the first certificate", - "certificate has expired", - "tls", - "ssl", -]; - -/** - * Return true when `err` represents a transient connect failure that makes - * sense to retry with backoff (e.g. refused connections, DNS blips, our own - * eager-connect timeout wrapper). Returns false for permanent failures such - * as authentication errors, TLS negotiation errors, and `ENOTFOUND`. - * - * Unknown errors are treated as retryable on purpose: transient-by-default - * is safer here because a duplicated retry is strictly cheaper than a spurious - * hard failure during catalog extraction. - */ -export function isRetryableConnectError(err: unknown): boolean { - if (!(err instanceof Error)) return true; - const code = (err as NodeJS.ErrnoException & { code?: string }).code; - - if (code && NON_RETRYABLE_PG_CODES.has(code)) return false; - if (code === "ENOTFOUND") return false; - if (code && typeof code === "string" && code.startsWith("ERR_TLS")) { - return false; - } - - const message = err.message?.toLowerCase() ?? ""; - // Our own eager-connect timeout wrapper is retryable (flaky network). - if (message.includes("timed out after")) return true; - for (const marker of TLS_MESSAGE_MARKERS) { - if (message.includes(marker)) return false; - } - return true; -} - -/** - * Retry an async `connect` operation with bounded exponential backoff. - * Stops immediately on a non-retryable error. On exhausted attempts, throws - * the last observed error. - * - * Exposed for testing — production call sites always go through - * {@link createManagedPool}. - */ -export async function connectWithRetry(opts: { - connect: (attempt: number) => Promise; - isRetryable?: (err: unknown) => boolean; - maxAttempts?: number; - baseBackoffMs?: number; - maxBackoffMs?: number; - sleep?: (ms: number) => Promise; -}): Promise { - const maxAttempts = opts.maxAttempts ?? DEFAULT_CONNECT_MAX_ATTEMPTS; - const baseBackoffMs = opts.baseBackoffMs ?? DEFAULT_CONNECT_BASE_BACKOFF_MS; - const maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_CONNECT_MAX_BACKOFF_MS; - const isRetryable = opts.isRetryable ?? isRetryableConnectError; - const sleep = - opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); - - let lastError: unknown; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await opts.connect(attempt); - } catch (err) { - lastError = err; - if (attempt >= maxAttempts || !isRetryable(err)) { - throw err; - } - const backoff = Math.min( - baseBackoffMs * 2 ** (attempt - 1), - maxBackoffMs, - ); - await sleep(backoff); - } - } - // Unreachable: loop either returns or throws. - throw lastError; -} - -/** - * Race `connect()` against a `timeoutMs` rejection and clear the timer when - * either side wins. If the timer is left running after a fast connect, the - * pending `setTimeout` keeps the event loop alive and the process hangs for - * the rest of `timeoutMs`. - */ -export function connectWithTimeout( - connect: () => Promise, - timeoutMs: number, - label: "source" | "target", -): Promise { - let timer: ReturnType; - return Promise.race([ - connect(), - new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error( - `Connection to ${label} database timed out after ${timeoutMs}ms. ` + - `The server may require SSL, use an invalid certificate, or be unreachable.`, - ), - ), - timeoutMs, - ); - }), - ]).finally(() => { - clearTimeout(timer); - }); -} - -/** - * Options for creating a Pool with event listeners. - */ -interface CreatePoolOptions extends Partial { - /** Called when a new client connects to the pool */ - onConnect?: (client: ClientBase) => void | Promise; - /** Called when an idle client emits an error */ - onError?: (err: Error, client: PoolClient) => void; - /** Called when a client is acquired from the pool */ - onAcquire?: (client: PoolClient) => void; - /** Called when a client is removed from the pool */ - onRemove?: (client: PoolClient) => void; -} - -/** - * Create a Pool with custom type handlers and optional event listeners. - * - * `connectionString` may be `undefined` when the caller needs pg to rely on - * explicit `host`/`port`/`user`/... fields from `options` instead — notably - * the bracketed-IPv6 workaround in {@link poolConfigFromUrl}, where passing - * the connection string would cause `pg-connection-string` to re-inject the - * bracketed host that breaks `getaddrinfo`. - */ -export function createPool( - connectionString: string | undefined, - options?: CreatePoolOptions, -): Pool { - const { onConnect, onError, onAcquire, onRemove, ...config } = options ?? {}; - const pool = new Pool({ - ...(connectionString ? { connectionString } : {}), - max: DEFAULT_POOL_MAX, - connectionTimeoutMillis: DEFAULT_CONNECTION_TIMEOUT_MS, - ...config, - }); - - if (onConnect) { - const pendingClientSetup = new WeakMap>(); - const waitForClientSetup = async (client: PoolClient) => { - const setup = pendingClientSetup.get(client); - if (setup) { - await setup; - return; - } - throw new Error( - "Internal error: pool client was acquired before async onConnect setup was registered. This indicates a bug in the pool wrapper logic; please report it with reproduction steps.", - ); - }; - const originalConnect = pool.connect.bind(pool); - - pool.on("connect", (client) => { - pendingClientSetup.set( - client, - Promise.resolve().then(() => onConnect(client)), - ); - }); - - pool.connect = (( - callback?: ( - err: Error | undefined, - client: PoolClient | undefined, - release: (err?: Error | boolean) => void, - ) => void, - ) => { - if (!callback) { - return originalConnect().then(async (client) => { - try { - await waitForClientSetup(client); - return client; - } catch (setupError) { - (client as PoolClient).release?.(setupError as Error); - throw setupError; - } - }); - } - - return originalConnect(async (err, client, release) => { - if (err || !client) { - callback(err, client, release); - return; - } - - try { - await waitForClientSetup(client); - callback(err, client, release); - } catch (setupError) { - release(setupError as Error); - callback(setupError as Error, undefined, () => {}); - } - }); - }) as Pool["connect"]; - } - if (onError) pool.on("error", onError); - if (onAcquire) pool.on("acquire", onAcquire); - if (onRemove) pool.on("remove", onRemove); - - return pool; -} - -/** - * Build a pg {@link PoolConfig} from a cleaned connection URL. - * - * For most URLs this just returns `{ connectionString }` and pg does its - * normal parsing. But for URLs whose hostname is a bracketed IPv6 literal - * (e.g. `postgresql://user@[::1]:5432/db`, as produced by - * {@link normalizeConnectionUrl}), we expand the URL into explicit - * `host`/`port`/`user`/`password`/`database` fields with a **bare** IPv6 - * host — no brackets. - * - * This works around a `pg-connection-string` quirk: its parser sets - * `config.host` to the WHATWG `URL.hostname`, which keeps the surrounding - * `[...]` for IPv6 literals. That bracketed value is then passed verbatim to - * `getaddrinfo`, which rejects it with `ENOTFOUND`. Since - * `pg`'s connection-parameters module does - * `Object.assign({}, config, parse(connectionString))`, any `host` we pass - * alongside `connectionString` gets clobbered — so we drop `connectionString` - * entirely on this path and hand pg the parsed fields directly. - * - * Remaining query parameters (e.g. `application_name`, `options`, - * `connect_timeout`) are forwarded as top-level config keys, mirroring how - * `pg-connection-string` would normally surface them. - */ -export function poolConfigFromUrl(cleanedUrl: string): PoolConfig { - const urlObj = new URL(cleanedUrl); - if (!urlObj.hostname.startsWith("[")) { - return { connectionString: cleanedUrl }; - } - - const config: Record = { - host: urlObj.hostname.slice(1, -1), - }; - if (urlObj.port) config.port = Number(urlObj.port); - if (urlObj.username) config.user = decodeURIComponent(urlObj.username); - if (urlObj.password) config.password = decodeURIComponent(urlObj.password); - if (urlObj.pathname.length > 1) { - config.database = decodeURIComponent(urlObj.pathname.slice(1)); - } - for (const [key, value] of urlObj.searchParams) { - config[key] = value; - } - return config as PoolConfig; -} - -/** - * End a pool and wait for all client sockets to fully close. - * - * pg-pool's `pool.end()` resolves once clients are removed from its - * internal bookkeeping, but the underlying `client.end()` calls (which - * close the TCP/TLS sockets) are fired asynchronously *after* that. - * If the server (e.g. a test container) is stopped right after - * `pool.end()` resolves, the still-open sockets receive an unexpected - * RST and emit unhandled "Connection terminated unexpectedly" errors. - * - * This helper waits for every `remove` event — which pg-pool emits - * inside each `client.end()` callback — ensuring all sockets are - * truly closed before it resolves. - */ -/** - * Create a pool from a connection URL with standard session setup: - * SSL parsing, search_path isolation, optional SET ROLE, and 57P01 suppression. - * - * Returns the pool and a `close` function that properly waits for all sockets - * to close (via {@link endPool}). - */ -export async function createManagedPool( - url: string, - options?: { role?: string; label?: "source" | "target" }, -): Promise<{ pool: Pool; close: () => Promise }> { - // Normalize percent-encoded IPv6 hosts (e.g. `2406%3A...%3Ab3c9`) into the - // canonical bracketed form before the URL reaches `parseSslConfig` or pg. - // Non-IPv6 hosts are returned unchanged. - const normalizedUrl = normalizeConnectionUrl(url); - const sslConfig = await parseSslConfig( - normalizedUrl, - options?.label ?? "target", - ); - // Expand bracketed-IPv6 URLs into explicit pg fields so the brackets never - // reach `getaddrinfo` — see `poolConfigFromUrl` for the full rationale. - const connectionConfig = poolConfigFromUrl(sslConfig.cleanedUrl); - const pool = createPool(connectionConfig.connectionString, { - ...connectionConfig, - ...(sslConfig.ssl !== undefined ? { ssl: sslConfig.ssl } : {}), - onError: (err: Error & { code?: string }) => { - if (err.code !== "57P01") { - console.error("Pool error:", err); - } - }, - onConnect: async (client) => { - await client.query("SET search_path = ''"); - if (options?.role) { - await client.query(`SET ROLE ${escapeIdentifier(options.role)}`); - } - }, - }); - - // Eagerly validate connectivity so SSL/auth failures surface immediately - // instead of hanging on the first real query. node-pg's connectionTimeoutMillis - // is not reliably enforced under Bun when SSL negotiation hangs. Transient - // failures (refused connections, flaky DNS, our own timeout wrapper) are - // retried with bounded exponential backoff; auth/TLS/ENOTFOUND fail fast. - const label = options?.label ?? "target"; - const timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS; - try { - const client = await connectWithRetry({ - connect: () => connectWithTimeout(() => pool.connect(), timeoutMs, label), - }); - client.release(); - } catch (err) { - await pool.end().catch(() => {}); - throw err; - } - - return { pool, close: () => endPool(pool) }; -} - -export function endPool(pool: Pool): Promise { - const clientCount = pool.totalCount; - - if (clientCount === 0) { - return pool.end(); - } - - return new Promise((resolve, reject) => { - let removed = 0; - pool.on("remove", function onRemove() { - if (++removed >= clientCount) { - pool.removeListener("remove", onRemove); - resolve(); - } - }); - pool.end().catch(reject); - }); -} diff --git a/packages/pg-delta/src/core/snapshot.test.ts b/packages/pg-delta/src/core/snapshot.test.ts new file mode 100644 index 000000000..2dc86db47 --- /dev/null +++ b/packages/pg-delta/src/core/snapshot.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { plan } from "../plan/plan.ts"; +import { USER_MAPPING_UNREADABLE } from "./diagnostic.ts"; +import { buildFactBase, type Fact } from "./fact.ts"; +import { deserializeSnapshot, serializeSnapshot } from "./snapshot.ts"; +import type { StableId } from "./stable-id.ts"; + +const fb = buildFactBase( + [ + { id: { kind: "schema", name: "public" }, payload: {} }, + { + id: { kind: "table", schema: "public", name: "t" }, + parent: { kind: "schema", name: "public" }, + payload: { persistence: "p" }, + }, + { id: { kind: "role", name: "r" }, payload: { login: true } }, + ], + [ + { + from: { kind: "table", schema: "public", name: "t" }, + to: { kind: "role", name: "r" }, + kind: "owner", + }, + ], +); + +describe("snapshot", () => { + test("round-trips hash-identically", () => { + const json = serializeSnapshot(fb, { pgVersion: "17.6" }); + const restored = deserializeSnapshot(json); + expect(restored.factBase.rootHash).toBe(fb.rootHash); + expect(restored.pgVersion).toBe("17.6"); + expect(restored.factBase.edges).toHaveLength(1); + }); + + test("carries formatVersion 1 and rejects unknown versions", () => { + const json = serializeSnapshot(fb, { pgVersion: "17.6" }); + expect(JSON.parse(json).formatVersion).toBe(1); + const tampered = JSON.stringify({ ...JSON.parse(json), formatVersion: 99 }); + expect(() => deserializeSnapshot(tampered)).toThrow(/format/i); + }); + + test("records the redaction mode so drift can re-extract identically", () => { + // metadata only — it must round-trip but never move the digest (an + // --unsafe-show-secrets snapshot carries redactSecrets:false). + const unsafe = serializeSnapshot(fb, { + pgVersion: "17.6", + redactSecrets: false, + }); + expect(deserializeSnapshot(unsafe).redactSecrets).toBe(false); + expect(deserializeSnapshot(unsafe).factBase.rootHash).toBe(fb.rootHash); + // a snapshot written without the field parses with redactSecrets undefined. + expect( + deserializeSnapshot(serializeSnapshot(fb, { pgVersion: "17.6" })) + .redactSecrets, + ).toBeUndefined(); + }); + + test("stamps and round-trips the capture profile (string / null / absent)", () => { + // a named profile round-trips as its declared id + expect( + deserializeSnapshot( + serializeSnapshot(fb, { pgVersion: "17.6", profile: "supabase" }), + ).profile, + ).toBe("supabase"); + // an explicit raw capture round-trips as null (distinct from legacy absent) + expect( + deserializeSnapshot( + serializeSnapshot(fb, { pgVersion: "17.6", profile: null }), + ).profile, + ).toBeNull(); + // a snapshot written without the field (legacy) parses with profile undefined + expect( + deserializeSnapshot(serializeSnapshot(fb, { pgVersion: "17.6" })).profile, + ).toBeUndefined(); + // the profile stamp is METADATA — it never moves the digest + expect( + deserializeSnapshot( + serializeSnapshot(fb, { pgVersion: "17.6", profile: "supabase" }), + ).factBase.rootHash, + ).toBe(fb.rootHash); + const withStamp = JSON.parse( + serializeSnapshot(fb, { pgVersion: "17.6", profile: "supabase" }), + ) as { digest: string }; + const withoutStamp = JSON.parse( + serializeSnapshot(fb, { pgVersion: "17.6" }), + ) as { digest: string }; + expect(withStamp.digest).toBe(withoutStamp.digest); + }); + + test("rejects corrupted content (digest re-verification)", () => { + const json = serializeSnapshot(fb, { pgVersion: "17.6" }); + const doc = JSON.parse(json); + doc.facts[1].payload.persistence = "u"; // tamper + expect(() => deserializeSnapshot(JSON.stringify(doc))).toThrow( + /digest|corrupt/i, + ); + }); +}); + +describe("snapshot — diagnostics (Codex P2, comment 3601826191)", () => { + const serverId: StableId = { kind: "server", name: "srv" }; + const mappingId: StableId = { + kind: "userMapping", + server: "srv", + role: "PUBLIC", + }; + const serverFact: Fact = { + id: serverId, + payload: { fdw: "fdw1", type: null, version: null, options: [] }, + }; + const mappingFact: Fact = { + id: mappingId, + parent: serverId, + payload: { options: [] }, + }; + + /** A fresh FactBase per test (unlike the shared `fb` above) — pushing onto + * `.diagnostics` mutates the instance, and these tests need that mutation + * isolated. */ + const withDiagnostic = (): ReturnType => { + const base = buildFactBase([serverFact], []); + base.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, + message: "srv/PUBLIC hidden", + }); + return base; + }; + + test("round-trip preserves diagnostics, including subject", () => { + const base = withDiagnostic(); + const json = serializeSnapshot(base, { pgVersion: "17.6" }); + const restored = deserializeSnapshot(json).factBase; + + expect(restored.diagnostics).toHaveLength(1); + const d = restored.diagnostics[0]!; + expect(d.code).toBe(USER_MAPPING_UNREADABLE); + expect(d.severity).toBe("warning"); + expect(d.message).toBe("srv/PUBLIC hidden"); + expect(d.subject).toEqual(mappingId); + }); + + test("plan()'s unreadable-user-mapping gate still fires across a deserialized snapshot", () => { + // RED today: the gate reads FactBase.diagnostics, but a round-tripped + // snapshot silently dropped them — no throw, plan() would happily emit + // DROP/CREATE USER MAPPING for a mapping whose true state is unknown. + const source = deserializeSnapshot( + serializeSnapshot(withDiagnostic(), { pgVersion: "17.6" }), + ).factBase; + const desired = buildFactBase([serverFact, mappingFact], []); + + expect(() => plan(source, desired)).toThrow( + /user mappings is unknown on one side/, + ); + }); + + test("digest is identical with and without diagnostics present", () => { + const bare = buildFactBase([serverFact], []); + const withDiag = withDiagnostic(); + expect(withDiag.rootHash).toBe(bare.rootHash); + + const bareDoc = JSON.parse( + serializeSnapshot(bare, { pgVersion: "17.6" }), + ) as { digest: string }; + const withDiagDoc = JSON.parse( + serializeSnapshot(withDiag, { pgVersion: "17.6" }), + ) as { digest: string }; + expect(withDiagDoc.digest).toBe(bareDoc.digest); + }); + + test("an old-format snapshot (no diagnostics field) deserializes fine, with an empty array", () => { + const json = serializeSnapshot(buildFactBase([serverFact], []), { + pgVersion: "17.6", + }); + const doc = JSON.parse(json) as Record; + delete doc["diagnostics"]; + const restored = deserializeSnapshot(JSON.stringify(doc)).factBase; + expect(restored.diagnostics).toEqual([]); + }); +}); diff --git a/packages/pg-delta/src/core/snapshot.ts b/packages/pg-delta/src/core/snapshot.ts new file mode 100644 index 000000000..2018f84c9 --- /dev/null +++ b/packages/pg-delta/src/core/snapshot.ts @@ -0,0 +1,201 @@ +/** + * Snapshot format v1 (target-architecture §3.1/§3.2): the serialized fact + * base. Version-tagged; digest re-verified on load (a corrupted snapshot + * must never silently plan). + */ +import type { Diagnostic } from "./diagnostic.ts"; +import { + buildFactBase, + type DependencyEdge, + type EdgeKind, + FactBase, +} from "./fact.ts"; +import type { Payload, PayloadValue } from "./hash.ts"; +import { encodeId, parseId } from "./stable-id.ts"; + +const FORMAT_VERSION = 1; + +interface SnapshotDiagnostic { + code: string; + severity: Diagnostic["severity"]; + subject?: string; + message: string; + context?: Record; +} + +interface SnapshotDoc { + formatVersion: number; + pgVersion: string; + /** ISO-8601 capture time; auditability only, never affects the digest */ + capturedAt?: string; + /** whether secrets were redacted when this snapshot was extracted. Recorded + * so `drift` re-extracts the live env with the SAME mode — an unredacted + * (`--unsafe-show-secrets`) snapshot compared against a default-redacted live + * extract would report placeholder-vs-real drift. Metadata only: it describes + * how the facts were produced and never affects the digest. */ + redactSecrets?: boolean; + /** the profile this snapshot was CAPTURED under (`pgdelta snapshot --profile`), + * as the profile's DECLARED id — the same value `Plan.profile.id` carries. So + * `drift`/`prove` re-extract the live env with the SAME handler-aware profile + * the snapshot's facts were produced with, instead of silently comparing a + * raw extract against handler-aware facts (or vice versa). Three-state, like + * `ExportManifest.defaultOwner`: a string id, `null` (captured raw — the + * reconcile treats this as the concrete "raw" profile), or ABSENT (a snapshot + * written before this field existed — legacy, the reconcile lets a --profile + * flag win). Metadata only: it describes how the facts were produced and + * NEVER affects the digest, which is `fb.rootHash`. */ + profile?: string | null; + digest: string; + facts: Array<{ id: string; parent?: string; payload: unknown }>; + edges: Array<{ from: string; to: string; kind: EdgeKind }>; + /** `FactBase.diagnostics` — carried through so a `plan()` gate that reads + * them (e.g. `USER_MAPPING_UNREADABLE`) still fires when one side of a + * diff is a deserialized snapshot rather than a live extraction. Metadata + * only, like `capturedAt`/`redactSecrets`: NEVER affects `digest`, which is + * `fb.rootHash` — a pure fold over facts/edges (see fact.ts) that never + * reads `.diagnostics`. Missing on an old-format snapshot (pre-dating this + * field) → deserializes as an empty array, no error; such a snapshot is + * simply ungated (matches its pre-existing behavior, never worse). */ + diagnostics?: SnapshotDiagnostic[]; +} + +/** bigint-safe JSON: bigints encode as {"$bigint":"..."} */ +function encodePayload(value: PayloadValue): unknown { + if (typeof value === "bigint") return { $bigint: value.toString() }; + if (Array.isArray(value)) return value.map(encodePayload); + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (v !== undefined) out[k] = encodePayload(v); + } + return out; + } + return value; +} + +function decodePayload(value: unknown): PayloadValue { + if (Array.isArray(value)) return value.map(decodePayload); + if (value !== null && typeof value === "object") { + const obj = value as Record; + if (typeof obj["$bigint"] === "string" && Object.keys(obj).length === 1) { + return BigInt(obj["$bigint"]); + } + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) out[k] = decodePayload(v); + return out; + } + return value as PayloadValue; +} + +export function serializeSnapshot( + fb: FactBase, + meta: { + pgVersion: string; + capturedAt?: string; + redactSecrets?: boolean; + profile?: string | null; + }, +): string { + const doc: SnapshotDoc = { + formatVersion: FORMAT_VERSION, + pgVersion: meta.pgVersion, + ...(meta.capturedAt !== undefined ? { capturedAt: meta.capturedAt } : {}), + ...(meta.redactSecrets !== undefined + ? { redactSecrets: meta.redactSecrets } + : {}), + // `null` is a MEANINGFUL value here (captured raw), distinct from ABSENT + // (legacy) — so include the key whenever it is not `undefined`. + ...(meta.profile !== undefined ? { profile: meta.profile } : {}), + digest: fb.rootHash, + facts: fb + .facts() + .map((f) => ({ + id: encodeId(f.id), + ...(f.parent !== undefined ? { parent: encodeId(f.parent) } : {}), + payload: encodePayload(f.payload), + })) + .sort((a, b) => (a.id < b.id ? -1 : 1)), + edges: fb.edges + .map((e) => ({ + from: encodeId(e.from), + to: encodeId(e.to), + kind: e.kind, + })) + .sort((a, b) => + `${a.from}|${a.kind}|${a.to}` < `${b.from}|${b.kind}|${b.to}` ? -1 : 1, + ), + // ALL diagnostics, not a code-specific subset (also closes the pre-existing + // INTENT_UNKEYED snapshot hole) — deterministically sorted so the document + // is stable regardless of extraction/accumulation order. + diagnostics: fb.diagnostics + .map((d) => ({ + code: d.code, + severity: d.severity, + ...(d.subject !== undefined ? { subject: encodeId(d.subject) } : {}), + message: d.message, + ...(d.context !== undefined ? { context: d.context } : {}), + })) + .sort((a, b) => { + if (a.code !== b.code) return a.code < b.code ? -1 : 1; + const aSubject = a.subject ?? ""; + const bSubject = b.subject ?? ""; + if (aSubject !== bSubject) return aSubject < bSubject ? -1 : 1; + return a.message < b.message ? -1 : a.message > b.message ? 1 : 0; + }), + }; + return JSON.stringify(doc, null, 2); +} + +export function deserializeSnapshot(json: string): { + factBase: FactBase; + pgVersion: string; + redactSecrets?: boolean; + profile?: string | null; +} { + const doc = JSON.parse(json) as SnapshotDoc; + if (doc.formatVersion !== FORMAT_VERSION) { + throw new Error( + `snapshot formatVersion ${doc.formatVersion} is not supported (expected ${FORMAT_VERSION})`, + ); + } + const facts = doc.facts.map((f) => ({ + id: parseId(f.id), + ...(f.parent !== undefined ? { parent: parseId(f.parent) } : {}), + payload: decodePayload(f.payload) as Payload, + })); + const edges: DependencyEdge[] = doc.edges.map((e) => ({ + from: parseId(e.from), + to: parseId(e.to), + kind: e.kind, + })); + const factBase = buildFactBase(facts, edges, "snapshot"); + if (factBase.rootHash !== doc.digest) { + throw new Error( + `snapshot digest mismatch — content is corrupt or was edited (expected ${doc.digest}, computed ${factBase.rootHash})`, + ); + } + // Missing on an old-format snapshot (pre-dating this field) → `?? []`, no + // error; rides on the FactBase itself (the same seam handler / extraction + // diagnostics use) so a `plan()` gate that reads `FactBase.diagnostics` + // (e.g. USER_MAPPING_UNREADABLE) still fires across a deserialized snapshot. + factBase.diagnostics.push( + ...(doc.diagnostics ?? []).map( + (d): Diagnostic => ({ + code: d.code, + severity: d.severity, + ...(d.subject !== undefined ? { subject: parseId(d.subject) } : {}), + message: d.message, + ...(d.context !== undefined ? { context: d.context } : {}), + }), + ), + ); + return { + factBase, + pgVersion: doc.pgVersion, + ...(doc.redactSecrets !== undefined + ? { redactSecrets: doc.redactSecrets } + : {}), + // preserve the three states: string / `null` (captured raw) / ABSENT (legacy). + ...(doc.profile !== undefined ? { profile: doc.profile } : {}), + }; +} diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts deleted file mode 100644 index c1c5ecdc8..000000000 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { Change } from "../change.types.ts"; -import { getSchema } from "../change-utils.ts"; -import { - GrantRoleDefaultPrivileges, - RevokeRoleDefaultPrivileges, -} from "../objects/role/changes/role.privilege.ts"; -import { - AlterTableAlterColumnAddIdentity, - AlterTableAlterColumnDropDefault, - AlterTableAlterColumnDropIdentity, - AlterTableAlterColumnSetDefault, -} from "../objects/table/changes/table.alter.ts"; -import type { Constraint } from "./types.ts"; - -/** - * Maps object type names to PostgreSQL default privilege objtype codes. - * This mirrors the mapping in base.default-privileges.ts. - */ -function objectTypeToObjtype(objectType: string): string | null { - switch (objectType) { - case "table": - case "view": - case "materialized_view": - return "r"; // Relations - case "sequence": - return "S"; // Sequences - case "procedure": - case "function": - case "aggregate": - return "f"; // Functions/routines - case "type": - case "domain": - case "enum": - case "range": - case "composite_type": - return "T"; // Types - case "schema": - return "n"; // Schemas - default: - return null; - } -} - -/** - * A function that generates constraints for a list of changes. - * Should be optimized to avoid O(N²) complexity by using lookups/indexing. - */ -type ConstraintGenerator = (changes: Change[]) => Constraint[]; - -/** - * Generate constraints to ensure ALTER DEFAULT PRIVILEGES comes before CREATE statements. - * - * Rules: - * - Only applies when the default privilege's schema matches the CREATE statement's schema - * (or if the default privilege is global, applies to all schemas) - * - Only applies when the default privilege's objtype matches the CREATE statement's object type - * - Excludes CREATE ROLE and CREATE SCHEMA since they are dependencies - * of ALTER DEFAULT PRIVILEGES and must come before it - * - * implementation: O(N) using schema/objtype indexing. - */ -function generateDefaultPrivilegeConstraints(changes: Change[]): Constraint[] { - const constraints: Constraint[] = []; - const defaultPrivilegeIndices: number[] = []; - // Map> - const createsByObjTypeAndSchema = new Map>(); - - // Pass 1: Index changes - for (let i = 0; i < changes.length; i++) { - const change = changes[i]; - - // Identify default privilege changes - if ( - change instanceof GrantRoleDefaultPrivileges || - change instanceof RevokeRoleDefaultPrivileges - ) { - defaultPrivilegeIndices.push(i); - continue; - } - - // Identify CREATE object changes (excluding role/schema) - if ( - change.operation === "create" && - change.scope === "object" && - change.objectType !== "role" && - change.objectType !== "schema" - ) { - const objTypeCode = objectTypeToObjtype(change.objectType); - if (!objTypeCode) continue; - - const schema = getSchema(change); - // Default privileges only apply to schema-contained objects. - if (schema) { - let schemaMap = createsByObjTypeAndSchema.get(objTypeCode); - if (!schemaMap) { - schemaMap = new Map(); - createsByObjTypeAndSchema.set(objTypeCode, schemaMap); - } - - let indices = schemaMap.get(schema); - if (!indices) { - indices = []; - schemaMap.set(schema, indices); - } - indices.push(i); - } - } - } - - // Pass 2: Generate constraints - for (const privIndex of defaultPrivilegeIndices) { - const privChange = changes[privIndex] as { - inSchema: string | null; - objtype: string; - }; - const privSchema = privChange.inSchema; - const privObjType = privChange.objtype; - - const schemaMap = createsByObjTypeAndSchema.get(privObjType); - if (!schemaMap) continue; - - if (privSchema === null) { - // Global default privilege: applies to ALL schemas - for (const indices of schemaMap.values()) { - for (const createIndex of indices) { - // (No self-check needed as types differ) - constraints.push({ - sourceChangeIndex: privIndex, - targetChangeIndex: createIndex, - source: "custom", - }); - } - } - } else { - // Specific schema: applies only to that schema - const indices = schemaMap.get(privSchema); - if (indices) { - for (const createIndex of indices) { - constraints.push({ - sourceChangeIndex: privIndex, - targetChangeIndex: createIndex, - source: "custom", - }); - } - } - } - } - - return constraints; -} - -function generateIdentityTransitionConstraints( - changes: Change[], -): Constraint[] { - const constraints: Constraint[] = []; - const dropDefaultByColumn = new Map(); - const dropIdentityByColumn = new Map(); - const addIdentityByColumn = new Map(); - const setDefaultByColumn = new Map(); - - for (let i = 0; i < changes.length; i++) { - const change = changes[i]; - const columnKey = - "table" in change && "column" in change - ? `${change.table.schema}.${change.table.name}.${change.column.name}` - : null; - if (!columnKey) continue; - - if (change instanceof AlterTableAlterColumnDropDefault) { - const entries = dropDefaultByColumn.get(columnKey) ?? []; - entries.push(i); - dropDefaultByColumn.set(columnKey, entries); - } else if (change instanceof AlterTableAlterColumnAddIdentity) { - const entries = addIdentityByColumn.get(columnKey) ?? []; - entries.push(i); - addIdentityByColumn.set(columnKey, entries); - } else if (change instanceof AlterTableAlterColumnDropIdentity) { - const entries = dropIdentityByColumn.get(columnKey) ?? []; - entries.push(i); - dropIdentityByColumn.set(columnKey, entries); - } else if (change instanceof AlterTableAlterColumnSetDefault) { - const entries = setDefaultByColumn.get(columnKey) ?? []; - entries.push(i); - setDefaultByColumn.set(columnKey, entries); - } - } - - // These rules only order same-column ALTERs inside the create/alter phase. - // Sequence drops are handled separately in the earlier drop phase. - for (const [columnKey, dropDefaultIndexes] of dropDefaultByColumn) { - const addIdentityIndexes = addIdentityByColumn.get(columnKey) ?? []; - for (const sourceIndex of dropDefaultIndexes) { - for (const targetIndex of addIdentityIndexes) { - constraints.push({ - sourceChangeIndex: sourceIndex, - targetChangeIndex: targetIndex, - source: "custom", - }); - } - } - } - - for (const [columnKey, dropIdentityIndexes] of dropIdentityByColumn) { - const setDefaultIndexes = setDefaultByColumn.get(columnKey) ?? []; - for (const sourceIndex of dropIdentityIndexes) { - for (const targetIndex of setDefaultIndexes) { - constraints.push({ - sourceChangeIndex: sourceIndex, - targetChangeIndex: targetIndex, - source: "custom", - }); - } - } - } - - return constraints; -} - -/** - * All custom constraint generators. - */ -const customConstraintGenerators: ConstraintGenerator[] = [ - generateDefaultPrivilegeConstraints, - generateIdentityTransitionConstraints, -]; - -/** - * Generate Constraints from custom constraint generators. - * - * Iterates through registered generators to produce constraints. - * Generators should be optimized (e.g. using indexing) to avoid O(N²) complexity. - */ -export function generateCustomConstraints(changes: Change[]): Constraint[] { - return customConstraintGenerators.flatMap((generate) => generate(changes)); -} diff --git a/packages/pg-delta/src/core/sort/cycle-breakers.test.ts b/packages/pg-delta/src/core/sort/cycle-breakers.test.ts deleted file mode 100644 index 95cb2e18b..000000000 --- a/packages/pg-delta/src/core/sort/cycle-breakers.test.ts +++ /dev/null @@ -1,836 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../change.types.ts"; -import { - AlterPublicationDropTables, - AlterPublicationSetOwner, -} from "../objects/publication/changes/publication.alter.ts"; -import { Publication } from "../objects/publication/publication.model.ts"; -import { - AlterTableDropColumn, - AlterTableDropConstraint, -} from "../objects/table/changes/table.alter.ts"; -import { DropTable } from "../objects/table/changes/table.drop.ts"; -import { Table } from "../objects/table/table.model.ts"; -import { stableId } from "../objects/utils.ts"; -import { tryBreakCycleByChangeInjection } from "./cycle-breakers.ts"; - -const baseTableProps = { - schema: "public", - persistence: "p" as const, - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - privileges: [], -}; - -function integerColumn(name: string, position: number) { - return { - name, - position, - data_type: "integer" as const, - data_type_str: "integer", - is_custom_type: false as const, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }; -} - -function fkConstraint(props: { - name: string; - fkColumn: string; - targetSchema: string; - targetTable: string; - targetColumn?: string; -}) { - const targetColumn = props.targetColumn ?? "id"; - return { - name: props.name, - constraint_type: "f" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: [props.fkColumn], - foreign_key_columns: [targetColumn], - foreign_key_table: props.targetTable, - foreign_key_schema: props.targetSchema, - foreign_key_table_is_partition: false, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: props.targetSchema, - foreign_key_effective_table: props.targetTable, - on_update: "a" as const, - on_delete: "a" as const, - match_type: "s" as const, - check_expression: null, - owner: "postgres", - definition: `FOREIGN KEY (${props.fkColumn}) REFERENCES ${props.targetSchema}.${props.targetTable}(${targetColumn})`, - comment: null, - }; -} - -function uniqueConstraint(name: string, column: string) { - return { - name, - constraint_type: "u" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: [column], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "postgres", - definition: `UNIQUE (${column})`, - comment: null, - }; -} - -describe("tryBreakCycleByChangeInjection", () => { - test("FK 2-cycle: injects one constraint drop per FK and updates externallyDroppedConstraints", () => { - // Schema: - // DROP TABLE a; DROP TABLE b; - // where a.b_id REFERENCES b, b.a_id REFERENCES a - // Cycle is over [DropTable(a), DropTable(b)] — both tables drop while - // their FKs still bind to each other. - const tableA = new Table({ - ...baseTableProps, - name: "a", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("b_id", 2), - ], - constraints: [ - fkConstraint({ - name: "a_b_fkey", - fkColumn: "b_id", - targetSchema: "public", - targetTable: "b", - }), - ], - }); - const tableB = new Table({ - ...baseTableProps, - name: "b", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("a_id", 2), - ], - constraints: [ - fkConstraint({ - name: "b_a_fkey", - fkColumn: "a_id", - targetSchema: "public", - targetTable: "a", - }), - ], - }); - const changes: Change[] = [ - new DropTable({ table: tableA }), - new DropTable({ table: tableB }), - ]; - - const broken = tryBreakCycleByChangeInjection([0, 1], changes); - if (broken === null) throw new Error("expected breaker to fire"); - - const injectedDrops = broken.filter( - (change): change is AlterTableDropConstraint => - change instanceof AlterTableDropConstraint, - ); - expect(injectedDrops).toHaveLength(2); - expect(injectedDrops.map((d) => d.constraint.name).sort()).toEqual([ - "a_b_fkey", - "b_a_fkey", - ]); - - const dropA = broken.find( - (change): change is DropTable => - change instanceof DropTable && - change.table.stableId === tableA.stableId, - ); - const dropB = broken.find( - (change): change is DropTable => - change instanceof DropTable && - change.table.stableId === tableB.stableId, - ); - if (!dropA || !dropB) throw new Error("expected both DropTables in result"); - - expect(dropA.externallyDroppedConstraints.has("a_b_fkey")).toBe(true); - expect(dropB.externallyDroppedConstraints.has("b_a_fkey")).toBe(true); - expect( - dropA.requires.includes(stableId.constraint("public", "a", "a_b_fkey")), - ).toBe(false); - expect( - dropB.requires.includes(stableId.constraint("public", "b", "b_a_fkey")), - ).toBe(false); - }); - - test("FK 3-cycle: injects three constraint drops and frees all three tables", () => { - // Schema: - // DROP TABLE a; DROP TABLE b; DROP TABLE c; - // where a.b_id REFERENCES b, b.c_id REFERENCES c, c.a_id REFERENCES a - // No mutual edges — would have stalled the old eager mutual-only - // breaker. The lazy dispatcher uses the cycle node-set directly. - const tableA = new Table({ - ...baseTableProps, - name: "a", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("b_id", 2), - ], - constraints: [ - fkConstraint({ - name: "a_b_fkey", - fkColumn: "b_id", - targetSchema: "public", - targetTable: "b", - }), - ], - }); - const tableB = new Table({ - ...baseTableProps, - name: "b", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("c_id", 2), - ], - constraints: [ - fkConstraint({ - name: "b_c_fkey", - fkColumn: "c_id", - targetSchema: "public", - targetTable: "c", - }), - ], - }); - const tableC = new Table({ - ...baseTableProps, - name: "c", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("a_id", 2), - ], - constraints: [ - fkConstraint({ - name: "c_a_fkey", - fkColumn: "a_id", - targetSchema: "public", - targetTable: "a", - }), - ], - }); - const changes: Change[] = [ - new DropTable({ table: tableA }), - new DropTable({ table: tableB }), - new DropTable({ table: tableC }), - ]; - - const broken = tryBreakCycleByChangeInjection([0, 1, 2], changes); - if (broken === null) throw new Error("expected breaker to fire"); - - const injectedDrops = broken.filter( - (change): change is AlterTableDropConstraint => - change instanceof AlterTableDropConstraint, - ); - expect(injectedDrops).toHaveLength(3); - expect(injectedDrops.map((d) => d.constraint.name).sort()).toEqual([ - "a_b_fkey", - "b_c_fkey", - "c_a_fkey", - ]); - - for (const t of [tableA, tableB, tableC]) { - const dropChange = broken.find( - (change): change is DropTable => - change instanceof DropTable && change.table.stableId === t.stableId, - ); - if (!dropChange) throw new Error(`missing DropTable for ${t.name}`); - expect(dropChange.externallyDroppedConstraints.size).toBe(1); - } - }); - - test("FK breaker skips when an explicit AlterTableDropConstraint already exists", () => { - // Diff layer emitted the constraint drop explicitly — breaker shouldn't - // duplicate it. Returns null (no change to make). - const tableA = new Table({ - ...baseTableProps, - name: "a", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("b_id", 2), - ], - constraints: [ - fkConstraint({ - name: "a_b_fkey", - fkColumn: "b_id", - targetSchema: "public", - targetTable: "b", - }), - ], - }); - const tableB = new Table({ - ...baseTableProps, - name: "b", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("a_id", 2), - ], - constraints: [ - fkConstraint({ - name: "b_a_fkey", - fkColumn: "a_id", - targetSchema: "public", - targetTable: "a", - }), - ], - }); - const changes: Change[] = [ - new AlterTableDropConstraint({ - table: tableA, - constraint: tableA.constraints[0], - }), - new AlterTableDropConstraint({ - table: tableB, - constraint: tableB.constraints[0], - }), - new DropTable({ table: tableA }), - new DropTable({ table: tableB }), - ]; - - // Cycle reported by sort phase only includes the DropTables (the - // existing constraint drops are at indices 0 and 1, but the cycle is - // between the DropTables at 2 and 3). - const broken = tryBreakCycleByChangeInjection([2, 3], changes); - expect(broken).toBeNull(); - }); - - test("publication-column on surviving table: rebuilds AlterTableDropColumn with omitTableRequirement", () => { - // Schema: - // CREATE PUBLICATION p FOR TABLE labs (id, summary); - // ALTER TABLE labs DROP COLUMN summary; - // Diff: - // AlterPublicationDropTables(p, [labs]) - // AlterTableDropColumn(labs.summary) - // Cycle: pub→col (catalog) and col→table (explicit requires). `labs` - // survives; breaker should rewrite the column drop to drop the - // table-requirement edge. - const tableLabs = new Table({ - ...baseTableProps, - name: "labs", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("summary", 2), - ], - }); - const summaryColumn = tableLabs.columns.find( - (column) => column.name === "summary", - ); - if (!summaryColumn) throw new Error("test setup: summary column missing"); - - const publication = new Publication({ - name: "p", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [ - { schema: "public", name: "labs", columns: ["id"], row_filter: null }, - ], - schemas: [], - }); - - const changes: Change[] = [ - new AlterPublicationDropTables({ - publication, - tables: [ - { - schema: "public", - name: "labs", - columns: ["id", "summary"], - row_filter: null, - }, - ], - }), - new AlterTableDropColumn({ - table: tableLabs, - column: summaryColumn, - }), - ]; - - const broken = tryBreakCycleByChangeInjection([0, 1], changes); - if (broken === null) throw new Error("expected breaker to fire"); - - const rewrittenDropColumn = broken.find( - (change): change is AlterTableDropColumn => - change instanceof AlterTableDropColumn, - ); - if (!rewrittenDropColumn) throw new Error("missing AlterTableDropColumn"); - - expect(rewrittenDropColumn.omitTableRequirement).toBe(true); - expect(rewrittenDropColumn.requires.includes(tableLabs.stableId)).toBe( - false, - ); - expect( - rewrittenDropColumn.requires.includes( - stableId.column("public", "labs", "summary"), - ), - ).toBe(true); - - // The publication change passes through untouched. - expect(broken[0]).toBe(changes[0]); - }); - - test("publication-column when table is also being dropped: returns null (don't interfere)", () => { - // If `labs` itself is being dropped, the existing structural - // rewrites in post-diff handle the redundant column drop. Flipping - // omitTableRequirement here would let the column drop reorder - // against the table drop and is unsafe. - const tableLabs = new Table({ - ...baseTableProps, - name: "labs", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("summary", 2), - ], - }); - const summaryColumn = tableLabs.columns.find( - (column) => column.name === "summary", - ); - if (!summaryColumn) throw new Error("test setup: summary column missing"); - const publication = new Publication({ - name: "p", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [], - schemas: [], - }); - - const changes: Change[] = [ - new AlterPublicationDropTables({ - publication, - tables: [ - { - schema: "public", - name: "labs", - columns: ["id"], - row_filter: null, - }, - ], - }), - new AlterTableDropColumn({ - table: tableLabs, - column: summaryColumn, - }), - new DropTable({ table: tableLabs }), - ]; - - const broken = tryBreakCycleByChangeInjection([0, 1, 2], changes); - expect(broken).toBeNull(); - }); - - test("publication FK-chain constraint-drop 3-cycle: injects terminal FK drop", () => { - // Schema: - // publication p includes labs and posts - // posts.lab_id REFERENCES labs(id) - // Diff drops posts and drops labs.unique_lab_id while also removing both - // tables from the publication. The FK edge from posts to the terminal - // constraint drop forms: - // AlterPublicationDropTables → DropTable(posts) - // DropTable(posts) → AlterTableDropConstraint(labs.unique_lab_id) - // AlterTableDropConstraint(labs.unique_lab_id) → AlterPublicationDropTables - const tableLabs = new Table({ - ...baseTableProps, - name: "labs", - columns: [{ ...integerColumn("id", 1), not_null: true }], - constraints: [uniqueConstraint("unique_lab_id", "id")], - }); - const tablePosts = new Table({ - ...baseTableProps, - name: "posts", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("lab_id", 2), - ], - constraints: [ - fkConstraint({ - name: "posts_lab_id_fkey", - fkColumn: "lab_id", - targetSchema: "public", - targetTable: "labs", - }), - ], - }); - const publication = new Publication({ - name: "p", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [ - { schema: "public", name: "labs", columns: null, row_filter: null }, - { schema: "public", name: "posts", columns: null, row_filter: null }, - ], - schemas: [], - }); - - const terminalDrop = new AlterTableDropConstraint({ - table: tableLabs, - constraint: tableLabs.constraints[0], - }); - const changes: Change[] = [ - new AlterPublicationDropTables({ - publication, - tables: publication.tables, - }), - new DropTable({ table: tablePosts }), - terminalDrop, - ]; - - const broken = tryBreakCycleByChangeInjection([0, 1, 2], changes); - if (broken === null) throw new Error("expected breaker to fire"); - - const injectedDrops = broken.filter( - (change): change is AlterTableDropConstraint => - change instanceof AlterTableDropConstraint && - change.table.stableId === tablePosts.stableId, - ); - expect(injectedDrops).toHaveLength(1); - expect(injectedDrops[0].constraint.name).toBe("posts_lab_id_fkey"); - - const rewrittenPostsDrop = broken.find( - (change): change is DropTable => - change instanceof DropTable && - change.table.stableId === tablePosts.stableId, - ); - if (!rewrittenPostsDrop) throw new Error("missing rewritten DropTable"); - expect( - rewrittenPostsDrop.externallyDroppedConstraints.has("posts_lab_id_fkey"), - ).toBe(true); - expect(broken).toContain(terminalDrop); - }); - - test("publication FK-chain constraint-drop 4-cycle: injects FK drops along the dropped-table chain", () => { - // Schema: - // publication p includes labs, posts, and post_attachments - // post_attachments.post_id REFERENCES posts(id) - // posts.lab_id REFERENCES labs(id) - // Diff drops post_attachments and posts, drops labs.unique_lab_id, - // and removes all three tables from the publication. - const tableLabs = new Table({ - ...baseTableProps, - name: "labs", - columns: [{ ...integerColumn("id", 1), not_null: true }], - constraints: [uniqueConstraint("unique_lab_id", "id")], - }); - const tablePosts = new Table({ - ...baseTableProps, - name: "posts", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("lab_id", 2), - ], - constraints: [ - fkConstraint({ - name: "posts_lab_id_fkey", - fkColumn: "lab_id", - targetSchema: "public", - targetTable: "labs", - }), - ], - }); - const tablePostAttachments = new Table({ - ...baseTableProps, - name: "post_attachments", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("post_id", 2), - ], - constraints: [ - fkConstraint({ - name: "post_attachments_post_id_fkey", - fkColumn: "post_id", - targetSchema: "public", - targetTable: "posts", - }), - ], - }); - const publication = new Publication({ - name: "p", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [ - { schema: "public", name: "labs", columns: null, row_filter: null }, - { - schema: "public", - name: "post_attachments", - columns: null, - row_filter: null, - }, - { schema: "public", name: "posts", columns: null, row_filter: null }, - ], - schemas: [], - }); - - const terminalDrop = new AlterTableDropConstraint({ - table: tableLabs, - constraint: tableLabs.constraints[0], - }); - const changes: Change[] = [ - new AlterPublicationDropTables({ - publication, - tables: publication.tables, - }), - new DropTable({ table: tablePostAttachments }), - new DropTable({ table: tablePosts }), - terminalDrop, - ]; - - const broken = tryBreakCycleByChangeInjection([0, 1, 2, 3], changes); - if (broken === null) throw new Error("expected breaker to fire"); - - const injectedDropNames = broken - .filter( - (change): change is AlterTableDropConstraint => - change instanceof AlterTableDropConstraint && change !== terminalDrop, - ) - .map((change) => change.constraint.name) - .sort(); - expect(injectedDropNames).toEqual([ - "post_attachments_post_id_fkey", - "posts_lab_id_fkey", - ]); - - for (const [tableId, constraintName] of [ - [tablePostAttachments.stableId, "post_attachments_post_id_fkey"], - [tablePosts.stableId, "posts_lab_id_fkey"], - ] as const) { - const rewrittenDrop = broken.find( - (change): change is DropTable => - change instanceof DropTable && change.table.stableId === tableId, - ); - if (!rewrittenDrop) throw new Error(`missing DropTable for ${tableId}`); - expect( - rewrittenDrop.externallyDroppedConstraints.has(constraintName), - ).toBe(true); - } - expect(broken).toContain(terminalDrop); - }); - - test("publication FK-chain 4-cycle with partial publication membership: injects FK drops", () => { - // Sentry SUPABASE-API-7RS / CLI-1605. Same shape as the previous test, - // but the publication only contains the terminal constraint's table - // (trades) and the first dropped table (public_offering_events) — the - // intermediate FK-chain table (trade_status_events) was never a member - // of supabase_realtime. The breaker must not require every dropped - // table in the cycle to be a publication member; the pub edge only - // needs one of them. - // - // Schema: - // trades.trade_id UNIQUE (trades_trade_id_key) — table survives - // trade_status_events.trade_id REFERENCES trades(trade_id) - // public_offering_events.source_event_id REFERENCES trade_status_events(id) - // publication supabase_realtime: trades, public_offering_events only - const tableTrades = new Table({ - ...baseTableProps, - name: "trades", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - { ...integerColumn("trade_id", 2), not_null: true }, - ], - constraints: [uniqueConstraint("trades_trade_id_key", "trade_id")], - }); - const tableTradeStatusEvents = new Table({ - ...baseTableProps, - name: "trade_status_events", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("trade_id", 2), - ], - constraints: [ - fkConstraint({ - name: "trade_status_events_trade_id_fkey", - fkColumn: "trade_id", - targetSchema: "public", - targetTable: "trades", - targetColumn: "trade_id", - }), - ], - }); - const tablePublicOfferingEvents = new Table({ - ...baseTableProps, - name: "public_offering_events", - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("source_event_id", 2), - ], - constraints: [ - fkConstraint({ - name: "public_offering_events_source_event_id_fkey", - fkColumn: "source_event_id", - targetSchema: "public", - targetTable: "trade_status_events", - }), - ], - }); - const publication = new Publication({ - name: "supabase_realtime", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [ - { - schema: "public", - name: "public_offering_events", - columns: null, - row_filter: null, - }, - { schema: "public", name: "trades", columns: null, row_filter: null }, - ], - schemas: [], - }); - - const terminalDrop = new AlterTableDropConstraint({ - table: tableTrades, - constraint: tableTrades.constraints[0], - }); - const changes: Change[] = [ - new AlterPublicationDropTables({ - publication, - tables: publication.tables, - }), - new DropTable({ table: tablePublicOfferingEvents }), - new DropTable({ table: tableTradeStatusEvents }), - terminalDrop, - ]; - - const broken = tryBreakCycleByChangeInjection([0, 1, 2, 3], changes); - if (broken === null) throw new Error("expected breaker to fire"); - - const injectedDropNames = broken - .filter( - (change): change is AlterTableDropConstraint => - change instanceof AlterTableDropConstraint && change !== terminalDrop, - ) - .map((change) => change.constraint.name) - .sort(); - expect(injectedDropNames).toEqual([ - "public_offering_events_source_event_id_fkey", - "trade_status_events_trade_id_fkey", - ]); - - for (const [tableId, constraintName] of [ - [ - tablePublicOfferingEvents.stableId, - "public_offering_events_source_event_id_fkey", - ], - [tableTradeStatusEvents.stableId, "trade_status_events_trade_id_fkey"], - ] as const) { - const rewrittenDrop = broken.find( - (change): change is DropTable => - change instanceof DropTable && change.table.stableId === tableId, - ); - if (!rewrittenDrop) throw new Error(`missing DropTable for ${tableId}`); - expect( - rewrittenDrop.externallyDroppedConstraints.has(constraintName), - ).toBe(true); - } - expect(broken).toContain(terminalDrop); - }); - - test("returns null for a cycle with no recognised pattern (e.g. publication-only)", () => { - // Cycle of `AlterPublicationSetOwner` changes — neither FK nor - // publication-column shape. Breaker must bail so the formatted - // CycleError surfaces instead of an unsafe rewrite. - const publication = new Publication({ - name: "p", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [], - schemas: [], - }); - const changes: Change[] = [ - new AlterPublicationSetOwner({ publication, owner: "alice" }), - new AlterPublicationSetOwner({ publication, owner: "bob" }), - ]; - - const broken = tryBreakCycleByChangeInjection([0, 1], changes); - expect(broken).toBeNull(); - }); -}); diff --git a/packages/pg-delta/src/core/sort/cycle-breakers.ts b/packages/pg-delta/src/core/sort/cycle-breakers.ts deleted file mode 100644 index 4a2333833..000000000 --- a/packages/pg-delta/src/core/sort/cycle-breakers.ts +++ /dev/null @@ -1,481 +0,0 @@ -import type { Change } from "../change.types.ts"; -import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts"; -import { - AlterTableDropColumn, - AlterTableDropConstraint, -} from "../objects/table/changes/table.alter.ts"; -import { DropTable } from "../objects/table/changes/table.drop.ts"; -import type { TableConstraintProps } from "../objects/table/table.model.ts"; -import { stableId } from "../objects/utils.ts"; - -/** - * Try to break an unbreakable cycle by INJECTING NEW CHANGES or REWRITING - * existing ones (rather than removing graph edges). - * - * Called by `sortPhaseChanges` when its edge-removal cycle handler has seen - * the same cycle twice — i.e. weak-edge filtering exhausted itself but the - * cycle is still there. At that point we know the cycle is composed of - * "hard" edges (explicit `requires` or pg_depend rows) that can only be - * broken by changing the change list itself. - * - * Returns a rewritten `phaseChanges` array, or `null` if no breaker matches - * (in which case the caller throws the existing CycleError). - */ -export function tryBreakCycleByChangeInjection( - cycleNodeIndexes: readonly number[], - phaseChanges: readonly Change[], -): Change[] | null { - // ─── Branch A: FK cycle among DropTable changes ────────────────────── - // Triggered when N≥2 dropped tables reference each other via foreign - // keys. With no surviving table on either side, every FK constraint - // stable-id ends up tied back to a DropTable node, and every - // pg_depend row produces a hard explicit edge between two DropTables. - // Edge filtering can't break it — the edges are not weak. - // - // Example (3-cycle): - // DROP TABLE a; DROP TABLE b; DROP TABLE c; - // where a.b_id REFERENCES b, b.c_id REFERENCES c, c.a_id REFERENCES a - // - // Fix: inject a dedicated `ALTER TABLE ... DROP CONSTRAINT fk` ahead of - // each DropTable in the cycle, and mark the constraint name on - // `DropTable.externallyDroppedConstraints` so the table drop won't try - // to re-claim the same constraint stable-id. The injected drops have - // their own stable-id ownership and run before any DropTable, breaking - // the cycle. - // - // This naturally handles any N (2-cycle, 3-cycle, …) because - // `findCycle` already gave us the full member list — no separate SCC - // enumeration needed. - const fkBroken = tryBreakFkCycle(cycleNodeIndexes, phaseChanges); - if (fkBroken) return fkBroken; - - // ─── Branch B: Publication ↔ Column on a surviving table ───────────── - // Triggered when a publication has an explicit column list and one of - // those columns is dropped on a table that itself is NOT being dropped - // (the table just loses one column). - // - // Example: - // CREATE PUBLICATION p FOR TABLE lab_results (id, flash_summary); - // ALTER TABLE lab_results DROP COLUMN flash_summary; - // - // Diff emits two drop-phase changes: - // AlterPublicationDropTables(p, [lab_results]) - // AlterTableDropColumn(lab_results.flash_summary) - // - // The cycle: - // pub:p → col:lab_results.flash_summary (catalog, pg_depend) - // col:lab_results.flash_summary → table:lab_results - // (explicit, AlterTableDropColumn.requires) - // - // Fix: rebuild the AlterTableDropColumn with `omitTableRequirement=true` - // so it no longer requires `table:lab_results`. Safe because - // `lab_results` survives the migration; its lifetime trivially covers - // the column drop. The catalog edge `pub → col` correctly orders the - // publication drop before the column drop. - const pubColBroken = tryBreakPublicationColumnCycle( - cycleNodeIndexes, - phaseChanges, - ); - if (pubColBroken) return pubColBroken; - - // ─── Branch C: Publication ↔ dropped FK chain ↔ constraint drop ────── - // Triggered when publication membership is being removed for tables in - // the same drop phase as a FK chain, and the chain ends at a separately - // emitted `AlterTableDropConstraint` on a table that is also being - // removed from the publication. - // - // Example (4-change cycle): - // AlterPublicationDropTables(p, [labs, posts, post_attachments]) - // DropTable(post_attachments) - // DropTable(posts) - // AlterTableDropConstraint(labs.unique_lab_id) - // - // Cycle: - // publication:p → table:post_attachments - // post_attachments.post_id_fkey → column:posts.id - // posts.lab_id_fkey → constraint:labs.unique_lab_id - // constraint:labs.unique_lab_id → table:labs - // - // Fix: inject explicit FK drops for the FK constraints claimed by the - // DropTables in the cycle, including FKs that point at the terminal - // dropped constraint. The publication and terminal constraint changes - // stay unchanged; only the intermediate FK ownership is reassigned from - // DropTable to dedicated AlterTableDropConstraint changes. - const pubFkConstraintBroken = tryBreakPublicationFkConstraintDropCycle( - cycleNodeIndexes, - phaseChanges, - ); - if (pubFkConstraintBroken) return pubFkConstraintBroken; - - // No known pattern. Returning null lets sortPhaseChanges throw the - // formatted CycleError with full diagnostic — better a clear bug - // report than silently shipping a broken plan. - return null; -} - -/** - * Branch A worker — inject `AlterTableDropConstraint` for every FK linking - * two DropTables in the cycle. - * - * Returns the rewritten changes array, or `null` if the cycle does not - * match (e.g. mixed types, or no cross-cycle FK exists). - */ -function tryBreakFkCycle( - cycleNodeIndexes: readonly number[], - phaseChanges: readonly Change[], -): Change[] | null { - // Guard: every member of the cycle must be a DropTable. Mixed cycles - // (e.g. DropTable + DropView, or DropTable + DropMaterializedView) are - // out of scope — they need a different breaker. - const cycleDropTables: DropTable[] = []; - for (const nodeIndex of cycleNodeIndexes) { - const change = phaseChanges[nodeIndex]; - if (!(change instanceof DropTable)) return null; - cycleDropTables.push(change); - } - - const cycleTableIds = new Set( - cycleDropTables.map((change) => change.table.stableId), - ); - - return injectFkConstraintDropsForDropTables({ - phaseChanges, - dropTables: cycleDropTables, - shouldInject: (fk, tableId) => - isCrossCycleFkConstraint(fk, tableId, cycleTableIds), - }); -} - -type FkConstraintPredicate = ( - fk: TableConstraintProps, - tableId: string, -) => boolean; - -/** - * Shared FK-drop injection used by Branch A and Branch C. The caller owns - * the cycle-specific matcher; this helper only handles the mechanical - * rewrite: add dedicated `AlterTableDropConstraint` changes and rebuild - * affected `DropTable`s with updated `externallyDroppedConstraints`. - */ -function injectFkConstraintDropsForDropTables({ - phaseChanges, - dropTables, - shouldInject, -}: { - phaseChanges: readonly Change[]; - dropTables: readonly DropTable[]; - shouldInject: FkConstraintPredicate; -}): Change[] | null { - // For each DropTable in the cycle, find every FK whose referenced table - // is also in the cycle. Each such FK becomes one injected - // `AlterTableDropConstraint` and one entry on the source table's - // `externallyDroppedConstraints`. - // - // 2-cycle example: { A→B, B→A } — two FKs, two injected drops. - // 3-cycle example: { A→B, B→C, C→A } — three FKs, three injected drops. - const injectedDropsByTableId = new Map(); - const updatedExternalsByTableId = new Map>(); - let didMutate = false; - - for (const dropTable of dropTables) { - const tableId = dropTable.table.stableId; - const existingExternals = new Set(dropTable.externallyDroppedConstraints); - let tableMutated = false; - - for (const fk of iterFkConstraints(dropTable.table.constraints)) { - if (!shouldInject(fk, tableId)) continue; - - // Skip if a same-table `AlterTableDropConstraint` is already in the - // change list — could happen if a previous breaker iteration - // injected one, or the diff layer emitted one explicitly. - if (existingExternals.has(fk.name)) continue; - if (alreadyHasExplicitDrop(phaseChanges, tableId, fk.name)) continue; - - const injected = new AlterTableDropConstraint({ - table: dropTable.table, - constraint: fk, - }); - const list = injectedDropsByTableId.get(tableId) ?? []; - list.push(injected); - injectedDropsByTableId.set(tableId, list); - existingExternals.add(fk.name); - tableMutated = true; - didMutate = true; - } - - if (tableMutated) { - updatedExternalsByTableId.set(tableId, existingExternals); - } - } - - if (!didMutate) return null; - - // Rebuild phaseChanges: keep all non-DropTable changes in place. For - // each DropTable in the cycle that gained injected drops, emit the - // injected drops first, then a fresh DropTable carrying the updated - // `externallyDroppedConstraints` so it stops claiming the FK - // stable-ids. - const rewritten: Change[] = []; - for (const change of phaseChanges) { - if (!(change instanceof DropTable)) { - rewritten.push(change); - continue; - } - const tableId = change.table.stableId; - const injected = injectedDropsByTableId.get(tableId); - if (injected) { - rewritten.push(...injected); - } - const updatedExternals = updatedExternalsByTableId.get(tableId); - if (updatedExternals) { - rewritten.push( - new DropTable({ - table: change.table, - externallyDroppedConstraints: updatedExternals, - }), - ); - } else { - rewritten.push(change); - } - } - return rewritten; -} - -/** - * Yield FK constraints on `constraints`. - * - * Partition clones are skipped because PostgreSQL drops them when the - * parent constraint is dropped. - */ -function* iterFkConstraints( - constraints: readonly TableConstraintProps[], -): Iterable { - for (const constraint of constraints) { - if (constraint.constraint_type !== "f") continue; - if (constraint.is_partition_clone) continue; - yield constraint; - } -} - -/** - * True when `constraint` references another DropTable in the cycle. - * - * Self-referencing FKs are skipped — they create a self-loop in the - * dependency graph which the existing sort-phase handler resolves on its - * own; injecting an `AlterTableDropConstraint` for a self-FK would just - * add noise. - */ -function isCrossCycleFkConstraint( - constraint: TableConstraintProps, - ownTableId: string, - cycleTableIds: ReadonlySet, -): boolean { - if (!constraint.foreign_key_schema || !constraint.foreign_key_table) { - return false; - } - const referencedId = stableId.table( - constraint.foreign_key_schema, - constraint.foreign_key_table, - ); - if (referencedId === ownTableId) return false; - return cycleTableIds.has(referencedId); -} - -/** - * True iff `phaseChanges` already contains an explicit - * `AlterTableDropConstraint(table, constraint)` for the given pair — - * either emitted by the diff layer or by a previous breaker iteration. - * Avoids duplicate constraint drops. - */ -function alreadyHasExplicitDrop( - phaseChanges: readonly Change[], - tableId: string, - constraintName: string, -): boolean { - for (const change of phaseChanges) { - if (!(change instanceof AlterTableDropConstraint)) continue; - if (change.table.stableId !== tableId) continue; - if (change.constraint.name === constraintName) return true; - } - return false; -} - -/** - * Branch B worker — break the publication↔column cycle by rebuilding the - * `AlterTableDropColumn` change with `omitTableRequirement=true`. - * - * Returns the rewritten changes array, or `null` if the cycle does not - * match (e.g. table is also being dropped, or no `AlterPublicationDropTables` - * references the table). - */ -function tryBreakPublicationColumnCycle( - cycleNodeIndexes: readonly number[], - phaseChanges: readonly Change[], -): Change[] | null { - // Find an `AlterTableDropColumn` and an `AlterPublicationDropTables` in - // the cycle that reference the same table. Both must be present — - // otherwise this is a different cycle shape. - let dropColumnIndex = -1; - let dropColumnChange: AlterTableDropColumn | null = null; - let pubMatchesTable = false; - let pubChange: AlterPublicationDropTables | null = null; - - for (const nodeIndex of cycleNodeIndexes) { - const change = phaseChanges[nodeIndex]; - if ( - change instanceof AlterTableDropColumn && - !change.omitTableRequirement - ) { - dropColumnIndex = nodeIndex; - dropColumnChange = change; - } else if (change instanceof AlterPublicationDropTables) { - pubChange = change; - } - } - if (dropColumnChange === null || pubChange === null) return null; - - // Verify the publication is actually dropping membership for the same - // table whose column is being dropped. Without this check we'd risk - // rewriting an unrelated AlterTableDropColumn that happens to share a - // cycle with some other publication change. - const targetTableId = dropColumnChange.table.stableId; - for (const t of pubChange.tables) { - if (stableId.table(t.schema, t.name) === targetTableId) { - pubMatchesTable = true; - break; - } - } - if (!pubMatchesTable) return null; - - // Verify the table is NOT itself being dropped. If `DropTable(T)` is in - // the same phase, the existing structural rewrites in - // `post-diff-normalization.ts` (replace-expansion superseded filter) - // already prune the redundant `AlterTableDropColumn`, so we should not - // see this combination here. Be defensive and bail anyway — flipping - // `omitTableRequirement` when T is being dropped would let the column - // drop reorder against the table drop, which is unsafe. - for (const change of phaseChanges) { - if ( - change instanceof DropTable && - change.table.stableId === targetTableId - ) { - return null; - } - } - - // Replace the AlterTableDropColumn with a fresh instance carrying - // `omitTableRequirement=true`. All other changes pass through - // unchanged. - const rewritten: Change[] = phaseChanges.slice(); - rewritten[dropColumnIndex] = new AlterTableDropColumn({ - table: dropColumnChange.table, - column: dropColumnChange.column, - omitTableRequirement: true, - }); - return rewritten; -} - -/** - * Branch C worker — break a publication membership removal cycle where - * dropped tables form a FK chain ending at a separately dropped referenced - * constraint. - */ -function tryBreakPublicationFkConstraintDropCycle( - cycleNodeIndexes: readonly number[], - phaseChanges: readonly Change[], -): Change[] | null { - let pubChange: AlterPublicationDropTables | null = null; - let terminalConstraintDrop: AlterTableDropConstraint | null = null; - const dropTables: DropTable[] = []; - - for (const nodeIndex of cycleNodeIndexes) { - const change = phaseChanges[nodeIndex]; - if (change instanceof AlterPublicationDropTables) { - if (pubChange !== null) return null; - pubChange = change; - } else if (change instanceof AlterTableDropConstraint) { - if (terminalConstraintDrop !== null) return null; - terminalConstraintDrop = change; - } else if (change instanceof DropTable) { - dropTables.push(change); - } else { - return null; - } - } - - if ( - pubChange === null || - terminalConstraintDrop === null || - dropTables.length === 0 - ) { - return null; - } - - const publicationTableIds = new Set( - pubChange.tables.map((table) => stableId.table(table.schema, table.name)), - ); - if (!publicationTableIds.has(terminalConstraintDrop.table.stableId)) { - return null; - } - - // At least one dropped table must be a publication member — that's the - // publication → DropTable edge that pulls the publication change into the - // cycle (the back-edge is the terminal constraint's table, checked above). - // Don't require ALL of them: publications like supabase_realtime commonly - // contain only a subset of tables, so intermediate FK-chain tables may not - // be members (Sentry SUPABASE-API-7RS / CLI-1605). - if ( - !dropTables.some((dropTable) => - publicationTableIds.has(dropTable.table.stableId), - ) - ) { - return null; - } - - const cycleDropTableIds = new Set( - dropTables.map((change) => change.table.stableId), - ); - let hasFkToTerminalConstraint = false; - - for (const dropTable of dropTables) { - for (const fk of iterFkConstraints(dropTable.table.constraints)) { - if (fkReferencesConstraint(fk, terminalConstraintDrop)) { - hasFkToTerminalConstraint = true; - break; - } - } - if (hasFkToTerminalConstraint) break; - } - if (!hasFkToTerminalConstraint) return null; - - return injectFkConstraintDropsForDropTables({ - phaseChanges, - dropTables, - shouldInject: (fk, tableId) => - isCrossCycleFkConstraint(fk, tableId, cycleDropTableIds) || - fkReferencesConstraint(fk, terminalConstraintDrop), - }); -} - -function fkReferencesConstraint( - fk: TableConstraintProps, - constraintDrop: AlterTableDropConstraint, -): boolean { - if ( - fk.foreign_key_schema !== constraintDrop.table.schema || - fk.foreign_key_table !== constraintDrop.table.name || - fk.foreign_key_columns === null - ) { - return false; - } - - return sameOrderedStrings( - fk.foreign_key_columns, - constraintDrop.constraint.key_columns, - ); -} - -function sameOrderedStrings(left: readonly string[], right: readonly string[]) { - if (left.length !== right.length) return false; - return left.every((value, index) => value === right[index]); -} diff --git a/packages/pg-delta/src/core/sort/debug-visualization.ts b/packages/pg-delta/src/core/sort/debug-visualization.ts deleted file mode 100644 index fd8820fb8..000000000 --- a/packages/pg-delta/src/core/sort/debug-visualization.ts +++ /dev/null @@ -1,239 +0,0 @@ -import debug from "debug"; -import type { Change } from "../change.types.ts"; -import { findCycle } from "./topological-sort.ts"; -import type { Constraint, GraphData, PgDependRow } from "./types.ts"; - -const debugGraph = debug("pg-delta:graph"); - -/** - * Generate a Mermaid diagram representation of the dependency graph for debugging. - */ -function generateMermaidDiagram( - phaseChanges: Change[], - graphData: GraphData, - edges: Array<[number, number]>, - requirementSets: Array>, - dependenciesByReferencedId: Map>, -): string { - const cycleNodeIndexes = findCycle(phaseChanges.length, edges) ?? []; - const mermaidLines: string[] = []; - mermaidLines.push("flowchart TD"); - - // Add nodes - for (let changeIndex = 0; changeIndex < phaseChanges.length; changeIndex++) { - const changeInstance = phaseChanges[changeIndex]; - const changeClassName = changeInstance?.constructor?.name ?? "Change"; - const truncatedCreates = Array.isArray(changeInstance.creates) - ? changeInstance.creates.slice(0, 3) - : []; - const nodeLabel = `${changeIndex}: ${changeClassName} ${ - truncatedCreates.length > 0 ? `[${truncatedCreates.join(",")}]` : "" - }`.replaceAll('"', "'"); - mermaidLines.push(` n${changeIndex}["${nodeLabel}"]`); - } - - // Add edges with descriptions - for (const [sourceIndex, targetIndex] of edges) { - const edgeDescription = describeEdge( - sourceIndex, - targetIndex, - graphData, - requirementSets, - dependenciesByReferencedId, - ).replaceAll('"', "'"); - if (edgeDescription.length > 0) { - mermaidLines.push( - ` n${sourceIndex} -- "${edgeDescription}" --> n${targetIndex}`, - ); - } else { - mermaidLines.push(` n${sourceIndex} --> n${targetIndex}`); - } - } - - // Highlight cycles if any - if (cycleNodeIndexes.length > 0) { - mermaidLines.push( - " classDef cycleNode fill:#ffe6e6,stroke:#ff4d4f,stroke-width:2px;", - ); - for (const nodeIndex of cycleNodeIndexes) { - mermaidLines.push(` class n${nodeIndex} cycleNode;`); - } - - const cycleEdges: Array<[number, number]> = []; - for ( - let cycleIndex = 0; - cycleIndex < cycleNodeIndexes.length; - cycleIndex++ - ) { - const sourceIndex = cycleNodeIndexes[cycleIndex]; - const targetIndex = - cycleNodeIndexes[(cycleIndex + 1) % cycleNodeIndexes.length]; - cycleEdges.push([sourceIndex, targetIndex]); - } - - let edgeIndex = 0; - for (const [sourceIndex, targetIndex] of edges) { - const edgeBelongsToCycle = cycleEdges.some( - ([cycleSourceIndex, cycleTargetIndex]) => - cycleSourceIndex === sourceIndex && cycleTargetIndex === targetIndex, - ); - if (edgeBelongsToCycle) { - mermaidLines.push( - ` linkStyle ${edgeIndex} stroke:#ff4d4f,stroke-width:2px;`, - ); - } - edgeIndex++; - } - } - - return mermaidLines.join("\n"); -} - -/** - * Build requirementSets from explicit requirements and constraints (for debug visualization). - * - * This reconstructs what requirements were inferred from constraints by looking at - * the constraints that were processed. Only processes catalog/explicit constraints, - * as custom constraints don't affect requirement sets. - */ -function buildRequirementSets( - explicitRequirementSets: Array>, - constraints: Constraint[], - changeIndexesByCreatedId: Map>, - _changeIndexesByExplicitRequirementId: Map>, -): Array> { - // Start with explicit requirements - const requirementSets: Array> = explicitRequirementSets.map( - (explicitRequirements) => new Set(explicitRequirements), - ); - - // Add requirements inferred from catalog/explicit constraints - // For each constraint with a reason, if the referenced ID is created by some change, - // then the target change requires the referenced ID - for (const constraint of constraints) { - // Only process catalog/explicit constraints (custom constraints don't affect requirements) - if (constraint.source === "custom" || !constraint.reason) continue; - - const referencedProducers = changeIndexesByCreatedId.get( - constraint.reason.referencedStableId, - ); - if (!referencedProducers || referencedProducers.size === 0) continue; - - // The target change requires the referenced stable ID - requirementSets[constraint.targetChangeIndex].add( - constraint.reason.referencedStableId, - ); - } - - return requirementSets; -} - -/** - * Build dependenciesByReferencedId from dependency rows (for debug visualization). - */ -function buildDependenciesByReferencedId( - dependencyRows: PgDependRow[], -): Map> { - const dependenciesByReferencedId = new Map>(); - for (const dependencyRow of dependencyRows) { - // Filter out unknown dependencies - if ( - dependencyRow.referenced_stable_id.startsWith("unknown:") || - dependencyRow.dependent_stable_id.startsWith("unknown:") - ) { - continue; - } - - let dependentIds = dependenciesByReferencedId.get( - dependencyRow.referenced_stable_id, - ); - if (!dependentIds) { - dependentIds = new Set(); - dependenciesByReferencedId.set( - dependencyRow.referenced_stable_id, - dependentIds, - ); - } - dependentIds.add(dependencyRow.dependent_stable_id); - } - return dependenciesByReferencedId; -} - -/** - * Describe an edge in the dependency graph for visualization. - */ -function describeEdge( - sourceIndex: number, - targetIndex: number, - graphData: GraphData, - requirementSets: Array>, - dependenciesByReferencedId: Map>, -): string { - // Check if target explicitly requires something created by source - for (const createdId of graphData.createdStableIdSets[sourceIndex]) { - if (requirementSets[targetIndex].has(createdId)) { - return `${createdId} -> (requires)`; - } - } - - // Check pg_depend relationships - for (const createdId of graphData.createdStableIdSets[sourceIndex]) { - const outgoingDependencies = dependenciesByReferencedId.get(createdId); - if (!outgoingDependencies) continue; - - // Check if target requires this ID - for (const requiredId of requirementSets[targetIndex]) { - if (outgoingDependencies.has(requiredId)) { - return `${createdId} -> ${requiredId}`; - } - } - - // Check if target creates something that depends on this ID - for (const targetCreatedId of graphData.createdStableIdSets[targetIndex]) { - if (outgoingDependencies.has(targetCreatedId)) { - return `${createdId} -> ${targetCreatedId}`; - } - } - } - - return ""; -} - -/** - * Print debug information about the dependency graph. - * - * Builds debug-only data structures (requirementSets, dependenciesByReferencedId) just-in-time. - */ -export function printDebugGraph( - phaseChanges: Change[], - graphData: GraphData, - edges: Array<[number, number]>, - dependencyRows: PgDependRow[], - constraints: Constraint[], -): void { - try { - // Build debug-only data structures just-in-time - const requirementSets = buildRequirementSets( - graphData.explicitRequirementSets, - constraints, - graphData.changeIndexesByCreatedId, - graphData.changeIndexesByExplicitRequirementId, - ); - const dependenciesByReferencedId = - buildDependenciesByReferencedId(dependencyRows); - - const mermaidDiagram = generateMermaidDiagram( - phaseChanges, - graphData, - edges, - requirementSets, - dependenciesByReferencedId, - ); - debugGraph( - "\n==== Mermaid (cycle detected) ====\n%s\n==== end ====", - mermaidDiagram, - ); - } catch { - // ignore debug printing errors - } -} diff --git a/packages/pg-delta/src/core/sort/dependency-filter.ts b/packages/pg-delta/src/core/sort/dependency-filter.ts deleted file mode 100644 index 533652c9c..000000000 --- a/packages/pg-delta/src/core/sort/dependency-filter.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { Change } from "../change.types.ts"; -import { CreateSequence } from "../objects/sequence/changes/sequence.create.ts"; -import { stableId } from "../objects/utils.ts"; -import { findConsumerIndexes } from "./graph-utils.ts"; -import type { Edge, GraphData } from "./types.ts"; - -/** - * Check if a sequence is owned by a given column. - * - * @param sequence - The sequence object with ownership information - * @param referencedStableId - The column stable ID to check against - * @returns true if the sequence is owned by the referenced column - */ -function isSequenceOwnedBy( - sequence: { - owned_by_schema: string | null; - owned_by_table: string | null; - owned_by_column: string | null; - }, - referencedStableId: string, -): boolean { - if ( - !sequence.owned_by_schema || - !sequence.owned_by_table || - !sequence.owned_by_column - ) { - return false; - } - - const ownedByColumnId = stableId.column( - sequence.owned_by_schema, - sequence.owned_by_table, - sequence.owned_by_column, - ); - - return referencedStableId === ownedByColumnId; -} - -/** - * Check if a sequence ownership dependency should be filtered to prevent cycles. - * - * CYCLE SCENARIO: - * When a sequence is owned by a table column that also uses the sequence (via DEFAULT), - * PostgreSQL's pg_depend creates a bidirectional dependency cycle: - * 1. column → sequence (column default depends on sequence) - * 2. sequence → column (sequence ownership depends on column) - * - * This creates: sequence → column → sequence (cycle!) - * - * HOW WE BREAK THE CYCLE: - * We filter out the ownership dependency edge (sequence → column) because: - * - CREATE phase: Sequences should be created before tables. Ownership is set later - * via ALTER SEQUENCE OWNED BY after both the sequence and table exist. - * - DROP phase: Prevents cycles when dropping sequences owned by tables that - * aren't being dropped. - * - * PARAMETERS: - * @param dependentStableId - The sequence stable ID (e.g., "sequence:schema.seq_name") - * @param referencedStableId - The column stable ID (e.g., "column:schema.table.col") - * Note: PostgreSQL's pg_depend creates sequence ownership dependencies on columns (not tables) - * when refobjsubid > 0, so we only check for column dependencies - * @param phaseChanges - All changes in the current phase - * @param graphData - Graph data structures for looking up changes - * @returns true if this ownership dependency should be filtered (removed) to break the cycle - */ -function shouldFilterSequenceOwnershipDependency( - dependentStableId: string, - referencedStableId: string, - phaseChanges: Change[], - graphData: GraphData, -): boolean { - // Early exit: only filter edges FROM sequences TO columns - // Note: PostgreSQL's pg_depend creates sequence ownership dependencies on columns (not tables) - // when refobjsubid > 0, so we only need to check for column dependencies - if ( - !dependentStableId.startsWith("sequence:") || - !referencedStableId.startsWith("column:") - ) { - return false; - } - - // Find all changes that create or consume this sequence - // (includes the CreateSequence change that creates it) - const changesInvolvingSequence = findConsumerIndexes( - dependentStableId, - graphData.changeIndexesByCreatedId, - graphData.changeIndexesByExplicitRequirementId, - ); - - // Check if any CreateSequence change creates a sequence that is owned by - // the referenced table/column. If so, filter this ownership dependency edge. - for (const changeIndex of changesInvolvingSequence) { - const change = phaseChanges[changeIndex]; - - // Only filter edges from CreateSequence changes, not AlterSequenceSetOwnedBy. - // AlterSequenceSetOwnedBy is a separate change that sets ownership after - // both the sequence and table exist, so it doesn't create the cycle. - if (!(change instanceof CreateSequence)) { - continue; - } - - // Check if this CreateSequence creates a sequence owned by the referenced table/column - if (isSequenceOwnedBy(change.sequence, referencedStableId)) { - return true; // Filter this edge to break the cycle - } - } - - return false; // Don't filter - this is not a cycle-causing ownership dependency -} - -/** - * Cycle-breaking filters for stable ID dependencies. - * - * Prevents cycles that would occur due to special PostgreSQL behaviors. - * Delegates to specific filter functions for each type of cycle. - * - * @param dependentStableId - The dependent object's stable ID - * @param referencedStableId - The referenced object's stable ID - * @param phaseChanges - All changes in the current phase - * @param graphData - Graph data structures for looking up changes - * @returns true if this dependency edge should be filtered (removed) to break a cycle - */ -function shouldFilterStableIdDependencyForCycleBreaking( - dependentStableId: string, - referencedStableId: string, - phaseChanges: Change[], - graphData: GraphData, -): boolean { - // Filter sequence ownership dependencies that create cycles with column defaults - if ( - shouldFilterSequenceOwnershipDependency( - dependentStableId, - referencedStableId, - phaseChanges, - graphData, - ) - ) { - return true; - } - - return false; -} - -/** - * Identify edges that are part of a cycle. - * - * Given cycle node indices, returns edges where both source and target are in the cycle - * and form consecutive nodes in the cycle path. - */ -export function getEdgesInCycle( - cycleNodeIndexes: number[], - edges: Edge[], -): Edge[] { - const cycleEdges: Edge[] = []; - - // Create a map of edges for quick lookup - const edgeMap = new Map(); - for (const edge of edges) { - const key = `${edge.sourceIndex}->${edge.targetIndex}`; - edgeMap.set(key, edge); - } - - // Find edges that connect consecutive nodes in the cycle - for (let i = 0; i < cycleNodeIndexes.length; i++) { - const sourceIndex = cycleNodeIndexes[i]; - const targetIndex = cycleNodeIndexes[(i + 1) % cycleNodeIndexes.length]; - const key = `${sourceIndex}->${targetIndex}`; - const edge = edgeMap.get(key); - if (edge) { - cycleEdges.push(edge); - } - } - - return cycleEdges; -} - -/** - * Filter edges involved in cycles based on their constraint's cycle-breaking rules. - * - * This is applied when cycles are detected to break them by removing problematic edges. - * Only filters edges that: - * 1. Are part of the detected cycle(s) - * 2. Have a reason (stable ID dependency) - custom constraints are never filtered - * 3. Match the cycle-breaking filter criteria - */ -export function filterEdgesForCycleBreaking( - edges: Edge[], - cycleNodeIndexes: number[], - phaseChanges: Change[], - graphData: GraphData, -): Edge[] { - // Get edges that are part of the cycle - const cycleEdges = getEdgesInCycle(cycleNodeIndexes, edges); - // Use string keys for comparison since Set.has() uses reference equality - const cycleEdgeKeys = new Set( - cycleEdges.map((e) => `${e.sourceIndex}->${e.targetIndex}`), - ); - - return edges.filter((edge) => { - const edgeKey = `${edge.sourceIndex}->${edge.targetIndex}`; - // If edge is not in the cycle, keep it - if (!cycleEdgeKeys.has(edgeKey)) { - return true; - } - - // Edge is in cycle - check if it should be filtered - const constraint = edge.constraint; - - // Custom constraints are never filtered - if (constraint.source === "custom") return true; - - const { dependentStableId, referencedStableId } = constraint.reason; - // Skip if dependentStableId is undefined (explicit requirement without created IDs) - if (!dependentStableId) return true; - - // Apply cycle-breaking filters - return false to filter out this edge - return !shouldFilterStableIdDependencyForCycleBreaking( - dependentStableId, - referencedStableId, - phaseChanges, - graphData, - ); - }); -} diff --git a/packages/pg-delta/src/core/sort/graph-builder.ts b/packages/pg-delta/src/core/sort/graph-builder.ts deleted file mode 100644 index eff6d2eee..000000000 --- a/packages/pg-delta/src/core/sort/graph-builder.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { Change } from "../change.types.ts"; -import { findConsumerIndexes } from "./graph-utils.ts"; -import type { - Constraint, - Edge, - GraphData, - PgDependRow, - PhaseSortOptions, -} from "./types.ts"; - -/** - * Convert catalog dependencies to Constraints. - * - * For each catalog dependency (stable ID → stable ID), finds the changes that - * create/require those stable IDs and creates Constraints between them. - * - * Filters out unknown stable IDs (basic validation). - * Cycle-breaking filters are applied later when detecting cycles. - */ -export function convertCatalogDependenciesToConstraints( - dependencyRows: PgDependRow[], - graphData: GraphData, -): Constraint[] { - const constraints: Constraint[] = []; - - for (const row of dependencyRows) { - // Filter out unknown stable IDs (basic validation) - if ( - row.referenced_stable_id.startsWith("unknown:") || - row.dependent_stable_id.startsWith("unknown:") - ) { - continue; - } - const producerIndexes = graphData.changeIndexesByCreatedId.get( - row.referenced_stable_id, - ); - if (!producerIndexes || producerIndexes.size === 0) continue; - - const consumerIndexes = findConsumerIndexes( - row.dependent_stable_id, - graphData.changeIndexesByCreatedId, - graphData.changeIndexesByExplicitRequirementId, - ); - if (consumerIndexes.size === 0) continue; - - for (const producerIndex of producerIndexes) { - for (const consumerIndex of consumerIndexes) { - if (producerIndex === consumerIndex) continue; - constraints.push({ - sourceChangeIndex: producerIndex, - targetChangeIndex: consumerIndex, - source: "catalog", - reason: { - dependentStableId: row.dependent_stable_id, - referencedStableId: row.referenced_stable_id, - }, - }); - } - } - } - - return constraints; -} - -/** - * Convert explicit requirements to Constraints. - * - * For each change that explicitly requires something: - * - If the change creates stable IDs, creates Constraints from producers of required IDs to this change - * - If the change doesn't create anything but requires something, creates Constraints from producers to this change - * - * Cycle-breaking filters are applied later when detecting cycles. - */ -export function convertExplicitRequirementsToConstraints( - phaseChanges: Change[], - graphData: GraphData, -): Constraint[] { - const constraints: Constraint[] = []; - - for ( - let consumerIndex = 0; - consumerIndex < phaseChanges.length; - consumerIndex++ - ) { - const createdIds = graphData.createdStableIdSets[consumerIndex]; - const requiredIds = graphData.explicitRequirementSets[consumerIndex]; - - if (requiredIds.size === 0) continue; - - // Collect dropped IDs for this change so we can skip requirements - // for stableIds that this change also drops. A change that drops a - // stableId should not depend on another change that creates the same - // stableId, because the entity already exists in the source database. - // This prevents false ordering constraints such as Grant → Revoke - // when both operate on the same ACL stableId. - const droppedIds = new Set(phaseChanges[consumerIndex].drops); - - for (const requiredId of requiredIds) { - if (droppedIds.has(requiredId)) { - continue; - } - - const producerIndexes = - graphData.changeIndexesByCreatedId.get(requiredId); - if (!producerIndexes || producerIndexes.size === 0) continue; - - if (createdIds.size > 0) { - for (const createdId of createdIds) { - for (const producerIndex of producerIndexes) { - if (producerIndex === consumerIndex) continue; - constraints.push({ - sourceChangeIndex: producerIndex, - targetChangeIndex: consumerIndex, - source: "explicit", - reason: { - dependentStableId: createdId, - referencedStableId: requiredId, - }, - }); - } - } - } else { - // Change doesn't create anything but requires something - for (const producerIndex of producerIndexes) { - if (producerIndex === consumerIndex) continue; - constraints.push({ - sourceChangeIndex: producerIndex, - targetChangeIndex: consumerIndex, - source: "explicit", - reason: { - referencedStableId: requiredId, - }, - }); - } - } - } - } - - return constraints; -} - -/** - * Build graph data structures from phase changes. - * - * Creates change sets and reverse indexes needed for converting dependencies to Constraints. - * In DROP phase (invert=true), dropped IDs are included in createdStableIdSets. - */ -export function buildGraphData( - phaseChanges: Change[], - options: PhaseSortOptions, -): GraphData { - const createdStableIdSets: Array> = phaseChanges.map( - (changeItem) => { - const createdIds = new Set(changeItem.creates); - if (options.invert) { - for (const droppedId of changeItem.drops ?? []) { - createdIds.add(droppedId); - } - // In-place mutations keep the object identity but invalidate - // dependents, so for drop-phase ordering they behave like producers of - // the invalidated ids without changing Change.drops. - for (const invalidatedId of changeItem.invalidates) { - createdIds.add(invalidatedId); - } - } - return createdIds; - }, - ); - - const explicitRequirementSets: Array> = phaseChanges.map( - (changeItem) => new Set(changeItem.requires ?? []), - ); - - const changeIndexesByCreatedId = new Map>(); - for (let changeIndex = 0; changeIndex < phaseChanges.length; changeIndex++) { - for (const createdId of createdStableIdSets[changeIndex]) { - let producerIndexes = changeIndexesByCreatedId.get(createdId); - if (!producerIndexes) { - producerIndexes = new Set(); - changeIndexesByCreatedId.set(createdId, producerIndexes); - } - producerIndexes.add(changeIndex); - } - } - - const changeIndexesByExplicitRequirementId = new Map>(); - for ( - let changeIndex = 0; - changeIndex < explicitRequirementSets.length; - changeIndex++ - ) { - for (const requiredId of explicitRequirementSets[changeIndex]) { - let consumerIndexes = - changeIndexesByExplicitRequirementId.get(requiredId); - if (!consumerIndexes) { - consumerIndexes = new Set(); - changeIndexesByExplicitRequirementId.set(requiredId, consumerIndexes); - } - consumerIndexes.add(changeIndex); - } - } - - return { - createdStableIdSets, - explicitRequirementSets, - changeIndexesByCreatedId, - changeIndexesByExplicitRequirementId, - }; -} - -/** - * Convert Constraints to edges. - */ -export function convertConstraintsToEdges( - constraints: Constraint[], - options: PhaseSortOptions, -): Edge[] { - const edges: Edge[] = []; - for (const constraint of constraints) { - if (constraint.sourceChangeIndex === constraint.targetChangeIndex) continue; - const sourceIndex = options.invert - ? constraint.targetChangeIndex - : constraint.sourceChangeIndex; - const targetIndex = options.invert - ? constraint.sourceChangeIndex - : constraint.targetChangeIndex; - edges.push({ - sourceIndex, - targetIndex, - constraint, - }); - } - return edges; -} - -/** - * Convert edges to simple edge pairs for cycle detection and sorting. - */ -export function edgesToPairs(edges: Edge[]): Array<[number, number]> { - return edges.map((edge) => [edge.sourceIndex, edge.targetIndex]); -} diff --git a/packages/pg-delta/src/core/sort/graph-utils.ts b/packages/pg-delta/src/core/sort/graph-utils.ts deleted file mode 100644 index c43cf6b08..000000000 --- a/packages/pg-delta/src/core/sort/graph-utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Edge } from "./types.ts"; - -/** - * Find all change indices that could be consumers for a given dependent stable ID. - * - * A consumer is either: - * 1. A change that explicitly requires the dependent ID, OR - * 2. A change that creates the dependent ID (since creating something implies - * it depends on its own dependencies) - */ -export function findConsumerIndexes( - dependentStableId: string, - changeIndexesByCreatedId: Map>, - changeIndexesByExplicitRequirementId: Map>, -): Set { - const consumerIndexes = new Set(); - - // Add changes that explicitly require this ID - const explicitConsumerIndexes = - changeIndexesByExplicitRequirementId.get(dependentStableId); - if (explicitConsumerIndexes) { - for (const consumerIndex of explicitConsumerIndexes) { - consumerIndexes.add(consumerIndex); - } - } - - // Add changes that create this ID (they are consumers of dependencies) - const dependentProducers = changeIndexesByCreatedId.get(dependentStableId); - if (dependentProducers) { - for (const producerIndex of dependentProducers) { - consumerIndexes.add(producerIndex); - } - } - - return consumerIndexes; -} - -/** - * Deduplicate edges, keeping the first occurrence. - */ -export function dedupeEdges(edges: Edge[]): Edge[] { - const seenEdges = new Set(); - const uniqueEdges: Edge[] = []; - for (const edge of edges) { - const edgeKey = `${edge.sourceIndex}->${edge.targetIndex}`; - if (seenEdges.has(edgeKey)) continue; - seenEdges.add(edgeKey); - uniqueEdges.push(edge); - } - return uniqueEdges; -} diff --git a/packages/pg-delta/src/core/sort/logical-sort.test.ts b/packages/pg-delta/src/core/sort/logical-sort.test.ts deleted file mode 100644 index b613ea733..000000000 --- a/packages/pg-delta/src/core/sort/logical-sort.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../change.types.ts"; -import { logicalSort } from "./logical-sort.ts"; - -function mockChange( - overrides: Partial<{ - objectType: string; - operation: string; - scope: string; - creates: string[]; - drops: string[]; - requires: string[]; - schema: string | null; - className: string; - inSchema: string | null; - objtype: string; - grantee: string; - eventTrigger: { function_schema: string }; - }>, -): Change { - const { - objectType = "table", - operation = "create", - scope = "object", - creates = [], - drops = [], - requires = [], - schema = "public", - className = "MockChange", - inSchema, - objtype, - grantee, - eventTrigger, - } = overrides; - - const change: Record = { - objectType, - operation, - scope, - creates, - drops, - requires, - }; - - if (objectType === "table") { - change.table = { schema, name: "t" }; - } else if (objectType === "schema") { - change.schema = { name: schema ?? "public" }; - } else if (objectType === "role") { - change.role = { name: "postgres" }; - } else if (objectType === "index") { - change.index = { schema, name: "idx" }; - } - - if (inSchema !== undefined) { - change.inSchema = inSchema; - } - if (objtype !== undefined) { - change.objtype = objtype; - } - if (grantee !== undefined) { - change.grantee = grantee; - } - if (eventTrigger !== undefined) { - change.eventTrigger = eventTrigger; - } - - Object.defineProperty(change, "constructor", { value: { name: className } }); - return change as unknown as Change; -} - -describe("logicalSort", () => { - test("returns empty array for empty input", () => { - expect(logicalSort([])).toEqual([]); - }); - - test("single change passes through", () => { - const c = mockChange({ creates: ["table:public.t"] }); - expect(logicalSort([c])).toEqual([c]); - }); - - describe("comment/privilege on constraint", () => { - test("CREATE comment on constraint groups with table", () => { - const tableCreate = mockChange({ - creates: ["table:public.t"], - }); - const commentOnConstraint = mockChange({ - scope: "comment", - operation: "create", - creates: ["comment:constraint:public.t.pk"], - requires: ["constraint:public.t.pk_name"], - }); - - const result = logicalSort([commentOnConstraint, tableCreate]); - expect(result).toHaveLength(2); - expect(result).toContain(tableCreate); - expect(result).toContain(commentOnConstraint); - }); - - test("DROP comment on constraint groups with table", () => { - const tableDrop = mockChange({ - operation: "drop", - drops: ["table:public.t"], - }); - const commentDrop = mockChange({ - scope: "comment", - operation: "drop", - creates: [], - requires: ["constraint:public.t.pk_name"], - }); - - const result = logicalSort([commentDrop, tableDrop]); - expect(result).toHaveLength(2); - }); - }); - - describe("comment/privilege on column", () => { - test("CREATE comment on column groups with table", () => { - const tableCreate = mockChange({ - creates: ["table:public.t"], - }); - const commentOnColumn = mockChange({ - scope: "comment", - operation: "create", - creates: ["comment:column:public.t.col"], - requires: ["column:public.t.col"], - }); - - const result = logicalSort([commentOnColumn, tableCreate]); - expect(result).toHaveLength(2); - }); - - test("DROP comment on column extracts table grouping key", () => { - const commentDrop = mockChange({ - scope: "comment", - operation: "drop", - creates: [], - requires: ["column:public.t.col"], - }); - const tableCreate = mockChange({ - creates: ["table:public.t"], - }); - - const result = logicalSort([commentDrop, tableCreate]); - expect(result).toHaveLength(2); - }); - }); - - describe("CREATE/DROP constraint", () => { - test("CREATE constraint groups with parent table", () => { - const tableCreate = mockChange({ - creates: ["table:public.t"], - }); - const constraintCreate = mockChange({ - operation: "create", - creates: ["constraint:public.t.pk_name"], - requires: ["table:public.t"], - }); - - const result = logicalSort([constraintCreate, tableCreate]); - expect(result).toHaveLength(2); - }); - - test("DROP constraint groups with parent table", () => { - const constraintDrop = mockChange({ - operation: "drop", - drops: ["constraint:public.t.pk_name"], - requires: ["table:public.t"], - }); - - const result = logicalSort([constraintDrop]); - expect(result).toHaveLength(1); - }); - }); - - describe("default_privilege scope", () => { - test("groups by role + schema combination", () => { - const defPriv = mockChange({ - scope: "default_privilege", - operation: "create", - creates: ["defacl:public.tables"], - requires: ["role:postgres", "schema:public"], - inSchema: "public", - }); - - const result = logicalSort([defPriv]); - expect(result).toHaveLength(1); - expect(result[0]).toBe(defPriv); - }); - - test("groups by role only when no schema", () => { - const defPriv = mockChange({ - scope: "default_privilege", - operation: "create", - creates: ["defacl:tables"], - requires: ["role:postgres"], - inSchema: null, - }); - - const result = logicalSort([defPriv]); - expect(result).toHaveLength(1); - }); - }); - - describe("ALTER with constraints and columns", () => { - test("ALTER with constraint creates groups with table", () => { - const alterConstraint = mockChange({ - operation: "alter", - creates: ["constraint:public.t.fk"], - requires: ["table:public.t"], - }); - - const result = logicalSort([alterConstraint]); - expect(result).toHaveLength(1); - }); - - test("ALTER with column creates groups with table", () => { - const alterColumn = mockChange({ - operation: "alter", - creates: ["column:public.t.new_col"], - requires: ["table:public.t"], - }); - - const result = logicalSort([alterColumn]); - expect(result).toHaveLength(1); - }); - - test("ALTER with constraint drops groups with table", () => { - const alterDropConstraint = mockChange({ - operation: "alter", - drops: ["constraint:public.t.fk"], - requires: ["table:public.t"], - }); - - const result = logicalSort([alterDropConstraint]); - expect(result).toHaveLength(1); - }); - - test("ALTER with constraint in requires groups with table", () => { - const alterValidateConstraint = mockChange({ - operation: "alter", - requires: ["constraint:public.t.pk"], - }); - - const result = logicalSort([alterValidateConstraint]); - expect(result).toHaveLength(1); - }); - }); - - describe("phase ordering", () => { - test("DROP changes come before CREATE changes", () => { - const createChange = mockChange({ - operation: "create", - creates: ["table:public.a"], - }); - const dropChange = mockChange({ - operation: "drop", - drops: ["table:public.b"], - }); - - const result = logicalSort([createChange, dropChange]); - expect(result[0]).toBe(dropChange); - expect(result[1]).toBe(createChange); - }); - }); - - describe("grouping related changes together", () => { - test("table create + constraint comment sort adjacent", () => { - const tableCreate = mockChange({ - creates: ["table:public.t"], - }); - const otherTable = mockChange({ - creates: ["table:public.z"], - }); - const commentOnConstraint = mockChange({ - scope: "comment", - operation: "create", - creates: ["comment:constraint:public.t.pk"], - requires: ["constraint:public.t.pk_name"], - }); - - const result = logicalSort([ - otherTable, - commentOnConstraint, - tableCreate, - ]); - expect(result).toHaveLength(3); - const tIdx = result.indexOf(tableCreate); - const cIdx = result.indexOf(commentOnConstraint); - const zIdx = result.indexOf(otherTable); - expect(Math.abs(tIdx - cIdx)).toBe(1); - expect(zIdx).not.toBe(tIdx + 1); - }); - - test("default_privilege sorts after schemas and roles", () => { - const schemaCreate = mockChange({ - objectType: "schema", - operation: "create", - creates: ["schema:public"], - schema: "public", - }); - const roleCreate = mockChange({ - objectType: "role", - operation: "create", - creates: ["role:postgres"], - schema: null, - }); - const defPriv = mockChange({ - scope: "default_privilege", - operation: "create", - creates: ["defacl:public.tables"], - requires: ["role:postgres", "schema:public"], - inSchema: "public", - }); - - const result = logicalSort([defPriv, schemaCreate, roleCreate]); - expect(result).toHaveLength(3); - expect(result.indexOf(defPriv)).toBeGreaterThan( - result.indexOf(schemaCreate), - ); - }); - - test("default_privilege orders deterministically by objtype then grantee", () => { - const baseRequires = ["role:postgres", "schema:public"]; - const defPrivTablesAnon = mockChange({ - scope: "default_privilege", - operation: "create", - creates: ["defacl:postgres:r:schema:public:grantee:anon"], - requires: baseRequires, - inSchema: "public", - objtype: "r", - grantee: "anon", - }); - const defPrivTablesAuthenticated = mockChange({ - scope: "default_privilege", - operation: "create", - creates: ["defacl:postgres:r:schema:public:grantee:authenticated"], - requires: baseRequires, - inSchema: "public", - objtype: "r", - grantee: "authenticated", - }); - const defPrivSequencesAnon = mockChange({ - scope: "default_privilege", - operation: "create", - creates: ["defacl:postgres:S:schema:public:grantee:anon"], - requires: baseRequires, - inSchema: "public", - objtype: "S", - grantee: "anon", - }); - const input = [ - defPrivTablesAuthenticated, - defPrivSequencesAnon, - defPrivTablesAnon, - ]; - const result = logicalSort(input); - expect(result).toHaveLength(3); - // Result ordered by canonical objtype (n,r,S,f,T) then grantee: r before S, anon before authenticated within r - const getKey = (c: Change) => - `${(c as { objtype: string }).objtype}:${(c as { grantee: string }).grantee}`; - expect(getKey(result[0])).toBe("r:anon"); - expect(getKey(result[1])).toBe("r:authenticated"); - expect(getKey(result[2])).toBe("S:anon"); - // Determinism: shuffling input must yield the same order - const shuffled = [...input].sort(() => Math.random() - 0.5); - const result2 = logicalSort(shuffled); - expect(result2.map(getKey)).toEqual(result.map(getKey)); - }); - }); -}); diff --git a/packages/pg-delta/src/core/sort/logical-sort.ts b/packages/pg-delta/src/core/sort/logical-sort.ts deleted file mode 100644 index 6ddbb5af6..000000000 --- a/packages/pg-delta/src/core/sort/logical-sort.ts +++ /dev/null @@ -1,573 +0,0 @@ -/** - * Logical pre-sorting for migration scripts. - * - * Groups changes by object type, stable ID, and scope to create a readable, - * logically organized migration script before dependency resolution. - * - * This is a pre-sorting step that runs before the dependency-based topological sort. - * It groups related changes together while preserving the ability for the dependency - * resolver to reorder within groups when necessary. - */ - -import type { Change } from "../change.types.ts"; -import { getSchema } from "../change-utils.ts"; -import { getExecutionPhase, isMetadataStableId, type Phase } from "./utils.ts"; - -/** - * Object type ordering for logical grouping. - * Lower numbers come first in the migration script. - */ -const OBJECT_TYPE_ORDER: Record = { - // CREATE/ALTER phase order (forward dependency) - schema: 1, - extension: 2, - role: 3, - language: 4, - collation: 5, - domain: 6, - enum: 7, - composite_type: 8, - range: 9, - sequence: 10, - procedure: 11, - aggregate: 12, - table: 13, - index: 14, // Grouped with tables/materialized views - view: 15, - materialized_view: 16, - trigger: 17, // Grouped with tables - rls_policy: 18, // Grouped with tables - rule: 19, // Grouped with tables/views - event_trigger: 20, - publication: 21, - subscription: 22, -}; - -/** - * Scope ordering within each stable ID group. - * Lower numbers come first. - */ -const SCOPE_ORDER_CREATE_ALTER: Record = { - default_privilege: 1, - object: 2, - comment: 3, - privilege: 4, - membership: 5, -}; - -const SCOPE_ORDER_DROP: Record = { - privilege: 1, - comment: 2, - object: 3, -}; - -/** - * Sub-entity object types that should be grouped by their parent's stable ID. - */ -const SUB_ENTITY_TYPES = new Set(["index", "trigger", "rls_policy", "rule"]); - -/** - * Regex for parsing stable IDs. - */ -const CONSTRAINT_REGEX = /^constraint:([^.]+)\.([^.]+)\./; -const COLUMN_REGEX = /^column:([^.]+)\.([^.]+)\./; - -/** - * Find the object stable ID from an array of stable IDs, skipping metadata stable IDs. - * Iterates through all stable IDs to find the first non-metadata one. - */ -function findObjectStableId(stableIds: string[]): string | null { - for (const id of stableIds) { - if (!isMetadataStableId(id)) { - return id; - } - } - // If all are metadata, return null (shouldn't happen, but safe fallback) - return stableIds.length > 0 ? stableIds[0] : null; -} - -/** - * Extract the main stable ID that a change is touching. - * - * For sub-entities (indexes, triggers, constraints, etc.), returns the parent's stable ID. - * For other changes, returns the primary stable ID being created/dropped/modified. - */ -function getMainStableId(change: Change): string | null { - // For sub-entities, extract parent stable ID from requires - if (SUB_ENTITY_TYPES.has(change.objectType)) { - return getParentStableId(change); - } - - // For metadata operations (comment, privilege): use requires to find object stable ID - // Check these BEFORE CREATE/DROP/ALTER logic to ensure they group with their target objects - if (change.scope === "comment" || change.scope === "privilege") { - // For CREATE comments/privileges: check creates first, but extract object stable ID from requires - if (change.operation === "create" && change.creates.length > 0) { - const createdId = change.creates[0]; - // If creating a comment/privilege, find the object stable ID from requires - if (isMetadataStableId(createdId)) { - const objectId = findObjectStableId(change.requires); - if (objectId) { - // Check if commenting on a constraint - extract table from it - if (objectId.startsWith("constraint:")) { - const match = objectId.match(CONSTRAINT_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - // Check if commenting on a column - extract table from it - // Format: column:schema.table.column - if (objectId.startsWith("column:")) { - const match = objectId.match(COLUMN_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - return objectId; - } - } - } - // For DROP/ALTER comments/privileges: find object stable ID from requires - if (change.requires.length > 0) { - const objectId = findObjectStableId(change.requires); - if (objectId) { - // Check if commenting on a constraint - extract table from it - if (objectId.startsWith("constraint:")) { - const match = objectId.match(CONSTRAINT_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - // Check if commenting on a column - extract table from it - // Format: column:schema.table.column - if (objectId.startsWith("column:")) { - const match = objectId.match(COLUMN_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - return objectId; - } - } - return null; - } - - // For default_privilege operations: group by role + schema combination (before CREATE so we group and use tiebreaker) - if (change.scope === "default_privilege") { - if (change.requires.length > 0) { - let grantingRole: string | null = null; - let schemaId: string | null = null; - for (const id of change.requires) { - if (id.startsWith("role:")) { - grantingRole = id; - } else if (id.startsWith("schema:")) { - schemaId = id; - } - } - if (schemaId && grantingRole) { - return `${grantingRole}:${schemaId}`; - } - return grantingRole ?? null; - } - } - - // For CREATE operations: check if creating a constraint (sub-entity of table) - if (change.operation === "create" && change.creates.length > 0) { - // Iterate through creates to find the first non-metadata stable ID - const createdId = findObjectStableId(change.creates); - if (createdId) { - if (createdId.startsWith("constraint:")) { - // Extract table stable ID from constraint stable ID - // Format: constraint:schema.table.constraint_name - const match = createdId.match(CONSTRAINT_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - return createdId; - } - // Fallback: if all creates are metadata (shouldn't happen for non-comment scopes), use first - return change.creates[0] ?? null; - } - - // For DROP operations: check if dropping a constraint (sub-entity of table) - if (change.operation === "drop" && change.drops.length > 0) { - // Iterate through drops to find the first non-metadata stable ID - const droppedId = findObjectStableId(change.drops); - if (droppedId) { - if (droppedId.startsWith("constraint:")) { - // Extract table stable ID from constraint stable ID - const match = droppedId.match(CONSTRAINT_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - return droppedId; - } - // Fallback: if all drops are metadata, use first - return change.drops[0] ?? null; - } - - // For ALTER operations: check if creating/dropping a constraint - // Skip this for privilege/comment/default_privilege scopes (handled above) - if (change.operation === "alter") { - // Check creates first (ADD CONSTRAINT, ADD COLUMN, etc.) - if (change.creates.length > 0) { - const createdId = findObjectStableId(change.creates); - if (createdId) { - if (createdId.startsWith("constraint:")) { - const match = createdId.match(CONSTRAINT_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - // Extract table stable ID from column stable IDs (for ALTER TABLE ADD COLUMN) - // Format: column:schema.table.column - if (createdId.startsWith("column:")) { - const match = createdId.match(COLUMN_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - return createdId; - } - // Fallback: if all creates are metadata, use first - return change.creates[0] ?? null; - } - // Check drops (DROP CONSTRAINT) - if (change.drops && change.drops.length > 0) { - const droppedId = findObjectStableId(change.drops); - if (droppedId) { - if (droppedId.startsWith("constraint:")) { - const match = droppedId.match(CONSTRAINT_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - return droppedId; - } - // Fallback: if all drops are metadata, use first - return change.drops[0] ?? null; - } - // Otherwise use requires (VALIDATE CONSTRAINT, etc.) - if (change.requires.length > 0) { - const requiredId = findObjectStableId(change.requires); - if (requiredId) { - // Check if requiring a constraint - extract table from it - if (requiredId.startsWith("constraint:")) { - const match = requiredId.match(CONSTRAINT_REGEX); - if (match) { - const [, schema, table] = match; - return `table:${schema}.${table}`; - } - } - return requiredId; - } - // Fallback: if all requires are metadata, use first - return change.requires[0] ?? null; - } - } - - // Fallback: try requires if available - if (change.requires.length > 0) { - return findObjectStableId(change.requires) ?? null; - } - - return null; -} - -/** - * Extract parent stable ID for sub-entities (indexes, triggers, RLS policies, rules). - * - * Looks for table/view/materialized view stable IDs in the change's requirements. - */ -function getParentStableId(change: Change): string | null { - const requires = change.requires; - - // Look for table, view, or materialized view stable IDs - for (const stableId of requires) { - if ( - stableId.startsWith("table:") || - stableId.startsWith("view:") || - stableId.startsWith("materializedView:") - ) { - return stableId; - } - } - - // Fallback: return first requires if available - return requires.length > 0 ? requires[0] : null; -} - -/** - * Get the effective object type for sorting purposes. - * For sub-entities, returns the parent's object type (table/view/materialized_view). - * For other objects, returns the object type as-is. - */ -function getEffectiveObjectType(change: Change): string { - // For sub-entities, determine parent type from stable ID - if (SUB_ENTITY_TYPES.has(change.objectType)) { - const parentStableId = getParentStableId(change); - if (parentStableId) { - if (parentStableId.startsWith("table:")) { - return "table"; - } - if (parentStableId.startsWith("view:")) { - return "view"; - } - if (parentStableId.startsWith("materializedView:")) { - return "materialized_view"; - } - } - } - return change.objectType; -} - -/** - * Get the object type order for sorting. - * Returns a high number for unknown types to sort them last. - */ -function getObjectTypeOrder(objectType: string): number { - return OBJECT_TYPE_ORDER[objectType] ?? 999; -} - -/** - * Get the scope order for sorting within a stable ID group. - */ -function getScopeOrder(scope: string, phase: Phase): number { - const orderMap = - phase === "drop" ? SCOPE_ORDER_DROP : SCOPE_ORDER_CREATE_ALTER; - return orderMap[scope] ?? 999; -} - -/** - * Logically pre-sort changes by grouping them into a readable structure. - * - * Groups changes by: - * 1. Phase (DROP vs CREATE/ALTER) - * 2. Object type (schema, table, index, etc.) - * 3. Main stable ID (table:public.users, etc.) - * 4. Scope (object, comment, privilege, etc.) - * - * Within each group, preserves the original order (stability). - * - * @param changes - Array of changes to sort - * @returns Logically grouped and sorted array of changes - */ -export function logicalSort(changes: Change[]): Change[] { - if (changes.length === 0) { - return changes; - } - - // Step 1: Partition by phase - const changesByPhase: Record = { - drop: [], - create_alter_object: [], - }; - - for (const change of changes) { - const phase = getExecutionPhase(change); - changesByPhase[phase].push(change); - } - - // Step 2: Sort each phase - const sortedDrop = sortPhase(changesByPhase.drop, "drop"); - const sortedCreateAlter = sortPhase( - changesByPhase.create_alter_object, - "create_alter_object", - ); - - // Step 3: Combine phases (DROP first, then CREATE/ALTER) - return [...sortedDrop, ...sortedCreateAlter]; -} - -/** - * Sort changes within a phase by object type, stable ID, and scope. - */ -function sortPhase(changes: Change[], phase: Phase): Change[] { - if (changes.length === 0) { - return changes; - } - - // Create a map to preserve original indices for stability - const changesWithIndices = changes.map((change, index) => ({ - change, - originalIndex: index, - })); - - // Sort by: schema → effective object type (only when schemas differ) → stable ID → actual object type → scope → original index - // Schema groups all objects within the same schema together - // Effective object type ensures schemas come before tables when comparing across schemas - // Stable ID groups sub-entities with their parents - // Actual object type orders sub-entities within their parent group - changesWithIndices.sort((a, b) => { - const changeA = a.change; - const changeB = b.change; - - // 1. Compare schemas (group objects by schema) - const schemaA = getSchema(changeA); - const schemaB = getSchema(changeB); - - // Non-schema objects (roles, languages, extensions, etc.) sort first - // Use a special prefix to ensure they come before schema objects - const schemaKeyA = schemaA === null ? "::" : schemaA; - const schemaKeyB = schemaB === null ? "::" : schemaB; - const schemaCompare = schemaKeyA.localeCompare(schemaKeyB); - if (schemaCompare !== 0) { - return schemaCompare; - } - - // 2. Compare effective object types (parent type for sub-entities) - // Only apply this ordering when schemas differ (for cross-schema ordering) - // Within the same schema, we want all objects grouped together - const effectiveTypeA = getEffectiveObjectType(changeA); - const effectiveTypeB = getEffectiveObjectType(changeB); - const effectiveTypeOrderA = getObjectTypeOrder(effectiveTypeA); - const effectiveTypeOrderB = getObjectTypeOrder(effectiveTypeB); - if (effectiveTypeOrderA !== effectiveTypeOrderB) { - return effectiveTypeOrderA - effectiveTypeOrderB; - } - - // 3. Compare main stable IDs (groups sub-entities with parents) - const stableIdA = getMainStableId(changeA); - const stableIdB = getMainStableId(changeB); - const stableIdCompare = (stableIdA ?? "").localeCompare(stableIdB ?? ""); - if (stableIdCompare !== 0) { - return stableIdCompare; - } - - // 4. Compare actual object types (orders sub-entities within parent group) - const typeOrderA = getObjectTypeOrder(changeA.objectType); - const typeOrderB = getObjectTypeOrder(changeB.objectType); - if (typeOrderA !== typeOrderB) { - return typeOrderA - typeOrderB; - } - - // 5. Compare scopes (within same stable ID and object type) - // Special handling: comments should come after CREATE object but before ALTER object - const scopeA = changeA.scope; - const scopeB = changeB.scope; - const operationA = changeA.operation; - const operationB = changeB.operation; - - // Special case: if one is "object" scope and one is "comment" scope - if (scopeA === "object" && scopeB === "comment") { - // Comment comes after CREATE object, but before ALTER object - if (operationA === "create") { - return -1; // CREATE object comes before comment (A < B) - } else if (operationA === "alter") { - return 1; // ALTER object comes after comment (A > B) - } - } else if (scopeA === "comment" && scopeB === "object") { - // Comment comes after CREATE object, but before ALTER object - if (operationB === "create") { - return 1; // CREATE object comes before comment (B < A, so A > B) - } else if (operationB === "alter") { - return -1; // ALTER object comes after comment (B > A, so A < B) - } - } - - // Special case: if one is ALTER TABLE ADD COLUMN and one is a column comment for that column - // Column comment should come right after ADD COLUMN - if ( - scopeA === "object" && - operationA === "alter" && - changeA.creates.length > 0 && - changeA.creates[0]?.startsWith("column:") - ) { - // This is ALTER TABLE ADD COLUMN - const addedColumnId = changeA.creates[0]; - if (scopeB === "comment" && changeB.requires.length > 0) { - const commentColumnId = changeB.requires[0]; - if (commentColumnId === addedColumnId) { - return -1; // ADD COLUMN comes before its column comment - } - } - } - if ( - scopeB === "object" && - operationB === "alter" && - changeB.creates.length > 0 && - changeB.creates[0]?.startsWith("column:") - ) { - // This is ALTER TABLE ADD COLUMN - const addedColumnId = changeB.creates[0]; - if (scopeA === "comment" && changeA.requires.length > 0) { - const commentColumnId = changeA.requires[0]; - if (commentColumnId === addedColumnId) { - return 1; // Column comment comes after ADD COLUMN - } - } - } - - // Special case: if both are comments, ensure table comments come before column comments - if (scopeA === "comment" && scopeB === "comment") { - // Check if one is a table comment and one is a column comment - const requiresA = - changeA.requires.length > 0 ? changeA.requires[0] : null; - const requiresB = - changeB.requires.length > 0 ? changeB.requires[0] : null; - - // Table comments require table stable ID, column comments require column stable ID - const isTableCommentA = requiresA?.startsWith("table:"); - const isTableCommentB = requiresB?.startsWith("table:"); - const isColumnCommentA = requiresA?.startsWith("column:"); - const isColumnCommentB = requiresB?.startsWith("column:"); - - // Table comments come before column comments - if (isTableCommentA && isColumnCommentB) { - return -1; // Table comment comes before column comment - } - if (isColumnCommentA && isTableCommentB) { - return 1; // Column comment comes after table comment - } - } - - // Default scope comparison - const scopeOrderA = getScopeOrder(scopeA, phase); - const scopeOrderB = getScopeOrder(scopeB, phase); - if (scopeOrderA !== scopeOrderB) { - return scopeOrderA - scopeOrderB; - } - - // 6. Compare operations (CREATE before ALTER within same stable ID, scope, and object type) - // This ensures CREATE ROLE comes before ALTER ROLE, CREATE SCHEMA before GRANT, etc. - const operationOrder: Record = { - create: 1, - alter: 2, - drop: 3, - }; - const operationOrderA = operationOrder[operationA] ?? 999; - const operationOrderB = operationOrder[operationB] ?? 999; - if (operationOrderA !== operationOrderB) { - return operationOrderA - operationOrderB; - } - - // 6b. For default_privilege: deterministic tiebreaker by objtype then grantee (canonical order for objtype) - if (scopeA === "default_privilege" && scopeB === "default_privilege") { - const defPrivA = changeA as { objtype: string; grantee: string }; - const defPrivB = changeB as { objtype: string; grantee: string }; - const objtypeOrder = (code: string) => - ({ n: 0, r: 1, S: 2, f: 3, T: 4 })[code] ?? 99; - const objtypeCompare = - objtypeOrder(defPrivA.objtype) - objtypeOrder(defPrivB.objtype); - if (objtypeCompare !== 0) return objtypeCompare; - const granteeCompare = defPrivA.grantee.localeCompare(defPrivB.grantee); - if (granteeCompare !== 0) return granteeCompare; - } - - // 7. Preserve original order (stability) - return a.originalIndex - b.originalIndex; - }); - - return changesWithIndices.map((item) => item.change); -} diff --git a/packages/pg-delta/src/core/sort/sort-changes.test.ts b/packages/pg-delta/src/core/sort/sort-changes.test.ts deleted file mode 100644 index 2305516e4..000000000 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Catalog, createEmptyCatalog } from "../catalog.model.ts"; -import type { Change } from "../change.types.ts"; -import type { PgDepend } from "../depend.ts"; -import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts"; -import { Publication } from "../objects/publication/publication.model.ts"; -import { - AlterTableAlterColumnType, - AlterTableDropConstraint, -} from "../objects/table/changes/table.alter.ts"; -import { DropTable } from "../objects/table/changes/table.drop.ts"; -import { Table } from "../objects/table/table.model.ts"; -import { CreateView } from "../objects/view/changes/view.create.ts"; -import { DropView } from "../objects/view/changes/view.drop.ts"; -import { View } from "../objects/view/view.model.ts"; -import { sortChanges } from "./sort-changes.ts"; - -const baseTableProps = { - schema: "public", - persistence: "p" as const, - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - privileges: [], -}; - -function integerColumn(name: string, position: number) { - return { - name, - position, - data_type: "integer", - data_type_str: "integer", - is_custom_type: false, - custom_type_type: null, - custom_type_category: null, - custom_type_schema: null, - custom_type_name: null, - not_null: false, - is_identity: false, - is_identity_always: false, - is_generated: false, - collation: null, - default: null, - comment: null, - }; -} - -function fkConstraint(props: { - name: string; - fkColumn: string; - targetTable: string; - targetColumn?: string; -}) { - const targetColumn = props.targetColumn ?? "id"; - return { - name: props.name, - constraint_type: "f" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: [props.fkColumn], - foreign_key_columns: [targetColumn], - foreign_key_table: props.targetTable, - foreign_key_schema: "public", - foreign_key_table_is_partition: false, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: "public", - foreign_key_effective_table: props.targetTable, - on_update: "a" as const, - on_delete: "a" as const, - match_type: "s" as const, - check_expression: null, - owner: "postgres", - definition: `FOREIGN KEY (${props.fkColumn}) REFERENCES public.${props.targetTable}(${targetColumn})`, - comment: null, - }; -} - -function uniqueConstraint(name: string, column: string) { - return { - name, - constraint_type: "u" as const, - deferrable: false, - initially_deferred: false, - validated: true, - is_local: true, - no_inherit: false, - is_temporal: false, - is_partition_clone: false, - parent_constraint_schema: null, - parent_constraint_name: null, - parent_table_schema: null, - parent_table_name: null, - key_columns: [column], - foreign_key_columns: null, - foreign_key_table: null, - foreign_key_schema: null, - foreign_key_table_is_partition: null, - foreign_key_parent_schema: null, - foreign_key_parent_table: null, - foreign_key_effective_schema: null, - foreign_key_effective_table: null, - on_update: null, - on_delete: null, - match_type: null, - check_expression: null, - owner: "postgres", - definition: `UNIQUE (${column})`, - comment: null, - }; -} - -function table( - name: string, - constraints: ConstructorParameters[0]["constraints"] = [], -) { - return new Table({ - ...baseTableProps, - name, - columns: [ - { ...integerColumn("id", 1), not_null: true }, - integerColumn("post_id", 2), - integerColumn("lab_id", 3), - ], - constraints, - }); -} - -function view(name: string, columns = [integerColumn("id", 1)]) { - return new View({ - schema: "public", - name, - definition: "SELECT id FROM users", - row_security: false, - force_row_security: false, - has_indexes: false, - has_rules: true, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d", - is_partition: false, - options: null, - partition_bound: null, - owner: "postgres", - comment: null, - columns, - privileges: [], - }); -} - -async function catalogWithDepends(depends: PgDepend[]) { - const base = await createEmptyCatalog(170000, "postgres"); - // oxlint-disable-next-line typescript/no-misused-spread - return new Catalog({ ...base, depends }); -} - -function changeLabel(change: Change) { - if (change instanceof AlterTableDropConstraint) { - return `${change.constructor.name}:${change.table.name}.${change.constraint.name}`; - } - if (change instanceof DropTable) { - return `${change.constructor.name}:${change.table.name}`; - } - return change.constructor.name; -} - -describe("sortChanges", () => { - test("orders dependent view drop before drop-phase column type rewrite", async () => { - const branchTable = table("users"); - const mainColumn = { - ...integerColumn("age", 4), - data_type: "numeric", - data_type_str: "numeric", - }; - const branchColumn = integerColumn("age", 4); - const dependentView = view("user_ages", [ - integerColumn("id", 1), - mainColumn, - ]); - const recreatedView = view("user_ages", [ - integerColumn("id", 1), - branchColumn, - ]); - const changes: Change[] = [ - new AlterTableAlterColumnType({ - table: branchTable, - column: branchColumn, - previousColumn: mainColumn, - }), - new DropView({ view: dependentView }), - new CreateView({ view: recreatedView }), - ]; - const mainCatalog = await catalogWithDepends([ - { - dependent_stable_id: dependentView.stableId, - referenced_stable_id: "column:public.users.age", - deptype: "n", - }, - ]); - const branchCatalog = await catalogWithDepends([]); - - const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); - - expect(sorted.map(changeLabel)).toEqual([ - "DropView", - "AlterTableAlterColumnType", - "CreateView", - ]); - }); - - test("breaks publication FK-chain constraint-drop cycle with one dropped table", async () => { - const labs = table("labs", [uniqueConstraint("unique_lab_id", "id")]); - const posts = table("posts", [ - fkConstraint({ - name: "posts_lab_id_fkey", - fkColumn: "lab_id", - targetTable: "labs", - }), - ]); - const publication = new Publication({ - name: "supabase_realtime", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [ - { - schema: "public", - name: "labs", - columns: null, - row_filter: null, - }, - { - schema: "public", - name: "posts", - columns: null, - row_filter: null, - }, - ], - schemas: [], - }); - const changes: Change[] = [ - new AlterPublicationDropTables({ - publication, - tables: publication.tables, - }), - new DropTable({ table: posts }), - new AlterTableDropConstraint({ - table: labs, - constraint: labs.constraints[0], - }), - ]; - const mainCatalog = await catalogWithDepends([ - { - dependent_stable_id: "publication:supabase_realtime", - referenced_stable_id: "table:public.posts", - deptype: "n", - }, - { - dependent_stable_id: "constraint:public.posts.posts_lab_id_fkey", - referenced_stable_id: "constraint:public.labs.unique_lab_id", - deptype: "n", - }, - ]); - const branchCatalog = await catalogWithDepends([]); - - const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); - - expect(sorted.map(changeLabel)).toContain( - "AlterTableDropConstraint:posts.posts_lab_id_fkey", - ); - }); - - test("breaks publication FK-chain constraint-drop cycle in the drop phase", async () => { - const labs = table("labs", [uniqueConstraint("unique_lab_id", "id")]); - const posts = table("posts", [ - fkConstraint({ - name: "posts_lab_id_fkey", - fkColumn: "lab_id", - targetTable: "labs", - }), - ]); - const postAttachments = table("post_attachments", [ - fkConstraint({ - name: "post_attachments_post_id_fkey", - fkColumn: "post_id", - targetTable: "posts", - }), - ]); - const publication = new Publication({ - name: "supabase_realtime", - owner: "postgres", - comment: null, - all_tables: false, - publish_insert: true, - publish_update: true, - publish_delete: true, - publish_truncate: true, - publish_via_partition_root: false, - tables: [ - { - schema: "public", - name: "labs", - columns: null, - row_filter: null, - }, - { - schema: "public", - name: "post_attachments", - columns: null, - row_filter: null, - }, - { - schema: "public", - name: "posts", - columns: null, - row_filter: null, - }, - ], - schemas: [], - }); - const changes: Change[] = [ - new AlterPublicationDropTables({ - publication, - tables: publication.tables, - }), - new DropTable({ table: postAttachments }), - new DropTable({ table: posts }), - new AlterTableDropConstraint({ - table: labs, - constraint: labs.constraints[0], - }), - ]; - const mainCatalog = await catalogWithDepends([ - { - dependent_stable_id: "publication:supabase_realtime", - referenced_stable_id: "table:public.post_attachments", - deptype: "n", - }, - { - dependent_stable_id: - "constraint:public.post_attachments.post_attachments_post_id_fkey", - referenced_stable_id: "column:public.posts.id", - deptype: "n", - }, - { - dependent_stable_id: "constraint:public.posts.posts_lab_id_fkey", - referenced_stable_id: "constraint:public.labs.unique_lab_id", - deptype: "n", - }, - ]); - const branchCatalog = await catalogWithDepends([]); - - const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); - - expect(sorted.map(changeLabel)).toContain( - "AlterTableDropConstraint:post_attachments.post_attachments_post_id_fkey", - ); - expect(sorted.map(changeLabel)).toContain( - "AlterTableDropConstraint:posts.posts_lab_id_fkey", - ); - }); -}); diff --git a/packages/pg-delta/src/core/sort/sort-changes.ts b/packages/pg-delta/src/core/sort/sort-changes.ts deleted file mode 100644 index 4b6ba9e23..000000000 --- a/packages/pg-delta/src/core/sort/sort-changes.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Phased dependency-graph sort for ordered schema changes. - * - * Changes are split into two execution phases: - * - `drop`: Destructive operations (executed first, in reverse dependency order) - * - `create_alter_object`: All remaining changes (executed second, in forward dependency order) - * - * Within each phase, changes are sorted using Constraints derived from: - * - Catalog dependencies (from pg_depend) - * - Explicit requirements (from Change.requires) - * - Custom constraints (change-to-change ordering rules) - */ - -import debug from "debug"; -import type { Catalog } from "../catalog.model.ts"; -import type { Change } from "../change.types.ts"; -import { generateCustomConstraints } from "./custom-constraints.ts"; -import { tryBreakCycleByChangeInjection } from "./cycle-breakers.ts"; -import { printDebugGraph } from "./debug-visualization.ts"; - -const debugGraph = debug("pg-delta:graph"); - -import { - filterEdgesForCycleBreaking, - getEdgesInCycle, -} from "./dependency-filter.ts"; -import { - buildGraphData, - convertCatalogDependenciesToConstraints, - convertConstraintsToEdges, - convertExplicitRequirementsToConstraints, - edgesToPairs, -} from "./graph-builder.ts"; -import { dedupeEdges } from "./graph-utils.ts"; -import { logicalSort } from "./logical-sort.ts"; -import { - findCycle, - formatCycleError, - performStableTopologicalSort, -} from "./topological-sort.ts"; -import type { PgDependRow, PhaseSortOptions } from "./types.ts"; -import { UnorderableCycleError } from "./unorderable-cycle-error.ts"; -import { getExecutionPhase, type Phase } from "./utils.ts"; - -// `sortPhaseChanges` caps the change-injection breaker at one round per -// node in the initial phase: there can never be more disjoint unbreakable -// cycles than there are change nodes (each cycle has ≥ 2 distinct nodes). -// The cap exists only to surface a buggy breaker as `CycleError` instead -// of an infinite loop — the actual loop-protection guarantee comes from -// `breakerRoundSignatures`, which throws the moment the same cycle -// reappears after a break. - -/** - * Sort changes using dependency information from catalogs and custom constraints. - * - * First applies logical pre-sorting to group related changes together, - * then applies dependency-based topological sorting to ensure correct execution order. - * - * @param catalogs - Main and branch catalogs containing dependency information - * @param changes - List of Change objects to order - * @returns Ordered list of Change objects - */ -export function sortChanges( - catalogs: { mainCatalog: Catalog; branchCatalog: Catalog }, - changes: Change[], -): Change[] { - // Step 1: Apply logical pre-sorting to group changes by object type, stable ID, and scope - const logicallySorted = logicalSort(changes); - - // Step 2: Apply dependency-based topological sorting - return sortChangesByPhasedGraph( - { - mainCatalog: { depends: catalogs.mainCatalog.depends }, - branchCatalog: { depends: catalogs.branchCatalog.depends }, - }, - logicallySorted, - ); -} - -/** - * Sort changes by phases, using dependency information in each phase. - * - * @param catalogContext - pg_depend rows from the main and branch catalogs - * @param changeList - list of Change objects to order - * @returns ordered list of Change objects - */ -function sortChangesByPhasedGraph( - catalogContext: { - mainCatalog: { depends: PgDependRow[] }; - branchCatalog: { depends: PgDependRow[] }; - }, - changeList: Change[], -): Change[] { - const changesByPhase: Record = { - drop: [], - create_alter_object: [], - }; - - // Partition changes into execution phases - for (const changeItem of changeList) { - const phase = getExecutionPhase(changeItem); - changesByPhase[phase].push(changeItem); - } - - // Sort DROP phase: reverse dependency order using main catalog dependencies - const sortedDropPhase = sortPhaseChanges( - changesByPhase.drop, - catalogContext.mainCatalog.depends, - { invert: true }, - ); - - // Sort CREATE/ALTER phase: forward dependency order using branch catalog dependencies - const sortedCreateAlterPhase = sortPhaseChanges( - changesByPhase.create_alter_object, - catalogContext.branchCatalog.depends, - {}, - ); - - return [...sortedDropPhase, ...sortedCreateAlterPhase]; -} - -/** - * Normalize a cycle by rotating it to start with the smallest node index, so - * cycles that loop through the same nodes in the same direction compare equal - * regardless of where DFS happened to enter them. - */ -function normalizeCycle(cycleNodeIndexes: number[]): string { - if (cycleNodeIndexes.length === 0) return ""; - const minIndex = Math.min(...cycleNodeIndexes); - const minIndexPos = cycleNodeIndexes.indexOf(minIndex); - const rotated = [ - ...cycleNodeIndexes.slice(minIndexPos), - ...cycleNodeIndexes.slice(0, minIndexPos), - ]; - return rotated.join(","); -} - -type SortRoundResult = - | { kind: "sorted"; sorted: Change[] } - | { - kind: "unbreakable"; - cycleNodeIndexes: number[]; - cycleEdges: ReturnType; - }; - -/** - * One attempt at sorting `phaseChanges`. Builds the graph from scratch, - * runs the iterative edge-removal cycle handler, and either returns a - * topologically sorted list or reports an unbreakable cycle so the caller - * can decide whether to dispatch a change-injection breaker. - * - * Algorithm: - * 1. Build graph data (change sets and reverse indexes). - * 2. Convert all sources to Constraints (catalog, explicit, custom). - * 3. Convert Constraints to edges. - * 4. Iteratively detect and break cycles by removing weak edges. - * 5. Perform stable topological sort on the acyclic graph. - * - * In DROP phase, edges are inverted so drops run in reverse dependency - * order. - */ -function attemptSortRound( - phaseChanges: Change[], - dependencyRows: PgDependRow[], - options: PhaseSortOptions, -): SortRoundResult { - // Step 1: Build graph data structures - const graphData = buildGraphData(phaseChanges, options); - - // Step 2: Convert all sources to Constraints - const catalogConstraints = convertCatalogDependenciesToConstraints( - dependencyRows, - graphData, - ); - const explicitConstraints = convertExplicitRequirementsToConstraints( - phaseChanges, - graphData, - ); - const customConstraintObjects = generateCustomConstraints(phaseChanges); - const allConstraints = [ - ...catalogConstraints, - ...explicitConstraints, - ...customConstraintObjects, - ]; - - // Step 3: Convert constraints to edges and deduplicate immediately - let edges = dedupeEdges(convertConstraintsToEdges(allConstraints, options)); - - // Step 4: Iteratively detect and break cycles by edge filtering. - // We loop until no cycles remain OR we see the same cycle twice — the - // latter signals that edge filtering exhausted itself. At that point - // the caller may dispatch a change-injection breaker; if no breaker - // matches, the original throw path runs. - const seenCycles = new Set(); - - while (true) { - const edgePairs = edgesToPairs(edges); - const cycleNodeIndexes = findCycle(phaseChanges.length, edgePairs); - - if (!cycleNodeIndexes) break; - - const cycleSignature = normalizeCycle(cycleNodeIndexes); - if (seenCycles.has(cycleSignature)) { - // Edge filtering can't break this cycle. Report it back to the - // caller so it can try change-injection before throwing. - return { - kind: "unbreakable", - cycleNodeIndexes, - cycleEdges: getEdgesInCycle(cycleNodeIndexes, edges), - }; - } - seenCycles.add(cycleSignature); - - edges = filterEdgesForCycleBreaking( - edges, - cycleNodeIndexes, - phaseChanges, - graphData, - ); - } - - const finalEdgePairs = edgesToPairs(edges); - - if (debugGraph.enabled) { - printDebugGraph( - phaseChanges, - graphData, - finalEdgePairs, - dependencyRows, - allConstraints, - ); - } - - // Step 5: Perform stable topological sort (no cycles, so this will succeed) - const topologicalOrder = performStableTopologicalSort( - phaseChanges.length, - finalEdgePairs, - ); - - if (!topologicalOrder || topologicalOrder.length !== phaseChanges.length) { - // This should never happen if findCycle returned null, but guard anyway - throw new UnorderableCycleError( - "CycleError: dependency graph contains a cycle", - ); - } - - return { - kind: "sorted", - sorted: topologicalOrder.map((changeIndex) => phaseChanges[changeIndex]), - }; -} - -/** - * Sort changes within a phase. Tries `attemptSortRound`; on an unbreakable - * cycle, dispatches to `tryBreakCycleByChangeInjection`, retries with the - * rewritten changes, and bails after `MAX_CYCLE_BREAKER_ROUNDS` to surface - * a buggy breaker as `CycleError` instead of an infinite loop. - * - * Best case (no cycles, the vast majority of plans): one round, no - * change-injection breaker code runs at all. - */ -function sortPhaseChanges( - initialPhaseChanges: Change[], - dependencyRows: PgDependRow[], - options: PhaseSortOptions = {}, -): Change[] { - if (initialPhaseChanges.length <= 1) return initialPhaseChanges; - - let phaseChanges = initialPhaseChanges; - const breakerRoundSignatures = new Set(); - - // `attemptSortRound` returns at most one unbreakable cycle per call, - // so a phase with K independent unbreakable cycles needs K+1 rounds. - // Every cycle contains ≥ 2 distinct change nodes, so the maximum - // possible value of K is `floor(initialPhaseChanges.length / 2)` — - // using `initialPhaseChanges.length` itself is therefore a real upper - // bound with one round of slack (and matches the early-return guard - // above, which already excluded length-0 and length-1 phases). - const maxRounds = initialPhaseChanges.length; - - for (let round = 0; round <= maxRounds; round++) { - const result = attemptSortRound(phaseChanges, dependencyRows, options); - if (result.kind === "sorted") return result.sorted; - - // Edge filtering hit an unbreakable cycle. Try the change-injection - // breakers (FK pattern, publication↔column pattern). If none matches, - // throw with the same diagnostic the original code emitted. - const broken = tryBreakCycleByChangeInjection( - result.cycleNodeIndexes, - phaseChanges, - ); - if (broken === null) { - throw new UnorderableCycleError( - formatCycleError( - result.cycleNodeIndexes, - phaseChanges, - result.cycleEdges, - ), - result.cycleNodeIndexes.map((index) => phaseChanges[index]), - ); - } - - // Loop guard: if the same cycle node-set re-appears after a break, - // the breaker isn't making progress. Throw with full context. - const signature = normalizeCycle(result.cycleNodeIndexes); - if (breakerRoundSignatures.has(signature)) { - throw new UnorderableCycleError( - formatCycleError( - result.cycleNodeIndexes, - phaseChanges, - result.cycleEdges, - ), - result.cycleNodeIndexes.map((index) => phaseChanges[index]), - ); - } - breakerRoundSignatures.add(signature); - - phaseChanges = broken; - } - - throw new UnorderableCycleError( - `CycleError: change-injection breaker exceeded ${maxRounds} rounds (one per node in the phase) — likely a buggy breaker rule`, - ); -} diff --git a/packages/pg-delta/src/core/sort/topological-sort.test.ts b/packages/pg-delta/src/core/sort/topological-sort.test.ts deleted file mode 100644 index f980423f0..000000000 --- a/packages/pg-delta/src/core/sort/topological-sort.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../change.types.ts"; -import { - findCycle, - formatCycleError, - performStableTopologicalSort, -} from "./topological-sort.ts"; -import type { Constraint } from "./types.ts"; - -function mockChange(name: string, creates: string[] = []): Change { - const change = { - objectType: "table", - operation: "create" as const, - scope: "object", - creates, - drops: [], - requires: [], - serialize: () => "", - }; - Object.defineProperty(change, "constructor", { value: { name } }); - return change as unknown as Change; -} - -describe("performStableTopologicalSort", () => { - test("no edges returns identity order", () => { - const result = performStableTopologicalSort(3, []); - expect(result).toEqual([0, 1, 2]); - }); - - test("linear chain produces correct order", () => { - const result = performStableTopologicalSort(3, [ - [0, 1], - [1, 2], - ]); - expect(result).toEqual([0, 1, 2]); - }); - - test("reversed linear chain reorders correctly", () => { - const result = performStableTopologicalSort(3, [ - [2, 1], - [1, 0], - ]); - expect(result).toEqual([2, 1, 0]); - }); - - test("diamond dependency resolves correctly", () => { - const result = performStableTopologicalSort(4, [ - [0, 1], - [0, 2], - [1, 3], - [2, 3], - ]); - expect(result).toEqual([0, 1, 2, 3]); - }); - - test("cycle returns null", () => { - const result = performStableTopologicalSort(2, [ - [0, 1], - [1, 0], - ]); - expect(result).toBeNull(); - }); - - test("stable ordering among unconstrained nodes", () => { - const result = performStableTopologicalSort(5, [[3, 4]]); - expect(result).toEqual([0, 1, 2, 3, 4]); - }); - - test("duplicate edges are handled", () => { - const result = performStableTopologicalSort(2, [ - [0, 1], - [0, 1], - ]); - expect(result).toEqual([0, 1]); - }); - - test("single node returns identity", () => { - const result = performStableTopologicalSort(1, []); - expect(result).toEqual([0]); - }); -}); - -describe("findCycle", () => { - test("no edges means no cycle", () => { - expect(findCycle(3, [])).toBeNull(); - }); - - test("linear chain has no cycle", () => { - expect( - findCycle(3, [ - [0, 1], - [1, 2], - ]), - ).toBeNull(); - }); - - test("simple cycle is detected", () => { - const cycle = findCycle(2, [ - [0, 1], - [1, 0], - ]); - expect(cycle).not.toBeNull(); - expect(cycle?.length).toBeGreaterThanOrEqual(2); - }); - - test("three-node cycle is detected", () => { - const cycle = findCycle(3, [ - [0, 1], - [1, 2], - [2, 0], - ]); - expect(cycle).not.toBeNull(); - expect(cycle?.length).toBe(3); - }); - - test("self-loop is detected", () => { - const cycle = findCycle(1, [[0, 0]]); - expect(cycle).not.toBeNull(); - }); - - test("cycle in subgraph is found", () => { - const cycle = findCycle(4, [ - [0, 1], - [2, 3], - [3, 2], - ]); - expect(cycle).not.toBeNull(); - expect(cycle).toContain(2); - expect(cycle).toContain(3); - }); -}); - -describe("formatCycleError", () => { - test("basic format without cycleEdges", () => { - const changes = [ - mockChange("CreateTable", ["table:public.a"]), - mockChange("CreateTable", ["table:public.b"]), - ]; - - const message = formatCycleError([0, 1], changes); - expect(message).toContain("CycleError"); - expect(message).toContain("2 changes"); - expect(message).toContain("CreateTable"); - expect(message).toContain("table:public.a"); - expect(message).toContain("table:public.b"); - expect(message).toContain("circular dependency"); - expect(message).not.toContain("Cycle path"); - }); - - test("catalog source with dependentStableId", () => { - const changes = [ - mockChange("CreateTable", ["table:public.a"]), - mockChange("CreateView", ["view:public.v"]), - ]; - - const constraint: Constraint = { - sourceChangeIndex: 0, - targetChangeIndex: 1, - source: "catalog", - reason: { - dependentStableId: "view:public.v", - referencedStableId: "table:public.a", - }, - }; - - const message = formatCycleError([0, 1], changes, [ - { sourceIndex: 0, targetIndex: 1, constraint }, - ]); - expect(message).toContain("Cycle path"); - expect(message).toContain("source: catalog"); - expect(message).toContain("Dependency: view:public.v → table:public.a"); - expect(message).toContain("Cycle-breaking filter did not match"); - expect(message).toContain("cycle-breaking filters were unable"); - }); - - test("explicit source without dependentStableId", () => { - const changes = [ - mockChange("CreateTable"), - mockChange("CreateView", ["view:public.v"]), - ]; - - const constraint: Constraint = { - sourceChangeIndex: 0, - targetChangeIndex: 1, - source: "explicit", - reason: { - referencedStableId: "table:public.a", - }, - }; - - const message = formatCycleError([0, 1], changes, [ - { sourceIndex: 0, targetIndex: 1, constraint }, - ]); - expect(message).toContain("source: explicit"); - expect(message).toContain("Requires: table:public.a"); - expect(message).toContain( - "Explicit requirement without created IDs (not filtered)", - ); - }); - - test("custom source constraint", () => { - const changes = [ - mockChange("CreateTable", ["table:public.a"]), - mockChange("CreateTable", ["table:public.b"]), - ]; - - const constraint: Constraint = { - sourceChangeIndex: 0, - targetChangeIndex: 1, - source: "custom", - }; - - const message = formatCycleError([0, 1], changes, [ - { sourceIndex: 0, targetIndex: 1, constraint }, - ]); - expect(message).toContain("source: custom"); - expect(message).toContain("Custom constraint (never filtered)"); - }); - - test("edge not found in cycleEdges", () => { - const changes = [ - mockChange("CreateTable", ["table:public.a"]), - mockChange("CreateTable", ["table:public.b"]), - mockChange("CreateView", ["view:public.v"]), - ]; - - const unrelatedConstraint: Constraint = { - sourceChangeIndex: 2, - targetChangeIndex: 0, - source: "custom", - }; - - const message = formatCycleError([0, 1], changes, [ - { sourceIndex: 2, targetIndex: 0, constraint: unrelatedConstraint }, - ]); - expect(message).toContain("(edge not found)"); - }); - - test("explicit source with dependentStableId uses filter message", () => { - const changes = [ - mockChange("CreateTable", ["table:public.a"]), - mockChange("CreateView", ["view:public.v"]), - ]; - - const constraint: Constraint = { - sourceChangeIndex: 0, - targetChangeIndex: 1, - source: "explicit", - reason: { - dependentStableId: "view:public.v", - referencedStableId: "table:public.a", - }, - }; - - const message = formatCycleError([0, 1], changes, [ - { sourceIndex: 0, targetIndex: 1, constraint }, - ]); - expect(message).toContain("Dependency: view:public.v → table:public.a"); - expect(message).toContain("Cycle-breaking filter did not match"); - }); - - test("change with many creates truncates", () => { - const changes = [ - mockChange("CreateTable", [ - "table:public.a", - "table:public.b", - "table:public.c", - ]), - ]; - - const message = formatCycleError([0], changes); - expect(message).toContain("table:public.a, table:public.b..."); - expect(message).not.toContain("table:public.c"); - }); -}); diff --git a/packages/pg-delta/src/core/sort/topological-sort.ts b/packages/pg-delta/src/core/sort/topological-sort.ts deleted file mode 100644 index ee57fec46..000000000 --- a/packages/pg-delta/src/core/sort/topological-sort.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { Change } from "../change.types.ts"; -import type { Constraint } from "./types.ts"; - -/** - * Stable topological sort. If multiple zero-indegree nodes exist, picks the - * smallest original index first to preserve input order among unconstrained items. - * Returns null on cycles. - */ -export function performStableTopologicalSort( - nodeCount: number, - edges: Array<[number, number]>, -): number[] | null { - const adjacencyList: Array> = Array.from( - { length: nodeCount }, - () => new Set(), - ); - const inDegreeCounts: number[] = Array.from({ length: nodeCount }, () => 0); - - for (const [sourceIndex, targetIndex] of edges) { - if (!adjacencyList[sourceIndex].has(targetIndex)) { - adjacencyList[sourceIndex].add(targetIndex); - inDegreeCounts[targetIndex]++; - } - } - - const candidateQueue: number[] = []; - for (let nodeIndex = 0; nodeIndex < nodeCount; nodeIndex++) { - if (inDegreeCounts[nodeIndex] === 0) candidateQueue.push(nodeIndex); - } - candidateQueue.sort((left, right) => left - right); - - const orderedNodeIndexes: number[] = []; - while (candidateQueue.length > 0) { - const nodeIndex = candidateQueue.shift() as number; - orderedNodeIndexes.push(nodeIndex); - for (const neighborIndex of adjacencyList[nodeIndex]) { - inDegreeCounts[neighborIndex]--; - if (inDegreeCounts[neighborIndex] === 0) { - let inserted = false; - for ( - let queuePosition = 0; - queuePosition < candidateQueue.length; - queuePosition++ - ) { - if (neighborIndex < candidateQueue[queuePosition]) { - candidateQueue.splice(queuePosition, 0, neighborIndex); - inserted = true; - break; - } - } - if (!inserted) candidateQueue.push(neighborIndex); - } - } - } - - if (orderedNodeIndexes.length !== nodeCount) return null; // cycle detected - return orderedNodeIndexes; -} - -/** - * Find one cycle (if any) and return its node indices in order. - */ -export function findCycle( - nodeCount: number, - edges: Array<[number, number]>, -): number[] | null { - const adjacencyList: Array = Array.from( - { length: nodeCount }, - () => [], - ); - for (const [sourceIndex, targetIndex] of edges) { - adjacencyList[sourceIndex].push(targetIndex); - } - - // 0 = unvisited, 1 = visiting, 2 = completed - const visitState: number[] = Array.from({ length: nodeCount }, () => 0); - const pathStack: number[] = []; - let cycleNodeIndexes: number[] | null = null; - - const depthFirstSearch = (nodeIndex: number) => { - if (cycleNodeIndexes) return; - visitState[nodeIndex] = 1; - pathStack.push(nodeIndex); - - for (const neighborIndex of adjacencyList[nodeIndex]) { - if (visitState[neighborIndex] === 0) { - depthFirstSearch(neighborIndex); - } else if (visitState[neighborIndex] === 1) { - const cycleStartIndex = pathStack.lastIndexOf(neighborIndex); - if (cycleStartIndex !== -1) { - cycleNodeIndexes = pathStack.slice(cycleStartIndex); - } - return; - } - if (cycleNodeIndexes) return; - } - - pathStack.pop(); - visitState[nodeIndex] = 2; - }; - - for ( - let nodeIndex = 0; - nodeIndex < nodeCount && !cycleNodeIndexes; - nodeIndex++ - ) { - if (visitState[nodeIndex] === 0) depthFirstSearch(nodeIndex); - } - - return cycleNodeIndexes; -} - -/** - * Format a cycle error message with details about the changes involved and the edges forming the cycle. - */ -export function formatCycleError( - cycleNodeIndexes: number[], - phaseChanges: Change[], - cycleEdges?: Array<{ - sourceIndex: number; - targetIndex: number; - constraint: Constraint; - }>, -): string { - const cycleChanges = cycleNodeIndexes.map((idx) => phaseChanges[idx]); - const changeDescriptions = cycleChanges.map((change, i) => { - const className = change?.constructor?.name ?? "Change"; - const creates = change.creates.slice(0, 2).join(", "); - return ` ${i + 1}. [${cycleNodeIndexes[i]}] ${className}${creates ? ` (creates: ${creates}${change.creates.length > 2 ? "..." : ""})` : ""}`; - }); - - let message = `CycleError: dependency graph contains a cycle involving ${cycleNodeIndexes.length} changes:\n${changeDescriptions.join("\n")}`; - - // Add cycle path information if edges are provided - if (cycleEdges && cycleEdges.length > 0) { - message += `\n\nCycle path (edges forming the cycle):`; - for (let i = 0; i < cycleNodeIndexes.length; i++) { - const sourceIndex = cycleNodeIndexes[i]; - const targetIndex = cycleNodeIndexes[(i + 1) % cycleNodeIndexes.length]; - const edge = cycleEdges.find( - (e) => e.sourceIndex === sourceIndex && e.targetIndex === targetIndex, - ); - - if (edge) { - const constraint = edge.constraint; - let edgeInfo = `\n [${sourceIndex}] → [${targetIndex}] (source: ${constraint.source})`; - - if ( - constraint.source === "catalog" || - constraint.source === "explicit" - ) { - if (constraint.reason.dependentStableId) { - edgeInfo += `\n Dependency: ${constraint.reason.dependentStableId} → ${constraint.reason.referencedStableId}`; - } else { - edgeInfo += `\n Requires: ${constraint.reason.referencedStableId}`; - } - } - - // Add why it wasn't filtered - if (constraint.source === "custom") { - edgeInfo += `\n Reason: Custom constraint (never filtered)`; - } else if ( - constraint.source === "explicit" && - !constraint.reason.dependentStableId - ) { - edgeInfo += `\n Reason: Explicit requirement without created IDs (not filtered)`; - } else { - edgeInfo += `\n Reason: Cycle-breaking filter did not match (edge preserved)`; - } - - message += edgeInfo; - } else { - message += `\n [${sourceIndex}] → [${targetIndex}] (edge not found)`; - } - } - } - - message += `\n\nThis usually indicates a circular dependency in the schema changes that cannot be resolved.`; - if (cycleEdges && cycleEdges.length > 0) { - message += `\nThe cycle-breaking filters were unable to break this cycle.`; - } - - return message; -} diff --git a/packages/pg-delta/src/core/sort/types.ts b/packages/pg-delta/src/core/sort/types.ts deleted file mode 100644 index b61d6a9fd..000000000 --- a/packages/pg-delta/src/core/sort/types.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * pg_depend rows that matter for ordering. - * - * These represent dependency relationships extracted from PostgreSQL's pg_depend catalog. - */ -export type PgDependRow = { - /** Object that depends on `referenced_stable_id`. */ - dependent_stable_id: string; - /** Object being depended upon. */ - referenced_stable_id: string; - /** - * Dependency type as defined in PostgreSQL's pg_depend.deptype. - * - * - "n" (normal): Ordinary dependency — if the referenced object is dropped, the dependent object is also dropped automatically. - * - "a" (auto): Automatically created dependency — the dependent object was created as a result of creating the referenced object, - * and should be dropped automatically when the referenced object is dropped, but not otherwise treated as a strong link. - * - "i" (internal): Internal dependency — the dependent object is a low-level part of the referenced object. - */ - deptype: "n" | "a" | "i"; -}; - -/** - * Constraint representing that one change must come before another. - * - * Unified abstraction for all ordering requirements: - * - Catalog dependencies (from pg_depend) → Constraints - * - Explicit requirements (from Change.requires) → Constraints - * - Custom constraints (change-to-change rules) → Constraints - */ -export type Constraint = - | CatalogConstraint - | ExplicitConstraint - | CustomConstraint; - -/** - * Base constraint properties shared by all constraint types. - */ -interface BaseConstraint { - /** Index of the change that must come first */ - sourceChangeIndex: number; - /** Index of the change that must come after */ - targetChangeIndex: number; -} - -/** - * Constraint from catalog dependencies (pg_depend). - * Always has both dependent and referenced stable IDs. - */ -interface CatalogConstraint extends BaseConstraint { - source: "catalog"; - /** The stable ID dependency that led to this constraint */ - reason: { - /** The stable ID that depends on referencedStableId */ - dependentStableId: string; - /** The stable ID being depended upon */ - referencedStableId: string; - }; -} - -/** - * Constraint from explicit requirements (Change.requires). - * Always has referencedStableId, but dependentStableId is optional - * if the change doesn't create anything. - */ -interface ExplicitConstraint extends BaseConstraint { - source: "explicit"; - /** The stable ID dependency that led to this constraint */ - reason: { - /** The stable ID that depends on referencedStableId (undefined if change doesn't create anything) */ - dependentStableId?: string; - /** The stable ID being depended upon */ - referencedStableId: string; - }; -} - -/** - * Constraint from custom constraint functions. - * No reason field since these are direct change-to-change ordering rules. - */ -interface CustomConstraint extends BaseConstraint { - source: "custom"; - /** Optional description for debugging */ - description?: string; -} - -export interface PhaseSortOptions { - /** If true, invert edges so drops run in reverse dependency order. */ - invert?: boolean; -} - -/** - * Edge with its originating constraint for filtering purposes. - */ -export interface Edge { - sourceIndex: number; - targetIndex: number; - constraint: Constraint; -} - -/** - * Graph data structures for converting dependencies to Constraints. - */ -export interface GraphData { - /** Maps each change index to the set of stable IDs it creates. */ - createdStableIdSets: Array>; - /** Maps each change index to the set of stable IDs it explicitly requires. */ - explicitRequirementSets: Array>; - /** Maps a stable ID to the set of change indices that create it. */ - changeIndexesByCreatedId: Map>; - /** Maps a stable ID to the set of change indices that explicitly require it. */ - changeIndexesByExplicitRequirementId: Map>; -} diff --git a/packages/pg-delta/src/core/sort/unorderable-cycle-error.test.ts b/packages/pg-delta/src/core/sort/unorderable-cycle-error.test.ts deleted file mode 100644 index 7feb381b5..000000000 --- a/packages/pg-delta/src/core/sort/unorderable-cycle-error.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createEmptyCatalog } from "../catalog.model.ts"; -import type { Change } from "../change.types.ts"; -import { BaseChange } from "../objects/base.change.ts"; -import { sortChanges } from "./sort-changes.ts"; -import { UnorderableCycleError } from "./unorderable-cycle-error.ts"; - -class MutualCreateChange extends BaseChange { - readonly operation = "create"; - readonly objectType = "table"; - readonly scope = "object"; - readonly table: { schema: string; name: string }; - private readonly dependsOn: string; - - constructor(name: string, dependsOn: string) { - super(); - this.table = { schema: "public", name }; - this.dependsOn = dependsOn; - } - - override get creates() { - return [`table:public.${this.table.name}`]; - } - - override get requires() { - return [`table:public.${this.dependsOn}`]; - } - - serialize(): string { - return `CREATE TABLE public.${this.table.name} ()`; - } -} - -describe("UnorderableCycleError", () => { - test("sortChanges throws a typed error carrying the offending cycle", async () => { - const a = new MutualCreateChange("a", "b"); - const b = new MutualCreateChange("b", "a"); - const catalog = await createEmptyCatalog(170000, "postgres"); - - let thrown: unknown; - try { - sortChanges({ mainCatalog: catalog, branchCatalog: catalog }, [ - a, - b, - ] as unknown as Change[]); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(UnorderableCycleError); - if (!(thrown instanceof UnorderableCycleError)) { - throw new Error("expected UnorderableCycleError"); - } - expect(thrown.name).toBe("UnorderableCycleError"); - expect(thrown.message).toContain("CycleError"); - expect(new Set(thrown.cycle)).toEqual( - new Set([a, b] as unknown as Change[]), - ); - }); -}); diff --git a/packages/pg-delta/src/core/sort/unorderable-cycle-error.ts b/packages/pg-delta/src/core/sort/unorderable-cycle-error.ts deleted file mode 100644 index 55d3936a3..000000000 --- a/packages/pg-delta/src/core/sort/unorderable-cycle-error.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Change } from "../change.types.ts"; - -/** - * Thrown by `sortChanges` when the dependency graph contains a cycle that - * neither weak-edge filtering nor the change-injection cycle breakers could - * resolve. - * - * `message` is the human-readable `formatCycleError` output (it starts with - * "CycleError:" for backward compatibility with log greps). - */ -export class UnorderableCycleError extends Error { - override readonly name = "UnorderableCycleError"; - /** - * Changes participating in the cycle, in cycle order. Empty when the - * failure came from an internal guard rather than a concrete cycle. - */ - readonly cycle: readonly Change[]; - - constructor(message: string, cycle: readonly Change[] = []) { - super(message); - this.cycle = cycle; - } -} diff --git a/packages/pg-delta/src/core/sort/utils.ts b/packages/pg-delta/src/core/sort/utils.ts deleted file mode 100644 index 1737615b3..000000000 --- a/packages/pg-delta/src/core/sort/utils.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Change } from "../change.types.ts"; -import { - AlterTableAlterColumnDropDefault, - AlterTableAlterColumnDropIdentity, - AlterTableAlterColumnType, -} from "../objects/table/changes/table.alter.ts"; - -/** - * Execution phases for changes. - */ -export type Phase = "drop" | "create_alter_object"; - -/** - * Check if a stable ID represents metadata (ACL, default privileges, comments, etc.) - * rather than an actual database object. - * - * Unified check used by both logical sorting and dependency sorting. - */ -export function isMetadataStableId(stableId: string): boolean { - return ( - stableId.startsWith("acl:") || - stableId.startsWith("defacl:") || - stableId.startsWith("aclcol:") || - stableId.startsWith("membership:") || - stableId.startsWith("comment:") - ); -} - -/** - * Determine the execution phase for a change based on its properties. - * - * Rules: - * - DROP operations → drop phase - * - CREATE operations → create_alter_object phase - * - ALTER operations with scope="privilege" → create_alter_object phase (metadata changes) - * - ALTER operations that drop actual objects → drop phase (destructive ALTER) - * - ALTER operations that don't drop objects → create_alter_object phase (non-destructive ALTER) - * - * Dependency-breaking ALTERs that remove a `pg_depend` edge to another - * object that may be dropped in the same plan (for example - * `ALTER COLUMN ... DROP DEFAULT` releasing a sequence reference, or - * `ALTER COLUMN ... TYPE ` releasing a user-defined type - * reference) are routed to the drop phase. The drop phase sorts in reverse - * dependency order using the main catalog, so the catalog edges already - * in `pg_depend` order the ALTER before any dependent `DROP TYPE` / - * `DROP SEQUENCE` / `DROP FUNCTION` and PostgreSQL no longer rejects the - * drop with error 2BP01. - */ -export function getExecutionPhase(change: Change): Phase { - // DROP operations always go to drop phase - if (change.operation === "drop") { - return "drop"; - } - - // CREATE operations always go to create_alter phase - if (change.operation === "create") { - return "create_alter_object"; - } - - // For ALTER operations, determine based on what they do - if (change.operation === "alter") { - // Privilege changes (metadata) always go to create_alter phase - if (change.scope === "privilege") { - return "create_alter_object"; - } - - // Check if this ALTER drops actual objects (not metadata) - const droppedIds = change.drops ?? []; - const dropsObjects = droppedIds.some( - (id: string) => !isMetadataStableId(id), - ); - - if (dropsObjects) { - // Destructive ALTER (DROP COLUMN, DROP CONSTRAINT, etc.) → drop phase - return "drop"; - } - - // Dependency-breaking column ALTERs that release a pg_depend edge. - // Routing these to the drop phase lets the existing catalog dependency - // edges (column → sequence, column → identity sequence) order them - // before the matching DROP statement. - if ( - change instanceof AlterTableAlterColumnDropDefault || - change instanceof AlterTableAlterColumnDropIdentity - ) { - return "drop"; - } - - // ALTER COLUMN ... TYPE only safely runs in the drop phase when the - // target type is built-in. For user-defined target types we cannot tell - // here whether the type is created in the same plan, and the create - // happens in create_alter phase, so we keep the alter in that phase to - // preserve the create-then-alter ordering. - if ( - change instanceof AlterTableAlterColumnType && - !change.column.is_custom_type - ) { - return "drop"; - } - - // Non-destructive ALTER (ADD COLUMN, GRANT, etc.) → create_alter phase - return "create_alter_object"; - } - - // Safe default - return "create_alter_object"; -} diff --git a/packages/pg-delta/src/core/stable-id.test.ts b/packages/pg-delta/src/core/stable-id.test.ts new file mode 100644 index 000000000..93780100e --- /dev/null +++ b/packages/pg-delta/src/core/stable-id.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, test } from "bun:test"; +import { encodeId, parseId, type StableId } from "./stable-id.ts"; + +/** Round-trip helper: encode → parse must reproduce the value exactly. */ +function roundtrip(id: StableId): void { + const encoded = encodeId(id); + expect(parseId(encoded)).toEqual(id); +} + +describe("encodeId", () => { + test("simple name kinds", () => { + expect(encodeId({ kind: "schema", name: "public" })).toBe("schema:public"); + expect(encodeId({ kind: "role", name: "app_user" })).toBe("role:app_user"); + expect(encodeId({ kind: "extension", name: "pgcrypto" })).toBe( + "extension:pgcrypto", + ); + }); + + test("schema-qualified kinds", () => { + expect(encodeId({ kind: "table", schema: "public", name: "users" })).toBe( + "table:public.users", + ); + expect(encodeId({ kind: "view", schema: "app", name: "v_users" })).toBe( + "view:app.v_users", + ); + expect( + encodeId({ kind: "index", schema: "public", name: "users_pkey" }), + ).toBe("index:public.users_pkey"); + }); + + test("sub-entity kinds", () => { + expect( + encodeId({ + kind: "column", + schema: "public", + table: "users", + name: "email", + }), + ).toBe("column:public.users.email"); + expect( + encodeId({ + kind: "constraint", + schema: "public", + table: "users", + name: "users_pkey", + }), + ).toBe("constraint:public.users.users_pkey"); + expect( + encodeId({ + kind: "default", + schema: "public", + table: "users", + name: "id", + }), + ).toBe("default:public.users.id"); + }); + + test("routines carry signatures", () => { + expect( + encodeId({ + kind: "function", + schema: "public", + name: "add", + args: ["integer", "integer"], + }), + ).toBe("function:public.add(integer,integer)"); + expect( + encodeId({ + kind: "procedure", + schema: "public", + name: "add", + args: ["integer", "integer"], + }), + ).toBe("procedure:public.add(integer,integer)"); + expect( + encodeId({ + kind: "procedure", + schema: "public", + name: "now_utc", + args: [], + }), + ).toBe("procedure:public.now_utc()"); + }); + + test("quotes parts containing delimiters", () => { + expect( + encodeId({ kind: "table", schema: "public", name: "weird.name" }), + ).toBe('table:public."weird.name"'); + expect(encodeId({ kind: "schema", name: 'has"quote' })).toBe( + 'schema:"has""quote"', + ); + expect(encodeId({ kind: "table", schema: "a:b", name: "c,d" })).toBe( + 'table:"a:b"."c,d"', + ); + }); + + test("wrapper kinds nest their target", () => { + const table: StableId = { kind: "table", schema: "public", name: "users" }; + expect(encodeId({ kind: "comment", target: table })).toBe( + "comment:(table:public.users)", + ); + expect(encodeId({ kind: "acl", target: table, grantee: "app_user" })).toBe( + "acl:(table:public.users).app_user", + ); + // column-level grant appends an optional `.column` segment + expect( + encodeId({ + kind: "acl", + target: table, + grantee: "app_user", + column: "email", + }), + ).toBe("acl:(table:public.users).app_user.email"); + expect( + encodeId({ kind: "securityLabel", target: table, provider: "selinux" }), + ).toBe("securityLabel:(table:public.users).selinux"); + }); + + test("membership and user mapping", () => { + expect( + encodeId({ kind: "membership", role: "admin", member: "alice" }), + ).toBe("membership:admin.alice"); + expect( + encodeId({ kind: "userMapping", server: "files", role: "bob" }), + ).toBe("userMapping:files.bob"); + }); + + test("extension intent carries ext.intentKind.key", () => { + expect( + encodeId({ + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "nightly", + }), + ).toBe("extensionIntent:pg_cron.job.nightly"); + // a jobname with delimiters must quote the key segment only + expect( + encodeId({ + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "night.ly job", + }), + ).toBe('extensionIntent:pg_cron.job."night.ly job"'); + }); +}); + +describe("parseId round-trips", () => { + const cases: StableId[] = [ + { kind: "schema", name: "public" }, + { kind: "role", name: "postgres" }, + { kind: "table", schema: "public", name: "users" }, + { kind: "table", schema: "Schema With Space", name: 'crazy."name"' }, + { kind: "column", schema: "s", table: "t", name: "c" }, + { kind: "column", schema: "a.b", table: "c:d", name: "e(f)" }, + { kind: "function", schema: "public", name: "fn", args: [] }, + { kind: "procedure", schema: "public", name: "fn", args: [] }, + { + kind: "procedure", + schema: "public", + name: "fn", + args: ["text", "integer[]"], + }, + { + kind: "procedure", + schema: "s", + name: "weird,fn", + args: ["my schema.my type"], + }, + { kind: "aggregate", schema: "public", name: "agg", args: ["numeric"] }, + { kind: "index", schema: "public", name: "idx" }, + { kind: "sequence", schema: "public", name: "users_id_seq" }, + { + kind: "comment", + target: { kind: "column", schema: "s", table: "t", name: "c" }, + }, + { + kind: "acl", + target: { kind: "procedure", schema: "s", name: "f", args: ["text"] }, + grantee: "PUBLIC", + }, + // column-level ACL: the optional `column` segment must round-trip + { + kind: "acl", + target: { kind: "table", schema: "public", name: "users" }, + grantee: "app_user", + column: "email", + }, + // hostile column name (dot, quote, uppercase) must round-trip too + { + kind: "acl", + target: { kind: "table", schema: "s", name: "t" }, + grantee: "PUBLIC", + column: 'weird."col".Name', + }, + { + kind: "securityLabel", + target: { kind: "table", schema: "s", name: "t" }, + provider: "dummy", + }, + // nested wrapper: comment on nothing weirder than facts allow, but the + // codec itself must support recursion + { + kind: "comment", + target: { + kind: "acl", + target: { kind: "schema", name: "s" }, + grantee: "g", + }, + }, + { kind: "membership", role: "r1", member: "r2" }, + { kind: "userMapping", server: "srv", role: "rl" }, + { kind: "typeAttribute", schema: "s", type: "addr", name: "city" }, + { kind: "typeAttribute", schema: "a.b", type: "weird type", name: "x,y" }, + { kind: "publicationRel", publication: "pub", schema: "s", table: "t" }, + { + kind: "publicationRel", + publication: "p.b", + schema: "weird s", + table: 't"q', + }, + { kind: "publicationSchema", publication: "pub", schema: "analytics" }, + { + kind: "defaultPrivilege", + role: "owner", + schema: "public", + objtype: "tables", + grantee: "app", + }, + { + kind: "defaultPrivilege", + role: "owner", + schema: null, + objtype: "functions", + grantee: "app", + }, + { kind: "extensionIntent", ext: "pg_cron", intentKind: "job", key: "nb" }, + { + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "weird.job, name", + }, + ]; + + for (const id of cases) { + test(`round-trip ${JSON.stringify(id)}`, () => roundtrip(id)); + } + + test("empty-string parts are quoted and round-trip", () => { + const id: StableId = { + kind: "defaultPrivilege", + role: "o", + schema: null, + objtype: "tables", + grantee: "g", + }; + const enc = encodeId(id); + expect(parseId(enc)).toEqual(id); + }); + + test("rejects malformed input", () => { + expect(() => parseId("notakind:foo")).toThrow(); + expect(() => parseId("table:only_one_part")).toThrow(); + expect(() => parseId("table:a.b.c.d")).toThrow(); + expect(() => parseId('table:"unterminated')).toThrow(); + expect(() => parseId("comment:(table:a.b")).toThrow(); + expect(() => parseId("")).toThrow(); + }); + + test("two distinct ids never encode to the same string", () => { + // the classic ambiguity: dots inside names vs structural dots + const a = encodeId({ kind: "table", schema: "a.b", name: "c" }); + const b = encodeId({ kind: "table", schema: "a", name: "b.c" }); + expect(a).not.toBe(b); + const c = encodeId({ kind: "column", schema: "a", table: "b", name: "c" }); + const d = encodeId({ kind: "table", schema: "a", name: "b.c" }); + expect(c).not.toBe(d); + }); +}); diff --git a/packages/pg-delta/src/core/stable-id.ts b/packages/pg-delta/src/core/stable-id.ts new file mode 100644 index 000000000..a9ed0646e --- /dev/null +++ b/packages/pg-delta/src/core/stable-id.ts @@ -0,0 +1,399 @@ +/** + * Typed stable identity — structured end-to-end (target-architecture §3.1). + * + * The ONLY place the canonical string encoding exists (guardrail 1). + * Extraction returns identity *parts*; this codec produces/parses strings, + * which appear only in persisted artifacts, graph keys, and logs. + * Identity, rename, and ACL invariants: docs/architecture/identity-and-acl.md. + */ + +/** Kinds identified by a single name (cluster- or database-global). */ +const SIMPLE_KINDS = [ + "schema", + "role", + "extension", + "language", + "eventTrigger", + "publication", + "subscription", + "fdw", + "server", +] as const; +export type SimpleKind = (typeof SIMPLE_KINDS)[number]; + +/** Kinds identified by (schema, name). Indexes are schema-scoped in PostgreSQL. */ +const QUALIFIED_KINDS = [ + "table", + "view", + "materializedView", + "foreignTable", + "sequence", + "index", + "collation", + "domain", + "type", +] as const; +export type QualifiedKind = (typeof QUALIFIED_KINDS)[number]; + +/** Kinds identified by (schema, table, name). For `default`, name = column name. */ +const SUBENTITY_KINDS = [ + "column", + "constraint", + "trigger", + "rule", + "policy", + "default", +] as const; +export type SubEntityKind = (typeof SUBENTITY_KINDS)[number]; + +/** Kinds identified by (schema, name, argument type list). PostgreSQL gives + * functions and procedures DIFFERENT DDL address syntax (COMMENT/GRANT/SECURITY + * LABEL ON FUNCTION vs ON PROCEDURE), so they are distinct id kinds — the + * renderer must never infer the address grammar from a payload field. */ +const ROUTINE_KINDS = ["function", "procedure", "aggregate"] as const; +export type RoutineKind = (typeof ROUTINE_KINDS)[number]; + +export type StableId = + | { kind: SimpleKind; name: string } + | { kind: QualifiedKind; schema: string; name: string } + | { kind: SubEntityKind; schema: string; table: string; name: string } + | { kind: RoutineKind; schema: string; name: string; args: string[] } + | { kind: "membership"; role: string; member: string } + | { kind: "userMapping"; server: string; role: string } + | { kind: "typeAttribute"; schema: string; type: string; name: string } + | { + kind: "publicationRel"; + publication: string; + schema: string; + table: string; + } + | { kind: "publicationSchema"; publication: string; schema: string } + | { kind: "comment"; target: StableId } + /** `column` is set for a COLUMN-level grant (`pg_attribute.attacl`, + * e.g. `GRANT SELECT (col) ON t TO r`): `target` stays the owning relation + * and `column` names the attribute the privileges are qualified by. Absent + * for an ordinary object-level ACL. */ + | { kind: "acl"; target: StableId; grantee: string; column?: string } + | { kind: "securityLabel"; target: StableId; provider: string } + | { + kind: "defaultPrivilege"; + role: string; + schema: string | null; + objtype: string; + grantee: string; + } + /** Extension intent (docs/architecture/extension-intent.md §3): a single + * generic kind for every stateful-extension intent fact (pg_cron jobs, + * future pgmq queues, …), keyed by `ext` + `intentKind` + `key`. Produced by + * the integration layer (handlers), never by core `pg_catalog` extraction — + * the codec gains ONE generic kind, not one per extension. */ + | { kind: "extensionIntent"; ext: string; intentKind: string; key: string }; + +export type FactKind = StableId["kind"]; + +/** A satellite fact (comment / acl / securityLabel) hangs off a target object + * via a `target` field, rather than being an object in its own right. Callers + * that special-case "object vs its metadata" test this instead of re-deriving + * the `"target" in id` shape (extension-member projection, orphan-satellite + * pruning). */ +export function isSatelliteId(id: StableId): boolean { + return "target" in id; +} + +/** Every `FactKind`, as a runtime array. The `satisfies` + the `_exhaustive` + * assignment below make this a COMPILE error if a new `StableId` kind is added + * without listing it here — which in turn keeps the role-name-bearing registry + * (role-rename-carry.ts) honest (a new kind must be classified). */ +export const ALL_FACT_KINDS = [ + ...SIMPLE_KINDS, + ...QUALIFIED_KINDS, + ...SUBENTITY_KINDS, + ...ROUTINE_KINDS, + "membership", + "userMapping", + "typeAttribute", + "publicationRel", + "publicationSchema", + "comment", + "acl", + "securityLabel", + "defaultPrivilege", + "extensionIntent", +] as const satisfies readonly FactKind[]; +// `satisfies` rejects an entry that is not a FactKind; this assertion rejects a +// FactKind that is MISSING from the array (it resolves to `never`, so the +// `= true` fails to compile). Together they pin ALL_FACT_KINDS === FactKind. +const _allFactKindsCoversUnion: FactKind extends (typeof ALL_FACT_KINDS)[number] + ? true + : never = true; +void _allFactKindsCoversUnion; + +const SIMPLE = new Set(SIMPLE_KINDS); +const QUALIFIED = new Set(QUALIFIED_KINDS); +const SUBENTITY = new Set(SUBENTITY_KINDS); +const ROUTINE = new Set(ROUTINE_KINDS); + +/** Characters that force a segment to be quoted. */ +const NEEDS_QUOTE = /[.:(),"\s]/; + +function seg(part: string): string { + if (part === "" || NEEDS_QUOTE.test(part)) { + return `"${part.replaceAll('"', '""')}"`; + } + return part; +} + +export function encodeId(id: StableId): string { + const k = id.kind; + switch (k) { + case "membership": + return `membership:${seg(id.role)}.${seg(id.member)}`; + case "userMapping": + return `userMapping:${seg(id.server)}.${seg(id.role)}`; + case "typeAttribute": + return `typeAttribute:${seg(id.schema)}.${seg(id.type)}.${seg(id.name)}`; + case "publicationRel": + return `publicationRel:${seg(id.publication)}.${seg(id.schema)}.${seg(id.table)}`; + case "publicationSchema": + return `publicationSchema:${seg(id.publication)}.${seg(id.schema)}`; + case "comment": + return `comment:(${encodeId(id.target)})`; + case "acl": + // column suffix only when set, so object-level ACL ids stay byte-identical + return `acl:(${encodeId(id.target)}).${seg(id.grantee)}${ + id.column !== undefined ? `.${seg(id.column)}` : "" + }`; + case "securityLabel": + return `securityLabel:(${encodeId(id.target)}).${seg(id.provider)}`; + case "defaultPrivilege": + return `defaultPrivilege:${seg(id.role)}.${seg(id.schema ?? "")}.${seg(id.objtype)}.${seg(id.grantee)}`; + case "extensionIntent": + return `extensionIntent:${seg(id.ext)}.${seg(id.intentKind)}.${seg(id.key)}`; + default: + if (SIMPLE.has(k)) return `${k}:${seg((id as { name: string }).name)}`; + if (QUALIFIED.has(k)) { + const q = id as { schema: string; name: string }; + return `${k}:${seg(q.schema)}.${seg(q.name)}`; + } + if (SUBENTITY.has(k)) { + const s = id as { schema: string; table: string; name: string }; + return `${k}:${seg(s.schema)}.${seg(s.table)}.${seg(s.name)}`; + } + if (ROUTINE.has(k)) { + const r = id as { schema: string; name: string; args: string[] }; + return `${k}:${seg(r.schema)}.${seg(r.name)}(${r.args.map(seg).join(",")})`; + } + throw new Error(`encodeId: unknown kind ${String(k)}`); + } +} + +class Cursor { + pos = 0; + constructor(readonly input: string) {} + + peek(): string | undefined { + return this.input[this.pos]; + } + + expect(ch: string): void { + if (this.input[this.pos] !== ch) { + throw new Error( + `parseId: expected '${ch}' at position ${this.pos} in '${this.input}'`, + ); + } + this.pos++; + } + + /** Read one segment: quoted ("" escapes) or bare (until a delimiter). */ + readSegment(): string { + if (this.peek() === '"') return this.readQuotedSegment(); + const start = this.pos; + while ( + this.pos < this.input.length && + !/[.:(),)]/.test(this.input[this.pos] as string) + ) { + this.pos++; + } + if (this.pos === start) { + throw new Error( + `parseId: empty segment at position ${this.pos} in '${this.input}'`, + ); + } + return this.input.slice(start, this.pos); + } + + private readQuotedSegment(): string { + this.pos++; + let out = ""; + for (;;) { + const ch = this.input[this.pos]; + if (ch === undefined) { + throw new Error(`parseId: unterminated quote in '${this.input}'`); + } + if (ch === '"') { + if (this.input[this.pos + 1] === '"') { + out += '"'; + this.pos += 2; + } else { + this.pos++; + return out; + } + } else { + out += ch; + this.pos++; + } + } + } + + atEnd(): boolean { + return this.pos >= this.input.length; + } +} + +function parseAt(c: Cursor): StableId { + // kind is always bare alphanumeric, never quoted + const kindStart = c.pos; + while (c.pos < c.input.length && /[a-zA-Z]/.test(c.input[c.pos] as string)) + c.pos++; + const kind = c.input.slice(kindStart, c.pos); + c.expect(":"); + + if (SIMPLE.has(kind)) { + return { kind: kind as SimpleKind, name: c.readSegment() }; + } + if (QUALIFIED.has(kind)) { + const schema = c.readSegment(); + c.expect("."); + const name = c.readSegment(); + return { kind: kind as QualifiedKind, schema, name }; + } + if (SUBENTITY.has(kind)) { + const schema = c.readSegment(); + c.expect("."); + const table = c.readSegment(); + c.expect("."); + const name = c.readSegment(); + return { kind: kind as SubEntityKind, schema, table, name }; + } + if (ROUTINE.has(kind)) { + const schema = c.readSegment(); + c.expect("."); + const name = c.readSegment(); + c.expect("("); + const args: string[] = []; + if (c.peek() !== ")") { + for (;;) { + args.push(c.readSegment()); + if (c.peek() === ",") { + c.pos++; + continue; + } + break; + } + } + c.expect(")"); + return { kind: kind as RoutineKind, schema, name, args }; + } + switch (kind) { + case "membership": { + const role = c.readSegment(); + c.expect("."); + const member = c.readSegment(); + return { kind, role, member }; + } + case "userMapping": { + const server = c.readSegment(); + c.expect("."); + const role = c.readSegment(); + return { kind, server, role }; + } + case "typeAttribute": { + const schema = c.readSegment(); + c.expect("."); + const type = c.readSegment(); + c.expect("."); + const name = c.readSegment(); + return { kind, schema, type, name }; + } + case "publicationRel": { + const publication = c.readSegment(); + c.expect("."); + const schema = c.readSegment(); + c.expect("."); + const table = c.readSegment(); + return { kind, publication, schema, table }; + } + case "publicationSchema": { + const publication = c.readSegment(); + c.expect("."); + const schema = c.readSegment(); + return { kind, publication, schema }; + } + case "comment": { + c.expect("("); + const target = parseAt(c); + c.expect(")"); + return { kind, target }; + } + case "acl": { + c.expect("("); + const target = parseAt(c); + c.expect(")"); + c.expect("."); + const grantee = c.readSegment(); + // optional `.column` segment for a COLUMN-level grant; mirrors encodeId, + // which appends it only when `column` is set. A trailing "." here (rather + // than end-of-input or a nesting ")") signals the column is present. + if (c.peek() === ".") { + c.pos++; + return { kind, target, grantee, column: c.readSegment() }; + } + return { kind, target, grantee }; + } + case "securityLabel": { + c.expect("("); + const target = parseAt(c); + c.expect(")"); + c.expect("."); + const provider = c.readSegment(); + return { kind, target, provider }; + } + case "defaultPrivilege": { + const role = c.readSegment(); + c.expect("."); + const schema = c.readSegment(); + c.expect("."); + const objtype = c.readSegment(); + c.expect("."); + const grantee = c.readSegment(); + return { + kind, + role, + schema: schema === "" ? null : schema, + objtype, + grantee, + }; + } + case "extensionIntent": { + const ext = c.readSegment(); + c.expect("."); + const intentKind = c.readSegment(); + c.expect("."); + const key = c.readSegment(); + return { kind, ext, intentKind, key }; + } + default: + throw new Error(`parseId: unknown kind '${kind}' in '${c.input}'`); + } +} + +export function parseId(encoded: string): StableId { + const c = new Cursor(encoded); + const id = parseAt(c); + if (!c.atEnd()) { + throw new Error( + `parseId: trailing input at position ${c.pos} in '${encoded}'`, + ); + } + return id; +} diff --git a/packages/pg-delta/src/core/test-utils/assert-valid-sql.ts b/packages/pg-delta/src/core/test-utils/assert-valid-sql.ts deleted file mode 100644 index c4c6dbf7b..000000000 --- a/packages/pg-delta/src/core/test-utils/assert-valid-sql.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { validateSqlSyntax } from "@supabase/pg-topo"; - -/** - * Assert that the given SQL string is syntactically valid PostgreSQL. - * - * Uses the PostgreSQL parser from `@supabase/pg-topo` to ensure that - * serialized DDL statements are syntactically correct. This catches - * issues like malformed function signatures, missing keywords, etc. - * - * @param sql - The SQL string to validate (typically from `change.serialize()`). - */ -export async function assertValidSql(sql: string): Promise { - try { - await validateSqlSyntax(sql); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown parser error"; - throw new Error(`Invalid SQL syntax: ${message}\nSQL: ${sql}`); - } -} diff --git a/packages/pg-delta/src/extract/dependencies.ts b/packages/pg-delta/src/extract/dependencies.ts new file mode 100644 index 000000000..997ea7305 --- /dev/null +++ b/packages/pg-delta/src/extract/dependencies.ts @@ -0,0 +1,476 @@ +/** Dependency edges: inheritance / partition edges and the authoritative + * pg_depend resolver (target-architecture §3.2, milestone A set-based form). */ +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { type ExtractContext, SYSTEM_SCHEMAS } from "./scope.ts"; + +export async function extractInheritanceEdges( + ctx: ExtractContext, +): Promise { + const { q, edges } = ctx; + // ── inheritance / partition edges (child depends on parent) ────────── + for (const row of await q(` + SELECT cn.nspname AS child_schema, cc.relname AS child_name, + pn.nspname AS parent_schema, pc.relname AS parent_name + FROM pg_inherits i + JOIN pg_class cc ON cc.oid = i.inhrelid + JOIN pg_namespace cn ON cn.oid = cc.relnamespace + JOIN pg_class pc ON pc.oid = i.inhparent + JOIN pg_namespace pn ON pn.oid = pc.relnamespace + WHERE cc.relkind IN ('r', 'p') + AND cn.nspname NOT IN ${SYSTEM_SCHEMAS}`)) { + edges.push({ + from: { + kind: "table", + schema: String(row["child_schema"]), + name: String(row["child_name"]), + }, + to: { + kind: "table", + schema: String(row["parent_schema"]), + name: String(row["parent_name"]), + }, + kind: "depends", + }); + } +} + +export async function extractDependencyEdges( + ctx: ExtractContext, +): Promise { + const { q, edges, diagnostics } = ctx; + // ── dependency edges from pg_depend (the authoritative source, P1) ─── + // Resolve each pg_depend endpoint to a StableId, set-based. The old form ran + // a ~160-line correlated CASE scalar subquery TWICE per pg_depend row + // (dependent + referenced) — ~86% of total extraction time on a large catalog + // (milestone A profile). Here, each resolver branch is a derived table built + // ONCE over its catalog; the distinct endpoint set is hash-joined to them and + // a classid CASE picks the right one (the classid gate on every join also + // prevents cross-catalog OID collisions). The json_build_object shapes are + // byte-identical to the old resolver, so toId() below is unchanged — only the + // evaluation strategy differs (the depend-edges oracle test pins the result). + const dependRows = await q(` + WITH dep AS ( + SELECT d.classid, d.objid, d.objsubid, + d.refclassid, d.refobjid, d.refobjsubid + FROM pg_depend d + WHERE d.deptype IN ('n', 'a') + -- sequence OWNED BY is carried as payload + ALTER SEQUENCE … OWNED BY + -- (pg_dump's model); the auto edge would cycle with the column default + AND NOT (d.deptype = 'a' + AND d.classid = 'pg_class'::regclass + AND d.refclassid = 'pg_class'::regclass + AND d.refobjsubid > 0 + AND EXISTS (SELECT 1 FROM pg_class sc + WHERE sc.oid = d.objid AND sc.relkind = 'S')) + ), + endpoints AS ( + SELECT classid, objid, objsubid FROM dep + UNION + SELECT refclassid, refobjid, refobjsubid FROM dep + ), + -- one derived table per resolver branch, each built once over its catalog + rel AS ( + SELECT rc.oid, rc.relkind, json_build_object('kind', + CASE rc.relkind + WHEN 'r' THEN 'table' WHEN 'p' THEN 'table' + WHEN 'f' THEN 'foreignTable' + WHEN 'v' THEN 'view' WHEN 'm' THEN 'materializedView' + WHEN 'i' THEN 'index' WHEN 'I' THEN 'index' + WHEN 'S' THEN 'sequence' END, + 'schema', rn.nspname, 'name', rc.relname) AS id + FROM pg_class rc JOIN pg_namespace rn ON rn.oid = rc.relnamespace + WHERE rc.relkind IN ('r','p','f','v','m','i','I','S') + ), + cbi AS ( + -- a constraint-backed index resolves to its constraint, not the index + SELECT con.conindid AS oid, + json_build_object('kind','constraint','schema',cn.nspname, + 'table',cc.relname,'name',con.conname) AS id + FROM pg_constraint con + JOIN pg_class cc ON cc.oid = con.conrelid + JOIN pg_namespace cn ON cn.oid = cc.relnamespace + WHERE con.contype IN ('p','u','x') AND con.conindid <> 0 + ), + col AS ( + SELECT att.attrelid AS oid, att.attnum AS subid, + json_build_object('kind','column','schema',rn.nspname, + 'table',rc.relname,'name',att.attname) AS id + FROM pg_attribute att + JOIN pg_class rc ON rc.oid = att.attrelid AND rc.relkind IN ('r','p','f') + JOIN pg_namespace rn ON rn.oid = rc.relnamespace + WHERE att.attnum > 0 AND NOT att.attisdropped + ), + proc AS ( + -- prokind 'w' (window functions) is extracted as a function fact + -- (src/extract/routines.ts: only 'p' -> procedure, everything else -> + -- function), so it MUST be resolvable here -- otherwise a pg_depend edge + -- into a user window function resolves to NULL and is silently dropped, + -- and a view/rule that uses it is never rebuilt against it (issue #333). + -- The kind CASE already maps 'w' to 'function' via ELSE. + SELECT pp.oid, json_build_object( + 'kind', CASE pp.prokind WHEN 'a' THEN 'aggregate' WHEN 'p' THEN 'procedure' ELSE 'function' END, + 'schema', pn.nspname, 'name', pp.proname, + 'args', ARRAY(SELECT format_type(t.t, NULL) + FROM unnest(pp.proargtypes) WITH ORDINALITY AS t(t, ord) + ORDER BY t.ord)::text[]) AS id + FROM pg_proc pp JOIN pg_namespace pn ON pn.oid = pp.pronamespace + WHERE pp.prokind IN ('f','p','a','w') + ), + tcon AS ( + SELECT con.oid, json_build_object('kind','constraint','schema',cn.nspname, + 'table',cc.relname,'name',con.conname) AS id + FROM pg_constraint con + JOIN pg_class cc ON cc.oid = con.conrelid + JOIN pg_namespace cn ON cn.oid = cc.relnamespace + WHERE con.conrelid <> 0 + ), + dcon AS ( + SELECT con.oid, json_build_object('kind','constraint','schema',dn.nspname, + 'table',dt.typname,'name',con.conname) AS id + FROM pg_constraint con + JOIN pg_type dt ON dt.oid = con.contypid + JOIN pg_namespace dn ON dn.oid = dt.typnamespace + WHERE con.contypid <> 0 + ), + typ AS ( + -- Resolve array types (typcategory 'A') to their ELEMENT type: a column or + -- argument of an array type records its pg_depend edge against the implicit + -- array type (e.g. _user_defined_filter), but the managed fact is the + -- element type. Mapping the array oid to the element's stable id keeps the + -- depends-on-element ordering correct for array-of-composite/domain/enum + -- columns and arguments (regression: realtime.subscription has a + -- user_defined_filter array column; without this the table/function was + -- ordered before the type). An array of a base type (e.g. int4) resolves to + -- a pg_catalog element that the typtype filter drops below -- harmless, as + -- builtins are unmanaged. + SELECT tt.oid, + CASE + WHEN base.typrelid <> 0::oid AND rc.oid IS NOT NULL THEN + json_build_object( + 'kind', CASE rc.relkind + WHEN 'v' THEN 'view' + WHEN 'm' THEN 'materializedView' + WHEN 'f' THEN 'foreignTable' + ELSE 'table' + END, + 'schema', rn.nspname, + 'name', rc.relname) + ELSE json_build_object( + 'kind', CASE base.typtype WHEN 'd' THEN 'domain' ELSE 'type' END, + 'schema', tn.nspname, + 'name', base.typname) + END AS id + FROM pg_type tt + JOIN pg_type base + ON base.oid = CASE + WHEN tt.typtype = 'b' AND tt.typcategory = 'A' AND tt.typelem <> 0 + THEN tt.typelem ELSE tt.oid END + JOIN pg_namespace tn ON tn.oid = base.typnamespace + LEFT JOIN pg_class rc ON rc.oid = base.typrelid + AND rc.relkind IN ('r', 'p', 'f', 'v', 'm') + LEFT JOIN pg_namespace rn ON rn.oid = rc.relnamespace + WHERE base.typtype IN ('d', 'e', 'c', 'r') + ), + -- a composite type's pg_class (relkind c) endpoint resolves to the composite + -- TYPE fact. pg_depend records a composite attribute's type dependency as + -- (classid=pg_class, objid=composite pg_class, objsubid=attnum) -> pg_type, + -- but the col CTE excludes relkind c. Attributing the edge to the top-level + -- type (not the folded typeAttribute child) keeps the create/teardown + -- ordering of a composite-uses-domain/type chain correct and independent of + -- child-drop folding. + comptype AS ( + SELECT cc.oid, json_build_object('kind','type','schema',cn.nspname, + 'name',cc.relname) AS id + FROM pg_class cc JOIN pg_namespace cn ON cn.oid = cc.relnamespace + WHERE cc.relkind = 'c' + ), + extm AS ( + -- a reference INTO an extension-member object resolves to the extension, + -- not the member fact (4b Stage 3): the member is observed but projected + -- out by default, so a member-targeted edge would be pruned with it and + -- lose the dependent's ordering on the extension. One join keyed by + -- (classid, objid) replaces the old per-branch nested pg_depend lookups; + -- an object belongs to at most one extension, so (classid, objid) is + -- unique here and the join never multiplies dep rows (the old form's + -- LIMIT 1 was guarding the same single-membership invariant). + SELECT ed.classid, ed.objid, + json_build_object('kind','extension','name',ext.extname) AS id + FROM pg_depend ed JOIN pg_extension ext ON ext.oid = ed.refobjid + WHERE ed.refclassid = 'pg_extension'::regclass AND ed.deptype = 'e' + ), + coll AS ( + SELECT cl.oid, json_build_object('kind','collation','schema',cln.nspname, + 'name',cl.collname) AS id + FROM pg_collation cl JOIN pg_namespace cln ON cln.oid = cl.collnamespace + ), + pol AS ( + SELECT po.oid, json_build_object('kind','policy','schema',pn.nspname, + 'table',pc.relname,'name',po.polname) AS id + FROM pg_policy po + JOIN pg_class pc ON pc.oid = po.polrelid + JOIN pg_namespace pn ON pn.oid = pc.relnamespace + ), + evt AS ( + SELECT et.oid, json_build_object('kind','eventTrigger','name',et.evtname) AS id + FROM pg_event_trigger et + ), + pub AS ( + SELECT pb.oid, json_build_object('kind','publication','name',pb.pubname) AS id + FROM pg_publication pb + ), + pubrel AS ( + SELECT pr.oid, json_build_object('kind','publication','name',pb.pubname) AS id + FROM pg_publication_rel pr JOIN pg_publication pb ON pb.oid = pr.prpubid + ), + fdw AS ( + SELECT fd.oid, json_build_object('kind','fdw','name',fd.fdwname) AS id + FROM pg_foreign_data_wrapper fd + ), + srv AS ( + SELECT fs.oid, json_build_object('kind','server','name',fs.srvname) AS id + FROM pg_foreign_server fs + ), + adef AS ( + SELECT ad.oid, json_build_object('kind','default','schema',dn.nspname, + 'table',dc.relname,'name',da.attname) AS id + FROM pg_attrdef ad + JOIN pg_class dc ON dc.oid = ad.adrelid + JOIN pg_namespace dn ON dn.oid = dc.relnamespace + JOIN pg_attribute da ON da.attrelid = ad.adrelid AND da.attnum = ad.adnum + ), + rw AS ( + -- A pg_rewrite endpoint resolves two different ways (issue #333): + -- - the '_RETURN' rule of a view/matview IS the relation's definition, + -- so its deps are attributed to the owning view/matview -- rebuilding + -- the relation recreates the rule. Only views/matviews carry a + -- '_RETURN' rule, so the relkind CASE only ever sees 'v'/'m' here. + -- - ANY other (user-created) rule is its OWN rule fact + -- (src/extract/relations.ts extracts every rulename <> '_RETURN' on any + -- relkind), so its deps must be attributed to the RULE fact, not the + -- owning relation. This is what lets a rule on a PLAIN TABLE (relkind + -- 'r'/'p') be rebuilt against a function it references before that + -- function is dropped. CONSTRAINT: a user rule ON A VIEW (not + -- '_RETURN') likewise resolves to the rule fact, exactly like a rule on + -- a table -- never folded into the view's '_RETURN' definition. + SELECT r.oid, + CASE WHEN r.rulename = '_RETURN' + THEN json_build_object( + 'kind', CASE vc.relkind WHEN 'm' THEN 'materializedView' ELSE 'view' END, + 'schema', vn.nspname, 'name', vc.relname) + ELSE json_build_object( + 'kind', 'rule', 'schema', vn.nspname, + 'table', vc.relname, 'name', r.rulename) + END AS id + FROM pg_rewrite r + JOIN pg_class vc ON vc.oid = r.ev_class + JOIN pg_namespace vn ON vn.oid = vc.relnamespace + ), + trg AS ( + SELECT tg.oid, json_build_object('kind','trigger','schema',tn.nspname, + 'table',tc.relname,'name',tg.tgname) AS id + FROM pg_trigger tg + JOIN pg_class tc ON tc.oid = tg.tgrelid + JOIN pg_namespace tn ON tn.oid = tc.relnamespace + WHERE NOT tg.tgisinternal + ), + nsp AS ( + SELECT ns.oid, json_build_object('kind','schema','name',ns.nspname) AS id + FROM pg_namespace ns + ), + -- MATERIALIZED: resolved is referenced twice (rd + rr joins); force a single + -- evaluation over the distinct endpoint set. + resolved AS MATERIALIZED ( + SELECT e.classid, e.objid, e.objsubid, + CASE + WHEN e.classid = 'pg_class'::regclass AND e.objsubid = 0 + THEN COALESCE(cbi.id, comptype.id, rel.id) + WHEN e.classid = 'pg_class'::regclass AND e.objsubid > 0 + -- a real column; a composite type's attribute resolves to its type; + -- else a view/matview column resolves to its relation + THEN COALESCE(col.id, comptype.id, CASE WHEN rel.relkind IN ('v','m') THEN rel.id END) + WHEN e.classid = 'pg_proc'::regclass THEN COALESCE(extm.id, proc.id) + WHEN e.classid = 'pg_constraint'::regclass THEN COALESCE(tcon.id, dcon.id) + WHEN e.classid = 'pg_type'::regclass THEN COALESCE(extm.id, typ.id) + WHEN e.classid = 'pg_opclass'::regclass THEN extm.id + WHEN e.classid = 'pg_opfamily'::regclass THEN extm.id + WHEN e.classid = 'pg_operator'::regclass THEN extm.id + WHEN e.classid = 'pg_collation'::regclass THEN coll.id + WHEN e.classid = 'pg_policy'::regclass THEN pol.id + WHEN e.classid = 'pg_event_trigger'::regclass THEN evt.id + WHEN e.classid = 'pg_publication'::regclass THEN pub.id + WHEN e.classid = 'pg_publication_rel'::regclass THEN pubrel.id + WHEN e.classid = 'pg_foreign_data_wrapper'::regclass + THEN COALESCE(extm.id, fdw.id) + WHEN e.classid = 'pg_foreign_server'::regclass THEN srv.id + WHEN e.classid = 'pg_attrdef'::regclass THEN adef.id + WHEN e.classid = 'pg_rewrite'::regclass THEN rw.id + WHEN e.classid = 'pg_trigger'::regclass THEN trg.id + WHEN e.classid = 'pg_namespace'::regclass THEN nsp.id + ELSE NULL + END AS id + FROM endpoints e + LEFT JOIN rel ON rel.oid = e.objid AND e.classid = 'pg_class'::regclass + LEFT JOIN cbi ON cbi.oid = e.objid AND e.classid = 'pg_class'::regclass AND e.objsubid = 0 + LEFT JOIN col ON col.oid = e.objid AND col.subid = e.objsubid AND e.classid = 'pg_class'::regclass + LEFT JOIN proc ON proc.oid = e.objid AND e.classid = 'pg_proc'::regclass + LEFT JOIN tcon ON tcon.oid = e.objid AND e.classid = 'pg_constraint'::regclass + LEFT JOIN dcon ON dcon.oid = e.objid AND e.classid = 'pg_constraint'::regclass + LEFT JOIN typ ON typ.oid = e.objid AND e.classid = 'pg_type'::regclass + LEFT JOIN comptype ON comptype.oid = e.objid AND e.classid = 'pg_class'::regclass + LEFT JOIN extm ON extm.classid = e.classid AND extm.objid = e.objid + LEFT JOIN coll ON coll.oid = e.objid AND e.classid = 'pg_collation'::regclass + LEFT JOIN pol ON pol.oid = e.objid AND e.classid = 'pg_policy'::regclass + LEFT JOIN evt ON evt.oid = e.objid AND e.classid = 'pg_event_trigger'::regclass + LEFT JOIN pub ON pub.oid = e.objid AND e.classid = 'pg_publication'::regclass + LEFT JOIN pubrel ON pubrel.oid = e.objid AND e.classid = 'pg_publication_rel'::regclass + LEFT JOIN fdw ON fdw.oid = e.objid AND e.classid = 'pg_foreign_data_wrapper'::regclass + LEFT JOIN srv ON srv.oid = e.objid AND e.classid = 'pg_foreign_server'::regclass + LEFT JOIN adef ON adef.oid = e.objid AND e.classid = 'pg_attrdef'::regclass + LEFT JOIN rw ON rw.oid = e.objid AND e.classid = 'pg_rewrite'::regclass + LEFT JOIN trg ON trg.oid = e.objid AND e.classid = 'pg_trigger'::regclass + LEFT JOIN nsp ON nsp.oid = e.objid AND e.classid = 'pg_namespace'::regclass + ) + SELECT rd.id AS dependent, rr.id AS referenced + FROM dep + JOIN resolved rd ON rd.classid = dep.classid + AND rd.objid = dep.objid + AND rd.objsubid = dep.objsubid + JOIN resolved rr ON rr.classid = dep.refclassid + AND rr.objid = dep.refobjid + AND rr.objsubid = dep.refobjsubid`); + + const toId = (raw: unknown): StableId | undefined => { + if (raw == null) return undefined; + const o = raw as Record; + switch (o["kind"]) { + case "schema": + return { kind: "schema", name: o["name"] as string }; + case "eventTrigger": + case "publication": + case "fdw": + case "server": + case "extension": + return { kind: o["kind"], name: o["name"] as string }; + case "table": + case "view": + case "materializedView": + case "index": + case "sequence": + case "domain": + case "type": + case "collation": + case "foreignTable": + return { + kind: o["kind"], + schema: o["schema"] as string, + name: o["name"] as string, + }; + case "column": + case "constraint": + case "default": + case "trigger": + case "policy": + case "rule": + return { + kind: o["kind"], + schema: o["schema"] as string, + table: o["table"] as string, + name: o["name"] as string, + }; + case "function": + case "procedure": + case "aggregate": + return { + kind: o["kind"], + schema: o["schema"] as string, + name: o["name"] as string, + args: (o["args"] as unknown as string[]).map(String), + }; + default: + return undefined; + } + }; + + // A pg_depend endpoint resolves to one of three things: + // - null JSON → a built-in / unmodeled object (pg_catalog type, …): + // legitimately skipped, NOT a gap. + // - structured obj → a user object the resolver recognized; toId must + // produce its id. If toId returns undefined here the + // resolver and the codec disagree — a real extraction + // gap, surfaced as a diagnostic (stage-2 doctrine). + // - resolved id whose fact is absent → a dangling edge, already turned + // into a diagnostic by the FactBase constructor. + // A user object can never live in a system schema, so any endpoint resolving + // into one is a built-in PostgreSQL guarantees — never extracted as a fact. + // Resolving it to "skip" (like a null endpoint) keeps the edge set identical + // (such an edge was already dropped as dangling by the FactBase constructor) + // while removing the flood of system `dangling_edge` diagnostics that would + // otherwise bury real user-facing warnings (review P1). + const isSystemScopedId = (id: StableId): boolean => { + const sysName = (n: string): boolean => + n === "pg_catalog" || + n === "information_schema" || + n.startsWith("pg_toast") || + n.startsWith("pg_temp"); + const schema = (id as { schema?: unknown }).schema; + if (typeof schema === "string" && sysName(schema)) return true; + if (id.kind === "schema") return sysName((id as { name: string }).name); + return false; + }; + const resolveEndpoint = ( + raw: unknown, + role: string, + ): StableId | undefined => { + if (raw == null) return undefined; // built-in / unmodeled — skip quietly + const id = toId(raw); + if (id === undefined) { + diagnostics.push({ + code: "unresolved_dependency", + severity: "warning", + message: `pg_depend ${role} ${JSON.stringify(raw)} was recognized by the resolver but the codec could not build its id — resolver/codec mismatch`, + }); + return id; + } + if (isSystemScopedId(id)) return undefined; // built-in catalog object — skip quietly + return id; + }; + const seenEdges = new Set(); + // Encoded ids of the `default` FACTS that actually exist. An ordinary column + // default is its own fact (alsoProduced by the column's CREATE) and carries the + // `default -> referenced` dep, so the column does NOT also need a shadow edge. + // A GENERATED column has NO default fact — pg records its deps on the attrdef — + // so the shadow edge is the only carrier and must be kept (review P2). + const defaultFactIds = new Set( + ctx.facts.filter((f) => f.id.kind === "default").map((f) => encodeId(f.id)), + ); + for (const row of dependRows) { + const from = resolveEndpoint(row["dependent"], "dependent"); + const to = resolveEndpoint(row["referenced"], "referenced"); + if (!from || !to) continue; + const key = JSON.stringify([from, to]); + if (seenEdges.has(key)) continue; + seenEdges.add(key); + edges.push({ from, to, kind: "depends" }); + // pg_attrdef dependencies resolve to `default` ids. For a GENERATED column + // there is no `default` fact (PG records the deps on the attrdef), so shadow + // the dep onto the column — it is the only carrier and drives ordering. + // For an ORDINARY default the `default` FACT exists and carries the dep + // itself (and is alsoProduced by the column's CREATE), so a column shadow is + // redundant AND harmful: if a policy filters the default add, the column is + // emitted without it, but buildActionGraph (unprojected) would still see the + // column -> referenced edge and reject it as a missing requirement (P2). + if (from.kind === "default" && !defaultFactIds.has(encodeId(from))) { + const columnFrom: StableId = { + kind: "column", + schema: (from as { schema: string }).schema, + table: (from as { table: string }).table, + name: (from as { name: string }).name, + }; + if (encodeId(columnFrom) === encodeId(to)) continue; + const columnKey = JSON.stringify([columnFrom, to]); + if (seenEdges.has(columnKey)) continue; + seenEdges.add(columnKey); + edges.push({ from: columnFrom, to, kind: "depends" }); + } + } +} diff --git a/packages/pg-delta/src/extract/event-triggers.ts b/packages/pg-delta/src/extract/event-triggers.ts new file mode 100644 index 000000000..3e75cfa08 --- /dev/null +++ b/packages/pg-delta/src/extract/event-triggers.ts @@ -0,0 +1,36 @@ +/** Event triggers (cluster-level, function-backed). */ +import type { StableId } from "../core/stable-id.ts"; +import { type ExtractContext, notExtensionMember } from "./scope.ts"; + +export async function extractEventTriggers(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushOwnerEdge } = ctx; + // ── event triggers ─────────────────────────────────────────────────── + for (const row of await q(` + SELECT e.evtname AS name, e.evtevent AS event, e.evtenabled AS enabled, + COALESCE(e.evttags, '{}')::text[] AS tags, + pn.nspname AS func_schema, p.proname AS func_name, + r.rolname AS owner, + obj_description(e.oid, 'pg_event_trigger') AS comment + FROM pg_event_trigger e + JOIN pg_proc p ON p.oid = e.evtfoid + JOIN pg_namespace pn ON pn.oid = p.pronamespace + JOIN pg_roles r ON r.oid = e.evtowner + WHERE ${notExtensionMember("pg_event_trigger", "e.oid")} + ORDER BY e.evtname`)) { + const evtId: StableId = { kind: "eventTrigger", name: String(row["name"]) }; + pushWithMeta( + { + id: evtId, + payload: { + event: String(row["event"]), + enabled: String(row["enabled"]), + tags: (row["tags"] as string[]).map(String).sort(), + functionSchema: String(row["func_schema"]), + functionName: String(row["func_name"]), + }, + }, + row, + ); + pushOwnerEdge(evtId, row["owner"]); + } +} diff --git a/packages/pg-delta/src/extract/extract.test.ts b/packages/pg-delta/src/extract/extract.test.ts new file mode 100644 index 000000000..379aba9dd --- /dev/null +++ b/packages/pg-delta/src/extract/extract.test.ts @@ -0,0 +1,77 @@ +/** + * Unit tests for extraction consistency invariants (src/extract/extract.ts). + * No Docker / database required. + * + * Hardening Item 4a / review #1: a metadata satellite (comment / acl / + * securityLabel) must never outlive its target. If the target object was + * filtered (e.g. an extension member), the satellite is dropped with a + * diagnostic — not left to throw at buildFactBase or orphan into a GRANT with + * no CREATE (CLI-1471). + */ +import { describe, expect, test } from "bun:test"; +import type { Fact } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { pruneOrphanedSatellites } from "./extract.ts"; + +const present: StableId = { kind: "table", schema: "public", name: "present" }; +const filtered: StableId = { + kind: "aggregate", + schema: "public", + name: "last", + args: ["anyelement"], +}; + +describe("pruneOrphanedSatellites — satellites never outlive their target", () => { + test("drops acl/comment/securityLabel whose target is absent; keeps the rest", () => { + const facts: Fact[] = [ + { id: present, payload: {} }, + // keep: target present + { + id: { kind: "acl", target: present, grantee: "r" }, + parent: present, + payload: { privileges: ["SELECT"] }, + }, + // drop: target (an extension-member aggregate) was filtered out + { + id: { kind: "acl", target: filtered, grantee: "r" }, + parent: filtered, + payload: { privileges: ["ALL"] }, + }, + { + id: { kind: "comment", target: filtered }, + parent: filtered, + payload: { text: "x" }, + }, + { + id: { kind: "securityLabel", target: filtered, provider: "p" }, + parent: filtered, + payload: { label: "secret" }, + }, + ]; + const { facts: kept, diagnostics } = pruneOrphanedSatellites(facts); + + const keptIds = kept.map((f) => encodeId(f.id)); + expect(keptIds).toContain(encodeId(present)); + expect(keptIds).toContain( + encodeId({ kind: "acl", target: present, grantee: "r" }), + ); + // the three satellites targeting the filtered aggregate are gone + expect(kept).toHaveLength(2); + expect(diagnostics).toHaveLength(3); + expect(diagnostics.every((d) => d.severity === "info")).toBe(true); + }); + + test("no-op when every satellite's target is present", () => { + const facts: Fact[] = [ + { id: present, payload: {} }, + { + id: { kind: "comment", target: present }, + parent: present, + payload: { text: "ok" }, + }, + ]; + const { facts: kept, diagnostics } = pruneOrphanedSatellites(facts); + expect(kept).toHaveLength(2); + expect(diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/pg-delta/src/extract/extract.ts b/packages/pg-delta/src/extract/extract.ts new file mode 100644 index 000000000..2846b1615 --- /dev/null +++ b/packages/pg-delta/src/extract/extract.ts @@ -0,0 +1,256 @@ +/** + * Stage 2: catalog → fact base (target-architecture §3.1–3.2). + * + * Doctrine carried from the old extractor corpus: + * - logical names, never physical attnums + * - canonical `pg_get_*def()` output as the comparison form + * - extraction queries return identity PARTS as columns; only the + * library-side codec builds identity strings (guardrail 1) + * + * Capture model: a single REPEATABLE READ READ ONLY transaction on one + * connection — consistent by construction. (Parallel workers via + * `pg_export_snapshot()` are a later optimization; serial is the documented + * fallback and plenty fast at current scale.) + * + * Kind coverage is the full v1 set — see packages/pg-delta/COVERAGE.md for + * the authoritative list (schemas, roles + memberships, extensions, tables and + * their sub-facts, foreign tables + their constraints, domains, types, indexes, + * sequences, views, materialized views, procedures/aggregates, collations, + * policies, triggers, event triggers, publications, subscriptions, FDWs, + * servers, user mappings, comments, ACLs, security labels). Extension-member + * objects carry `memberOfExtension` provenance edges and are projected out of + * the managed view by default (managed-view architecture). + * + * The per-family query builders live in sibling modules (`./roles.ts`, + * `./relations.ts`, `./types.ts`, …) and share the scope, SQL fragments, and + * the mutable extraction context defined in `./scope.ts`. `extractOnClient` + * below is the orchestrator: it calls each family in a fixed order so the + * resulting fact / edge / diagnostic ordering is identical regardless of how + * the builders are grouped into files. + */ +import type { Pool, PoolClient } from "pg"; +import type { Diagnostic } from "../core/diagnostic.ts"; +import { + buildFactBase, + type DependencyEdge, + type Fact, + type FactBase, + type FactSource, +} from "../core/fact.ts"; +import type { ExtensionHandler, HandlerContext } from "./handler.ts"; +import { + extractDependencyEdges, + extractInheritanceEdges, +} from "./dependencies.ts"; +import { extractEventTriggers } from "./event-triggers.ts"; +import { extractForeign } from "./foreign.ts"; +import { extractPolicies } from "./policies.ts"; +import { extractPublications, extractSubscriptions } from "./publications.ts"; +import { + extractColumns, + extractIndexes, + extractRules, + extractSequences, + extractTableConstraints, + extractTables, + extractTriggers, + extractViews, +} from "./relations.ts"; +import { extractRolesAndGrants } from "./roles.ts"; +import { extractAggregates, extractRoutines } from "./routines.ts"; +import { extractSchemasAndExtensions } from "./schemas.ts"; +import { + createExtractContext, + ExtractionTimeoutError, + pruneOrphanedSatellites, +} from "./scope.ts"; +import { extractSecurityLabels } from "./security-labels.ts"; +import { extractCollations, extractDomains, extractTypes } from "./types.ts"; +import { detectUnmodeledKinds } from "./unmodeled.ts"; + +// re-exported for the public API surface (src/index.ts and the test suite) +export { ExtractionTimeoutError, pruneOrphanedSatellites }; + +export interface ExtractResult { + factBase: FactBase; + pgVersion: string; + diagnostics: Diagnostic[]; +} + +export interface ExtractOptions { + source?: FactSource; + statementTimeoutMs?: number; + /** + * Extension handlers, run on the SAME snapshot-bound transaction as core + * extraction (before COMMIT), so handler-produced `managedBy` edges describe + * the same moment in database time as the core facts. Default: none (bare + * core extraction; the corpus path). An integration supplies its profile's + * handlers here so the managed view is coherent. + */ + handlers?: readonly ExtensionHandler[]; + /** + * Redact sensitive foreign-data option values and subscription conninfo at + * extract time (default true). When false, real credentials are kept in the + * fact base and therefore surface in EVERY downstream channel — plan SQL, + * snapshot, declarative export, plan artifact, and the fingerprint digest. + * This is an explicit, loud escape hatch (it raises a `secret-redaction- + * disabled` warning diagnostic): only disable it when the output is destined + * for a trusted target that needs working credentials. Source and desired + * extractions must use the SAME setting or the diff is meaningless. + */ + redactSecrets?: boolean; +} + +export async function extract( + pool: Pool, + options: ExtractOptions = {}, +): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY"); + // Canonicalize the deparse path (pg_dump convention, post-CVE-2018-1058): + // `format_type` and every `pg_get_*def` / `pg_get_expr` path-relativizes + // names, so anything visible on the session `search_path` comes back + // UNQUALIFIED. Pinning to `pg_catalog` forces every non-catalog reference to + // be schema-qualified, so the SAME catalog hashes identically regardless of + // the database's / role's / connection's default path. SET LOCAL scopes it + // to this transaction and is discarded on COMMIT/ROLLBACK, so pooled + // connections are untouched. + await client.query("SET LOCAL search_path TO 'pg_catalog'"); + // Opt-in per-statement budget: a runaway catalog query on a pathological + // schema aborts with an actionable ExtractionTimeoutError (see scope.ts q()) + // instead of hanging. Default is unlimited — never abort a legitimate + // large extraction unless the caller asked for a budget. + if (options.statementTimeoutMs !== undefined) { + await client.query( + `SET LOCAL statement_timeout = ${Math.max(0, Math.floor(options.statementTimeoutMs))}`, + ); + } + const result = await extractOnClient( + client, + options.source ?? "liveDb", + options.statementTimeoutMs, + options.handlers ?? [], + options.redactSecrets ?? true, + ); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } +} + +async function extractOnClient( + client: PoolClient, + source: FactSource, + statementTimeoutMs: number | undefined, + handlers: readonly ExtensionHandler[], + redactSecrets: boolean, +): Promise { + const ctx = createExtractContext(client, statementTimeoutMs, redactSecrets); + + // Explicit, loud opt-out: disabling redaction means real credentials flow + // into plan SQL, snapshot, export, the plan artifact, and the fingerprint. + // Surface it as a warning so it is never silent. + if (!redactSecrets) { + ctx.diagnostics.push({ + code: "secret-redaction-disabled", + severity: "warning", + message: + "Secret redaction is DISABLED: foreign-data option values and subscription conninfo are emitted in cleartext in plan SQL, the catalog snapshot, declarative export, and the plan artifact. Do not persist these artifacts to untrusted locations.", + }); + } + + const pgVersion = + ((await ctx.q(`SHOW server_version`))[0]?.["server_version"] as string) ?? + "unknown"; + + // The call order IS the extraction order: facts / edges / diagnostics are + // accumulated in `ctx` in the order these run, so this sequence is preserved + // exactly from the pre-split single-function extractor. + await extractRolesAndGrants(ctx); + await extractSchemasAndExtensions(ctx); + await extractTables(ctx); + await extractColumns(ctx); + await extractTableConstraints(ctx); + await extractIndexes(ctx); + await extractSequences(ctx); + await extractViews(ctx); + await extractRoutines(ctx); + await extractTriggers(ctx); + await extractPolicies(ctx); + await extractDomains(ctx); + await extractTypes(ctx); + await extractCollations(ctx); + await extractEventTriggers(ctx); + await extractRules(ctx); + await extractAggregates(ctx); + await extractForeign(ctx); + await extractPublications(ctx); + await extractSubscriptions(ctx); + await extractSecurityLabels(ctx); + await extractInheritanceEdges(ctx); + await extractDependencyEdges(ctx); + + // drop metadata satellites whose target was filtered (Item 4a) before + // building — a satellite with a missing target would otherwise throw + const pruned = pruneOrphanedSatellites(ctx.facts); + ctx.diagnostics.push(...pruned.diagnostics); + let factBase = buildFactBase(pruned.facts, ctx.edges, source); + + // Extension handlers run HERE — on the same snapshot-bound client, inside the + // still-open REPEATABLE READ transaction (extract() COMMITs only after this + // returns). Handler queries therefore see the exact snapshot core extraction + // saw, so the `managedBy` edges they emit line up with the core fact base + // even under concurrent DDL / partition creation. The handler context exposes + // only the timeout-aware query runner, not the catalog push helpers — handlers + // contribute their own facts/edges, they do not mutate the core buffers. + if (handlers.length > 0) { + const handlerCtx: HandlerContext = { query: ctx.q }; + const extraFacts: Fact[] = []; + const extraEdges: DependencyEdge[] = []; + const extraDiagnostics: Diagnostic[] = []; + for (const handler of handlers) { + const captured = await handler.capture(handlerCtx, factBase); + extraFacts.push(...captured.facts); + extraEdges.push(...captured.edges); + if (captured.diagnostics) extraDiagnostics.push(...captured.diagnostics); + } + if (extraFacts.length > 0 || extraEdges.length > 0) { + factBase = buildFactBase( + [...factBase.facts(), ...extraFacts], + [...factBase.edges, ...extraEdges], + source, + ); + } + // handler diagnostics ride on the fact base itself — `plan()` reads + // `rawDesired.diagnostics` to gate a desired-side unkeyed-intent (an unnamed + // pg_cron job that can never converge). Pushed before line ~221 copies + // factBase.diagnostics into ctx.diagnostics, so they also reach + // ExtractResult.diagnostics for CLI rendering. + factBase.diagnostics.push(...extraDiagnostics); + } + + // Diagnostics a query-family builder flagged as needing to ride on the + // FactBase itself (e.g. a skipped user-mapping fact `plan()` must gate + // against — see ExtractContext.factDiagnostics). MUST be pushed AFTER the + // handler block above, not before: when a handler contributes facts/edges, + // `factBase` is REASSIGNED to a fresh instance with an empty `.diagnostics` + // array (buildFactBase never carries diagnostics over), so pushing here + // first would silently orphan them for exactly the integration-profile + // (Supabase / handler-bearing) callers plan()'s gate most needs to protect. + // A handler's capture() therefore sees the PRE-rebuild base without these — + // harmless today since no handler inspects diagnostics. Folded into + // `ctx.diagnostics` by the copy below, same as handler diagnostics. + factBase.diagnostics.push(...ctx.factDiagnostics); + + // dangling edges (e.g. references to unextracted kinds) become diagnostics + ctx.diagnostics.push(...factBase.diagnostics); + // catalog completeness: user objects in kinds we don't model are reported, + // never silently missed (review finding 1). Same snapshot, one round-trip. + ctx.diagnostics.push(...(await detectUnmodeledKinds(client))); + return { factBase, pgVersion, diagnostics: ctx.diagnostics }; +} diff --git a/packages/pg-delta/src/extract/foreign.ts b/packages/pg-delta/src/extract/foreign.ts new file mode 100644 index 000000000..aba03eaa8 --- /dev/null +++ b/packages/pg-delta/src/extract/foreign.ts @@ -0,0 +1,207 @@ +/** Foreign-data objects: FDWs, servers, user mappings, and foreign tables. */ +import { USER_MAPPING_UNREADABLE } from "../core/diagnostic.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { + aclJson, + type ExtractContext, + notExtensionMember, + parseAcl, + USER_SCHEMA_FILTER, +} from "./scope.ts"; +import { redactOptionStrings } from "./sensitive-options.ts"; + +export async function extractForeign(ctx: ExtractContext): Promise { + const { q, facts, pushWithMeta, pushOwnerEdge, factDiagnostics } = ctx; + // Redact sensitive option values unless the caller explicitly opted out. + const opts = (raw: string[]): string[] => + ctx.redactSecrets ? redactOptionStrings(raw) : raw; + // ── foreign data wrappers / servers / user mappings / foreign tables ─ + for (const row of await q(` + SELECT f.fdwname AS name, r.rolname AS owner, + CASE WHEN f.fdwhandler <> 0 THEN f.fdwhandler::regproc::text END AS handler, + CASE WHEN f.fdwvalidator <> 0 THEN f.fdwvalidator::regproc::text END AS validator, + COALESCE(ARRAY(SELECT opt FROM unnest(f.fdwoptions) opt ORDER BY opt), '{}')::text[] AS options, + obj_description(f.oid, 'pg_foreign_data_wrapper') AS comment, + ${aclJson("f.fdwacl", "F", "f.fdwowner")} AS acl + FROM pg_foreign_data_wrapper f + JOIN pg_roles r ON r.oid = f.fdwowner + WHERE ${notExtensionMember("pg_foreign_data_wrapper", "f.oid")} + ORDER BY f.fdwname`)) { + const fdwId: StableId = { kind: "fdw", name: String(row["name"]) }; + pushWithMeta( + { + id: fdwId, + payload: { + handler: row["handler"] == null ? null : (row["handler"] as string), + validator: + row["validator"] == null ? null : (row["validator"] as string), + options: opts((row["options"] as string[]).map(String)), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushOwnerEdge(fdwId, row["owner"]); + } + for (const row of await q(` + SELECT s.srvname AS name, f.fdwname AS fdw, r.rolname AS owner, + s.srvtype AS type, s.srvversion AS version, + (SELECT e.extname FROM pg_depend d + JOIN pg_extension e ON e.oid = d.refobjid + WHERE d.classid = 'pg_foreign_data_wrapper'::regclass + AND d.objid = f.oid + AND d.refclassid = 'pg_extension'::regclass + AND d.deptype = 'e' + LIMIT 1) AS fdw_extension, + COALESCE(ARRAY(SELECT opt FROM unnest(s.srvoptions) opt ORDER BY opt), '{}')::text[] AS options, + obj_description(s.oid, 'pg_foreign_server') AS comment, + ${aclJson("s.srvacl", "S", "s.srvowner")} AS acl + FROM pg_foreign_server s + JOIN pg_foreign_data_wrapper f ON f.oid = s.srvfdw + JOIN pg_roles r ON r.oid = s.srvowner + WHERE ${notExtensionMember("pg_foreign_server", "s.oid")} + ORDER BY s.srvname`)) { + const srvId: StableId = { kind: "server", name: String(row["name"]) }; + pushWithMeta( + { + id: srvId, + // an extension-provided FDW has no fact of its own — parent the + // server to the extension instead so the reference resolves + parent: + row["fdw_extension"] != null + ? { kind: "extension", name: row["fdw_extension"] as string } + : { kind: "fdw", name: String(row["fdw"]) }, + payload: { + fdw: String(row["fdw"]), + type: row["type"] == null ? null : (row["type"] as string), + version: row["version"] == null ? null : (row["version"] as string), + options: opts((row["options"] as string[]).map(String)), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushOwnerEdge(srvId, row["owner"]); + } + // pg_user_mapping is superuser/owner-only (it can carry FDW credentials in + // umoptions) — a non-superuser role gets `permission denied for table + // pg_user_mapping` on ANY query referencing it, even one matching zero rows + // (Postgres checks table-level SELECT privilege before evaluating). Probe + // once and fall back to the world-readable `pg_user_mappings` view. + // + // The view NULLs `umoptions` for a row the caller isn't authorized on, but + // NULL is also the value of a mapping that genuinely HAS no options — those + // two cases are indistinguishable from `umoptions` alone. Naively coalescing + // NULL to '{}' would fabricate a "no options" fact for a row the view is + // actually HIDING, which could then mislead diff/plan into wrong DDL. So + // `options_known` recomputes the view's own authorization predicate to tell + // "genuinely empty" from "hidden" apart: a genuinely empty, visible mapping + // still produces a fact; a hidden one is SKIPPED with a diagnostic instead. + // The predicate mirrors `pg_get_viewdef('pg_user_mappings')` exactly + // (identical on PG14 and PG17): non-NULL umoptions is proof of visibility by + // itself; a mapping FOR a specific role is visible to that role only if it + // is ALSO the server owner (or a member of it) or holds USAGE on the + // server — a user does NOT automatically see their own mapping's options + // (verified empirically: a bare NOSUPERUSER role with no grants sees NULL + // even querying its own mapping); a PUBLIC mapping is visible to anyone who + // is a member of the server's owner role. (The view's third disjunct, + // `current_user` being a superuser, is omitted here — this fallback only + // ever runs for a role `has_table_privilege` already found non-superuser.) + // If a PG version's view is MORE permissive than this predicate, its + // non-NULL `umoptions` still classifies correctly on its own; the only + // failure direction is over-skipping, never fabricating. + // + // A warning alone is not enough (Codex P1 on PR #338): if the OTHER side of + // a diff CAN see this mapping, the missing fact reads as an intentional + // add/remove and `plan()` would emit a wrong CREATE/DROP USER MAPPING. So + // each skipped row's diagnostic carries `subject` = its would-be stable id + // and is pushed onto `factDiagnostics` (rides on the FactBase, not just + // `ExtractResult.diagnostics`) — `plan()`'s gate escalates to fatal exactly + // when a delta actually touches one of these subjects. + const userMappingReadable = Boolean( + ( + await q( + `SELECT has_table_privilege('pg_catalog.pg_user_mapping', 'SELECT') AS ok`, + ) + )[0]?.["ok"], + ); + const userMappingRows = userMappingReadable + ? await q(` + SELECT s.srvname AS server, COALESCE(r.rolname, 'PUBLIC') AS role, + COALESCE(ARRAY(SELECT opt FROM unnest(u.umoptions) opt ORDER BY opt), '{}')::text[] AS options + FROM pg_user_mapping u + JOIN pg_foreign_server s ON s.oid = u.umserver + LEFT JOIN pg_roles r ON r.oid = u.umuser + WHERE ${notExtensionMember("pg_foreign_server", "s.oid")} + ORDER BY s.srvname, 2`) + : await q(` + SELECT v.srvname AS server, + CASE WHEN v.umuser = 0 THEN 'PUBLIC' ELSE v.usename END AS role, + COALESCE(ARRAY(SELECT opt FROM unnest(v.umoptions) opt ORDER BY opt), '{}')::text[] AS options, + (v.umoptions IS NOT NULL + OR (v.umuser <> 0 AND v.usename = current_user + AND (pg_has_role(s.srvowner, 'USAGE') OR has_server_privilege(s.oid, 'USAGE'))) + OR (v.umuser = 0 AND pg_has_role(s.srvowner, 'USAGE'))) AS options_known + FROM pg_user_mappings v + JOIN pg_foreign_server s ON s.oid = v.srvid + WHERE ${notExtensionMember("pg_foreign_server", "v.srvid")} + ORDER BY v.srvname, 2`); + for (const row of userMappingRows) { + const server = String(row["server"]); + const role = String(row["role"]); + const userMappingId: StableId = { kind: "userMapping", server, role }; + if (!userMappingReadable && row["options_known"] === false) { + factDiagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: userMappingId, + message: + `User-mapping options for ${server}/${role} are hidden from the current role by ` + + "pg_user_mappings (it isn't the mapping's server owner, the mapped user, or a " + + "member of the PUBLIC mapping's server owner) — the mapping's state is unknown, " + + "so it was SKIPPED rather than recorded with fabricated empty options.", + }); + continue; + } + facts.push({ + id: userMappingId, + parent: { kind: "server", name: server }, + payload: { + options: opts((row["options"] as string[]).map(String)), + }, + }); + } + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS name, r.rolname AS owner, + s.srvname AS server, + COALESCE(ARRAY(SELECT opt FROM unnest(ft.ftoptions) opt ORDER BY opt), '{}')::text[] AS options, + obj_description(c.oid, 'pg_class') AS comment, + ${aclJson("c.relacl", "r", "c.relowner")} AS acl + FROM pg_foreign_table ft + JOIN pg_class c ON c.oid = ft.ftrelid + JOIN pg_foreign_server s ON s.oid = ft.ftserver + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_roles r ON r.oid = c.relowner + WHERE ${USER_SCHEMA_FILTER} + AND ${notExtensionMember("pg_class", "c.oid")} + ORDER BY n.nspname, c.relname`)) { + const ftId: StableId = { + kind: "foreignTable", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id: ftId, + parent: { kind: "server", name: String(row["server"]) }, + payload: { + server: String(row["server"]), + options: opts((row["options"] as string[]).map(String)), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushOwnerEdge(ftId, row["owner"]); + } +} diff --git a/packages/pg-delta/src/extract/handler.ts b/packages/pg-delta/src/extract/handler.ts new file mode 100644 index 000000000..75b112d18 --- /dev/null +++ b/packages/pg-delta/src/extract/handler.ts @@ -0,0 +1,91 @@ +/** + * Extension handler contract (docs/architecture/extension-intent.md §4.1). + * + * A handler is a data package that teaches the integration layer about ONE + * stateful extension (pg_partman, pgmq, pg_cron, …). It reads the extension's + * OWN catalogs — `part_config`, `cron.job`, pgmq's `meta`, none of which are + * `pg_catalog`, so handlers live ABOVE core (P1: capture, never parse) — and + * emits facts + edges that are merged into the core fact base. + * + * The contract lives in the extract layer (not policy/) on purpose: `extract` + * invokes handlers inside its own snapshot-bound transaction, so it must be able + * to reference these types WITHOUT importing `policy/` (which already imports + * `extract`, so the reverse import would be a cycle). Concrete handlers + * (`pgPartmanHandler`) live in `src/policy/extensions/` and import this type. + * + * Phase A (this slice): handlers emit only `managedBy` edges on the objects the + * extension created operationally, so the managed view (resolveView) projects + * them out of the schema diff (no data loss). Phase B adds intent facts + replay + * rules. + */ +import type { Diagnostic } from "../core/diagnostic.ts"; +import type { DependencyEdge, Fact, FactBase } from "../core/fact.ts"; +import type { IntentKindRule } from "../plan/rules.ts"; +import type { Row } from "./scope.ts"; + +/** + * The snapshot-bound context handed to a handler's `capture`: a query runner + * tied to the SAME `REPEATABLE READ READ ONLY` transaction (and the same + * timeout budget) as core catalog extraction. Handler-produced facts/edges + * therefore describe the exact same moment in database time as the core facts — + * the coherent-catalog-read guarantee holds across the integration layer too. + */ +export interface HandlerContext { + /** Run a query on the core extraction snapshot (timeout-aware, same client). */ + query(sql: string): Promise; +} + +export interface CaptureResult { + /** Intent facts (Phase B). Empty for filter-only handlers. */ + facts: Fact[]; + /** Provenance edges (`managedBy`) marking operationally-created objects. */ + edges: DependencyEdge[]; + /** Diagnostics the capture surfaces — e.g. an unnamed pg_cron job that cannot + * be keyed as an intent fact. Rides on the resulting `FactBase.diagnostics` + * (so `plan()` can gate on a desired-side "intent-unkeyed" diagnostic) AND on + * `ExtractResult.diagnostics` (for CLI rendering). Optional; most captures + * emit none. */ + diagnostics?: Diagnostic[]; +} + +export interface ExtensionHandler { + /** The `pg_extension` name this handler manages. */ + readonly extension: string; + /** + * Read the extension's own catalogs and emit facts + edges. Returns empty + * when the extension is not installed. Runs on the same snapshot-bound `ctx` + * as core extraction. Must NOT mutate `current`; it is provided so the handler + * can target only objects that exist as facts (and avoid dangling edges). + */ + capture(ctx: HandlerContext, current: FactBase): Promise; + /** + * Phase B (docs/architecture/extension-intent.md §4.1): replay rules for this + * handler's intent kinds, keyed by `intentKind` (e.g. `job` for pg_cron). The + * resolved profile folds these into the plan's rule resolver, so the generic + * planner dispatches an `extensionIntent` fact exactly like a schema kind. + * Absent for filter-only Phase-A handlers (pg_partman today), which emit + * `managedBy` edges but no intent facts. + */ + readonly intentKinds?: Record; + /** + * Optional guard for extensions that can only run their DDL/intent in a + * SPECIFIC database (pg_cron's schedule* functions work solely in the cluster's + * `cron.database_name`). `schema apply` runs this BEFORE loading declarative + * files into the shadow: if any file matches `matchesStatement` and the shadow + * is not `capable`, it errors out early with `reason` instead of getting stuck + * mid-load. Generic — pgmq / pg_partman have no such constraint and omit it. + */ + readonly shadowPrecheck?: ShadowPrecheck; +} + +/** A handler's shadow-capability guard (see {@link ExtensionHandler.shadowPrecheck}). */ +export interface ShadowPrecheck { + /** True when a (literal-masked) declarative statement expresses this handler's + * DDL/intent — e.g. a `cron.schedule(...)` call or `CREATE EXTENSION pg_cron`. */ + matchesStatement(maskedStatement: string): boolean; + /** Whether the given database (the shadow) can execute the matched statements. + * `query` runs against the shadow. */ + capable( + query: (sql: string) => Promise, + ): Promise<{ capable: true } | { capable: false; reason: string }>; +} diff --git a/packages/pg-delta/src/extract/policies.ts b/packages/pg-delta/src/extract/policies.ts new file mode 100644 index 000000000..6636ca03b --- /dev/null +++ b/packages/pg-delta/src/extract/policies.ts @@ -0,0 +1,52 @@ +/** Row-level security policies. */ +import { + type ExtractContext, + notExtensionMember, + USER_SCHEMA_FILTER, +} from "./scope.ts"; + +export async function extractPolicies(ctx: ExtractContext): Promise { + const { q, pushWithMeta } = ctx; + // ── row-level security policies ────────────────────────────────────── + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS table, pol.polname AS name, + pol.polcmd AS cmd, pol.polpermissive AS permissive, + pg_get_expr(pol.polqual, pol.polrelid) AS using_expr, + pg_get_expr(pol.polwithcheck, pol.polrelid) AS check_expr, + CASE WHEN pol.polroles = '{0}'::oid[] THEN ARRAY['PUBLIC']::text[] + ELSE ARRAY(SELECT rolname::text FROM pg_roles WHERE oid = ANY(pol.polroles) ORDER BY rolname) + END AS roles, + obj_description(pol.oid, 'pg_policy') AS comment + FROM pg_policy pol + JOIN pg_class c ON c.oid = pol.polrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE ${USER_SCHEMA_FILTER} + AND ${notExtensionMember("pg_class", "c.oid")} + ORDER BY n.nspname, c.relname, pol.polname`)) { + pushWithMeta( + { + id: { + kind: "policy", + schema: String(row["schema"]), + table: String(row["table"]), + name: String(row["name"]), + }, + parent: { + kind: "table", + schema: String(row["schema"]), + name: String(row["table"]), + }, + payload: { + cmd: String(row["cmd"]), + permissive: Boolean(row["permissive"]), + usingExpr: + row["using_expr"] == null ? null : (row["using_expr"] as string), + checkExpr: + row["check_expr"] == null ? null : (row["check_expr"] as string), + roles: (row["roles"] as string[]).map(String), + }, + }, + row, + ); + } +} diff --git a/packages/pg-delta/src/extract/publications.ts b/packages/pg-delta/src/extract/publications.ts new file mode 100644 index 000000000..f4e6ea6a3 --- /dev/null +++ b/packages/pg-delta/src/extract/publications.ts @@ -0,0 +1,211 @@ +/** Publications (+ their table / schema member facts) and subscriptions. */ +import type { StableId } from "../core/stable-id.ts"; +import { type ExtractContext, notExtensionMember } from "./scope.ts"; +import { SUBSCRIPTION_CONNINFO_PLACEHOLDER } from "./sensitive-options.ts"; + +export async function extractPublications(ctx: ExtractContext): Promise { + const { q, facts, pushWithMeta, pushOwnerEdge } = ctx; + // Publication column lists (pg_publication_rel.prattrs), row filters + // (pr.prqual), and schema membership (pg_publication_namespace) are all + // PostgreSQL 15+. On PG14 those catalog columns / relations do not exist, so + // the query degrades to bare table membership (no column list / WHERE, no + // schema publications) — exactly the publication feature set PG14 has. + const major = Math.floor( + Number( + ( + await q(`SELECT current_setting('server_version_num')::int AS num`) + )[0]?.["num"] ?? 0, + ) / 10000, + ); + const columnsExpr = + major >= 15 + ? `(SELECT array_agg(att.attname::text ORDER BY att.attname) + FROM unnest(pr.prattrs) WITH ORDINALITY AS pa(attnum, ord) + JOIN pg_attribute att ON att.attrelid = pc.oid AND att.attnum = pa.attnum)` + : `NULL`; + const whereExpr = major >= 15 ? `pg_get_expr(pr.prqual, pr.prrelid)` : `NULL`; + const schemasExpr = + major >= 15 + ? `(SELECT array_agg(pn2.nspname::text ORDER BY 1) + FROM pg_publication_namespace pns + JOIN pg_namespace pn2 ON pn2.oid = pns.pnnspid + WHERE pns.pnpubid = p.oid)` + : `NULL::text[]`; + // ── publications ───────────────────────────────────────────────────── + for (const row of await q(` + SELECT p.pubname AS name, r.rolname AS owner, + p.puballtables AS all_tables, p.pubviaroot AS via_root, + p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, + (SELECT json_agg(json_build_object( + 'schema', pn.nspname, 'name', pc.relname, + 'columns', ${columnsExpr}, + 'where', ${whereExpr} + ) ORDER BY pn.nspname, pc.relname) + FROM pg_publication_rel pr + JOIN pg_class pc ON pc.oid = pr.prrelid + JOIN pg_namespace pn ON pn.oid = pc.relnamespace + WHERE pr.prpubid = p.oid) AS tables, + ${schemasExpr} AS schemas, + obj_description(p.oid, 'pg_publication') AS comment + FROM pg_publication p + JOIN pg_roles r ON r.oid = p.pubowner + WHERE ${notExtensionMember("pg_publication", "p.oid")} + ORDER BY p.pubname`)) { + const publish: string[] = []; + if (row["pubinsert"]) publish.push("insert"); + if (row["pubupdate"]) publish.push("update"); + if (row["pubdelete"]) publish.push("delete"); + if (row["pubtruncate"]) publish.push("truncate"); + const pubName = String(row["name"]); + const pubId: StableId = { kind: "publication", name: pubName }; + pushWithMeta( + { + id: pubId, + payload: { + allTables: Boolean(row["all_tables"]), + viaRoot: Boolean(row["via_root"]), + publish, + }, + }, + row, + ); + pushOwnerEdge(pubId, row["owner"]); + // each published table / schema is its own fact (granularity is one): + // members are managed with ALTER PUBLICATION ADD/DROP, and a column-list + // or WHERE change diffs at table grain instead of churning the whole + // publication payload. + const tables = + (row["tables"] as + | { + schema: string; + name: string; + columns: string[] | null; + where: string | null; + }[] + | null) ?? []; + for (const t of tables) { + facts.push({ + id: { + kind: "publicationRel", + publication: pubName, + schema: t.schema, + table: t.name, + }, + parent: pubId, + payload: { + columns: t.columns == null ? null : t.columns.map(String), + where: t.where ?? null, + }, + }); + } + for (const s of ((row["schemas"] as string[] | null) ?? []).map(String)) { + facts.push({ + id: { kind: "publicationSchema", publication: pubName, schema: s }, + parent: pubId, + payload: {}, + }); + } + } +} + +export async function extractSubscriptions(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushOwnerEdge } = ctx; + const major = Math.floor( + Number( + ( + await q(`SELECT current_setting('server_version_num')::int AS num`) + )[0]?.["num"] ?? 0, + ) / 10000, + ); + // substream is boolean on PG15 (on/off) and a "char" on PG16+ ('f'/'t'/'p', + // 'p' = parallel). Normalise to the CREATE/ALTER keyword in SQL. + const streamingExpr = + major >= 16 + ? `CASE s.substream WHEN 'p' THEN 'parallel' WHEN 't' THEN 'on' ELSE 'off' END` + : `CASE WHEN s.substream THEN 'on' ELSE 'off' END`; + // disable_on_error and two_phase are PG15+; run_as_owner and origin are + // PG16+ — NULL on older servers so the rule omits them entirely. (binary and + // streaming are PG14+ and synchronous_commit is PG10+, so they need no gate + // for our supported range.) + const disableOnErrorExpr = + major >= 15 ? `s.subdisableonerr` : `NULL::boolean`; + const twoPhaseExpr = + major >= 15 ? `(s.subtwophasestate <> 'd')` : `NULL::boolean`; + const runAsOwnerExpr = major >= 16 ? `s.subrunasowner` : `NULL::boolean`; + const originExpr = major >= 16 ? `s.suborigin` : `NULL::text`; + // `subconninfo` is revoked from non-superusers by default (unlike every + // other pg_subscription column, which PUBLIC can read) — selecting it + // unconditionally makes the WHOLE query fail `permission denied for table + // pg_subscription` for such a caller. Postgres's column permission check is + // static (keyed on which columns the query TEXT references, independent of + // matched rows), so a runtime `CASE WHEN has_column_privilege(...) THEN + // s.subconninfo ELSE NULL END` does NOT work — the column reference alone + // still trips the check. Gate it the same way `major` gates version-specific + // columns above: probe once, then build the column reference conditionally. + const conninfoReadable = Boolean( + ( + await q( + `SELECT has_column_privilege('pg_subscription', 'subconninfo', 'SELECT') AS ok`, + ) + )[0]?.["ok"], + ); + const conninfoExpr = conninfoReadable ? "s.subconninfo" : "NULL::text"; + // ── subscriptions (database-local rows only) ───────────────────────── + for (const row of await q(` + SELECT s.subname AS name, r.rolname AS owner, s.subenabled AS enabled, + ${conninfoExpr} AS conninfo, s.subslotname AS slot_name, + s.subpublications::text[] AS publications, + s.subbinary AS binary, + ${streamingExpr} AS streaming, + s.subsynccommit AS synchronous_commit, + ${disableOnErrorExpr} AS disable_on_error, + ${twoPhaseExpr} AS two_phase, + ${runAsOwnerExpr} AS run_as_owner, + ${originExpr} AS origin, + obj_description(s.oid, 'pg_subscription') AS comment + FROM pg_subscription s + JOIN pg_roles r ON r.oid = s.subowner + JOIN pg_database d ON d.oid = s.subdbid + WHERE d.datname = current_database() + ORDER BY s.subname`)) { + const subId: StableId = { kind: "subscription", name: String(row["name"]) }; + pushWithMeta( + { + id: subId, + payload: { + // Non-semantic (leading `_`, dropped from the content hash): carries + // the source server major so the plan rule can gate `two_phase` + // handling — ALTER SUBSCRIPTION … SET (two_phase) is PG17+ only. + _serverMajor: major, + enabled: Boolean(row["enabled"]), + // conninfo is fully env-dependent and carries credentials — emit the + // placeholder unless the caller explicitly opted out of redaction + // (see sensitive-options.ts). `row["conninfo"]` is also null when + // this role lacks column privilege on subconninfo (conninfoExpr + // above) — the real value is unrecoverable either way, so that also + // falls back to the placeholder rather than the string "null". + conninfo: + ctx.redactSecrets || row["conninfo"] == null + ? SUBSCRIPTION_CONNINFO_PLACEHOLDER + : (row["conninfo"] as string), + slotName: + row["slot_name"] == null ? null : (row["slot_name"] as string), + publications: (row["publications"] as string[]).map(String).sort(), + binary: Boolean(row["binary"]), + streaming: String(row["streaming"]), + synchronousCommit: String(row["synchronous_commit"]), + disableOnError: + row["disable_on_error"] == null + ? null + : Boolean(row["disable_on_error"]), + twoPhase: row["two_phase"] == null ? null : Boolean(row["two_phase"]), + runAsOwner: + row["run_as_owner"] == null ? null : Boolean(row["run_as_owner"]), + origin: row["origin"] == null ? null : (row["origin"] as string), + }, + }, + row, + ); + pushOwnerEdge(subId, row["owner"]); + } +} diff --git a/packages/pg-delta/src/extract/relations.ts b/packages/pg-delta/src/extract/relations.ts new file mode 100644 index 000000000..6ca9c6603 --- /dev/null +++ b/packages/pg-delta/src/extract/relations.ts @@ -0,0 +1,548 @@ +/** Relations and their sub-objects: tables, columns + defaults, table + * constraints, indexes, sequences, views + materialized views, triggers, and + * rewrite rules. */ +import type { StableId } from "../core/stable-id.ts"; +import { + aclJson, + aclJsonMemberAware, + type ExtractContext, + memberExtensionExpr, + notExtensionMember, + parseAcl, + schemaId, + USER_SCHEMA_FILTER, +} from "./scope.ts"; + +/** Canonicalize pg_class.reloptions (a text[] of `key=value`) to a sorted array + * so the payload hash is order-independent, or null when there are none. */ +function reloptions(row: Record): string[] | null { + const raw = row["reloptions"]; + if (raw == null) return null; + const arr = (raw as string[]).slice().sort(); + return arr.length > 0 ? arr : null; +} + +export async function extractTables(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS name, r.rolname AS owner, + c.relpersistence AS persistence, + c.relrowsecurity AS row_security, + c.relforcerowsecurity AS force_row_security, + c.relreplident AS replica_identity, + (SELECT ic.relname FROM pg_index i + JOIN pg_class ic ON ic.oid = i.indexrelid + WHERE i.indrelid = c.oid AND i.indisreplident) AS replica_identity_index, + CASE WHEN c.relkind = 'p' THEN pg_get_partkeydef(c.oid) END AS partition_key, + c.reloptions AS reloptions, + pg_get_expr(c.relpartbound, c.oid) AS partition_bound, + (SELECT json_build_object('schema', pn.nspname, 'name', pc.relname) + FROM pg_inherits inh + JOIN pg_class pc ON pc.oid = inh.inhparent + JOIN pg_namespace pn ON pn.oid = pc.relnamespace + WHERE inh.inhrelid = c.oid + -- Multi-parent support is tracked separately; until then capture the + -- FIRST-declared parent deterministically. Without ORDER BY the + -- unordered LIMIT 1 can pick a different parent across extractions, + -- flapping the fact hash and causing spurious table replaces. + ORDER BY inh.inhseqno + LIMIT 1) AS parent_table, + obj_description(c.oid, 'pg_class') AS comment, + ${aclJsonMemberAware("c.relacl", "r", "c.relowner", "pg_class", "c.oid")} AS acl, + ${memberExtensionExpr("pg_class", "c.oid")} AS ext_member_of + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_roles r ON r.oid = c.relowner + WHERE c.relkind IN ('r', 'p') AND ${USER_SCHEMA_FILTER} + ORDER BY n.nspname, c.relname`)) { + const id: StableId = { + kind: "table", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { + persistence: String(row["persistence"]), + rowSecurity: Boolean(row["row_security"]), + forceRowSecurity: Boolean(row["force_row_security"]), + replicaIdentity: String(row["replica_identity"]), + replicaIdentityIndex: + row["replica_identity_index"] == null + ? null + : (row["replica_identity_index"] as string), + partitionKey: + row["partition_key"] == null + ? null + : (row["partition_key"] as string), + partitionBound: + row["partition_bound"] == null + ? null + : (row["partition_bound"] as string), + parentTable: + row["parent_table"] == null + ? null + : (row["parent_table"] as { schema: string; name: string }), + reloptions: reloptions(row), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } +} + +export async function extractColumns(ctx: ExtractContext): Promise { + const { q, facts, pushWithMeta } = ctx; + // ── columns + defaults (defaults are their own facts, like pg_attrdef) ─ + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS table, a.attname AS name, + a.attnum AS position, + c.relkind AS table_kind, + format_type(a.atttypid, a.atttypmod) AS type, + a.attnotnull AS not_null, + NULLIF(a.attidentity, '') AS identity, + (SELECT json_build_object('schema', sn.nspname, 'name', sc.relname) + FROM pg_depend d + JOIN pg_class sc ON sc.oid = d.objid + JOIN pg_namespace sn ON sn.oid = sc.relnamespace + WHERE d.classid = 'pg_class'::regclass + AND d.refclassid = 'pg_class'::regclass + AND d.refobjid = c.oid AND d.refobjsubid = a.attnum + AND d.deptype = 'i' AND sc.relkind = 'S' + LIMIT 1) AS identity_sequence, + (SELECT json_build_object( + 'increment', sq.seqincrement::text, 'start', sq.seqstart::text, + 'minValue', sq.seqmin::text, 'maxValue', sq.seqmax::text, + 'cache', sq.seqcache::text, 'cycle', sq.seqcycle) + FROM pg_depend d + JOIN pg_sequence sq ON sq.seqrelid = d.objid + WHERE d.classid = 'pg_class'::regclass + AND d.refclassid = 'pg_class'::regclass + AND d.refobjid = c.oid AND d.refobjsubid = a.attnum + AND d.deptype = 'i' + LIMIT 1) AS identity_options, + NULLIF(a.attgenerated, '') AS generated, + CASE WHEN a.attcollation <> t.typcollation THEN ( + SELECT quote_ident(cn.nspname) || '.' || quote_ident(co.collname) + FROM pg_collation co JOIN pg_namespace cn ON cn.oid = co.collnamespace + WHERE co.oid = a.attcollation) + END AS collation, + pg_get_expr(ad.adbin, ad.adrelid) AS default_expr, + col_description(c.oid, a.attnum) AS comment, + -- column-level ACL (pg_attribute.attacl). Columns have no built-in + -- default privileges, so acldefault('c', owner) is empty: a NULL + -- attacl yields no acl facts, and a non-NULL one lists only explicit + -- GRANT SELECT/INSERT/UPDATE/REFERENCES (col) entries. + ${aclJson("a.attacl", "c", "c.relowner")} AS acl + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_type t ON t.oid = a.atttypid + LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum + WHERE c.relkind IN ('r', 'p', 'f') AND a.attnum > 0 AND NOT a.attisdropped + AND a.attislocal + AND ${USER_SCHEMA_FILTER} + AND ${notExtensionMember("pg_class", "c.oid")} + ORDER BY n.nspname, c.relname, a.attname`)) { + const tableId: StableId = { + kind: String(row["table_kind"]) === "f" ? "foreignTable" : "table", + schema: String(row["schema"]), + name: String(row["table"]), + }; + const columnId: StableId = { + kind: "column", + schema: String(row["schema"]), + table: String(row["table"]), + name: String(row["name"]), + }; + const generated = row["generated"] != null; + pushWithMeta( + { + id: columnId, + parent: tableId, + payload: { + // `_position` is the declared column position (pg_attribute.attnum). + // Column ORDER is row-layout state (SELECT *, positional INSERT, the + // relation's row type), so a from-empty CREATE must render columns in + // this order — but positional IDENTITY is not desired state (columns + // are name-keyed, like composite attributes), so the `_`-prefix + // excludes it from the hash and diff (core/hash.ts, core/diff.ts): an + // order-only reshuffle on an EXISTING table stays undiffable by design. + // attnum has HOLES after DROP COLUMN, but ordering the survivors by it + // still yields their declared order, which is what matters. The plan's + // ordering phase (plan/phases/action-graph.ts) and the partitioned + // inline-column path (plan/rules/tables.ts) render in this order. + _position: Number(row["position"]), + type: String(row["type"]), + notNull: Boolean(row["not_null"]), + identity: + row["identity"] == null + ? null + : { + generation: row["identity"] as string, + sequence: row["identity_sequence"] as { + schema: string; + name: string; + } | null, + options: + row["identity_options"] == null + ? null + : (row["identity_options"] as { + increment: string; + start: string; + minValue: string; + maxValue: string; + cache: string; + cycle: boolean; + }), + }, + collation: + row["collation"] == null ? null : (row["collation"] as string), + generatedExpr: + generated && row["default_expr"] != null + ? (row["default_expr"] as string) + : null, + }, + }, + row, + ); + if (!generated && row["default_expr"] != null) { + facts.push({ + id: { + kind: "default", + schema: String(row["schema"]), + table: String(row["table"]), + name: String(row["name"]), + }, + parent: columnId, + payload: { expr: row["default_expr"] as string }, + }); + } + // Column-level grants (attacl): one acl satellite per grantee, targeting the + // owning relation but qualified by this column. Parent is the column so the + // grant folds into the column/table drop, exactly like the default above. + for (const acl of parseAcl(row["acl"])) { + facts.push({ + id: { + kind: "acl", + target: tableId, + grantee: acl.grantee, + column: String(row["name"]), + }, + parent: columnId, + payload: { privileges: acl.privileges, grantable: acl.grantable }, + }); + } + } +} + +export async function extractTableConstraints( + ctx: ExtractContext, +): Promise { + const { q, pushWithMeta } = ctx; + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS table, con.conname AS name, + c.relkind AS table_kind, + pg_get_constraintdef(con.oid) AS def, + con.contype AS type, con.convalidated AS validated, + obj_description(con.oid, 'pg_constraint') AS comment + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + -- 'f' = foreign tables: they carry only CHECK constraints (no p/u/f/x), + -- so the contype filter already scopes them; serialized via ALTER FOREIGN + -- TABLE (constraintTarget keys off the parent's foreignTable kind). + WHERE con.contype IN ('p', 'u', 'f', 'c', 'x') AND con.conislocal + AND c.relkind IN ('r', 'p', 'f') AND ${USER_SCHEMA_FILTER} + AND ${notExtensionMember("pg_class", "c.oid")} + ORDER BY n.nspname, c.relname, con.conname`)) { + pushWithMeta( + { + id: { + kind: "constraint", + schema: String(row["schema"]), + table: String(row["table"]), + name: String(row["name"]), + }, + parent: { + kind: String(row["table_kind"]) === "f" ? "foreignTable" : "table", + schema: String(row["schema"]), + name: String(row["table"]), + }, + payload: { + def: String(row["def"]), + type: String(row["type"]), + validated: Boolean(row["validated"]), + }, + }, + row, + ); + } +} + +export async function extractIndexes(ctx: ExtractContext): Promise { + const { q, pushWithMeta } = ctx; + // ── indexes (excluding constraint-backed ones) ─────────────────────── + for (const row of await q(` + SELECT n.nspname AS schema, ic.relname AS name, c.relname AS table, + c.relkind AS table_kind, + pg_get_indexdef(i.indexrelid) AS def, + -- A partitioned PARENT index (relkind 'I') is legitimately + -- indisvalid=false whenever a child index is unattached, and + -- pg_get_indexdef renders it as CREATE INDEX ... ON ONLY ..., which + -- itself produces an invalid parent (children attach separately). So + -- its indisvalid is attach-state, not repair-worthy corruption: + -- force it valid here so it never drives a diff (the unmodeled + -- attach-state stays tracked in #332). Only REGULAR indexes ('i') + -- carry their real indisvalid, which is what catches a failed + -- CREATE INDEX CONCURRENTLY. + CASE WHEN ic.relkind = 'I' THEN true ELSE i.indisvalid END AS valid, + obj_description(i.indexrelid, 'pg_class') AS comment + FROM pg_index i + JOIN pg_class ic ON ic.oid = i.indexrelid + JOIN pg_class c ON c.oid = i.indrelid + JOIN pg_namespace n ON n.oid = ic.relnamespace + WHERE c.relkind IN ('r', 'p', 'm') AND ${USER_SCHEMA_FILTER} + -- Exclude indexes OWNED by a constraint (PRIMARY KEY / UNIQUE / EXCLUSION), + -- which are serialized via the constraint, not as standalone CREATE INDEX. + -- Gate on contype: a FOREIGN KEY constraint also sets conindid — to the + -- index on the REFERENCED table it depends on — so an unqualified check + -- wrongly drops a standalone unique index the moment any FK references it + -- (regression: realtime.tenants' unique index on external_id, referenced by + -- an FK from _realtime.extensions, vanished from extraction). + AND NOT EXISTS ( + SELECT 1 FROM pg_constraint pc + WHERE pc.conindid = i.indexrelid AND pc.contype IN ('p', 'u', 'x') + ) + AND NOT EXISTS (SELECT 1 FROM pg_inherits ih WHERE ih.inhrelid = i.indexrelid) + AND ${notExtensionMember("pg_class", "c.oid")} + ORDER BY n.nspname, ic.relname`)) { + const tableKind = + String(row["table_kind"]) === "m" ? "materializedView" : "table"; + pushWithMeta( + { + id: { + kind: "index", + schema: String(row["schema"]), + name: String(row["name"]), + }, + parent: { + kind: tableKind, + schema: String(row["schema"]), + name: String(row["table"]), + }, + // `valid` (pg_index.indisvalid) is SEMANTIC state, not just metadata: a + // failed/cancelled CREATE INDEX CONCURRENTLY leaves indisvalid=false with + // a def IDENTICAL to the desired valid index, so without this field the + // unusable index would hash EQUAL to the valid one and retry planning / + // the proof would consider it converged. Including it in the payload + // (hashed) makes invalid ≠ valid, and the `valid: "replace"` attribute + // strategy repairs it via drop + recreate (the standard fix). A fresh + // CREATE INDEX in a SQL-loaded shadow is always valid=true, so the desired + // side naturally carries true and never churns a healthy index. + // + // NOTE (#332): the `valid` SELECT above deliberately forces partitioned + // PARENT indexes (relkind 'I') to true. Their indisvalid tracks child + // ATTACH-state (and pg_get_indexdef renders them `ON ONLY`, which itself + // produces an invalid parent), so surfacing it here would spuriously + // fail convergence on every partitioned-index scenario. That attach-state + // remains unmodeled and tracked in #332; only regular indexes drive the + // valid diff. + payload: { def: String(row["def"]), valid: Boolean(row["valid"]) }, + }, + row, + ); + } +} + +export async function extractSequences(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + // ── sequences (identity-column internals excluded) ─────────────────── + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS name, r.rolname AS owner, + format_type(s.seqtypid, NULL) AS data_type, + s.seqstart::text AS start, s.seqincrement::text AS increment, + s.seqmin::text AS min_value, s.seqmax::text AS max_value, + s.seqcache::text AS cache, s.seqcycle AS cycle, + (SELECT json_build_object('schema', tn.nspname, 'table', tc.relname, + 'column', ta.attname) + FROM pg_depend od + JOIN pg_class tc ON tc.oid = od.refobjid + JOIN pg_namespace tn ON tn.oid = tc.relnamespace + JOIN pg_attribute ta ON ta.attrelid = tc.oid AND ta.attnum = od.refobjsubid + WHERE od.classid = 'pg_class'::regclass AND od.objid = c.oid + AND od.refclassid = 'pg_class'::regclass AND od.deptype = 'a' + AND od.refobjsubid > 0 + LIMIT 1) AS owned_by, + obj_description(c.oid, 'pg_class') AS comment, + ${aclJsonMemberAware("c.relacl", "s", "c.relowner", "pg_class", "c.oid")} AS acl, + ${memberExtensionExpr("pg_class", "c.oid")} AS ext_member_of + FROM pg_sequence s + JOIN pg_class c ON c.oid = s.seqrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_roles r ON r.oid = c.relowner + WHERE ${USER_SCHEMA_FILTER} + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid + AND d.deptype = 'i') + ORDER BY n.nspname, c.relname`)) { + const id: StableId = { + kind: "sequence", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { + dataType: String(row["data_type"]), + start: String(row["start"]), + increment: String(row["increment"]), + minValue: String(row["min_value"]), + maxValue: String(row["max_value"]), + cache: String(row["cache"]), + cycle: Boolean(row["cycle"]), + ownedBy: + row["owned_by"] == null + ? null + : (row["owned_by"] as { + schema: string; + table: string; + column: string; + }), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } +} + +export async function extractViews(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + // ── views + materialized views ─────────────────────────────────────── + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS name, r.rolname AS owner, + c.relkind AS kind, + pg_get_viewdef(c.oid) AS def, + c.reloptions AS reloptions, + obj_description(c.oid, 'pg_class') AS comment, + ${aclJsonMemberAware("c.relacl", "r", "c.relowner", "pg_class", "c.oid")} AS acl, + ${memberExtensionExpr("pg_class", "c.oid")} AS ext_member_of + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_roles r ON r.oid = c.relowner + WHERE c.relkind IN ('v', 'm') AND ${USER_SCHEMA_FILTER} + ORDER BY n.nspname, c.relname`)) { + const id: StableId = { + kind: String(row["kind"]) === "m" ? "materializedView" : "view", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { def: String(row["def"]), reloptions: reloptions(row) }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } +} + +export async function extractTriggers(ctx: ExtractContext): Promise { + const { q, pushWithMeta } = ctx; + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS table, t.tgname AS name, + c.relkind AS table_kind, + pg_get_triggerdef(t.oid) AS def, + t.tgenabled AS enabled, + obj_description(t.oid, 'pg_trigger') AS comment + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT t.tgisinternal AND t.tgparentid = 0 AND ${USER_SCHEMA_FILTER} + AND ${notExtensionMember("pg_class", "c.oid")} + ORDER BY n.nspname, c.relname, t.tgname`)) { + const relkind = String(row["table_kind"]); + pushWithMeta( + { + id: { + kind: "trigger", + schema: String(row["schema"]), + table: String(row["table"]), + name: String(row["name"]), + }, + parent: { + kind: + relkind === "v" + ? "view" + : relkind === "m" + ? "materializedView" + : relkind === "f" + ? "foreignTable" + : "table", + schema: String(row["schema"]), + name: String(row["table"]), + }, + payload: { + def: String(row["def"]), + enabled: String(row["enabled"]), + }, + }, + row, + ); + } +} + +export async function extractRules(ctx: ExtractContext): Promise { + const { q, pushWithMeta } = ctx; + // ── rewrite rules (user rules; the view _RETURN rule is the view def) ─ + for (const row of await q(` + SELECT n.nspname AS schema, c.relname AS table, c.relkind AS table_kind, + rw.rulename AS name, pg_get_ruledef(rw.oid) AS def, + rw.ev_enabled AS enabled, + obj_description(rw.oid, 'pg_rewrite') AS comment + FROM pg_rewrite rw + JOIN pg_class c ON c.oid = rw.ev_class + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE rw.rulename <> '_RETURN' AND ${USER_SCHEMA_FILTER} + AND ${notExtensionMember("pg_class", "c.oid")} + ORDER BY n.nspname, c.relname, rw.rulename`)) { + const relkind = String(row["table_kind"]); + pushWithMeta( + { + id: { + kind: "rule", + schema: String(row["schema"]), + table: String(row["table"]), + name: String(row["name"]), + }, + parent: { + kind: + relkind === "v" + ? "view" + : relkind === "m" + ? "materializedView" + : "table", + schema: String(row["schema"]), + name: String(row["table"]), + }, + payload: { def: String(row["def"]), enabled: String(row["enabled"]) }, + }, + row, + ); + } +} diff --git a/packages/pg-delta/src/extract/roles.ts b/packages/pg-delta/src/extract/roles.ts new file mode 100644 index 000000000..8525ab41e --- /dev/null +++ b/packages/pg-delta/src/extract/roles.ts @@ -0,0 +1,185 @@ +/** Cluster-level role state: roles, role memberships, and default privileges. */ +import type { ExtractContext } from "./scope.ts"; + +export async function extractRolesAndGrants( + ctx: ExtractContext, +): Promise { + const { q, facts } = ctx; + + // ── roles (cluster-level) ──────────────────────────────────────────── + for (const row of await q(` + SELECT r.rolname AS name, r.rolsuper, r.rolinherit, r.rolcreaterole, + r.rolcreatedb, r.rolcanlogin, r.rolreplication, r.rolbypassrls, + COALESCE((SELECT array_agg(cfg ORDER BY cfg) + FROM pg_db_role_setting s, unnest(s.setconfig) cfg + WHERE s.setrole = r.oid AND s.setdatabase = 0), + '{}')::text[] AS config + FROM pg_roles r + WHERE r.rolname NOT LIKE 'pg\\_%' + ORDER BY r.rolname`)) { + facts.push({ + id: { kind: "role", name: String(row["name"]) }, + payload: { + superuser: Boolean(row["rolsuper"]), + inherit: Boolean(row["rolinherit"]), + createRole: Boolean(row["rolcreaterole"]), + createDb: Boolean(row["rolcreatedb"]), + login: Boolean(row["rolcanlogin"]), + replication: Boolean(row["rolreplication"]), + bypassRls: Boolean(row["rolbypassrls"]), + config: (row["config"] as string[]).map(String), + }, + }); + } + + // ── role memberships (cluster-level; multi-grantor rows deduped) ───── + for (const row of await q(` + SELECT r1.rolname AS role, r2.rolname AS member, + bool_or(m.admin_option) AS admin + FROM pg_auth_members m + JOIN pg_roles r1 ON r1.oid = m.roleid + JOIN pg_roles r2 ON r2.oid = m.member + WHERE r1.rolname NOT LIKE 'pg\\_%' AND r2.rolname NOT LIKE 'pg\\_%' + GROUP BY 1, 2 + ORDER BY 1, 2`)) { + facts.push({ + id: { + kind: "membership", + role: String(row["role"]), + member: String(row["member"]), + }, + payload: { admin: Boolean(row["admin"]) }, + }); + } + + // ── default privileges ─────────────────────────────────────────────── + // `pg_default_acl` stores the RESULTING default ACL, so a revoked built-in + // default (e.g. `ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM + // PUBLIC`) shows up only as the ABSENCE of that grantee's row — there is no + // explicit "no privileges" entry. Mirror `aclJson` (scope.ts): when the + // object kind grants PUBLIC (functions EXECUTE, types USAGE) or the owner a + // built-in default, and the stored acl has dropped it, synthesize an empty + // grantee row carrying `revoked_default` (the built-in privileges that were + // removed) so the diff can plan the REVOKE — and, in reverse, restore the + // default with a GRANT. The "has a PUBLIC/owner default" test is derived from + // acldefault() itself, so it stays correct across kinds and PG versions. + // `defaclobjtype` uses 'S' for sequences where acldefault() wants 's'. + // Model each fact as a DEVIATION from the built-in default, not the raw stored + // ACL. `pg_default_acl` materializes the whole effective default, so a grantee + // that sits at its built-in default (e.g. the owner keeping its create-time + // grant) appears in the row even though it is not a customization — extracting + // it would make a customized row assert grants a fresh database already has, + // and DROPPING that fact would wrongly REVOKE the built-in default. So: + // • a grantee whose stored privileges EQUAL its built-in default → no fact; + // • a grantee that DIFFERS (custom grant, partial change, grant option) → + // a fact carrying its actual privileges; + // • a grantee that HAS a built-in default but is ABSENT from the stored acl + // (the default was revoked, e.g. `REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC`) + // → an empty marker carrying `revoked_default` so the diff can plan the + // REVOKE and, in reverse, restore the default with a GRANT — + // with a conditional carve-out for the grantor's OWN self-entry, whose + // absence is a behavioral no-op in some shapes but a real revoke in others. + // The key distinction is PER-SCHEMA vs GLOBAL: + // • PER-SCHEMA (defaclnamespace <> 0): at object-creation time Postgres + // ALWAYS re-adds the owner's acldefault entry to the new object's ACL + // regardless of whether the stored row carried a self-entry (a table + // created by the owner gets `{owner=arwdDxtm/owner,…}` either way). A + // row built from grants to OTHER roles never materializes the owner + // self-entry (`{r2=r/r}`), so a "revoked" owner self-entry here is a + // no-op — never emit a marker for it, or owner-present and owner-absent + // rows would extract DIFFERENTLY and break round-trip. + // • GLOBAL (defaclnamespace = 0): a grant to another role DOES + // materialize the owner self-entry (`{owner=arwdDxtm/owner,r2=r/owner}`), + // and Postgres uses the stored acl VERBATIM at creation. So if the + // owner is revoked while OTHER grantees remain (`{r2=r/owner}`), a table + // made by the owner really lacks the owner's own privileges — a genuine + // customization → EMIT the marker. The one exception is a BARE global + // self-revoke with nothing else granted: the stored row is EMPTY, the + // created table's relacl degenerates to NULL and the owner keeps its + // privileges → no-op → no marker. (Verified on postgres:17.) + // (A grantor self-entry that DIFFERS from acldefault — a partial + // self-reduction — is still present in the row and handled by the first + // branch above, so it is not lost.) + // The built-in default is derived from acldefault() (kind/version-robust); + // `defaclobjtype` uses 'S' for sequences where acldefault() wants 's'. + const defaclCode = `CASE d.defaclobjtype WHEN 'S' THEN 's' ELSE d.defaclobjtype END`; + for (const row of await q(` + SELECT dr.rolname AS role, n.nspname AS schema, d.defaclobjtype AS objtype, + acl.grantee_name AS grantee, acl.privileges, acl.grantable, + acl.revoked_default + FROM pg_default_acl d + JOIN pg_roles dr ON dr.oid = d.defaclrole + LEFT JOIN pg_namespace n ON n.oid = d.defaclnamespace, + LATERAL ( + WITH stored AS ( + SELECT e.grantee AS grantee_oid, + COALESCE(g.rolname, 'PUBLIC') AS grantee_name, + array_agg(e.privilege_type ORDER BY e.privilege_type) AS privileges, + array_agg(e.privilege_type ORDER BY e.privilege_type) + FILTER (WHERE e.is_grantable) AS grantable + FROM aclexplode(d.defaclacl) e + LEFT JOIN pg_roles g ON g.oid = e.grantee + GROUP BY 1, 2 + ), + def AS ( + SELECT x.grantee AS grantee_oid, + array_agg(x.privilege_type ORDER BY x.privilege_type) AS privileges + FROM aclexplode(acldefault(${defaclCode}, d.defaclrole)) x + GROUP BY 1 + ) + -- present grantees whose privileges DEVIATE from the built-in default + SELECT s.grantee_name, s.privileges, s.grantable, NULL::text[] AS revoked_default + FROM stored s + LEFT JOIN def dd ON dd.grantee_oid = s.grantee_oid + WHERE s.privileges IS DISTINCT FROM dd.privileges + OR s.grantable IS NOT NULL + UNION ALL + -- grantees that HAVE a built-in default but are ABSENT (revoked). The + -- grantor's OWN self-entry is a special case: its absence is a behavioral + -- no-op in two shapes (canonicalize by never emitting a marker), but a + -- REAL revoke in a third (emit the marker): + -- • PER-SCHEMA row (defaclnamespace <> 0): Postgres ALWAYS re-adds the + -- owner's acldefault entry at object-creation time → no-op. + -- • BARE GLOBAL row with an EMPTY stored acl (the owner-revoke is the + -- only customization): the created table's relacl degenerates to NULL + -- and the owner keeps its privileges → no-op. + -- • GLOBAL row that still carries OTHER grantees: Postgres uses the + -- stored acl VERBATIM at creation, so an object made by the owner + -- really lacks the owner's own privileges → a genuine customization + -- that must round-trip → emit the marker. + SELECT CASE WHEN dd.grantee_oid = 0 THEN 'PUBLIC' + ELSE (SELECT rolname FROM pg_roles WHERE oid = dd.grantee_oid) END, + ARRAY[]::text[], NULL::text[], dd.privileges + FROM def dd + WHERE NOT EXISTS (SELECT 1 FROM stored s WHERE s.grantee_oid = dd.grantee_oid) + AND ( + -- a non-owner absentee is always a real revoke + dd.grantee_oid <> d.defaclrole + -- the owner's OWN absence is a real revoke ONLY on a GLOBAL row that + -- still carries other grantees (per-schema → owner re-merged at CREATE; + -- bare empty global row → owner keeps privileges — both no-ops) + OR (d.defaclnamespace = 0 AND EXISTS (SELECT 1 FROM stored s2)) + ) + ) acl + ORDER BY 1, 2, 3, 4`)) { + const revokedDefault = (row["revoked_default"] as string[] | null) ?? null; + facts.push({ + id: { + kind: "defaultPrivilege", + role: String(row["role"]), + schema: row["schema"] == null ? null : (row["schema"] as string), + objtype: String(row["objtype"]), + grantee: String(row["grantee"]), + }, + payload: { + privileges: (row["privileges"] as string[]).map(String), + grantable: ((row["grantable"] as string[] | null) ?? []).map(String), + // non-semantic metadata (excluded from hash/diff): the built-in default + // privileges this empty marker revoked, so the drop can restore them. + ...(revokedDefault != null + ? { _revokedDefault: revokedDefault.map(String) } + : {}), + }, + }); + } +} diff --git a/packages/pg-delta/src/extract/routines.ts b/packages/pg-delta/src/extract/routines.ts new file mode 100644 index 000000000..40e38b679 --- /dev/null +++ b/packages/pg-delta/src/extract/routines.ts @@ -0,0 +1,195 @@ +/** Routines: functions / procedures and aggregates. */ +import type { StableId } from "../core/stable-id.ts"; +import { + aclJsonMemberAware, + type ExtractContext, + memberExtensionExpr, + parseAcl, + schemaId, + USER_SCHEMA_FILTER, +} from "./scope.ts"; + +export async function extractRoutines(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + // ── routines (functions + procedures; pg_get_functiondef canonical) ── + for (const row of await q(` + SELECT n.nspname AS schema, p.proname AS name, r.rolname AS owner, + p.prokind AS prokind, + ARRAY(SELECT format_type(t.t, NULL) + FROM unnest(p.proargtypes) WITH ORDINALITY AS t(t, ord) + ORDER BY t.ord)::text[] AS identity_args, + pg_get_functiondef(p.oid) AS def, + pg_get_function_result(p.oid) AS return_type, + pg_get_function_arguments(p.oid) AS arg_signature, + -- proconfig GUC NAMES only (name=value split in POSTGRES, never in + -- TS): a structured, non-semantic duplicate of the routine's SET + -- header clauses used purely for the seed replayability decision. + (SELECT array_agg(split_part(c, '=', 1)) + FROM unnest(p.proconfig) AS c) AS config_gucs, + l.lanname AS language, + obj_description(p.oid, 'pg_proc') AS comment, + ${aclJsonMemberAware("p.proacl", "f", "p.proowner", "pg_proc", "p.oid")} AS acl, + ${memberExtensionExpr("pg_proc", "p.oid")} AS ext_member_of + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + JOIN pg_roles r ON r.oid = p.proowner + JOIN pg_language l ON l.oid = p.prolang + WHERE p.prokind IN ('f', 'p', 'w') AND ${USER_SCHEMA_FILTER} + AND NOT EXISTS ( + SELECT 1 FROM pg_depend idep + WHERE idep.classid = 'pg_proc'::regclass AND idep.objid = p.oid + AND idep.deptype = 'i') + ORDER BY n.nspname, p.proname`)) { + const args = (row["identity_args"] as string[]).map(String); + // GUC names this routine's proconfig SETs (from `config_gucs`, split in SQL). + // Carried as the `_`-prefixed `_configGucs` so it is NON-SEMANTIC: dropped + // from the hash and diff (core/hash.ts), exactly like `_position` on type + // attributes. It exists ONLY so the co-located shadow seed can DECIDE whether + // a routine is replayable by a non-superuser (a SUSET-context GUC in the SET + // header makes CREATE fail 42501) WITHOUT parsing the `def` SQL text — the SET + // clauses are already semantic inside `def`; this is a structured duplicate + // for that decision alone. Omitted when empty so it never appears in payloads + // that have no proconfig. + const configGucs = ((row["config_gucs"] as string[] | null) ?? []).map( + String, + ); + // prokind distinguishes procedures ('p') from functions ('f'/'w'); the kind + // lives in the id (not the payload) so satellite renderers address the + // routine with the correct DDL keyword (FUNCTION vs PROCEDURE). Window + // functions ('w') are still FUNCTIONs for DDL — they only differ by `isWindow`. + const id: StableId = { + kind: String(row["prokind"]) === "p" ? "procedure" : "function", + schema: String(row["schema"]), + name: String(row["name"]), + args, + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { + def: String(row["def"]), + // Classification fields for the change path: `def` (pg_get_functiondef) + // is itself a CREATE OR REPLACE, so a body/volatility/… change alters + // in place — but return type, language, and window-kind are things + // CREATE OR REPLACE refuses or cannot express, so a change to any of + // them must demolish (see plan/rules/routines.ts). They deliberately + // double-count with `def` (all change together) so they carry no extra + // diff signal; they exist only to route the change. `return_type` is + // NULL for procedures (null-stable — no delta among procedures). + returnType: + row["return_type"] == null ? null : (row["return_type"] as string), + // full argument signature (names / modes / defaults). CREATE OR + // REPLACE refuses to rename a parameter or remove a default, so ANY + // arg-signature change must demolish. Arg TYPES are identity (a + // different stable id → natural drop+create), so within a stable id + // this differs only by name/mode/default — the part OR-REPLACE can't + // always express. + argSignature: String(row["arg_signature"]), + language: String(row["language"]), + isWindow: String(row["prokind"]) === "w", + ...(configGucs.length > 0 ? { _configGucs: configGucs } : {}), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } +} + +export async function extractAggregates(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + // ── aggregates (CREATE AGGREGATE is reconstructed from pg_aggregate) ─ + for (const row of await q(` + SELECT n.nspname AS schema, p.proname AS name, r.rolname AS owner, + ARRAY(SELECT format_type(t.t, NULL) + FROM unnest(p.proargtypes) WITH ORDINALITY AS t(t, ord) + ORDER BY t.ord)::text[] AS identity_args, + a.aggkind AS agg_kind, a.aggnumdirectargs AS num_direct_args, + a.aggtransfn::regproc::text AS sfunc, + format_type(a.aggtranstype, NULL) AS stype, + a.aggtransspace AS sspace, + CASE WHEN a.aggfinalfn <> 0 THEN a.aggfinalfn::regproc::text END AS finalfunc, + a.aggfinalextra AS finalfunc_extra, + a.aggfinalmodify AS finalfunc_modify, + CASE WHEN a.aggcombinefn <> 0 THEN a.aggcombinefn::regproc::text END AS combinefunc, + CASE WHEN a.aggserialfn <> 0 THEN a.aggserialfn::regproc::text END AS serialfunc, + CASE WHEN a.aggdeserialfn <> 0 THEN a.aggdeserialfn::regproc::text END AS deserialfunc, + CASE WHEN a.aggmtransfn <> 0 THEN a.aggmtransfn::regproc::text END AS msfunc, + CASE WHEN a.aggminvtransfn <> 0 THEN a.aggminvtransfn::regproc::text END AS minvfunc, + CASE WHEN a.aggmtranstype <> 0 THEN format_type(a.aggmtranstype, NULL) END AS mstype, + a.aggmtransspace AS msspace, + CASE WHEN a.aggmfinalfn <> 0 THEN a.aggmfinalfn::regproc::text END AS mfinalfunc, + a.aggmfinalextra AS mfinalfunc_extra, + a.aggmfinalmodify AS mfinalfunc_modify, + a.agginitval AS initcond, + a.aggminitval AS minitcond, + CASE WHEN a.aggsortop <> 0 THEN ( + SELECT 'OPERATOR(' || quote_ident(opn.nspname) || '.' || o.oprname || ')' + FROM pg_operator o + JOIN pg_namespace opn ON opn.oid = o.oprnamespace + WHERE o.oid = a.aggsortop) END AS sortop, + p.proparallel AS parallel, + obj_description(p.oid, 'pg_proc') AS comment, + ${aclJsonMemberAware("p.proacl", "f", "p.proowner", "pg_proc", "p.oid")} AS acl, + ${memberExtensionExpr("pg_proc", "p.oid")} AS ext_member_of + FROM pg_proc p + JOIN pg_aggregate a ON a.aggfnoid = p.oid + JOIN pg_namespace n ON n.oid = p.pronamespace + JOIN pg_roles r ON r.oid = p.proowner + WHERE p.prokind = 'a' AND ${USER_SCHEMA_FILTER} + ORDER BY n.nspname, p.proname`)) { + const id: StableId = { + kind: "aggregate", + schema: String(row["schema"]), + name: String(row["name"]), + args: (row["identity_args"] as string[]).map(String), + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { + aggKind: String(row["agg_kind"]), + numDirectArgs: Number(row["num_direct_args"]), + sfunc: String(row["sfunc"]), + stype: String(row["stype"]), + sspace: Number(row["sspace"]), + finalfunc: + row["finalfunc"] == null ? null : (row["finalfunc"] as string), + finalfuncExtra: Boolean(row["finalfunc_extra"]), + finalfuncModify: String(row["finalfunc_modify"]), + combinefunc: + row["combinefunc"] == null ? null : (row["combinefunc"] as string), + serialfunc: + row["serialfunc"] == null ? null : (row["serialfunc"] as string), + deserialfunc: + row["deserialfunc"] == null + ? null + : (row["deserialfunc"] as string), + msfunc: row["msfunc"] == null ? null : (row["msfunc"] as string), + minvfunc: + row["minvfunc"] == null ? null : (row["minvfunc"] as string), + mstype: row["mstype"] == null ? null : (row["mstype"] as string), + msspace: Number(row["msspace"]), + mfinalfunc: + row["mfinalfunc"] == null ? null : (row["mfinalfunc"] as string), + mfinalfuncExtra: Boolean(row["mfinalfunc_extra"]), + mfinalfuncModify: String(row["mfinalfunc_modify"]), + initcond: + row["initcond"] == null ? null : (row["initcond"] as string), + minitcond: + row["minitcond"] == null ? null : (row["minitcond"] as string), + sortop: row["sortop"] == null ? null : (row["sortop"] as string), + parallel: String(row["parallel"]), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } +} diff --git a/packages/pg-delta/src/extract/schemas.ts b/packages/pg-delta/src/extract/schemas.ts new file mode 100644 index 000000000..77c280f1b --- /dev/null +++ b/packages/pg-delta/src/extract/schemas.ts @@ -0,0 +1,63 @@ +/** Schemas and extensions. */ +import type { StableId } from "../core/stable-id.ts"; +import { + aclJsonMemberAware, + type ExtractContext, + memberExtensionExpr, + parseAcl, + USER_SCHEMA_FILTER, +} from "./scope.ts"; + +export async function extractSchemasAndExtensions( + ctx: ExtractContext, +): Promise { + const { q, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + + // ── schemas ────────────────────────────────────────────────────────── + for (const row of await q(` + SELECT n.nspname AS name, r.rolname AS owner, + obj_description(n.oid, 'pg_namespace') AS comment, + ${aclJsonMemberAware("n.nspacl", "n", "n.nspowner", "pg_namespace", "n.oid")} AS acl, + ${memberExtensionExpr("pg_namespace", "n.oid")} AS ext_member_of + FROM pg_namespace n + JOIN pg_roles r ON r.oid = n.nspowner + WHERE ${USER_SCHEMA_FILTER} + ORDER BY n.nspname`)) { + const id: StableId = { kind: "schema", name: String(row["name"]) }; + pushWithMeta( + { + id, + payload: {}, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } + + // ── extensions (version deliberately excluded from the payload) ───── + // Whether CREATE EXTENSION emits `SCHEMA ` is decided at PLAN time from the + // schema's presence (extension create rule), not from an extract-time signal: + // Postgres records no schema→extension ownership edge (deptype 'e' never + // exists), so there is nothing to extract here for that decision. + for (const row of await q(` + SELECT e.extname AS name, n.nspname AS schema, + e.extrelocatable AS relocatable, + obj_description(e.oid, 'pg_extension') AS comment + FROM pg_extension e + JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname <> 'plpgsql' + ORDER BY e.extname`)) { + pushWithMeta( + { + id: { kind: "extension", name: String(row["name"]) }, + payload: { + schema: String(row["schema"]), + relocatable: Boolean(row["relocatable"]), + }, + }, + row, + ); + } +} diff --git a/packages/pg-delta/src/extract/scope.ts b/packages/pg-delta/src/extract/scope.ts new file mode 100644 index 000000000..e34ac3c9c --- /dev/null +++ b/packages/pg-delta/src/extract/scope.ts @@ -0,0 +1,547 @@ +/** + * Shared extraction scope (stage 2): the constants, SQL fragments, satellite + * pruning, and the per-extraction mutable context that the per-family query + * builders compose. Splitting the family builders into their own modules keeps + * each catalog family local; this module is the one place the shared pieces + * live (target-architecture §3.1–3.2). + * ACL identity/equality invariants: docs/architecture/identity-and-acl.md. + */ +import type { PoolClient } from "pg"; +import type { Diagnostic } from "../core/diagnostic.ts"; +import type { DependencyEdge, Fact } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; + +/** Postgres SQLSTATE for a statement cancelled by `statement_timeout`. */ +const QUERY_CANCELED = "57014"; + +/** + * Whether a role name is a built-in (reserved) Postgres role. These `pg_`-prefixed + * roles (`pg_database_owner`, `pg_read_all_data`, …) are never extracted as + * managed facts — role extraction filters them out with `rolname NOT LIKE 'pg\_%'` + * (see extractRolesAndGrants in ./roles.ts). This predicate is the TS mirror of + * that SQL filter, used to suppress owner edges pointing at a built-in role: such + * an edge would always be dangling (its target role fact is never emitted) and is + * pruned by buildFactBase anyway, so emitting it only produces spurious + * `dangling_edge` warnings. + */ +function isBuiltinRoleName(name: string): boolean { + return name.startsWith("pg_"); +} + +/** + * A short, human-readable identifier for an extraction query: its first FROM + * relation plus a head of the text. Used to name the query that blew the + * statement-timeout budget so the failure is actionable, not opaque. + */ +function queryLabel(sql: string): string { + const flat = sql.replace(/\s+/g, " ").trim(); + const from = /\bFROM\s+(?:pg_catalog\.)?(\w+)/i.exec(flat); + const head = flat.slice(0, 60); + return from ? `${from[1]} (${head}…)` : head; +} + +/** + * Thrown when an extraction query exceeds the caller-supplied + * `statementTimeoutMs` budget. Turns a runaway catalog query on a pathological + * schema into actionable output — it names the offending query and the budget — + * instead of an opaque `canceling statement due to statement timeout` or an + * indefinite hang (milestone A — performance). + */ +export class ExtractionTimeoutError extends Error { + readonly code = "extraction_timeout"; + readonly queryLabel: string; + readonly timeoutMs: number; + readonly diagnostic: Diagnostic; + constructor(label: string, timeoutMs: number) { + super( + `extraction query ${label} exceeded the ${timeoutMs}ms statement_timeout budget`, + ); + this.name = "ExtractionTimeoutError"; + this.queryLabel = label; + this.timeoutMs = timeoutMs; + this.diagnostic = { + code: this.code, + severity: "error", + message: this.message, + context: { queryLabel: label, timeoutMs }, + }; + } +} + +/** + * Consistency invariant (hardening Item 4a; review #1): a metadata satellite + * (comment / acl / securityLabel) must never outlive its target. Most are + * pushed via `pushWithMeta` alongside their object, so a filtered object never + * emits them — but standalone satellite extraction (security labels) can emit + * one whose target was filtered (e.g. an extension-member object). Such a + * satellite would make `buildFactBase` throw on a missing parent, or — if it + * survived — yield an orphan GRANT/COMMENT at plan time (CLI-1471). Drop them + * here with an `info` diagnostic so the exclusion is visible, never silent. + */ +export function pruneOrphanedSatellites(facts: Fact[]): { + facts: Fact[]; + diagnostics: Diagnostic[]; +} { + const present = new Set(facts.map((f) => encodeId(f.id))); + const kept: Fact[] = []; + const diagnostics: Diagnostic[] = []; + for (const fact of facts) { + if ("target" in fact.id) { + const targetKey = encodeId(fact.id.target); + if (!present.has(targetKey)) { + diagnostics.push({ + code: "orphaned_satellite", + severity: "info", + subject: fact.id, + message: `dropped ${fact.id.kind} whose target ${targetKey} was not extracted (filtered)`, + }); + continue; + } + } + kept.push(fact); + } + return { facts: kept, diagnostics }; +} + +/** Schemas never treated as user state. */ +export const SYSTEM_SCHEMAS = `('pg_catalog', 'information_schema')`; +export const USER_SCHEMA_FILTER = ` + n.nspname NOT IN ${SYSTEM_SCHEMAS} + AND n.nspname NOT LIKE 'pg\\_toast%' + AND n.nspname NOT LIKE 'pg\\_temp%'`; + +/** Anti-join fragment: exclude objects owned by extensions, for the sub-entity + * and rare member-root families that are NOT yet flipped to `memberOfExtension` + * provenance edges (the flipped families use memberExtensionExpr/pushMemberEdge + * instead; see COVERAGE.md "extension member handling" + tier-4-deferrals.md). */ +export function notExtensionMember(classid: string, oidExpr: string): string { + return `NOT EXISTS ( + SELECT 1 FROM pg_depend ext_d + WHERE ext_d.classid = '${classid}'::regclass + AND ext_d.objid = ${oidExpr} + AND ext_d.deptype = 'e')`; +} + +export interface Row { + [key: string]: unknown; +} + +/** Provenance flip (4b): a scalar subquery selecting the name of the + * extension that OWNS this object (pg_depend deptype 'e'), or NULL. plpgsql + * is excluded to match the extensions extractor, which omits it — an edge to + * it would dangle. A flipped family SELECTs this AS ext_member_of and the + * loop calls pushMemberEdge so the member is observed AND tagged, instead of + * anti-joined away with notExtensionMember. */ +export const memberExtensionExpr = (classid: string, oidExpr: string): string => + `( + SELECT ext.extname + FROM pg_depend ext_d + JOIN pg_extension ext ON ext.oid = ext_d.refobjid + WHERE ext_d.classid = '${classid}'::regclass + AND ext_d.objid = ${oidExpr} + AND ext_d.refclassid = 'pg_extension'::regclass + AND ext_d.deptype = 'e' + AND ext.extname <> 'plpgsql' + LIMIT 1)`; + +/** ACL subquery: aggregated per grantee, sorted, PUBLIC for grantee 0. + * A NULL acl column means "the built-in default" — coalescing through + * acldefault() (pg_dump's model) makes NULL and an explicitly + * instantiated default extract identically, so a REVOKE that merely + * materializes the owner's implicit grant is not a diff. + * + * Revoked PUBLIC default: PostgreSQL grants the built-in default (USAGE on + * types/languages, EXECUTE on functions) to PUBLIC automatically on CREATE. + * When the acl is customized (non-NULL) and that PUBLIC default has been taken + * away (no PUBLIC row), we still emit an empty PUBLIC entry so the diff plans a + * `REVOKE ALL … FROM PUBLIC` that clears the create-time default. Without it a + * freshly created object would keep the default and never converge. The + * "kind has a PUBLIC default" test is derived from acldefault() itself, so it + * stays correct across object kinds and PG versions. */ +export const aclJson = ( + aclColumn: string, + objtype: string, + ownerColumn: string, +) => ` + (SELECT json_agg(json_build_object( + 'grantee', acl.grantee_name, + 'privileges', acl.privileges, + 'grantable', acl.grantable, + -- The owner's create-time default privilege set for this object kind + -- (carried ONLY on the owner's row). Lets the planner's default-ACL + -- elision tell "owner kept the full default" (elidable) apart from + -- "owner revoked one of their defaults" (must keep the REVOKE/GRANT), + -- without hardcoding the version-dependent set (PG17 added MAINTAIN). + 'ownerDefault', CASE + WHEN acl.grantee_name = ( + SELECT rolname FROM pg_roles WHERE oid = ${ownerColumn}) + THEN (SELECT array_agg(d.privilege_type ORDER BY d.privilege_type) + FROM aclexplode(acldefault('${objtype}', ${ownerColumn})) d + WHERE d.grantee = ${ownerColumn}) + ELSE NULL END) ORDER BY acl.grantee_name) + FROM ( + SELECT COALESCE(g.rolname, 'PUBLIC') AS grantee_name, + -- DISTINCT: aclexplode() yields one row per GRANTOR, so a privilege + -- granted to one grantee by two grantors appears twice. pg-delta + -- models the EFFECTIVE privilege set, not who granted it, so grantor + -- identity is intentionally ignored — de-duplicate to avoid rendering + -- a doubled privilege list (GRANT SELECT, SELECT ...), which + -- Postgres collapses on apply so a re-extract no longer matches and + -- the proof drifts. + array_agg(DISTINCT e.privilege_type ORDER BY e.privilege_type) AS privileges, + array_agg(DISTINCT e.privilege_type ORDER BY e.privilege_type) + FILTER (WHERE e.is_grantable) AS grantable + FROM aclexplode(COALESCE(${aclColumn}, acldefault('${objtype}', ${ownerColumn}))) e + LEFT JOIN pg_roles g ON g.oid = e.grantee + GROUP BY 1 + UNION ALL + SELECT 'PUBLIC', ARRAY[]::text[], NULL::text[] + WHERE ${aclColumn} IS NOT NULL + AND EXISTS ( + SELECT 1 FROM aclexplode(acldefault('${objtype}', ${ownerColumn})) d + WHERE d.grantee = 0) + AND NOT EXISTS ( + SELECT 1 FROM aclexplode(${aclColumn}) a WHERE a.grantee = 0) + UNION ALL + -- Revoked OWNER default (mirror of the PUBLIC case): PostgreSQL grants the + -- owner its full default on CREATE, so a full owner revoke + -- (REVOKE ALL ON ... FROM owner) leaves a non-NULL acl with NO owner row. + -- Emit an empty owner entry so the diff plans a REVOKE ALL FROM owner that + -- clears the create-time default; without it a freshly created object keeps + -- PostgreSQL built-in owner privileges and never converges. + SELECT (SELECT rolname FROM pg_roles WHERE oid = ${ownerColumn}), + ARRAY[]::text[], NULL::text[] + WHERE ${aclColumn} IS NOT NULL + AND EXISTS ( + SELECT 1 FROM aclexplode(acldefault('${objtype}', ${ownerColumn})) d + WHERE d.grantee = ${ownerColumn}) + AND NOT EXISTS ( + SELECT 1 FROM aclexplode(${aclColumn}) a + WHERE a.grantee = ${ownerColumn}) + ) acl)`; + +/** + * ACL delta for an EXTENSION MEMBER, pg_dump's `pg_init_privs` model: emit only + * the grantees whose CURRENT privilege/grant-option set differs from the object's + * as-installed set (`pg_init_privs.initprivs`, or `acldefault` when the extension + * recorded no init row — a default-acl member). CREATE EXTENSION re-establishes + * the init state, so a member that was never customized yields NO acl facts (no + * churn on plain extensions); a customization layered afterward surfaces as its + * delta grantees. Three shapes, via a FULL OUTER JOIN of current vs init: + * - added / upgraded grantee (in cur, differs from ini) → current privileges, + * `_initPrivs` = the install entry (null if the grantee had none at install); + * - fully-REVOKED init grantee (in ini, absent from cur, e.g. Supabase's + * `REVOKE EXECUTE … FROM PUBLIC` hardening) → an empty-privileges marker so + * the create renders a lone `REVOKE ALL` (grantActions); + * - grant-option-only change (same privileges, different grantable) → included. + * + * Same JSON shape as `aclJson` (+ non-semantic `_initPrivs`) so `parseAcl` reads + * both. It omits `_ownerDefault` — a member delta carries only non-default + * entries, so there is nothing for default-ACL elision to reconcile. `_initPrivs` + * lets the DROP path RESTORE the install state instead of a blind `REVOKE ALL` + * (see the `acl` rule in plan/rules/metadata.ts). + */ +export const memberAclDeltaJson = ( + aclColumn: string, + objtype: string, + ownerColumn: string, + classoid: string, + oidExpr: string, +) => ` + (SELECT json_agg(json_build_object( + 'grantee', d.grantee, 'privileges', d.privileges, + 'grantable', d.grantable, '_initPrivs', d.init_privs + ) ORDER BY d.grantee) + FROM ( + WITH cur AS ( + SELECT COALESCE(g.rolname, 'PUBLIC') AS grantee, + -- DISTINCT across grantors (aclexplode emits one row per grantor); + -- grantor identity is intentionally ignored — see aclJson. + array_agg(DISTINCT e.privilege_type ORDER BY e.privilege_type) AS privileges, + COALESCE(array_agg(DISTINCT e.privilege_type ORDER BY e.privilege_type) + FILTER (WHERE e.is_grantable), ARRAY[]::text[]) AS grantable + FROM aclexplode(COALESCE(${aclColumn}, acldefault('${objtype}', ${ownerColumn}))) e + LEFT JOIN pg_roles g ON g.oid = e.grantee + GROUP BY 1 + ), + ini AS ( + SELECT COALESCE(g.rolname, 'PUBLIC') AS grantee, + -- DISTINCT across grantors (aclexplode emits one row per grantor); + -- grantor identity is intentionally ignored — see aclJson. + array_agg(DISTINCT e.privilege_type ORDER BY e.privilege_type) AS privileges, + COALESCE(array_agg(DISTINCT e.privilege_type ORDER BY e.privilege_type) + FILTER (WHERE e.is_grantable), ARRAY[]::text[]) AS grantable + FROM aclexplode(COALESCE( + (SELECT ip.initprivs FROM pg_init_privs ip + WHERE ip.objoid = ${oidExpr} + AND ip.classoid = '${classoid}'::regclass + AND ip.objsubid = 0), + acldefault('${objtype}', ${ownerColumn}))) e + LEFT JOIN pg_roles g ON g.oid = e.grantee + GROUP BY 1 + ) + SELECT + COALESCE(cur.grantee, ini.grantee) AS grantee, + COALESCE(cur.privileges, ARRAY[]::text[]) AS privileges, + COALESCE(cur.grantable, ARRAY[]::text[]) AS grantable, + CASE WHEN ini.grantee IS NOT NULL + THEN json_build_object( + 'privileges', ini.privileges, 'grantable', ini.grantable) + END AS init_privs + FROM cur FULL OUTER JOIN ini USING (grantee) + WHERE cur.privileges IS DISTINCT FROM ini.privileges + OR cur.grantable IS DISTINCT FROM ini.grantable + ) d)`; + +/** + * Member-aware ACL: an extension member (pg_depend deptype 'e') uses the + * init-privs delta (`memberAclDeltaJson`); everything else uses the full + * `aclJson`. The member OBJECT is projected reference-only in the view (never a + * create/drop/alter), so only these satellite customizations flow to the diff. + */ +export const aclJsonMemberAware = ( + aclColumn: string, + objtype: string, + ownerColumn: string, + classoid: string, + oidExpr: string, +) => ` + CASE WHEN EXISTS ( + SELECT 1 FROM pg_depend md + WHERE md.classid = '${classoid}'::regclass AND md.objid = ${oidExpr} + AND md.refclassid = 'pg_extension'::regclass AND md.deptype = 'e') + THEN ${memberAclDeltaJson(aclColumn, objtype, ownerColumn, classoid, oidExpr)} + ELSE ${aclJson(aclColumn, objtype, ownerColumn)} + END`; + +/** The as-installed ACL entry for an extension member (from pg_init_privs / + * acldefault), carried non-semantically so the DROP path can restore it. */ +export interface InitPrivs { + privileges: string[]; + grantable: string[]; +} + +export const parseAcl = ( + raw: unknown, +): { + grantee: string; + privileges: string[]; + grantable: string[]; + ownerDefault?: string[]; + initPrivs?: InitPrivs; +}[] => { + if (raw == null) return []; + const entries = raw as { + grantee: string; + privileges: string[]; + grantable: string[] | null; + ownerDefault: string[] | null; + _initPrivs: { privileges: string[]; grantable: string[] | null } | null; + }[]; + return entries.map((e) => ({ + grantee: e.grantee, + privileges: e.privileges, + grantable: e.grantable ?? [], + ...(e.ownerDefault != null ? { ownerDefault: e.ownerDefault } : {}), + ...(e._initPrivs != null + ? { + initPrivs: { + privileges: e._initPrivs.privileges, + grantable: e._initPrivs.grantable ?? [], + }, + } + : {}), + })); +}; + +export const schemaId = (name: unknown): StableId => ({ + kind: "schema", + name: String(name), +}); + +/** The per-extraction mutable context: the accumulating fact / edge / + * diagnostic buffers, the timeout-aware query runner, and the satellite / + * provenance / owner push helpers that close over the buffers. The per-family + * query builders receive this and push into it; the order in which the + * orchestrator (`extractOnClient`) calls them is what defines the resulting + * fact / edge ordering. */ +export interface ExtractContext { + q: (sql: string) => Promise; + facts: Fact[]; + edges: DependencyEdge[]; + diagnostics: Diagnostic[]; + /** Diagnostics that must ALSO ride on the resulting `FactBase` (not just + * `ExtractResult.diagnostics`), because `plan()` reads `FactBase.diagnostics` + * to gate against a delta it cannot trust (mirrors how extension-handler + * diagnostics are threaded — see extract.ts). Push here instead of + * `diagnostics` when a downstream `plan()` gate needs to see it; the + * orchestrator copies this onto `factBase.diagnostics` right after + * construction, which the final step then folds into `diagnostics` too, so + * every consumer still sees it exactly once. */ + factDiagnostics: Diagnostic[]; + /** When false, sensitive option values and subscription conninfo are kept in + * cleartext in the fact base (and therefore in every downstream channel). + * Default true; see sensitive-options.ts and extract.ts. */ + redactSecrets: boolean; + pushWithMeta: ( + fact: Fact, + row: Row, + aclTargets?: { + privileges: string[]; + grantable: string[]; + grantee: string; + ownerDefault?: string[]; + initPrivs?: InitPrivs; + }[], + ) => void; + pushMemberEdge: (id: StableId, row: Row) => void; + pushOwnerEdge: (id: StableId, owner: unknown) => void; + pushSeclabel: (target: StableId, provider: string, label: string) => void; +} + +export function createExtractContext( + client: PoolClient, + statementTimeoutMs?: number, + redactSecrets = true, +): ExtractContext { + const facts: Fact[] = []; + const edges: DependencyEdge[] = []; + const diagnostics: Diagnostic[] = []; + const factDiagnostics: Diagnostic[] = []; + + const q = async (sql: string): Promise => { + try { + return (await client.query(sql)).rows as Row[]; + } catch (error) { + if ( + statementTimeoutMs !== undefined && + (error as { code?: string }).code === QUERY_CANCELED + ) { + throw new ExtractionTimeoutError(queryLabel(sql), statementTimeoutMs); + } + throw error; + } + }; + + /** Helper: push a fact plus its optional comment/acl satellite facts. */ + const pushWithMeta = ( + fact: Fact, + row: Row, + aclTargets?: { + privileges: string[]; + grantable: string[]; + grantee: string; + ownerDefault?: string[]; + initPrivs?: InitPrivs; + }[], + ): void => { + facts.push(fact); + const comment = row["comment"]; + if (typeof comment === "string") { + // a constraint on a DOMAIN needs `COMMENT ON CONSTRAINT … ON DOMAIN …`, + // not the table form — but commentTarget only sees the (identically + // shaped) constraint id and `drop` has no FactView to look up the parent. + // Carry the discriminator on the satellite payload so every comment + // callback (create / alter / drop) renders the right target. + const onDomain = + fact.id.kind === "constraint" && fact.parent?.kind === "domain"; + facts.push({ + id: { kind: "comment", target: fact.id }, + parent: fact.id, + payload: onDomain + ? { text: comment, onDomain: true } + : { text: comment }, + }); + } + for (const acl of aclTargets ?? []) { + facts.push({ + id: { kind: "acl", target: fact.id, grantee: acl.grantee }, + parent: fact.id, + payload: { + privileges: acl.privileges, + grantable: acl.grantable, + // owner-only NON-SEMANTIC metadata (`_` prefix → excluded from the + // hash/diff, see hash.ts): the owner's create-time default privilege + // set, consumed by the planner's default-ACL elision + // (elideDefaultAclCreates). It is version-dependent (PG17 added + // MAINTAIN), so it must NOT join the equality surface or it would cause + // spurious cross-version / snapshot diff deltas and fingerprint drift. + ...(acl.ownerDefault !== undefined + ? { _ownerDefault: acl.ownerDefault } + : {}), + // as-installed ACL for an extension member (non-semantic `_` prefix): + // lets the DROP path restore install state instead of REVOKE ALL. + // Built as a fresh literal so it satisfies the Payload index signature. + ...(acl.initPrivs !== undefined + ? { + _initPrivs: { + privileges: acl.initPrivs.privileges, + grantable: acl.initPrivs.grantable, + }, + } + : {}), + }, + }); + } + }; + + /** Emit a `memberOfExtension` edge from `id` to its owning extension when the + * row's `ext_member_of` column (from memberExtensionExpr) is set. The edge's + * `from` is the exact fact id, so it can never drift from the fact. */ + const pushMemberEdge = (id: StableId, row: Row): void => { + const ext = row["ext_member_of"]; + if (typeof ext === "string") { + edges.push({ + from: id, + to: { kind: "extension", name: ext }, + kind: "memberOfExtension", + }); + } + }; + + /** Emit an `owner` edge from `id` to its owning role when the owner value is + * a non-empty, non-built-in role. buildFactBase prunes dangling edges silently + * (e.g. a system-role owner not extracted) so out-of-view owners just get no + * edge — but a built-in (`pg_`-prefixed) owner such as `pg_database_owner` is + * NEVER extracted as a role fact, so its edge is always dangling; skipping it + * here avoids the recurring `dangling_edge` warning without changing the fact + * base (the edge would be pruned regardless). */ + const pushOwnerEdge = (id: StableId, owner: unknown): void => { + if ( + typeof owner === "string" && + owner.length > 0 && + !isBuiltinRoleName(owner) + ) { + edges.push({ + from: id, + to: { kind: "role", name: owner }, + kind: "owner", + }); + } + }; + + const pushSeclabel = ( + target: StableId, + provider: string, + label: string, + ): void => { + facts.push({ + id: { kind: "securityLabel", target, provider }, + parent: target, + payload: { label }, + }); + }; + + return { + q, + facts, + edges, + diagnostics, + factDiagnostics, + redactSecrets, + pushWithMeta, + pushMemberEdge, + pushOwnerEdge, + pushSeclabel, + }; +} diff --git a/packages/pg-delta/src/extract/security-labels.ts b/packages/pg-delta/src/extract/security-labels.ts new file mode 100644 index 000000000..b460fe98f --- /dev/null +++ b/packages/pg-delta/src/extract/security-labels.ts @@ -0,0 +1,298 @@ +/** Security labels (satellite facts, like comments). */ +import type { StableId } from "../core/stable-id.ts"; +import { + type ExtractContext, + notExtensionMember, + SYSTEM_SCHEMAS, + USER_SCHEMA_FILTER, +} from "./scope.ts"; + +/** pg_class relkinds that map to a SECURITY-LABEL-renderable, modeled stable id. + * Shared by the relation resolver and the unresolved-label diagnostic so the + * two can never drift. (Indexes/toast/composite-table relkinds are excluded: + * PostgreSQL rejects `SECURITY LABEL ON INDEX …` outright — verified on PG17 — + * so such a label can never exist in pg_seclabel.) */ +const LABELED_RELKINDS: Record = { + r: "table", + p: "table", + v: "view", + m: "materializedView", + S: "sequence", + f: "foreignTable", +}; + +/** pg_class relkinds that produce COLUMN facts (relations.ts extracts columns + * only for these). A positive `objsubid` label on any OTHER relkind (a view, + * matview, sequence, …) has no column fact to parent on, so it must NOT become a + * fact — it is surfaced as an unresolved-label diagnostic instead. */ +const COLUMN_BEARING_RELKINDS = new Set(["r", "p", "f"]); + +/** classoids whose pg_seclabel rows the resolver turns into facts UNCONDITIONALLY. + * The unresolved-label diagnostic flags any local label OUTSIDE this set (e.g. a + * label on a LANGUAGE, LARGE OBJECT, or a pg_class relkind we don't model). + * `pg_type` is DELIBERATELY absent: only the modeled type KINDS resolve (see + * {@link modeledTypeKindSql}), so it is special-cased in both the resolver and + * the diagnostic rather than blanket-resolved. */ +const RESOLVED_LOCAL_CLASSOIDS = [ + "pg_proc", + "pg_namespace", + "pg_event_trigger", + "pg_publication", + "pg_subscription", +] as const; + +/** pg_type kinds `extractTypes` (src/extract/types.ts) actually models: domains + * ('d'), enums ('e'), ranges ('r'), and STANDALONE composites — a composite + * whose backing pg_class relkind is 'c'. A table's row type is also typtype='c' + * but its pg_class relkind is 'r'/'p', and a base/shell/pseudo type is some + * OTHER typtype; none of those get a `type` fact. A SECURITY LABEL on such an + * unmodeled pg_type row therefore has no parent fact to attach to — pushing it + * anyway made buildFactBase throw missing-parent and crash extraction. The + * predicate (bound to a pg_type alias) is shared by the resolver query and the + * unresolved-label diagnostic so the two can never drift. */ +function modeledTypeKindSql(alias: string): string { + return `(${alias}.typtype IN ('d', 'e', 'r') + OR (${alias}.typtype = 'c' AND EXISTS ( + SELECT 1 FROM pg_class tc WHERE tc.oid = ${alias}.typrelid AND tc.relkind = 'c')))`; +} + +export async function extractSecurityLabels( + ctx: ExtractContext, +): Promise { + const { q, pushSeclabel, diagnostics } = ctx; + // ── security labels (satellite facts, like comments) ──────────────── + // pg_seclabel / pg_shseclabel are EMPTY unless a label provider module + // labeled something — and every row is therefore USER-applied state (there + // are no built-in labels). One cheap existence probe gates the resolver + // queries so a label-free database (the overwhelming common case) pays + // a single round trip. The target's identity parts come back as a resolved + // StableId built inline; any label whose target the resolver cannot map to a + // supported modeled stable id is surfaced as a diagnostic (never dropped). + const hasSeclabels = Boolean( + ( + await q( + `SELECT EXISTS (SELECT 1 FROM pg_seclabel) + OR EXISTS (SELECT 1 FROM pg_shseclabel) AS present`, + ) + )[0]?.["present"], + ); + if (!hasSeclabels) return; + + // relations (tables/views/matviews/sequences/foreign tables) + columns + for (const row of await q(` + SELECT sl.provider, sl.label, sl.objsubid, + n.nspname AS schema, c.relname AS name, c.relkind AS relkind, + a.attname AS column + FROM pg_seclabel sl + JOIN pg_class c ON c.oid = sl.objoid AND sl.classoid = 'pg_class'::regclass + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = sl.objsubid + WHERE ${USER_SCHEMA_FILTER} + ORDER BY 1, 4, 5`)) { + const schema = String(row["schema"]); + const relkind = String(row["relkind"]); + if (Number(row["objsubid"]) > 0) { + // A column label only resolves when the relation actually produces column + // facts (tables / partitioned tables / foreign tables). A label on a VIEW + // or matview column has no column fact to parent on; pushing it anyway made + // buildFactBase throw missing-parent and crash extraction. Skip it here — + // the unresolved-label diagnostic pass below reports it (strict mode blocks, + // default mode warns). The metadata-fidelity gap stays tracked in #332. + if (COLUMN_BEARING_RELKINDS.has(relkind)) { + pushSeclabel( + { + kind: "column", + schema, + table: String(row["name"]), + name: String(row["column"]), + }, + String(row["provider"]), + String(row["label"]), + ); + } + continue; + } + const kind = LABELED_RELKINDS[relkind]; + if (kind === undefined) continue; // unresolved-relkind label → diagnosed below + pushSeclabel( + { kind, schema, name: String(row["name"]) } as StableId, + String(row["provider"]), + String(row["label"]), + ); + } + // routines (functions / procedures / aggregates) + for (const row of await q(` + SELECT sl.provider, sl.label, n.nspname AS schema, p.proname AS name, + p.prokind AS prokind, + ARRAY(SELECT format_type(t.t, NULL) + FROM unnest(p.proargtypes) WITH ORDINALITY AS t(t, ord) + ORDER BY t.ord)::text[] AS args + FROM pg_seclabel sl + JOIN pg_proc p ON p.oid = sl.objoid AND sl.classoid = 'pg_proc'::regclass + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE ${USER_SCHEMA_FILTER} + ORDER BY 1, 3, 4`)) { + const prokind = String(row["prokind"]); + pushSeclabel( + { + kind: + prokind === "a" + ? "aggregate" + : prokind === "p" + ? "procedure" + : "function", + schema: String(row["schema"]), + name: String(row["name"]), + args: (row["args"] as string[]).map(String), + }, + String(row["provider"]), + String(row["label"]), + ); + } + // schemas + for (const row of await q(` + SELECT sl.provider, sl.label, n.nspname AS name + FROM pg_seclabel sl + JOIN pg_namespace n ON n.oid = sl.objoid AND sl.classoid = 'pg_namespace'::regclass + WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS} AND n.nspname NOT LIKE 'pg\\_%' + ORDER BY 1, 3`)) { + pushSeclabel( + { kind: "schema", name: String(row["name"]) }, + String(row["provider"]), + String(row["label"]), + ); + } + // types / domains + for (const row of await q(` + SELECT sl.provider, sl.label, n.nspname AS schema, t.typname AS name, + t.typtype AS typtype + FROM pg_seclabel sl + JOIN pg_type t ON t.oid = sl.objoid AND sl.classoid = 'pg_type'::regclass + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE ${USER_SCHEMA_FILTER} AND ${modeledTypeKindSql("t")} + ORDER BY 1, 3, 4`)) { + pushSeclabel( + { + kind: String(row["typtype"]) === "d" ? "domain" : "type", + schema: String(row["schema"]), + name: String(row["name"]), + }, + String(row["provider"]), + String(row["label"]), + ); + } + // event triggers (scoped like the event-trigger extractor: not extension-owned) + for (const row of await q(` + SELECT sl.provider, sl.label, e.evtname AS name + FROM pg_seclabel sl + JOIN pg_event_trigger e ON e.oid = sl.objoid AND sl.classoid = 'pg_event_trigger'::regclass + WHERE ${notExtensionMember("pg_event_trigger", "e.oid")} + ORDER BY 1, 3`)) { + pushSeclabel( + { kind: "eventTrigger", name: String(row["name"]) }, + String(row["provider"]), + String(row["label"]), + ); + } + // publications + for (const row of await q(` + SELECT sl.provider, sl.label, p.pubname AS name + FROM pg_seclabel sl + JOIN pg_publication p ON p.oid = sl.objoid AND sl.classoid = 'pg_publication'::regclass + WHERE ${notExtensionMember("pg_publication", "p.oid")} + ORDER BY 1, 3`)) { + pushSeclabel( + { kind: "publication", name: String(row["name"]) }, + String(row["provider"]), + String(row["label"]), + ); + } + // subscriptions (per-database catalog; superuser-visible) + for (const row of await q(` + SELECT sl.provider, sl.label, s.subname AS name + FROM pg_seclabel sl + JOIN pg_subscription s ON s.oid = sl.objoid AND sl.classoid = 'pg_subscription'::regclass + ORDER BY 1, 3`)) { + pushSeclabel( + { kind: "subscription", name: String(row["name"]) }, + String(row["provider"]), + String(row["label"]), + ); + } + // roles (shared catalog). `pg_roles` (not `pg_authid`) so a non-superuser + // caller can read it — `pg_authid` itself is superuser-only and would throw + // `permission denied for table pg_authid`; `pg_roles` exposes the same oid + // + rolname surface this query needs (classoid stays `pg_authid`::regclass — + // that is the underlying catalog `pg_seclabel`/`pg_shseclabel` rows key on). + for (const row of await q(` + SELECT sl.provider, sl.label, r.rolname AS name + FROM pg_shseclabel sl + JOIN pg_roles r ON r.oid = sl.objoid AND sl.classoid = 'pg_authid'::regclass + WHERE r.rolname NOT LIKE 'pg\\_%' + ORDER BY 1, 3`)) { + pushSeclabel( + { kind: "role", name: String(row["name"]) }, + String(row["provider"]), + String(row["label"]), + ); + } + + // ── unresolved-label diagnostic (stage-2 doctrine: detect, never silently + // drop). Every pg_seclabel/pg_shseclabel row the resolver above did NOT turn + // into a fact is a user-applied label on a target the engine cannot manage + // (a LANGUAGE, LARGE OBJECT, DATABASE, TABLESPACE, an unmodeled pg_class + // relkind, …). Without this, source and desired could both omit such a label + // and the proof would pass vacuously (review P1). + const classoidList = RESOLVED_LOCAL_CLASSOIDS.map( + (c) => `'${c}'::regclass`, + ).join(", "); + const relkindList = Object.keys(LABELED_RELKINDS) + .map((k) => `'${k}'`) + .join(", "); + const columnRelkindList = [...COLUMN_BEARING_RELKINDS] + .map((k) => `'${k}'`) + .join(", "); + for (const row of await q(` + SELECT obj_class, + count(*)::int AS count, + (array_agg(descr ORDER BY descr))[1:5] AS samples + FROM ( + SELECT sl.classoid::regclass::text AS obj_class, + sl.classoid::regclass::text || ' #' || sl.objoid::text AS descr + FROM pg_seclabel sl + WHERE NOT ( + (sl.classoid = 'pg_class'::regclass AND EXISTS ( + SELECT 1 FROM pg_class c WHERE c.oid = sl.objoid AND ( + -- a column label resolves only on a column-bearing relkind; a + -- view/matview column label is unresolved (no column fact) + (sl.objsubid > 0 AND c.relkind IN (${columnRelkindList})) + OR (sl.objsubid = 0 AND c.relkind IN (${relkindList}))))) + -- a pg_type label resolves only for a MODELED type kind; a label on a + -- base/shell/table-rowtype pg_type is unresolved (no type fact) + OR (sl.classoid = 'pg_type'::regclass AND EXISTS ( + SELECT 1 FROM pg_type t WHERE t.oid = sl.objoid AND ${modeledTypeKindSql("t")})) + OR sl.classoid IN (${classoidList}) + ) + UNION ALL + SELECT sl.classoid::regclass::text, + sl.classoid::regclass::text || ' #' || sl.objoid::text + FROM pg_shseclabel sl + WHERE sl.classoid <> 'pg_authid'::regclass + ) u + GROUP BY obj_class + ORDER BY obj_class`)) { + const count = Number(row["count"]); + const objClass = String(row["obj_class"]); + const samples = (row["samples"] as string[] | null) ?? []; + const more = count > samples.length ? ", …" : ""; + diagnostics.push({ + code: "unresolved_security_label", + severity: "warning", + message: + `${count} security label${count === 1 ? "" : "s"} on unmodeled ` + + `target${count === 1 ? "" : "s"} (${objClass}) cannot be managed by this engine ` + + `(e.g. ${samples.join(", ")}${more}) — the label is reported, not applied`, + context: { objClass, count, samples }, + }); + } +} diff --git a/packages/pg-delta/src/extract/sensitive-options.test.ts b/packages/pg-delta/src/extract/sensitive-options.test.ts new file mode 100644 index 000000000..8c3784472 --- /dev/null +++ b/packages/pg-delta/src/extract/sensitive-options.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { + redactOptionStrings, + SUBSCRIPTION_CONNINFO_PLACEHOLDER, +} from "./sensitive-options.ts"; + +describe("redactOptionStrings", () => { + test("keeps allowlisted (non-credential) option values verbatim", () => { + expect( + redactOptionStrings([ + "host=remote.example.com", + "port=5432", + "user=fdw_reader", + "use_remote_estimate=true", + "schema_name=remote_schema", + ]), + ).toEqual([ + "host=remote.example.com", + "port=5432", + "user=fdw_reader", + "use_remote_estimate=true", + "schema_name=remote_schema", + ]); + }); + + test("replaces non-allowlisted values with __OPTION___ placeholders", () => { + expect( + redactOptionStrings([ + "password=real-secret", + "passfile=/etc/secrets/pass", + "passcode=krb", + "sslpassword=ssl-secret", + "api_key=abc123", + ]), + ).toEqual([ + "password=__OPTION_PASSWORD__", + "passfile=__OPTION_PASSFILE__", + "passcode=__OPTION_PASSCODE__", + "sslpassword=__OPTION_SSLPASSWORD__", + "api_key=__OPTION_API_KEY__", + ]); + }); + + test("keeps the non-secret postgres_fdw user-mapping option password_required", () => { + // password_required is a documented postgres_fdw user-mapping option, not a + // credential. Redacting it would make `password_required=false` invisible to + // diff (both sides redact to the same placeholder) and emit a placeholder on + // export/plan-from-empty. It must survive verbatim. + expect( + redactOptionStrings([ + "password_required=false", + "password_required=true", + ]), + ).toEqual(["password_required=false", "password_required=true"]); + }); + + test("keeps file_fdw's non-secret filename / null options verbatim", () => { + // file_fdw's `filename` is a filesystem path (not a credential); redacting + // it makes a default-redacted export create a foreign table pointing at the + // literal `__OPTION_FILENAME__`, which converges while every query fails. + // `null` is file_fdw's null-marker string, equally non-secret. + expect( + redactOptionStrings([ + "filename=/srv/data/export.csv", + "null=\\N", + "format=csv", + "header=true", + ]), + ).toEqual([ + "filename=/srv/data/export.csv", + "null=\\N", + "format=csv", + "header=true", + ]); + }); + + test("keeps the non-secret postgres_fdw connection option service", () => { + // `service` names a pg_service.conf connection-service entry — a reference, + // not a credential (the actual host/user/password live in that file). It is + // a documented libpq/postgres_fdw connection option; redacting it would make + // a service-name change invisible to diff (both sides redact to the same + // placeholder) and emit `service=__OPTION_SERVICE__` on export/plan-from-empty. + expect(redactOptionStrings(["service=prod"])).toEqual(["service=prod"]); + }); + + test("allowlist match is case-insensitive; placeholder upper-cases the key", () => { + expect(redactOptionStrings(["HOST=h", "Password=secret"])).toEqual([ + "HOST=h", + "Password=__OPTION_PASSWORD__", + ]); + }); + + test("splits on the first '=' so values containing '=' are not truncated", () => { + expect(redactOptionStrings(["host=a=b=c"])).toEqual(["host=a=b=c"]); + expect(redactOptionStrings(["token=a=b=c"])).toEqual([ + "token=__OPTION_TOKEN__", + ]); + }); + + test("leaves a valueless option untouched", () => { + expect(redactOptionStrings(["solo"])).toEqual(["solo"]); + }); + + test("conninfo placeholder carries no real values", () => { + expect(SUBSCRIPTION_CONNINFO_PLACEHOLDER).toBe( + "host=__CONN_HOST__ port=__CONN_PORT__ dbname=__CONN_DBNAME__ user=__CONN_USER__ password=__CONN_PASSWORD__", + ); + }); +}); diff --git a/packages/pg-delta/src/extract/sensitive-options.ts b/packages/pg-delta/src/extract/sensitive-options.ts new file mode 100644 index 000000000..a3c031a1f --- /dev/null +++ b/packages/pg-delta/src/extract/sensitive-options.ts @@ -0,0 +1,154 @@ +/** + * Sensitive-value redaction for foreign-data objects and subscriptions. + * + * Foreign-data wrappers (`pg_foreign_data_wrapper.fdwoptions`), servers + * (`pg_foreign_server.srvoptions`), user mappings (`pg_user_mapping.umoptions`), + * foreign tables (`pg_foreign_table.ftoptions`), and subscriptions + * (`pg_subscription.subconninfo`) all store libpq/FDW credentials in cleartext. + * Any code path that emits these verbatim — plan SQL, catalog snapshots + * (and thus the fingerprint digest computed over them), declarative export, + * and the serialized plan artifact — would leak credentials to disk, stdout, + * CI logs, and version control. + * + * Redaction happens HERE, at extract time, so the placeholder propagates + * uniformly to every downstream channel via the fact base. A useful + * side-effect: a change to only a secret value redacts identically on both + * sides of the diff, so it produces no spurious ALTER (env-dependent + * credentials are not "drift"). + * + * Option redaction is **allowlist-based**: a value is replaced with + * `__OPTION___` unless its key is in {@link SAFE_OPTION_KEYS}. The + * failure mode of a missing entry is "the plan shows a placeholder instead of + * the real value" — annoying but safe; a denylist's failure mode is a leaked + * secret, which is the bug this prevents. Match is case-insensitive but exact: + * a key like `password_validator_extension` redacts unless explicitly listed. + */ + +const SAFE_OPTION_KEYS = new Set([ + // libpq connection params (non-credential subset). + // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS + "host", + "hostaddr", + "port", + "dbname", + "user", + "sslmode", + "sslcompression", + "sslcert", + "sslkey", + "sslrootcert", + "sslcrl", + "sslcrldir", + "sslsni", + "requirepeer", + "krbsrvname", + "gsslib", + "sspi", + "gssencmode", + "gssdelegation", + "channel_binding", + "target_session_attrs", + // names a pg_service.conf connection-service entry — a reference, not a + // credential (the real host/user/password live in that file). Must stay + // visible so a service-name change is a real diff and export/plan-from-empty + // preserves it instead of emitting `service=__OPTION_SERVICE__`. + "service", + "application_name", + "fallback_application_name", + "connect_timeout", + "client_encoding", + "options", + "keepalives", + "keepalives_idle", + "keepalives_interval", + "keepalives_count", + "tcp_user_timeout", + "replication", + "load_balance_hosts", + // postgres_fdw behavior tuning. + // https://www.postgresql.org/docs/current/postgres-fdw.html#POSTGRES-FDW-OPTIONS-CONNECTION + "use_remote_estimate", + "fdw_startup_cost", + "fdw_tuple_cost", + "fetch_size", + "batch_size", + "async_capable", + "analyze_sampling", + "parallel_commit", + "parallel_abort", + // postgres_fdw user-mapping behavior flag (non-credential): documented as a + // normal option; `password_required=false` lets a non-superuser mapping + // connect without a password. Must stay visible so the diff and + // export/plan-from-empty preserve this security-relevant setting. + "password_required", + "extensions", + "updatable", + "truncatable", + "schema_name", + "table_name", + "column_name", + // Common shape for table-like FDWs (file_fdw, cloud-storage wrappers). + "schema", + "database", + "table", + "format", + "header", + "delimiter", + "quote", + "escape", + "encoding", + "compression", + // file_fdw's remaining non-secret COPY-shaped options. + // https://www.postgresql.org/docs/current/file-fdw.html + // `filename`/`program` name the data source (a path / shell command, not a + // credential); `null`/`force_not_null`/`force_null` are COPY formatting + // knobs. Redacting `filename` in particular makes a default-redacted export + // create a foreign table pointing at the literal `__OPTION_FILENAME__`, + // which converges while every query fails. + "filename", + "program", + "null", + "force_not_null", + "force_null", + // Cloud / Supabase Wrappers non-credential shape. + // https://github.com/supabase/wrappers + "region", + "endpoint", + "bucket", + "prefix", + "location", + "project_id", + "dataset_id", + "dataset", + "workspace", + "organization", + "api_version", +]); + +/** + * Subscription conninfo is fully environment-dependent (host, port, dbname, + * user, password all differ per environment and the password is a secret), so + * the entire string is replaced with a fixed placeholder rather than redacted + * field-by-field. This keeps it out of every output channel AND makes + * conninfo-only changes invisible to the diff. + */ +export const SUBSCRIPTION_CONNINFO_PLACEHOLDER = + "host=__CONN_HOST__ port=__CONN_PORT__ dbname=__CONN_DBNAME__ user=__CONN_USER__ password=__CONN_PASSWORD__"; + +/** + * Redact one `key=value` option string (the shape stored in `pg_*options` + * arrays and consumed by `splitOption` in the plan renderer). Splits on the + * first `=` so values containing `=` survive intact. + */ +function redactOptionString(opt: string): string { + const i = opt.indexOf("="); + if (i === -1) return opt; // valueless option (shouldn't happen) — leave as-is + const key = opt.slice(0, i); + if (SAFE_OPTION_KEYS.has(key.toLowerCase())) return opt; + return `${key}=__OPTION_${key.toUpperCase()}__`; +} + +/** Redact every non-allowlisted value in a `key=value` options array. */ +export function redactOptionStrings(options: readonly string[]): string[] { + return options.map(redactOptionString); +} diff --git a/packages/pg-delta/src/extract/types.ts b/packages/pg-delta/src/extract/types.ts new file mode 100644 index 000000000..3847bc163 --- /dev/null +++ b/packages/pg-delta/src/extract/types.ts @@ -0,0 +1,324 @@ +/** User-defined types: domains (+ their CHECK constraints), enums / composites + * / ranges, and collations. */ +import type { StableId } from "../core/stable-id.ts"; +import { + aclJsonMemberAware, + type ExtractContext, + memberExtensionExpr, + notExtensionMember, + parseAcl, + schemaId, + USER_SCHEMA_FILTER, +} from "./scope.ts"; + +export async function extractDomains(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + // ── domains (+ their CHECK constraints as facts) ───────────────────── + for (const row of await q(` + SELECT n.nspname AS schema, t.typname AS name, r.rolname AS owner, + format_type(t.typbasetype, t.typtypmod) AS base_type, + t.typnotnull AS not_null, t.typdefault AS default_expr, + CASE WHEN t.typcollation <> bt.typcollation THEN ( + SELECT quote_ident(cn.nspname) || '.' || quote_ident(co.collname) + FROM pg_collation co JOIN pg_namespace cn ON cn.oid = co.collnamespace + WHERE co.oid = t.typcollation) + END AS collation, + obj_description(t.oid, 'pg_type') AS comment, + ${aclJsonMemberAware("t.typacl", "T", "t.typowner", "pg_type", "t.oid")} AS acl, + ${memberExtensionExpr("pg_type", "t.oid")} AS ext_member_of + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_roles r ON r.oid = t.typowner + JOIN pg_type bt ON bt.oid = t.typbasetype + WHERE t.typtype = 'd' AND ${USER_SCHEMA_FILTER} + ORDER BY n.nspname, t.typname`)) { + const id: StableId = { + kind: "domain", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { + baseType: String(row["base_type"]), + notNull: Boolean(row["not_null"]), + default: + row["default_expr"] == null + ? null + : (row["default_expr"] as string), + collation: + row["collation"] == null ? null : (row["collation"] as string), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } + for (const row of await q(` + SELECT n.nspname AS schema, t.typname AS domain, con.conname AS name, + pg_get_constraintdef(con.oid) AS def, + con.contype AS type, con.convalidated AS validated, + obj_description(con.oid, 'pg_constraint') AS comment + FROM pg_constraint con + JOIN pg_type t ON t.oid = con.contypid + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE con.contypid <> 0 AND ${USER_SCHEMA_FILTER} + AND ${notExtensionMember("pg_type", "t.oid")} + ORDER BY n.nspname, t.typname, con.conname`)) { + pushWithMeta( + { + id: { + kind: "constraint", + schema: String(row["schema"]), + table: String(row["domain"]), + name: String(row["name"]), + }, + parent: { + kind: "domain", + schema: String(row["schema"]), + name: String(row["domain"]), + }, + payload: { + def: String(row["def"]), + type: String(row["type"]), + validated: Boolean(row["validated"]), + }, + }, + row, + ); + } +} + +export async function extractTypes(ctx: ExtractContext): Promise { + const { q, facts, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + // ── types: enums, standalone composites, ranges ────────────────────── + for (const row of await q(` + SELECT n.nspname AS schema, t.typname AS name, r.rolname AS owner, + ARRAY(SELECT e.enumlabel::text FROM pg_enum e + WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder) AS values, + obj_description(t.oid, 'pg_type') AS comment, + ${aclJsonMemberAware("t.typacl", "T", "t.typowner", "pg_type", "t.oid")} AS acl, + ${memberExtensionExpr("pg_type", "t.oid")} AS ext_member_of + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_roles r ON r.oid = t.typowner + WHERE t.typtype = 'e' AND ${USER_SCHEMA_FILTER} + ORDER BY n.nspname, t.typname`)) { + const id: StableId = { + kind: "type", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { + variant: "enum", + values: (row["values"] as string[]).map(String), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } + for (const row of await q(` + SELECT n.nspname AS schema, t.typname AS name, r.rolname AS owner, + (SELECT json_agg(json_build_object( + 'name', a.attname, + 'position', a.attnum, + 'type', format_type(a.atttypid, a.atttypmod), + 'collation', CASE WHEN a.attcollation <> at.typcollation THEN ( + SELECT quote_ident(cn.nspname) || '.' || quote_ident(co.collname) + FROM pg_collation co JOIN pg_namespace cn ON cn.oid = co.collnamespace + WHERE co.oid = a.attcollation) END + ) ORDER BY a.attnum) + FROM pg_attribute a + JOIN pg_type at ON at.oid = a.atttypid + WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped) AS attrs, + obj_description(t.oid, 'pg_type') AS comment, + ${aclJsonMemberAware("t.typacl", "T", "t.typowner", "pg_type", "t.oid")} AS acl, + ${memberExtensionExpr("pg_type", "t.oid")} AS ext_member_of + FROM pg_type t + JOIN pg_class tc ON tc.oid = t.typrelid AND tc.relkind = 'c' + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_roles r ON r.oid = t.typowner + WHERE t.typtype = 'c' AND ${USER_SCHEMA_FILTER} + ORDER BY n.nspname, t.typname`)) { + const typeId: StableId = { + kind: "type", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id: typeId, + parent: schemaId(row["schema"]), + payload: { variant: "composite" }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(typeId, row); + pushOwnerEdge(typeId, row["owner"]); + // each attribute is its own fact (granularity is one, §3.1) — enables + // attribute-grain diffs and ALTER TYPE … RENAME ATTRIBUTE rename + // detection. Positional IDENTITY is not desired state (attributes are + // name-keyed, like columns), but render ORDER is — carried below as + // `_position` (see the payload comment). + const attrs = + (row["attrs"] as + | { + name: string; + position: number; + type: string; + collation: string | null; + }[] + | null) ?? []; + for (const a of attrs) { + facts.push({ + id: { + kind: "typeAttribute", + schema: String(row["schema"]), + type: String(row["name"]), + name: a.name, + }, + parent: typeId, + // `_position` is the declared attribute position (pg_attribute.attnum). + // Row layout — hence render order — is desired state, but positional + // identity is not (attributes are name-keyed sub-facts, like columns), + // so the `_`-prefix excludes it from the hash and diff (core/hash.ts, + // core/diff.ts) while the composite CREATE TYPE rule still renders + // attributes in this order (plan/rules/types.ts). + payload: { + type: a.type, + collation: a.collation ?? null, + _position: a.position, + }, + }); + } + } + // rngmultitypid (the auto-created multirange type) is PG14+; on PG13 the + // column does not exist, so the multirange name degrades to NULL. + const major = Math.floor( + Number( + ( + await q(`SELECT current_setting('server_version_num')::int AS num`) + )[0]?.["num"] ?? 0, + ) / 10000, + ); + const multirangeExpr = + major >= 14 + ? `(SELECT quote_ident(mn.nspname) || '.' || quote_ident(mt.typname) + FROM pg_type mt JOIN pg_namespace mn ON mn.oid = mt.typnamespace + WHERE mt.oid = rng.rngmultitypid)` + : `NULL::text`; + for (const row of await q(` + SELECT n.nspname AS schema, t.typname AS name, r.rolname AS owner, + format_type(rng.rngsubtype, NULL) AS subtype, + -- pin SUBTYPE_OPCLASS only when it is not the subtype's default + -- operator class (pg_dump's rule); the default is implied by SUBTYPE + CASE WHEN NOT opc.opcdefault THEN + quote_ident(opcn.nspname) || '.' || quote_ident(opc.opcname) + END AS subtype_opclass, + CASE WHEN rng.rngcollation <> 0 THEN ( + SELECT quote_ident(cn.nspname) || '.' || quote_ident(co.collname) + FROM pg_collation co JOIN pg_namespace cn ON cn.oid = co.collnamespace + WHERE co.oid = rng.rngcollation) END AS collation, + CASE WHEN rng.rngsubdiff <> 0 THEN rng.rngsubdiff::regproc::text END AS subtype_diff, + ${multirangeExpr} AS multirange_type_name, + obj_description(t.oid, 'pg_type') AS comment, + ${aclJsonMemberAware("t.typacl", "T", "t.typowner", "pg_type", "t.oid")} AS acl, + ${memberExtensionExpr("pg_type", "t.oid")} AS ext_member_of + FROM pg_range rng + JOIN pg_type t ON t.oid = rng.rngtypid + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_roles r ON r.oid = t.typowner + JOIN pg_opclass opc ON opc.oid = rng.rngsubopc + JOIN pg_namespace opcn ON opcn.oid = opc.opcnamespace + WHERE t.typtype = 'r' AND ${USER_SCHEMA_FILTER} + ORDER BY n.nspname, t.typname`)) { + const id: StableId = { + kind: "type", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { + variant: "range", + subtype: String(row["subtype"]), + subtypeOpclass: + row["subtype_opclass"] == null + ? null + : (row["subtype_opclass"] as string), + collation: + row["collation"] == null ? null : (row["collation"] as string), + subtypeDiff: + row["subtype_diff"] == null + ? null + : (row["subtype_diff"] as string), + multirangeTypeName: + row["multirange_type_name"] == null + ? null + : (row["multirange_type_name"] as string), + }, + }, + row, + parseAcl(row["acl"]), + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } +} + +export async function extractCollations(ctx: ExtractContext): Promise { + const { q, pushWithMeta, pushMemberEdge, pushOwnerEdge } = ctx; + // ── collations (collversion deliberately excluded from equality) ───── + for (const row of await q(` + SELECT n.nspname AS schema, c.collname AS name, r.rolname AS owner, + c.collprovider AS provider, c.collisdeterministic AS deterministic, + to_jsonb(c) AS raw, + obj_description(c.oid, 'pg_collation') AS comment, + ${memberExtensionExpr("pg_collation", "c.oid")} AS ext_member_of + FROM pg_collation c + JOIN pg_namespace n ON n.oid = c.collnamespace + JOIN pg_roles r ON r.oid = c.collowner + WHERE ${USER_SCHEMA_FILTER} + ORDER BY n.nspname, c.collname`)) { + const raw = row["raw"] as Record; + const locale = + (raw["colllocale"] as string | null) ?? + (raw["colliculocale"] as string | null) ?? + null; + const id: StableId = { + kind: "collation", + schema: String(row["schema"]), + name: String(row["name"]), + }; + pushWithMeta( + { + id, + parent: schemaId(row["schema"]), + payload: { + provider: String(row["provider"]), + deterministic: Boolean(row["deterministic"]), + locale, + lcCollate: (raw["collcollate"] as string | null) ?? null, + lcCtype: (raw["collctype"] as string | null) ?? null, + }, + }, + row, + ); + pushMemberEdge(id, row); + pushOwnerEdge(id, row["owner"]); + } +} diff --git a/packages/pg-delta/src/extract/unmodeled.ts b/packages/pg-delta/src/extract/unmodeled.ts new file mode 100644 index 000000000..9b5065381 --- /dev/null +++ b/packages/pg-delta/src/extract/unmodeled.ts @@ -0,0 +1,194 @@ +/** + * Catalog completeness check — the v1 correctness floor (review finding 1). + * + * `extract()` only emits facts for the kinds it models. A user-created object + * in a kind it does NOT model would otherwise be invisible: never a fact, + * never a delta, never mentioned in the plan or the proof. That is a SILENT + * miss, and a migration tool that silently drops part of your schema from its + * view is not trustworthy. (The proof loop reads source and desired through + * the same extractor, so a blind spot can even let a proof pass vacuously.) + * + * This module scans — in the SAME repeatable-read snapshot as the rest of + * extraction — for present-but-unmodeled USER objects, returning one + * `unmodeled_kind` warning per kind found. It is provenance-aware: built-in + * (initdb-pinned) and extension-owned objects are the system's / an + * extension's internals, NOT user state, so they are excluded — matching the + * extractor's own `notExtensionMember` anti-join. + * + * "Detect, don't model": v1 need not MODEL these kinds (that is demand-driven, + * post-v1 — add an extractor + rule + corpus scenario when a real schema needs + * one). v1 must never SILENTLY miss them. Strict-coverage mode (the CLI / + * frontend seam) escalates these warnings to a hard stop. + */ +import type { PoolClient } from "pg"; +import type { Diagnostic } from "../core/diagnostic.ts"; + +/** + * A probe for one unmodeled catalog kind. + * - `kind` : human-readable label (also the `context.kind` discriminator) + * - `classid`: the catalog's regclass, used to test pg_depend provenance + * - `oid` : SQL expression for the object's oid within `from` + * - `name` : SQL expression producing a human-readable name per object + * - `from` : FROM/JOIN clause exposing `oid` and `name` + * - `where` : optional extra predicate (e.g. procedural-languages-only) + */ +interface UnmodeledProbe { + kind: string; + classid: string; + oid: string; + name: string; + from: string; + where?: string; +} + +/** + * PostgreSQL's FirstNormalObjectId. Every object created during initdb (a + * system built-in) has an OID below this; the live server's OID counter starts + * here and only ever issues OIDs >= it, so `oid >= 16384` ⟺ created after + * initdb — a user or extension object. This is the canonical system/user + * boundary in PG 14+, which retired the old `pg_depend` deptype='p' pin rows. + */ +const FIRST_NORMAL_OID = 16384; + +/** Owned by an extension (deptype 'e' on the dependent side) — the same + * provenance the extractor uses to exclude extension members. */ +function isExtensionMember(classid: string, oid: string): string { + return `EXISTS (SELECT 1 FROM pg_depend de + WHERE de.classid = '${classid}'::regclass + AND de.objid = ${oid} AND de.deptype = 'e')`; +} + +const PROBES: readonly UnmodeledProbe[] = [ + { + kind: "cast", + classid: "pg_cast", + oid: "c.oid", + name: "format_type(c.castsource, NULL) || ' AS ' || format_type(c.casttarget, NULL)", + from: "pg_cast c", + }, + { + kind: "operator", + classid: "pg_operator", + oid: "o.oid", + name: "o.oprname", + from: "pg_operator o", + }, + { + kind: "operator class", + classid: "pg_opclass", + oid: "opc.oid", + name: "opc.opcname", + from: "pg_opclass opc", + }, + { + kind: "operator family", + classid: "pg_opfamily", + oid: "opf.oid", + name: "opf.opfname", + from: "pg_opfamily opf", + }, + { + kind: "text search configuration", + classid: "pg_ts_config", + oid: "tc.oid", + name: "tc.cfgname", + from: "pg_ts_config tc", + }, + { + kind: "text search dictionary", + classid: "pg_ts_dict", + oid: "td.oid", + name: "td.dictname", + from: "pg_ts_dict td", + }, + { + kind: "text search parser", + classid: "pg_ts_parser", + oid: "tp.oid", + name: "tp.prsname", + from: "pg_ts_parser tp", + }, + { + kind: "text search template", + classid: "pg_ts_template", + oid: "tt.oid", + name: "tt.tmplname", + from: "pg_ts_template tt", + }, + { + kind: "statistics object", + classid: "pg_statistic_ext", + oid: "se.oid", + name: "se.stxname", + from: "pg_statistic_ext se", + }, + { + kind: "language", + classid: "pg_language", + oid: "l.oid", + name: "l.lanname", + from: "pg_language l", + // procedural languages only — excludes the built-in internal/c/sql + // languages (lanispl = false); plpgsql is extension-owned and so is + // filtered by the extension-member check. + where: "l.lanispl", + }, + { + kind: "transform", + classid: "pg_transform", + oid: "tr.oid", + name: "format_type(tr.trftype, NULL) || ' / ' || (SELECT ll.lanname FROM pg_language ll WHERE ll.oid = tr.trflang)", + from: "pg_transform tr", + }, +]; + +function probeSql(p: UnmodeledProbe): string { + const filters = [ + p.where, + `${p.oid} >= ${FIRST_NORMAL_OID}`, + `NOT ${isExtensionMember(p.classid, p.oid)}`, + ].filter(Boolean); + return `SELECT '${p.kind}'::text AS kind, + count(*)::int AS count, + (array_agg(nm ORDER BY nm))[1:5] AS samples + FROM ( + SELECT ${p.name} AS nm + FROM ${p.from} + WHERE ${filters.join(" AND ")} + ) s`; +} + +interface ProbeRow { + kind: string; + count: number; + samples: string[] | null; +} + +/** + * Scan for present-but-unmodeled USER objects, returning one `unmodeled_kind` + * warning per kind found. Runs ONE union query so it shares the caller's + * snapshot and costs a single round-trip; the per-kind probes stay declarative + * (add a row to `PROBES` to cover a newly relevant kind). + */ +export async function detectUnmodeledKinds( + client: PoolClient, +): Promise { + const sql = PROBES.map(probeSql).join("\nUNION ALL\n"); + const { rows } = await client.query(sql); + const diagnostics: Diagnostic[] = []; + for (const row of rows) { + if (row.count <= 0) continue; + const samples = row.samples ?? []; + const more = row.count > samples.length ? ", …" : ""; + diagnostics.push({ + code: "unmodeled_kind", + severity: "warning", + message: + `${row.count} unmodeled "${row.kind}" object${row.count === 1 ? "" : "s"} ` + + `not managed by this engine (e.g. ${samples.join(", ")}${more}) — ` + + `v1 detects but does not model this kind`, + context: { kind: row.kind, count: row.count, samples }, + }); + } + return diagnostics; +} diff --git a/packages/pg-delta/src/frontends/export-intent.test.ts b/packages/pg-delta/src/frontends/export-intent.test.ts new file mode 100644 index 000000000..b7556ef10 --- /dev/null +++ b/packages/pg-delta/src/frontends/export-intent.test.ts @@ -0,0 +1,56 @@ +/** + * Regression (PR #318 review): `schema export --profile supabase` extracts a + * named pg_cron job as an `extensionIntent` fact, but `exportSqlFiles`' internal + * `plan(∅ → fb)` must be given the profile's intent rules — otherwise the rule + * resolver throws "no intent rule registered" and export fails instead of + * writing files. Pins that `intentRules` is forwarded. Pure — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { buildIntentRuleIndex } from "../plan/rules.ts"; +import { pgCronHandler } from "../policy/extensions/index.ts"; +import { exportSqlFiles } from "./export-sql-files.ts"; + +const pgCron: StableId = { kind: "extension", name: "pg_cron" }; +const jobId: StableId = { + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "nightly_prune", +}; + +const fb = buildFactBase( + [ + { id: { kind: "schema", name: "public" }, payload: {} }, + { id: pgCron, payload: { schema: "pg_catalog", relocatable: false } }, + { + id: jobId, + payload: { + schedule: "0 0 * * *", + command: "DELETE FROM public.audit_log", + database: "postgres", + username: "postgres", + active: true, + }, + }, + ], + [{ from: jobId, to: pgCron, kind: "depends" }], +); + +const intentRules = buildIntentRuleIndex([pgCronHandler]); + +describe("schema export forwards intent rules to the internal plan()", () => { + test("with intentRules, a named cron job exports as its replay SQL", () => { + const dump = exportSqlFiles(fb, { layout: "by-object", intentRules }) + .map((f) => f.sql) + .join("\n"); + expect(dump).toContain("cron.schedule_in_database('nightly_prune'"); + }); + + test("WITHOUT intentRules, export throws the unregistered-rule error (the bug this guards)", () => { + expect(() => exportSqlFiles(fb, { layout: "by-object" })).toThrow( + /no intent rule registered/, + ); + }); +}); diff --git a/packages/pg-delta/src/frontends/export-manifest.test.ts b/packages/pg-delta/src/frontends/export-manifest.test.ts new file mode 100644 index 000000000..47ea67a89 --- /dev/null +++ b/packages/pg-delta/src/frontends/export-manifest.test.ts @@ -0,0 +1,127 @@ +/** + * The export manifest records a directory export's redaction mode so + * `schema apply --dir` re-extracts with the same mode (PR #307 review #3505088638). + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + EXPORT_MANIFEST_FILE, + readExportManifest, + writeExportManifest, +} from "./export-manifest.ts"; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "pgdn-manifest-")); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("export manifest", () => { + test("round-trips the redaction mode, profile, and scope", () => { + writeExportManifest(dir, { + redactSecrets: false, + profile: "supabase", + scope: "cluster", + }); + expect(readExportManifest(dir)).toEqual({ + redactSecrets: false, + profile: "supabase", + scope: "cluster", + }); + writeExportManifest(dir, { redactSecrets: true, scope: "database" }); + expect(readExportManifest(dir)).toEqual({ + redactSecrets: true, + scope: "database", + }); + }); + + test("round-trips defaultOwner as a role name, null (verbose), or absent", () => { + writeExportManifest(dir, { + redactSecrets: true, + scope: "database", + defaultOwner: "postgres", + }); + expect(readExportManifest(dir)).toEqual({ + redactSecrets: true, + scope: "database", + defaultOwner: "postgres", + }); + + // null (verbose export) is a recorded value distinct from absent. + writeExportManifest(dir, { + redactSecrets: true, + scope: "database", + defaultOwner: null, + }); + expect(readExportManifest(dir)).toEqual({ + redactSecrets: true, + scope: "database", + defaultOwner: null, + }); + + // absent (pre-feature / cluster-scope export): field simply not present. + writeExportManifest(dir, { redactSecrets: true, scope: "cluster" }); + const read = readExportManifest(dir); + expect(read).toEqual({ redactSecrets: true, scope: "cluster" }); + expect("defaultOwner" in (read as object)).toBe(false); + }); + + test("round-trips the owned files list (sorted POSIX relative paths)", () => { + const files = ["cluster/roles.sql", "schemas/app/tables/t.sql"]; + writeExportManifest(dir, { + redactSecrets: true, + scope: "database", + files, + }); + expect(readExportManifest(dir)).toEqual({ + redactSecrets: true, + scope: "database", + files, + }); + }); + + test("drops a wrong-typed files field (non-array or non-string members)", () => { + // A non-array value is dropped entirely. + writeFileSync( + join(dir, EXPORT_MANIFEST_FILE), + `{"formatVersion":1,"redactSecrets":true,"files":"nope"}`, + "utf8", + ); + expect(readExportManifest(dir)).toEqual({ redactSecrets: true }); + + // An array with a non-string member is dropped entirely. + writeFileSync( + join(dir, EXPORT_MANIFEST_FILE), + `{"formatVersion":1,"redactSecrets":true,"files":["ok.sql",3]}`, + "utf8", + ); + expect(readExportManifest(dir)).toEqual({ redactSecrets: true }); + }); + + test("returns undefined when no manifest exists (older / hand-authored dir)", () => { + expect(readExportManifest(dir)).toBeUndefined(); + }); + + test("throws on a present-but-unparseable manifest (fail closed)", () => { + // A corrupt manifest must abort before planning, not silently fall back to + // raw defaults — that would drop the recorded profile/scope/redaction mode + // and plan a destructive apply against the target's real platform state. + writeFileSync(join(dir, EXPORT_MANIFEST_FILE), "{ not json", "utf8"); + expect(() => readExportManifest(dir)).toThrow(); + }); + + test("drops wrong-typed fields from a valid manifest", () => { + // Valid JSON with unknown / wrong-typed fields is not "malformed": parse it + // and drop the bad fields (a forward-compatible manifest still applies). + writeFileSync( + join(dir, EXPORT_MANIFEST_FILE), + `{"formatVersion":1,"redactSecrets":"yes","profile":3}`, + "utf8", + ); + expect(readExportManifest(dir)).toEqual({}); + }); +}); diff --git a/packages/pg-delta/src/frontends/export-manifest.ts b/packages/pg-delta/src/frontends/export-manifest.ts new file mode 100644 index 000000000..4fcffbdc1 --- /dev/null +++ b/packages/pg-delta/src/frontends/export-manifest.ts @@ -0,0 +1,132 @@ +/** + * Metadata manifest for a `schema export` directory. + * + * A directory export has no single artifact to stamp (unlike a plan or snapshot + * JSON), so `schema export` drops this small sidecar recording: + * - the redaction mode, so `schema apply --dir` re-extracts the shadow with the + * SAME mode (an unsafe export round-trips real FDW/user-mapping/subscription + * credentials without re-passing `--unsafe-show-secrets`, and a redacted + * export is not silently applied unredacted); + * - the integration profile the export was projected with, so `schema apply` + * defaults to it — otherwise a `--profile supabase` export applied under the + * default (raw) profile would read the target's platform schemas/roles as + * drift and plan destructive drops. + * + * The file is a dotfile with a `.json` extension, so the SQL loader + * (`collectSqlFiles`, `.sql` only) never treats it as declarative input and the + * export pruner (`.sql` only) never removes it (PR #307 review P1/P2). + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export const EXPORT_MANIFEST_FILE = ".pgdelta-export.json"; + +export interface ExportManifest { + /** whether secrets were redacted when the directory was exported */ + redactSecrets?: boolean; + /** the integration profile id the export was projected with (e.g. "supabase") */ + profile?: string; + /** the management scope the export was projected with. `database` omits + * cluster-global roles/memberships; `cluster` includes them. `schema apply` + * defaults to this and rejects a contradicting `--scope`. */ + scope?: "database" | "cluster"; + /** the DIGEST of the baseline subtracted when the directory was exported. + * `schema apply` reconciles the baseline it resolves against this and fails + * loud on a mismatch — so an export whose platform objects were omitted by a + * baseline can't be applied under a profile that no longer subtracts the same + * baseline (which would read those platform objects as source-only drops). */ + baselineDigest?: string; + /** the DEFAULT OWNER the database-scope export kept implicit (its `ALTER … + * OWNER TO` suppressed), so `schema apply` reconstructs the identical view and + * can fail closed when the applier role differs. A role NAME → that role was + * the default. `null` → verbose export (`--default-owner none`; every OWNER TO + * emitted), no default. FIELD ABSENT → a pre-feature or hand-authored dir + * (distinct from `null`): `schema apply` resolves the chain against the target + * and warns. */ + defaultOwner?: string | null; + /** the relative POSIX paths (`/` separators), SORTED, of the `.sql` files this + * export OWNS. `schema export` prunes only files in this list that dropped out + * of the new set; a `.sql` file NOT recorded here is treated as unmanaged (hand + * authored) and refused rather than silently deleted. FIELD ABSENT → a + * pre-feature or hand-authored dir: every existing `.sql` is unmanaged. */ + files?: string[]; +} + +export function writeExportManifest( + dir: string, + manifest: { + redactSecrets: boolean; + profile?: string; + scope?: "database" | "cluster"; + baselineDigest?: string; + defaultOwner?: string | null; + files?: string[]; + }, +): void { + writeFileSync( + join(dir, EXPORT_MANIFEST_FILE), + `${JSON.stringify({ formatVersion: 1, ...manifest }, null, 2)}\n`, + "utf8", + ); +} + +/** + * The recorded manifest, or `undefined` when no readable manifest exists (an + * export produced before this metadata, or a hand-authored directory). Unknown + * or wrong-typed fields are dropped. + */ +export function readExportManifest(dir: string): ExportManifest | undefined { + const path = join(dir, EXPORT_MANIFEST_FILE); + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (err) { + // Only a genuinely absent manifest is a soft fall-back (older export or a + // hand-authored directory). A present-but-unreadable file must fail closed: + // silently ignoring it would drop the recorded profile/scope/redaction mode + // and plan a destructive apply against the target's real platform state. + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new Error( + `cannot read export manifest ${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + let doc: Record; + try { + doc = JSON.parse(raw) as Record; + } catch (err) { + throw new Error( + `malformed export manifest ${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const manifest: ExportManifest = {}; + if (typeof doc["redactSecrets"] === "boolean") { + manifest.redactSecrets = doc["redactSecrets"]; + } + if (typeof doc["profile"] === "string") { + manifest.profile = doc["profile"]; + } + if (doc["scope"] === "database" || doc["scope"] === "cluster") { + manifest.scope = doc["scope"]; + } + if (typeof doc["baselineDigest"] === "string") { + manifest.baselineDigest = doc["baselineDigest"]; + } + // defaultOwner distinguishes three states: a role NAME, explicit `null` + // (verbose export), and ABSENT (pre-feature / hand-authored). `null` is a + // valid recorded value, so it must round-trip — only a wrong-typed value is + // dropped. Use `in` because `=== null` and "absent" are different fields. + if ("defaultOwner" in doc) { + const v = doc["defaultOwner"]; + if (typeof v === "string" || v === null) { + manifest.defaultOwner = v; + } + } + // files: accept only an array whose every element is a string. A non-array or + // a member of the wrong type drops the whole field (treated as absent → the + // pruner refuses to delete any existing `.sql` as if hand-authored). + const files = doc["files"]; + if (Array.isArray(files) && files.every((f) => typeof f === "string")) { + manifest.files = files as string[]; + } + return manifest; +} diff --git a/packages/pg-delta/src/frontends/export-projection.test.ts b/packages/pg-delta/src/frontends/export-projection.test.ts new file mode 100644 index 000000000..84a4e28ff --- /dev/null +++ b/packages/pg-delta/src/frontends/export-projection.test.ts @@ -0,0 +1,51 @@ +/** + * `schema export --profile` must export the MANAGED VIEW, not the raw + * extraction (Codex review): an object in a policy-hidden schema must not be + * written into the declarative files (or it reappears as drift on apply). This + * pins the projection composition the CLI command now performs + * (resolveView → exportSqlFiles); before, the raw fact base was exported. + * Pure — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import { exportSqlFiles } from "./export-sql-files.ts"; +import { resolveView } from "../policy/policy.ts"; +import { supabasePolicy } from "../policy/supabase.ts"; + +const facts: Fact[] = [ + { id: { kind: "schema", name: "app" }, payload: {} }, + { id: { kind: "schema", name: "auth" }, payload: {} }, // system schema + { + id: { kind: "table", schema: "app", name: "widgets" }, + parent: { kind: "schema", name: "app" }, + payload: { persistence: "p" }, + }, + { + id: { kind: "table", schema: "auth", name: "users" }, // platform object + parent: { kind: "schema", name: "auth" }, + payload: { persistence: "p" }, + }, +]; + +describe("schema export projects the managed view", () => { + test("a policy-hidden schema's objects are not exported", () => { + const fb = buildFactBase(facts, []); + const view = resolveView(fb, supabasePolicy); + const dump = exportSqlFiles(view, { layout: "by-object" }) + .map((f) => `${f.name}\n${f.sql}`) + .join("\n"); + expect(dump).toContain("widgets"); // user object survives + expect(dump).not.toContain("auth"); // platform schema/table excluded + }); + + test("without a policy the raw fact base is exported (identity projection)", () => { + const fb = buildFactBase(facts, []); + const dump = exportSqlFiles(resolveView(fb, undefined), { + layout: "by-object", + }) + .map((f) => `${f.name}\n${f.sql}`) + .join("\n"); + expect(dump).toContain("widgets"); + expect(dump).toContain("auth"); + }); +}); diff --git a/packages/pg-delta/src/frontends/export-public-schema.test.ts b/packages/pg-delta/src/frontends/export-public-schema.test.ts new file mode 100644 index 000000000..eeb7df260 --- /dev/null +++ b/packages/pg-delta/src/frontends/export-public-schema.test.ts @@ -0,0 +1,51 @@ +/** + * A customization of the pre-existing `public` schema (a non-default ACL such as + * REVOKE CREATE ON SCHEMA public FROM PUBLIC, or a changed COMMENT) must be + * EXPORTED. The export baseline seeds only `public`'s existence — not its + * acl/comment — so these facts diff against a pristine baseline like every other + * schema and are emitted, instead of being masked by a same-valued baseline + * (PR #307 review: public-schema ACL/comment preservation). Pure — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import { exportSqlFiles } from "./export-sql-files.ts"; + +function exportOf(facts: Fact[]): string { + return exportSqlFiles(buildFactBase(facts, [])) + .map((f) => f.sql) + .join("\n"); +} + +describe("export preserves public-schema customizations", () => { + test("a non-default COMMENT ON SCHEMA public is exported", () => { + const sql = exportOf([ + { id: { kind: "schema", name: "public" }, payload: {} }, + { + id: { kind: "comment", target: { kind: "schema", name: "public" } }, + parent: { kind: "schema", name: "public" }, + payload: { text: "custom note" }, + }, + ]); + expect(sql).toContain("custom note"); + // the schema itself still must NOT be recreated (it always exists). + expect(sql).not.toContain("CREATE SCHEMA"); + }); + + test("a customized public ACL (no CREATE for PUBLIC) is exported", () => { + const sql = exportOf([ + { id: { kind: "schema", name: "public" }, payload: {} }, + { + id: { + kind: "acl", + target: { kind: "schema", name: "public" }, + grantee: "PUBLIC", + }, + parent: { kind: "schema", name: "public" }, + // PUBLIC keeps only USAGE — CREATE has been revoked. + payload: { privileges: ["USAGE"], grantable: [] }, + }, + ]); + expect(sql).toContain(`SCHEMA "public"`); + expect(sql).toContain("REVOKE ALL ON SCHEMA"); + }); +}); diff --git a/packages/pg-delta/src/frontends/export-schema-adp.test.ts b/packages/pg-delta/src/frontends/export-schema-adp.test.ts new file mode 100644 index 000000000..df0bacc8d --- /dev/null +++ b/packages/pg-delta/src/frontends/export-schema-adp.test.ts @@ -0,0 +1,70 @@ +/** + * A schema-scoped ALTER DEFAULT PRIVILEGES must NOT be exported into the atomic + * cluster/roles.sql file (PR #307 review #3500714148). It depends on the schema + * (created in schemas//schema.sql), but `schema apply` disables + * statement reordering whenever an ADP is present, so the raw file-granular + * loader runs roles.sql as one transaction — the ADP fails on the not-yet- + * created schema and rolls back CREATE ROLE with it, deadlocking the reload. + * Routing the schema-scoped ADP into its schema's directory lets the loader's + * defer-and-retry converge. Pure — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type DependencyEdge, type Fact } from "../core/fact.ts"; +import { exportSqlFiles } from "./export-sql-files.ts"; + +const facts: Fact[] = [ + { id: { kind: "role", name: "alice" }, payload: {} }, + { id: { kind: "schema", name: "app" }, payload: {} }, + { + id: { + kind: "defaultPrivilege", + role: "alice", + schema: "app", + objtype: "r", + grantee: "alice", + }, + payload: { privileges: ["SELECT"], grantable: [] }, + }, + // a global (schema-null) ADP stays in the role file (no cross-file dep) + { + id: { + kind: "defaultPrivilege", + role: "alice", + schema: null, + objtype: "f", + grantee: "alice", + }, + payload: { privileges: ["EXECUTE"], grantable: [] }, + }, +]; +const edges: DependencyEdge[] = [ + { + from: { kind: "schema", name: "app" }, + to: { kind: "role", name: "alice" }, + kind: "owner", + }, +]; + +function fileOf(layout: "by-object" | "grouped", needle: string): string { + const f = exportSqlFiles(buildFactBase(facts, edges), { layout }).find( + (file) => file.sql.includes(needle), + ); + if (f === undefined) throw new Error(`no file contains ${needle}`); + return f.name; +} + +describe("schema-scoped ADP export routing", () => { + for (const layout of ["by-object", "grouped"] as const) { + test(`schema-scoped ADP is split out of the role file (${layout})`, () => { + const name = fileOf(layout, "IN SCHEMA"); + expect(name).not.toBe("cluster/roles.sql"); + expect(name.startsWith("schemas/app/")).toBe(true); + }); + + test(`a global ADP stays in the role file (${layout})`, () => { + // the FUNCTIONS (objtype f) ADP has no schema — keep it with the roles + const name = fileOf(layout, "ON FUNCTIONS"); + expect(name).toBe("cluster/roles.sql"); + }); + } +}); diff --git a/packages/pg-delta/src/frontends/export-sql-files.ts b/packages/pg-delta/src/frontends/export-sql-files.ts new file mode 100644 index 000000000..db639e814 --- /dev/null +++ b/packages/pg-delta/src/frontends/export-sql-files.ts @@ -0,0 +1,892 @@ +/** + * Declarative export (stage 9 deliverable 6): render a fact base to SQL + * files via the planner (plan(∅ → fb) — the same renderer as everything + * else) and split the statements across files by a mapping policy. + * + * Two layouts: + * - "by-object" (default): the human layout users know from the old + * engine's exporter — cluster/roles.sql, schemas//tables/.sql, … + * Files within a path are emitted in plan (dependency) order, but the + * loader's lexicographic discovery may need its bounded retry rounds for + * cross-file references. Fidelity is the gate: load(export(fb)) ≡ fb. + * - "ordered": file names carry a zero-padded sequence prefix in plan + * order, so lexicographic discovery IS dependency order and the loader + * converges with zero deferred rounds (the stage-9 zero-round gate). + */ +import { buildFactBase, type FactBase } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { plan, type Action } from "../plan/plan.ts"; +import type { IntentRuleIndex } from "../plan/rules.ts"; +import { extensionMemberReferenceOnly } from "../policy/view.ts"; +import type { SqlFile } from "./load-sql-files.ts"; +import { + formatSqlStatements, + type SqlFormatOptions, +} from "./sql-format/index.ts"; + +/** Group objects by a name pattern into a named directory/file (v1 parity). */ +export interface ExportGroupingPattern { + /** Regex (as a string) tested against the object's name; first match wins. */ + pattern: string; + /** Group name used as the directory (subdirectory mode) or file (single-file). */ + name: string; +} + +/** v1-parity grouping options, honored only by the "grouped" layout. */ +export interface ExportGrouping { + /** How a matched group is organized on disk (default: "subdirectory"). */ + mode?: "single-file" | "subdirectory"; + /** Name-pattern → group rules; first match wins. */ + groupPatterns?: ExportGroupingPattern[]; + /** Schemas collapsed to one file per category (e.g. schemas/partman/tables.sql). */ + flatSchemas?: string[]; + /** File partition children into their parent table's file (default: true). */ + autoGroupPartitions?: boolean; +} + +export interface ExportOptions { + layout?: "by-object" | "ordered" | "grouped"; + /** Grouping rules for the "grouped" layout; ignored by other layouts. */ + grouping?: ExportGrouping; + /** Pretty-print each file's SQL with the formatter (frontends/sql-format). + * Off by default (output is the renderer's raw SQL). Layout-agnostic. + * Advisory/cosmetic — the fidelity gate (load(export) ≡ fb) still holds. */ + format?: SqlFormatOptions; + /** Non-fatal warnings (e.g. an invalid group-pattern regex). */ + onWarning?: (message: string) => void; + /** Schemas/roles the active profile assumes present-but-unmanaged at apply + * time. Forwarded to the internal `plan()` so its action-graph guard does not + * reject a managed-view action that consumes an assumed-but-filtered object + * (e.g. `CREATE EXTENSION … SCHEMA extensions`, `GRANT … TO anon`). Empty for + * the `raw` profile (no policy) — an identity projection (review P1). */ + assumedSchemas?: string[]; + assumedRoles?: string[]; + /** Intent-rule index from the active profile's handlers (e.g. pg_cron under + * `--profile supabase`). Forwarded to the internal `plan()` so an + * `extensionIntent` fact in `fb` (a named cron job) renders its replay SQL + * instead of throwing "no intent rule registered". Omit for profiles with no + * intent handlers. */ + intentRules?: IntentRuleIndex; +} + +/** Assemble a file's SQL from bare (semicolon-less) statements: optionally + * pretty-print them, then re-attach `;` and join. Centralizes the + * format-or-not decision so every layout formats identically. */ +function renderFileSql( + bareStatements: string[], + format: SqlFormatOptions | undefined, +): string { + const statements = format + ? formatSqlStatements(bareStatements, format) + : bareStatements; + return `${statements.map((s) => `${s};`).join("\n\n")}\n`; +} + +/** The subject deciding an action's file: produced fact, else consumed. */ +function subjectOf(action: Action): StableId | undefined { + return action.produces[0] ?? action.consumes[0]; +} + +/** Satellite facts (comment/acl) file with their target. */ +function fileTarget(id: StableId): StableId { + if (id.kind === "comment" || id.kind === "acl") { + return fileTarget((id as { target: StableId }).target); + } + return id; +} + +/** Like {@link fileTarget} but also unwraps securityLabel — used for category + * classification in the grouped layout (kept out of the by-object path). */ +function groupingTarget(id: StableId): StableId { + if ( + id.kind === "comment" || + id.kind === "acl" || + id.kind === "securityLabel" + ) { + return groupingTarget((id as { target: StableId }).target); + } + return id; +} + +/** Semantic file categories, in the fixed emission order the grouped layout + * uses (ported + extended from the v1 exporter). */ +const CATEGORY_ORDER = [ + "cluster", + "schema", + "extensions", + "types", + "domains", + "collations", + "sequences", + "tables", + "indexes", + "foreign_tables", + "views", + "matviews", + "functions", + "procedures", + "aggregates", + "publications", + "subscriptions", + "event_triggers", + "misc", +] as const; +type Category = (typeof CATEGORY_ORDER)[number]; +const CATEGORY_PRIORITY: Record = Object.fromEntries( + CATEGORY_ORDER.map((c, i) => [c, i]), +) as Record; + +/** Stable-id kind → category. Table-scoped satellites and indexes map to where + * their DDL is filed, so a file holds one category. */ +const CATEGORY_OF_KIND: Record = { + role: "cluster", + membership: "cluster", + defaultPrivilege: "cluster", + fdw: "cluster", + server: "cluster", + userMapping: "cluster", + publication: "publications", + subscription: "subscriptions", + eventTrigger: "event_triggers", + extension: "extensions", + schema: "schema", + type: "types", + domain: "domains", + collation: "collations", + sequence: "sequences", + table: "tables", + column: "tables", + default: "tables", + constraint: "tables", + trigger: "tables", + policy: "tables", + rule: "tables", + index: "indexes", + foreignTable: "foreign_tables", + view: "views", + materializedView: "matviews", + function: "functions", + procedure: "procedures", + aggregate: "aggregates", +}; + +function categoryOf(id: StableId): Category { + return CATEGORY_OF_KIND[groupingTarget(id).kind] ?? "misc"; +} + +/** The schema + grouping name of an object, or `undefined` schema for + * cluster-level objects (which the grouped layout never regroups). */ +function schemaAndName(id: StableId): { + schema?: string; + objectName?: string; +} { + const t = groupingTarget(id); + if (t.kind === "schema") { + const name = (t as { name: string }).name; + return { schema: name, objectName: name }; + } + // table-scoped satellites group under their owning table's name + if (TABLE_SCOPED.has(t.kind)) { + const s = t as { schema: string; table: string }; + return { schema: s.schema, objectName: s.table }; + } + if ("schema" in t && "name" in t) { + const s = t as { schema: string; name: string }; + return { schema: s.schema, objectName: s.name }; + } + return {}; +} + +/** If the action's table is a partition child, the parent table's name. */ +function partitionParentName(id: StableId, fb: FactBase): string | undefined { + const t = groupingTarget(id); + let tableId: StableId | undefined; + if (t.kind === "table") { + tableId = t; + } else if (TABLE_SCOPED.has(t.kind)) { + const s = t as { schema: string; table: string }; + tableId = { kind: "table", schema: s.schema, name: s.table }; + } + if (tableId === undefined) return undefined; + const payload = fb.get(tableId)?.payload as + | { partitionBound?: unknown; parentTable?: { name: string } | null } + | undefined; + if ( + payload?.partitionBound != null && + payload.parentTable != null && + typeof payload.parentTable.name === "string" + ) { + return payload.parentTable.name; + } + return undefined; +} + +const VERB_PRIORITY: Record = { create: 0, alter: 1, drop: 2 }; +function scopeRank(id: StableId): number { + switch (id.kind) { + case "comment": + return 1; + case "securityLabel": + return 2; + case "acl": + return 3; + case "defaultPrivilege": + return 4; + case "membership": + return 5; + default: + return 0; + } +} + +const CLUSTER_FILES: Record = { + role: "cluster/roles.sql", + membership: "cluster/roles.sql", + defaultPrivilege: "cluster/roles.sql", + fdw: "cluster/foreign_data_wrappers.sql", + server: "cluster/foreign_data_wrappers.sql", + userMapping: "cluster/foreign_data_wrappers.sql", + publication: "cluster/publications.sql", + subscription: "cluster/subscriptions.sql", + eventTrigger: "cluster/event_triggers.sql", +}; + +const SCHEMA_DIRS: Record = { + type: "types", + domain: "domains", + collation: "collations", + sequence: "sequences", + table: "tables", + view: "views", + materializedView: "materialized_views", + foreignTable: "foreign_tables", + function: "functions", + procedure: "functions", + aggregate: "functions", +}; + +/** Table-scoped satellites write into their table's file. */ +const TABLE_SCOPED = new Set([ + "column", + "default", + "constraint", + "trigger", + "policy", + "rule", +]); + +/** + * Make a database identifier safe as a single path segment: PostgreSQL names + * can contain `/`, `\`, `..`, and other path-significant characters, which + * would otherwise let an object name escape the output directory or collide + * with `.`/`..` (review P2). encodeURIComponent handles separators reversibly; + * the extra rule encodes dot-only segments (`.`, `..`) which it leaves alone. + * Ordinary identifiers (alphanumerics + `_`) pass through unchanged, so the + * common export layout is unaffected. + */ +function seg(name: string): string { + return encodeURIComponent(name).replace(/^\.+$/, (m) => + m.replace(/\./g, "%2E"), + ); +} + +/** Precomputed routing context threaded through {@link pathFor}: the cyclic-FK + * split set, the extension-member map, the index→relation parent map, the + * relation-kind map, and the concurrent-index exception set. Built once per + * export. */ +interface PathContext { + /** Encoded ids of cycle-participating FK constraints (→ `.fk.sql`). */ + readonly cyclicFks: ReadonlySet; + /** Encoded extension-member object id → owning extension name. Satellites + * (acl/comment) targeting a member route to the extension's own file. */ + readonly memberExt: ReadonlyMap; + /** Encoded index id → its owning relation's file dir + name (from the index + * fact's parent link): indexes CO-LOCATE with their table / matview file. */ + readonly indexParent: ReadonlyMap; + /** `schema\u0000name` → file dir for the relation ("views" / + * "materialized_views"); absent = "tables". Routes a TABLE_SCOPED satellite + * (an INSTEAD OF trigger on a view, a rule) to its actual relation's file. */ + readonly relationDir: ReadonlyMap; + /** Encoded ids of indexes whose rendered SQL is CREATE INDEX CONCURRENTLY — + * non-transactional statements must stay ALONE in their file (loader + * contract), so these keep their own `indexes/.sql` path. */ + readonly concurrentIndexes: ReadonlySet; +} + +/** Encoded member object id → owning extension name, from the + * `memberOfExtension` edges extraction records. */ +function extensionMembersByEncoded(fb: FactBase): Map { + const map = new Map(); + for (const edge of fb.edges) { + if (edge.kind !== "memberOfExtension") continue; + if (edge.to.kind !== "extension") continue; + map.set(encodeId(edge.from), (edge.to as { name: string }).name); + } + return map; +} + +/** Encoded index id → owning relation (dir + name), from the index facts' + * parent links (extraction records the table / materialized view as the + * index's parent). */ +function indexParentsByEncoded( + fb: FactBase, +): Map { + const map = new Map(); + for (const fact of fb.facts()) { + if (fact.id.kind !== "index" || fact.parent === undefined) continue; + const parent = fact.parent as { kind: string; name?: string }; + if (typeof parent.name !== "string") continue; + map.set(encodeId(fact.id), { + dir: parent.kind === "materializedView" ? "materialized_views" : "tables", + name: parent.name, + }); + } + return map; +} + +/** `schema\u0000name` → file dir for VIEW / MATERIALIZED VIEW relations, so a + * TABLE_SCOPED satellite naming that relation routes to the right file. */ +function relationDirsByName(fb: FactBase): Map { + const map = new Map(); + for (const fact of fb.facts()) { + if (fact.id.kind !== "view" && fact.id.kind !== "materializedView") { + continue; + } + const id = fact.id as { schema: string; name: string }; + map.set( + nameKey(id.schema, id.name), + fact.id.kind === "view" ? "views" : "materialized_views", + ); + } + return map; +} + +/** Explanatory header prepended to every split `.fk.sql` file. */ +const FK_SPLIT_HEADER = + "-- Foreign keys in a cross-table reference cycle are split out of their\n" + + "-- table's file: each file loads atomically, so keeping them inline would\n" + + "-- deadlock the loader (every file would need a table another pending file\n" + + "-- creates). These statements apply once all referenced tables exist.\n\n"; + +/** Join a schema-qualified relation name into an opaque map key. NUL + * (backslash-u0000) cannot appear in an identifier, so keys are + * collision-free (a space separator could collide: "a b"+"c" vs + * "a"+"b c"). EVERY producer and consumer of these keys goes through + * this helper. */ +function nameKey(schema: string, name: string): string { + return `${schema}\u0000${name}`; +} + +/** The owning table of a table-scoped (or table) id, as an opaque key. */ +function owningTableKey(id: StableId): string | undefined { + if (id.kind === "table") { + const t = id as { schema: string; name: string }; + return nameKey(t.schema, t.name); + } + if (TABLE_SCOPED.has(id.kind)) { + const t = id as unknown as { schema: string; table: string }; + return nameKey(t.schema, t.table); + } + return undefined; +} + +/** + * Encoded ids of FOREIGN KEY constraints that participate in a cross-table + * reference CYCLE (mutual FKs, or a longer loop). ONLY these move to a sibling + * `
.fk.sql`: export files apply atomically, so a reference cycle across + * table files can never load (each file needs a table another un-committed file + * creates), while every ACYCLIC FK resolves through the loader's bounded retry + * and stays inline in its table's file for readability. + * + * Reference edges come from extraction's pg_depend edges (an FK constraint + * `depends` on the referenced table's columns / unique constraint), so no SQL + * is parsed here. Cycle test: Tarjan SCC over the table-reference graph — an FK + * is cyclic iff its owning table and a referenced table share a component. + */ +function cyclicForeignKeys(fb: FactBase): Set { + interface Fk { + encoded: string; + owner: string; + refs: Set; + } + const fks = new Map(); + for (const fact of fb.facts()) { + if (fact.id.kind !== "constraint") continue; + if ((fact.payload as { type?: unknown }).type !== "f") continue; + const owner = owningTableKey(fact.id); + if (owner === undefined) continue; + const encoded = encodeId(fact.id); + fks.set(encoded, { encoded, owner, refs: new Set() }); + } + if (fks.size === 0) return new Set(); + + // table-reference graph: owner → referenced table, per FK edge + const adjacency = new Map>(); + for (const edge of fb.edges) { + const fk = fks.get(encodeId(edge.from)); + if (fk === undefined) continue; + const ref = owningTableKey(edge.to); + if (ref === undefined || ref === fk.owner) continue; + fk.refs.add(ref); + let targets = adjacency.get(fk.owner); + if (targets === undefined) { + targets = new Set(); + adjacency.set(fk.owner, targets); + } + targets.add(ref); + } + + // Tarjan SCC (iterative — no recursion-depth ceiling on large schemas) + const sccOf = new Map(); + { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + let counter = 0; + let sccCount = 0; + const nodes = new Set(adjacency.keys()); + for (const targets of adjacency.values()) { + for (const t of targets) nodes.add(t); + } + interface Frame { + node: string; + neighbors: string[]; + i: number; + } + const visit = (frames: Frame[], node: string): void => { + index.set(node, counter); + low.set(node, counter); + counter++; + stack.push(node); + onStack.add(node); + frames.push({ node, neighbors: [...(adjacency.get(node) ?? [])], i: 0 }); + }; + for (const root of nodes) { + if (index.has(root)) continue; + const frames: Frame[] = []; + visit(frames, root); + while (frames.length > 0) { + const frame = frames[frames.length - 1]!; + if (frame.i < frame.neighbors.length) { + const next = frame.neighbors[frame.i++]!; + if (!index.has(next)) { + visit(frames, next); + } else if (onStack.has(next)) { + low.set( + frame.node, + Math.min(low.get(frame.node)!, index.get(next)!), + ); + } + } else { + frames.pop(); + const parent = frames[frames.length - 1]; + if (parent !== undefined) { + low.set( + parent.node, + Math.min(low.get(parent.node)!, low.get(frame.node)!), + ); + } + if (low.get(frame.node) === index.get(frame.node)) { + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + sccOf.set(member, sccCount); + } while (member !== frame.node); + sccCount++; + } + } + } + } + } + + const cyclic = new Set(); + for (const fk of fks.values()) { + const ownerScc = sccOf.get(fk.owner); + if (ownerScc === undefined) continue; + for (const ref of fk.refs) { + if (sccOf.get(ref) === ownerScc) { + cyclic.add(fk.encoded); + break; + } + } + } + return cyclic; +} + +function pathFor(id: StableId, ctx: PathContext): string { + const target = fileTarget(id); + const kind = target.kind; + // A satellite (acl/comment, unwrapped by fileTarget) whose target is an + // EXTENSION MEMBER files into the owning extension's file, next to its + // CREATE EXTENSION. Member objects themselves never yield actions + // (reference-only), so only satellites reach this branch. Keeps a real DB + // with e.g. pgTAP from sprouting hundreds of REVOKE-only function files — + // the state stays fully managed, only its file placement changes. + const memberExtension = ctx.memberExt.get(encodeId(target)); + if (memberExtension !== undefined) { + return `cluster/extensions/${seg(memberExtension)}.sql`; + } + // A schema-scoped ALTER DEFAULT PRIVILEGES depends on its schema, so it must + // NOT share the atomic cluster/roles.sql file with CREATE ROLE: with reorder + // disabled (any ADP present) the raw loader would roll the role back when the + // ADP fails on the not-yet-created schema. File it under the schema instead, + // where the loader's defer-and-retry converges (review P2). A global ADP + // (schema null) has no such dependency and stays with the roles. + if (kind === "defaultPrivilege") { + const schema = (target as { schema: string | null }).schema; + if (schema !== null) { + return `schemas/${seg(schema)}/default_privileges.sql`; + } + } + const clusterFile = CLUSTER_FILES[kind]; + if (clusterFile !== undefined) return clusterFile; + if (kind === "extension") { + return `cluster/extensions/${seg((target as { name: string }).name)}.sql`; + } + if (kind === "schema") { + return `schemas/${seg((target as { name: string }).name)}/schema.sql`; + } + if (TABLE_SCOPED.has(kind)) { + const t = target as { schema: string; table: string }; + // A foreign key participating in a cross-table reference CYCLE cannot stay + // in its table's file: the loader applies each file atomically, so two + // mutually-referencing tables would each need a table the other + // un-committed file creates, and neither could ever commit. Those FKs — + // and their comment/acl satellites, already unwrapped by `fileTarget` — + // move to a sibling `
.fk.sql` loaded once all referenced tables + // exist (pg_dump's post-data precedent). ACYCLIC FKs (the common case) + // stay INLINE for readability — the loader's bounded retry orders their + // files. `cyclicFks` is precomputed by {@link cyclicForeignKeys}. + if (target.kind === "constraint" && ctx.cyclicFks.has(encodeId(target))) { + return `schemas/${seg(t.schema)}/tables/${seg(t.table)}.fk.sql`; + } + // The `table` field of a TABLE_SCOPED id names a RELATION, not necessarily + // a table: an INSTEAD OF trigger / rule / comment can target a view or a + // materialized view — file those with their actual relation. + const relationDir = + ctx.relationDir.get(nameKey(t.schema, t.table)) ?? "tables"; + return `schemas/${seg(t.schema)}/${relationDir}/${seg(t.table)}.sql`; + } + if (kind === "index") { + const t = target as { schema: string; name: string }; + // Indexes CO-LOCATE with their owning relation's file (old-engine + // behavior, restored for readability) — the owning table / matview comes + // from the index fact's parent link. EXCEPT a CREATE INDEX CONCURRENTLY + // (only rendered under the opt-in `concurrentIndexes` param): it is + // non-transactional and must stay ALONE in its file (loader contract). + const parent = ctx.indexParent.get(encodeId(target)); + if (parent !== undefined && !ctx.concurrentIndexes.has(encodeId(target))) { + return `schemas/${seg(t.schema)}/${parent.dir}/${seg(parent.name)}.sql`; + } + return `schemas/${seg(t.schema)}/indexes/${seg(t.name)}.sql`; + } + const dir = SCHEMA_DIRS[kind]; + if (dir !== undefined) { + const t = target as { schema: string; name: string }; + return `schemas/${seg(t.schema)}/${dir}/${seg(t.name)}.sql`; + } + return "cluster/misc.sql"; +} + +export function exportSqlFiles( + fb: FactBase, + options: ExportOptions = {}, +): SqlFile[] { + const layout = options.layout ?? "by-object"; + // Render against a PRISTINE baseline, not absolute emptiness, so the export + // reflects what a real target already has: + // - schema "public" always exists, so seed its EXISTENCE (a CREATE SCHEMA + // public could never replay). Its acl/comment are deliberately NOT seeded: + // they diff like every other schema's, so a customized public (REVOKE + // CREATE FROM PUBLIC, a changed COMMENT) is exported rather than masked by + // a same-valued baseline (review: public-schema ACL/comment preservation). + // - reference-only facts are assumed-present platform objects (e.g. + // auth.users under --profile supabase). diff/plan don't consult + // `referenceOnly` — the DB-to-DB path relies on both sides carrying them — + // but the from-pristine export has no such symmetry, so seed them here. + // Then a managed child kept in the view (a user trigger on auth.users) + // resolves its requirement against the baseline instead of throwing + // "missing requirement", and the assumed parent is not itself recreated + // (review #3501088189). + // + // EXCEPT extension members (net.http_get, partman.part_config): they must + // NOT be seeded. A member's parent — the install schema — can be a MANAGED + // fact this export recreates (e.g. `partman`); seeding the member without + // its ancestor throws "missing parent" in buildFactBase (Codex #3537607461), + // and seeding the ancestor too would suppress its own CREATE SCHEMA + // (buildFactBase seeds existence → diff skips it), breaking `CREATE EXTENSION + // … WITH SCHEMA` reload. Members need no seeding regardless: `CREATE EXTENSION` + // materializes them and the planner's requirement guard satisfies any + // consumer via `memberExtensionPresent` (a member satellite — a user + // GRANT/COMMENT on a member — still exports, its requirement met the same + // way). Assumed-schema facts stay seeded: that category is parent-closed + // (the schema fact is itself reference-only), so no dangling parent arises. + const members = extensionMemberReferenceOnly(fb); + const pristine = fb.facts().filter((fact) => { + const id = fact.id; + if (id.kind === "schema" && (id as { name: string }).name === "public") + return true; + const key = encodeId(id); + return fb.referenceOnly.has(key) && !members.has(key); + }); + const baseline = buildFactBase(pristine, []); + // FKs inside a cross-table reference cycle stay as ALTERs (routed to a + // sibling `.fk.sql`); everything else folds inline. Computed BEFORE the plan + // so the fold pass can exclude them. + const cyclicFks = cyclicForeignKeys(fb); + // `fb` is the already-resolved managed view, so we do NOT re-run policy + // filtering / serialize rules here; we only forward the assumed schema/role + // sets so the requirement guard exempts actions consuming assumed-but-filtered + // objects (review P1). `foldConstraints` renders validated table constraints + // INLINE in their CREATE TABLE (export files are consumed by the retry / + // reorder loader, where that is safe — see PlanOptions.foldConstraints). + const rendered = plan(baseline, fb, { + foldConstraints: { exclude: cyclicFks }, + ...(options.assumedSchemas !== undefined + ? { assumedSchemas: options.assumedSchemas } + : {}), + ...(options.assumedRoles !== undefined + ? { assumedRoles: options.assumedRoles } + : {}), + ...(options.intentRules !== undefined + ? { intentRules: options.intentRules } + : {}), + }); + + // Routing context, computed once per export: the cyclic-FK split set (empty + // for ~all schemas), the extension-member map (satellites on members file + // with their extension), the index→relation parent map (co-location), the + // relation-kind map (satellites on views), and the concurrent-index + // exception set (must stay alone in their file). + const pathContext: PathContext = { + cyclicFks, + memberExt: extensionMembersByEncoded(fb), + indexParent: indexParentsByEncoded(fb), + relationDir: relationDirsByName(fb), + concurrentIndexes: new Set( + rendered.actions + .filter( + (a) => + a.produces[0]?.kind === "index" && /\bCONCURRENTLY\b/i.test(a.sql), + ) + .map((a) => encodeId(a.produces[0]!)), + ), + }; + + if (layout === "grouped") { + return exportGrouped(rendered.actions, fb, options, pathContext); + } + + // group statements by file, preserving plan order within AND across + // groups (first-statement order decides file order). Statements are stored + // BARE (no trailing `;`) so the optional formatter sees clean input. + const files = new Map(); + rendered.actions.forEach((action, position) => { + const subject = subjectOf(action); + const path = + subject === undefined + ? "cluster/misc.sql" + : pathFor(subject, pathContext); + const entry = files.get(path) ?? { firstAt: position, statements: [] }; + entry.statements.push(action.sql); + files.set(path, entry); + }); + + if (layout === "ordered") { + // statement-true splitting: runs of CONSECUTIVE same-object actions + // become one numbered file, so lexicographic discovery IS plan order + // and the loader converges in a single pass — an object interleaved + // with its dependencies simply spans several numbered files + const runs: { path: string; statements: string[] }[] = []; + rendered.actions.forEach((action) => { + const subject = subjectOf(action); + const path = + subject === undefined + ? "cluster/misc.sql" + : pathFor(subject, pathContext); + const last = runs[runs.length - 1]; + if (last !== undefined && last.path === path) { + last.statements.push(action.sql); + } else { + runs.push({ path, statements: [action.sql] }); + } + }); + return runs.map((run, index) => ({ + name: `${String(index).padStart(4, "0")}_${run.path.replaceAll("/", "_")}`, + sql: renderFileSql(run.statements, options.format), + })); + } + + const ordered = [...files.entries()].sort( + (a, b) => a[1].firstAt - b[1].firstAt, + ); + return ordered.map(([path, entry]) => ({ + name: path, + sql: + (path.endsWith(".fk.sql") ? FK_SPLIT_HEADER : "") + + renderFileSql(entry.statements, options.format), + })); +} + +interface CompiledPattern { + regex: RegExp; + name: string; +} + +function compilePatterns( + patterns: ExportGroupingPattern[], + onWarning?: (message: string) => void, +): CompiledPattern[] { + const compiled: CompiledPattern[] = []; + for (const p of patterns) { + try { + compiled.push({ regex: new RegExp(p.pattern), name: p.name }); + } catch (error) { + onWarning?.( + `ignoring invalid group-pattern /${p.pattern}/: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return compiled; +} + +/** + * The "grouped" layout (v1 parity): order files by semantic category rather than + * plan order, sort statements within a file for readability, and apply opt-in + * grouping — flat schemas, partition-with-parent, and name patterns. Fidelity is + * still the gate (the loader's retry rounds absorb the non-dependency order). + */ +function exportGrouped( + actions: Action[], + fb: FactBase, + options: ExportOptions, + pathContext: PathContext, +): SqlFile[] { + const grouping = options.grouping ?? {}; + const mode = grouping.mode ?? "subdirectory"; + const autoGroupPartitions = grouping.autoGroupPartitions !== false; + const flatSet = new Set(grouping.flatSchemas ?? []); + const patterns = compilePatterns( + grouping.groupPatterns ?? [], + options.onWarning, + ); + + // Category that FOLLOWS the co-location routing: an index takes its owning + // relation's category (tables / matviews); a TABLE_SCOPED satellite on a + // view / matview takes that relation's category — so flat-schema collapse + // keeps co-located statements in the same per-category file as their + // relation instead of splitting them back out. + const categoryFor = (id: StableId): Category => { + const t = groupingTarget(id); + if (t.kind === "index") { + const encoded = encodeId(t); + const parent = pathContext.indexParent.get(encoded); + if (parent !== undefined && !pathContext.concurrentIndexes.has(encoded)) { + return parent.dir === "materialized_views" ? "matviews" : "tables"; + } + } + if (TABLE_SCOPED.has(t.kind)) { + const s = t as unknown as { schema: string; table: string }; + const dir = pathContext.relationDir.get(nameKey(s.schema, s.table)); + if (dir === "views") return "views"; + if (dir === "materialized_views") return "matviews"; + } + return categoryOf(id); + }; + + const groupedPath = (id: StableId): string => { + const base = pathFor(id, pathContext); + // Cycle-participating FKs keep their sibling `
.fk.sql` path in EVERY + // grouping mode: the flat-schema / name-pattern regrouping below would + // otherwise fold them back into an atomic per-schema file, re-introducing + // the mutual-FK load deadlock the split exists to prevent (two flat schemas + // referencing each other). pathFor routes only cyclic FKs to `.fk.sql`, so + // the suffix uniquely identifies them. + if (base.endsWith(".fk.sql")) return base; + // Satellites routed to an extension's file stay there: their TARGET has a + // schema (e.g. a pgcrypto function in `public`), so the flat/pattern + // regrouping below would otherwise pull them back into `schemas//…`. + if (base.startsWith("cluster/extensions/")) return base; + // a CONCURRENTLY index (or one with no resolvable parent) keeps its own + // indexes/.sql file — flat regrouping would fold the + // non-transactional statement into an atomic multi-statement file. + if (base.includes("/indexes/")) return base; + const { schema, objectName } = schemaAndName(id); + // cluster-level objects (no schema) are never regrouped + if (schema === undefined) return base; + + const category = categoryFor(id); + + // flat schema: collapse to one file per category (schema.sql stays put) + if (flatSet.has(schema)) { + return category === "schema" + ? base + : `schemas/${seg(schema)}/${category}.sql`; + } + + // partition child → its parent table's file (co-locate with the parent) + if (autoGroupPartitions) { + const parent = partitionParentName(id, fb); + if (parent !== undefined) { + return `schemas/${seg(schema)}/tables/${seg(parent)}.sql`; + } + } + + // name patterns: first match wins + if (objectName !== undefined) { + for (const p of patterns) { + if (p.regex.test(objectName)) { + return mode === "single-file" + ? `schemas/${seg(schema)}/${category}/${seg(p.name)}.sql` + : `schemas/${seg(schema)}/${seg(p.name)}/${category}.sql`; + } + } + } + return base; + }; + + interface GroupedFile { + category: Category; + items: { sql: string; verbRank: number; scopeRank: number; at: number }[]; + } + const files = new Map(); + actions.forEach((action, at) => { + const subject = subjectOf(action); + const path = + subject === undefined ? "cluster/misc.sql" : groupedPath(subject); + const category = subject === undefined ? "misc" : categoryFor(subject); + const entry = files.get(path) ?? { category, items: [] }; + entry.items.push({ + sql: action.sql, + verbRank: VERB_PRIORITY[action.verb] ?? 99, + scopeRank: subject === undefined ? 0 : scopeRank(subject), + at, + }); + files.set(path, entry); + }); + + // file order: category priority, then path (deterministic, not plan order) + const orderedPaths = [...files.entries()].sort((a, b) => { + const c = + CATEGORY_PRIORITY[a[1].category] - CATEGORY_PRIORITY[b[1].category]; + return c !== 0 ? c : a[0].localeCompare(b[0]); + }); + + return orderedPaths.map(([path, entry]) => { + // within-file order: create→alter, then object→comment→…, stable by position + const statements = [...entry.items] + .sort( + (a, b) => + a.verbRank - b.verbRank || a.scopeRank - b.scopeRank || a.at - b.at, + ) + .map((item) => item.sql); + return { + name: path, + sql: + (path.endsWith(".fk.sql") ? FK_SPLIT_HEADER : "") + + renderFileSql(statements, options.format), + }; + }); +} diff --git a/packages/pg-delta/src/frontends/index.ts b/packages/pg-delta/src/frontends/index.ts new file mode 100644 index 000000000..3af63af8d --- /dev/null +++ b/packages/pg-delta/src/frontends/index.ts @@ -0,0 +1,57 @@ +/** + * Frontends barrel: public frontend modules for schema export / plan / render / + * shadow provisioning, plus the lower-level SQL load/export helpers. + */ +export { + loadSqlFiles, + ShadowLoadError, + type SqlFile, + type LoadResult, +} from "./load-sql-files.ts"; + +export { exportSqlFiles, type ExportOptions } from "./export-sql-files.ts"; + +export { saveSnapshot, loadSnapshot } from "./snapshot-file.ts"; + +export { + EXPORT_MANIFEST_FILE, + readExportManifest, + writeExportManifest, + type ExportManifest, +} from "./export-manifest.ts"; + +export { + buildSchemaExport, + type BuildSchemaExportOptions, + type SchemaExportResult, + type ManagementScope, +} from "./schema-export.ts"; + +export { + planSchemaFiles, + prepareSchemaFiles, + reconcileSchemaManifest, + SchemaFrontendError, + type PlanSchemaFilesOptions, + type PlanSchemaFilesResult, + type PreparedSchemaFiles, + type PrepareSchemaFilesOptions, + type ReconcileSchemaManifestFlags, + type ReconciledSchemaOptions, +} from "./schema-plan.ts"; + +export { + renderPlanFiles, + type RenderPlanFilesOptions, + type RenderPlanFilesResult, + type RenderedPlanFile, +} from "./render-plan-files.ts"; + +export { + provisionCoLocatedShadow, + ShadowProvisionError, + isShadowProvisionError, + withDatabaseName, + type CoLocatedShadow, + type ProvisionCoLocatedShadowOptions, +} from "./shadow.ts"; diff --git a/packages/pg-delta/src/frontends/load-sql-files.test.ts b/packages/pg-delta/src/frontends/load-sql-files.test.ts new file mode 100644 index 000000000..4fef9ebb7 --- /dev/null +++ b/packages/pg-delta/src/frontends/load-sql-files.test.ts @@ -0,0 +1,266 @@ +/** + * Unit tests for the SQL-file transaction-control scanner (review finding 6). + * + * The loader wraps each file in an explicit BEGIN/COMMIT for atomic retry. A + * file containing its OWN transaction-control statement (COMMIT, BEGIN, …) + * would break that guarantee — committing partial DDL before a later statement + * fails. `findTransactionControl` rejects such files, but must NOT false-fire + * on the same keywords appearing in comments, string literals, dollar-quoted + * function bodies, or PG14+ `BEGIN ATOMIC` bodies. + * + * No Docker required (pure string scan). + */ +import { describe, expect, test } from "bun:test"; +import { + findClusterDdlStatements, + findDefaultPrivilegeStatements, + findSessionSettingStatements, + findTransactionControl, + stripClusterDdl, +} from "./load-sql-files.ts"; + +describe("cluster-DDL scanner (database scope guard)", () => { + test("detects role lifecycle, membership, and role metadata", () => { + expect(findClusterDdlStatements(`CREATE ROLE app NOLOGIN;`)).toEqual([ + "CREATE ROLE", + ]); + expect( + findClusterDdlStatements(`ALTER ROLE app SET search_path=x;`), + ).toEqual(["ALTER ROLE"]); + expect(findClusterDdlStatements(`DROP USER app;`)).toEqual(["DROP ROLE"]); + expect(findClusterDdlStatements(`GRANT app TO reader;`)).toEqual([ + "GRANT (role membership)", + ]); + expect(findClusterDdlStatements(`COMMENT ON ROLE app IS 'x';`)).toEqual([ + "COMMENT ON ROLE", + ]); + }); + + test("does NOT flag USER MAPPING statements (database-local FDW objects)", () => { + // `CREATE|ALTER|DROP USER MAPPING` is a database-local FDW object emitted by + // pg-delta's own foreign-data exports; the role-lifecycle rules must not + // misclassify it as cluster-global role DDL (or database-scope apply would + // reject / --skip-cluster-ddl would strip user mappings from our exports). + expect( + findClusterDdlStatements( + `CREATE USER MAPPING FOR postgres SERVER s OPTIONS (user 'u');`, + ), + ).toEqual([]); + expect( + findClusterDdlStatements( + `ALTER USER MAPPING FOR postgres SERVER s OPTIONS (SET user 'x');`, + ), + ).toEqual([]); + expect( + findClusterDdlStatements(`DROP USER MAPPING IF EXISTS FOR u SERVER s;`), + ).toEqual([]); + // stripClusterDdl keeps them intact (not skipped as cluster DDL) + const { kept, skipped } = stripClusterDdl( + `CREATE USER MAPPING FOR postgres SERVER s OPTIONS (user 'u');`, + ); + expect(skipped).toEqual([]); + expect(kept).toContain("USER MAPPING"); + // non-regression: a genuine role (CREATE USER) is still detected + expect(findClusterDdlStatements(`CREATE USER app LOGIN;`)).toEqual([ + "CREATE ROLE", + ]); + }); + + test("does NOT flag database-local privilege grants (they have ON)", () => { + expect(findClusterDdlStatements(`GRANT SELECT ON t TO reader;`)).toEqual( + [], + ); + expect( + findClusterDdlStatements(`REVOKE ALL ON SCHEMA app FROM PUBLIC;`), + ).toEqual([]); + expect(findClusterDdlStatements(`CREATE TABLE t (id int);`)).toEqual([]); + }); + + test("ignores keywords inside comments/strings", () => { + expect( + findClusterDdlStatements(`-- CREATE ROLE app\nCREATE TABLE t (id int);`), + ).toEqual([]); + }); + + test("stripClusterDdl removes role DDL, keeps the rest (block-aware)", () => { + const sql = `CREATE ROLE app NOLOGIN; +CREATE SCHEMA s; +CREATE FUNCTION s.f() RETURNS int LANGUAGE sql AS $$ SELECT 1; $$; +GRANT app TO reader;`; + const { kept, skipped } = stripClusterDdl(sql); + expect(skipped).toEqual(["CREATE ROLE app NOLOGIN", "GRANT app TO reader"]); + expect(kept).toContain("CREATE SCHEMA s"); + expect(kept).toContain("SELECT 1"); // function body not mis-split + expect(kept).not.toContain("CREATE ROLE"); + expect(kept).not.toMatch(/GRANT app TO/); + }); +}); + +describe("findTransactionControl — rejects top-level transaction control", () => { + test("a bare COMMIT between statements is detected", () => { + const found = findTransactionControl( + `CREATE TABLE t (id int); COMMIT; CREATE TABLE u (id int);`, + ); + expect(found.join(" ")).toContain("COMMIT"); + }); + + test("BEGIN / ROLLBACK / SAVEPOINT / RELEASE are detected", () => { + expect(findTransactionControl(`BEGIN;`).join(" ")).toContain("BEGIN"); + expect(findTransactionControl(`ROLLBACK;`).join(" ")).toContain("ROLLBACK"); + expect(findTransactionControl(`SAVEPOINT sp;`).join(" ")).toContain( + "SAVEPOINT", + ); + expect(findTransactionControl(`RELEASE SAVEPOINT sp;`).join(" ")).toContain( + "RELEASE", + ); + expect(findTransactionControl(`START TRANSACTION;`).join(" ")).toContain( + "START TRANSACTION", + ); + expect( + findTransactionControl(`PREPARE TRANSACTION 'gid';`).join(" "), + ).toContain("PREPARE TRANSACTION"); + }); +}); + +describe("findTransactionControl — no false positives", () => { + test("clean DDL is accepted", () => { + expect( + findTransactionControl(`CREATE SCHEMA s; CREATE TABLE s.t (id int);`), + ).toEqual([]); + }); + + test("the keyword inside a single-quoted literal is ignored", () => { + expect( + findTransactionControl( + `CREATE FUNCTION f() RETURNS text LANGUAGE sql AS 'SELECT ''COMMIT''';`, + ), + ).toEqual([]); + }); + + test("the keyword inside a line comment is ignored", () => { + expect( + findTransactionControl(`-- COMMIT later\nCREATE TABLE t (id int);`), + ).toEqual([]); + }); + + test("transaction control inside a dollar-quoted body is ignored", () => { + expect( + findTransactionControl( + `CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $$ BEGIN COMMIT; END; $$;`, + ), + ).toEqual([]); + }); + + test("a PG14+ BEGIN ATOMIC function body is accepted", () => { + expect( + findTransactionControl( + `CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; END;`, + ), + ).toEqual([]); + }); +}); + +describe("findSessionSettingStatements — detects search_path / role barriers", () => { + test("SET search_path is detected (with SESSION / LOCAL variants)", () => { + expect(findSessionSettingStatements(`SET search_path TO app;`)).toContain( + "SET search_path", + ); + expect( + findSessionSettingStatements(`SET SESSION search_path TO app, public;`), + ).toContain("SET search_path"); + expect( + findSessionSettingStatements(`SET LOCAL search_path = app;`), + ).toContain("SET search_path"); + }); + + test("SET SCHEMA (a search_path alias) is detected", () => { + expect( + findSessionSettingStatements(`SET SCHEMA 'app';`).length, + ).toBeGreaterThan(0); + expect( + findSessionSettingStatements(`SET LOCAL SCHEMA 'app';`).length, + ).toBeGreaterThan(0); + }); + + test("SET ROLE / SET SESSION AUTHORIZATION are detected", () => { + expect(findSessionSettingStatements(`SET ROLE app_owner;`)).toContain( + "SET ROLE", + ); + expect( + findSessionSettingStatements(`SET SESSION AUTHORIZATION app_owner;`), + ).toContain("SET SESSION AUTHORIZATION"); + }); + + test("RESET of role / search_path / ALL is detected", () => { + expect(findSessionSettingStatements(`RESET ROLE;`).length).toBeGreaterThan( + 0, + ); + expect( + findSessionSettingStatements(`RESET search_path;`).length, + ).toBeGreaterThan(0); + expect(findSessionSettingStatements(`RESET ALL;`).length).toBeGreaterThan( + 0, + ); + }); + + test("statements mixed with other DDL in one file are detected", () => { + expect( + findSessionSettingStatements( + `CREATE SCHEMA app; SET search_path TO app; CREATE TABLE t (id int);`, + ), + ).toContain("SET search_path"); + }); + + test("no false positives on unrelated SET or on quoted/commented keywords", () => { + // an unrelated GUC does not change object resolution / ownership + expect( + findSessionSettingStatements(`SET statement_timeout = '5s';`), + ).toEqual([]); + // keyword in a literal or comment is ignored + expect( + findSessionSettingStatements( + `CREATE FUNCTION f() RETURNS text LANGUAGE sql AS 'SELECT ''SET search_path''';`, + ), + ).toEqual([]); + expect( + findSessionSettingStatements(`-- SET ROLE app\nCREATE TABLE t (id int);`), + ).toEqual([]); + }); +}); + +describe("findDefaultPrivilegeStatements — reorder barrier", () => { + test("ALTER DEFAULT PRIVILEGES is detected", () => { + expect( + findDefaultPrivilegeStatements( + `ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO anon;`, + ).length, + ).toBeGreaterThan(0); + expect( + findDefaultPrivilegeStatements( + `ALTER DEFAULT PRIVILEGES FOR ROLE alice GRANT EXECUTE ON FUNCTIONS TO PUBLIC;`, + ).length, + ).toBeGreaterThan(0); + }); + + test("mixed with other DDL in one file is detected", () => { + expect( + findDefaultPrivilegeStatements( + `CREATE SCHEMA app; ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO anon; CREATE TABLE app.t (id int);`, + ).length, + ).toBeGreaterThan(0); + }); + + test("no false positives on plain ALTER / GRANT or quoted keywords", () => { + expect( + findDefaultPrivilegeStatements(`ALTER TABLE t ADD COLUMN c int;`), + ).toEqual([]); + expect( + findDefaultPrivilegeStatements(`GRANT SELECT ON t TO anon;`), + ).toEqual([]); + expect( + findDefaultPrivilegeStatements( + `CREATE FUNCTION f() RETURNS text LANGUAGE sql AS 'SELECT ''ALTER DEFAULT PRIVILEGES''';`, + ), + ).toEqual([]); + }); +}); diff --git a/packages/pg-delta/src/frontends/load-sql-files.ts b/packages/pg-delta/src/frontends/load-sql-files.ts new file mode 100644 index 000000000..108c78d83 --- /dev/null +++ b/packages/pg-delta/src/frontends/load-sql-files.ts @@ -0,0 +1,1195 @@ +/** + * Stage 7: the shadow-DB frontend — SQL files → fact base + * (target-architecture §3.2). Parser-free by design: + * - ordering: bounded retry rounds at FILE granularity against the shadow + * (fail-safe — errors surface before anything is extracted) + * - body validation: routines re-validated with checks ON after loading + * - shared-object isolation: pg_roles + pg_auth_members snapshot before/after; + * leakage fails in "databaseScratch" mode (skipped in "isolatedCluster" mode) + * - DML rejection: any user table containing rows fails, by observation + * + * ## Loader modes + * + * ### "databaseScratch" (default) + * The shadow database lives on a shared PostgreSQL cluster. Cluster-level + * objects (roles, role memberships) are visible to every other database on + * the same cluster, so any file that creates roles or modifies memberships + * would pollute the shared catalog — this is called a "leak". The loader + * snapshots pg_roles and pg_auth_members before loading and after; if the + * sets differ, it throws a ShadowLoadError. Use this mode for typical CI / + * tooling usage where one cluster hosts many test databases. + * + * ### "isolatedCluster" + * The shadow database has its own dedicated PostgreSQL cluster (e.g. from + * isolatedClusterPair()). Because no other database shares that cluster, + * role/membership side-effects are confined and harmless. The shared-object + * snapshot check is SKIPPED entirely; files that CREATE ROLE or GRANT role + * memberships will load successfully. Use this mode when your SQL files + * intentionally manage cluster-level state. + */ +import type { Pool, PoolClient, QueryResult } from "pg"; +import type { Diagnostic } from "../core/diagnostic.ts"; +import { qid } from "../plan/render.ts"; +import { buildFactBase, type FactBase } from "../core/fact.ts"; +import { + extract, + type ExtractOptions, + type ExtractResult, +} from "../extract/extract.ts"; +import { notExtensionMember, USER_SCHEMA_FILTER } from "../extract/scope.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { splitSqlStatements } from "./sql-format/format-utils.ts"; + +/** SQLSTATE 25001 ("active_sql_transaction") — raised when a statement that + * cannot run inside a transaction block (CREATE INDEX CONCURRENTLY, VACUUM, …) + * is attempted within one. Detection by effect, not by parsing (P1). */ +function isNonTransactional(error: unknown): boolean { + const code = (error as { code?: unknown }).code; + return ( + code === "25001" || + (error instanceof Error && + /cannot run inside a transaction block/i.test(error.message)) + ); +} + +/** + * The only statement the raw 25001 fallback (below) is allowed to run. The raw + * retry executes OUTSIDE the per-file transaction that otherwise confines the + * load to the throwaway shadow database, so on a co-located shadow an unlisted + * statement would escape the sandbox and hit the target's live cluster. CREATE + * INDEX CONCURRENTLY is the one non-transactional statement a declarative schema + * legitimately contains; every other 25001-raiser (VACUUM, REINDEX, CREATE + * DATABASE / TABLESPACE, ALTER SYSTEM, CREATE SUBSCRIPTION opening a live + * replication connection, …) is refused. Match by effect (Postgres already + * signalled 25001) then by masked skeleton, never by parsing. + */ +const RAW_FALLBACK_ALLOWLIST: RegExp[] = [ + /^\s*create\s+(unique\s+)?index\s+concurrently\b/i, +]; + +/** + * Apply one file's SQL inside an EXPLICIT transaction (hardening Item 6 / + * review #5), so a mid-file failure leaves NO partial state and the file can be + * cleanly retried in a later round — instead of relying on PostgreSQL's + * implicit multi-statement-query transaction. This guarantee holds because + * `loadSqlFiles` first rejects any file that manages its own transaction + * (findTransactionControl), so the file cannot COMMIT partway through. A + * statement that cannot run in a transaction block (e.g. CREATE INDEX + * CONCURRENTLY) is re-run RAW on the throwaway shadow, detected by effect + * (SQLSTATE 25001); its real error, if any, still surfaces to the caller. + */ +async function applyFile(client: PoolClient, sql: string): Promise { + try { + await client.query("BEGIN"); + await client.query(sql); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + if (isNonTransactional(error)) { + // a statement that cannot run in a transaction block (CREATE INDEX + // CONCURRENTLY, …) must be the file's ONLY statement: a raw whole-file + // retry of a multi-statement file applies the rest non-atomically and can + // leave the shadow partially loaded on a later failure (review P2). Reuse + // the literal/comment/dollar-quote mask so `;` inside bodies isn't counted. + const masked = maskLiteralsAndComments(sql); + const statementCount = masked + .split(";") + .filter((s) => s.trim() !== "").length; + if (statementCount > 1) { + throw new ShadowLoadError( + `a non-transactional statement (e.g. CREATE INDEX CONCURRENTLY) must be the only statement in its file, but this file has ${statementCount} statements — move the non-transactional statement into its own file`, + [ + { + code: "mixed_nontransactional_file", + severity: "error", + message: `file mixes a non-transactional statement with ${statementCount - 1} other statement(s)`, + }, + ], + ); + } + // The raw retry runs OUTSIDE the per-file transaction, bypassing the + // sandbox that confines the load to the throwaway shadow. Only CREATE + // INDEX CONCURRENTLY may take that path; anything else (VACUUM, CREATE + // DATABASE / TABLESPACE, ALTER SYSTEM, …) could mutate the target's live + // cluster, so refuse it instead of executing it unsandboxed. + if (!RAW_FALLBACK_ALLOWLIST.some((re) => re.test(masked.trim()))) { + throw new ShadowLoadError( + "a non-transactional statement other than CREATE INDEX CONCURRENTLY cannot be loaded: the raw retry runs outside the shadow's transactional sandbox and could touch the target's live cluster", + [ + { + code: "unsupported_non_transactional", + severity: "error", + message: `refused non-transactional statement: ${sql.trim().slice(0, 80)}`, + }, + ], + ); + } + await client.query(sql); + return; + } + throw error; + } +} + +/** + * Enrich a failed file's error with the offending statement's location. A file + * is applied as ONE multi-statement query, so node-postgres sets `position` (a + * 1-based character offset into the file's SQL) on the DatabaseError. Turn that + * into an "at line N: " suffix so a stuck / non-converged load reports + * WHICH statement failed inside a multi-statement file, not just the file name + + * bare message. The position comes straight from PostgreSQL — no SQL parsing. + */ +function describeFileFailure(sql: string, error: unknown): string { + const base = error instanceof Error ? error.message : String(error); + const raw = (error as { position?: unknown } | null)?.position; + const pos = + typeof raw === "string" + ? Number.parseInt(raw, 10) + : typeof raw === "number" + ? raw + : Number.NaN; + if (!Number.isFinite(pos) || pos < 1 || pos > sql.length) return base; + const before = sql.slice(0, pos - 1); + const line = before.split("\n").length; + const lineStart = before.lastIndexOf("\n") + 1; + const nl = sql.indexOf("\n", pos - 1); + const lineText = sql.slice(lineStart, nl === -1 ? undefined : nl).trim(); + const excerpt = lineText.length > 80 ? `${lineText.slice(0, 80)}…` : lineText; + return `${base} — at line ${line}: ${excerpt}`; +} + +/** + * Blank out comments and string/identifier/dollar-quoted literals, replacing + * their contents (and any `;` inside them) with spaces so the remaining "code + * skeleton" can be scanned for statement-level keywords without a SQL grammar. + * This is literal masking, not dependency parsing — it does not violate the + * parser-free / "Postgres is the elaborator" principle. + */ +function maskLiteralsAndComments(sql: string): string { + const out: string[] = []; + let i = 0; + const n = sql.length; + while (i < n) { + const c = sql[i] as string; + const next = sql[i + 1]; + // line comment + if (c === "-" && next === "-") { + while (i < n && sql[i] !== "\n") i++; + continue; + } + // block comment (nested, as in PostgreSQL) + if (c === "/" && next === "*") { + let depth = 1; + i += 2; + while (i < n && depth > 0) { + if (sql[i] === "/" && sql[i + 1] === "*") { + depth++; + i += 2; + } else if (sql[i] === "*" && sql[i + 1] === "/") { + depth--; + i += 2; + } else i++; + } + out.push(" "); + continue; + } + // single-quoted string ('' is an escaped quote) + if (c === "'") { + i++; + while (i < n) { + if (sql[i] === "'" && sql[i + 1] === "'") i += 2; + else if (sql[i] === "'") { + i++; + break; + } else i++; + } + out.push(" "); + continue; + } + // double-quoted identifier ("" is an escaped quote) + if (c === '"') { + i++; + while (i < n) { + if (sql[i] === '"' && sql[i + 1] === '"') i += 2; + else if (sql[i] === '"') { + i++; + break; + } else i++; + } + out.push(" "); + continue; + } + // dollar-quoted string: $tag$ ... $tag$ (tag may be empty) + if (c === "$") { + const tagMatch = /^\$[A-Za-z_]?[A-Za-z0-9_]*\$/.exec(sql.slice(i)); + if (tagMatch) { + const tag = tagMatch[0]; + const end = sql.indexOf(tag, i + tag.length); + i = end === -1 ? n : end + tag.length; + out.push(" "); + continue; + } + } + out.push(c); + i++; + } + return out.join(""); +} + +/** Statement-leading transaction-control forms. `BEGIN ATOMIC` (a PG14+ SQL + * function body) is explicitly NOT transaction control. */ +const TXN_CONTROL_RULES: ReadonlyArray<{ re: RegExp; label: string }> = [ + { re: /^start\s+transaction\b/i, label: "START TRANSACTION" }, + { re: /^prepare\s+transaction\b/i, label: "PREPARE TRANSACTION" }, + { re: /^begin(?!\s+atomic\b)\b/i, label: "BEGIN" }, + { re: /^commit\b/i, label: "COMMIT" }, + { re: /^rollback\b/i, label: "ROLLBACK" }, + { re: /^abort\b/i, label: "ABORT" }, + { re: /^end\s+(work|transaction)\b/i, label: "END TRANSACTION" }, + { re: /^savepoint\b/i, label: "SAVEPOINT" }, + { re: /^release\b/i, label: "RELEASE" }, +]; + +/** + * Return the transaction-control statement labels found at STATEMENT LEVEL in a + * SQL file (empty when clean). The loader rejects any non-empty result: a + * declarative file must not manage its own transaction, or it could commit + * partial DDL before a later statement fails (review finding 6). Keywords + * appearing inside comments, string/dollar-quoted literals, or PG14+ + * `BEGIN ATOMIC` bodies are NOT flagged. + */ +export function findTransactionControl(sql: string): string[] { + const skeleton = maskLiteralsAndComments(sql); + const found: string[] = []; + for (const raw of skeleton.split(";")) { + const stmt = raw.trim(); + if (stmt === "") continue; + for (const { re, label } of TXN_CONTROL_RULES) { + if (re.test(stmt)) { + found.push(label); + break; + } + } + } + return found; +} + +/** Statement-leading session-setting forms that change object resolution or + * ownership for every statement that follows them on the same session: + * `SET search_path` (where unqualified names resolve), `SET ROLE` / + * `SET SESSION AUTHORIZATION` (who owns created objects), and the matching + * RESETs. `SET LOCAL`/`SET SESSION` modifiers are tolerated. Unrelated GUCs + * (e.g. `SET statement_timeout`) are NOT flagged — they don't affect the + * extracted schema. */ +const SESSION_SETTING_RULES: ReadonlyArray<{ re: RegExp; label: string }> = [ + { + re: /^set\s+(?:session\s+|local\s+)?search_path\b/i, + label: "SET search_path", + }, + { + // `SET SCHEMA 'x'` is documented as an alias for `SET search_path TO x`. + re: /^set\s+(?:session\s+|local\s+)?schema\b/i, + label: "SET SCHEMA", + }, + { re: /^set\s+(?:session\s+|local\s+)?role\b/i, label: "SET ROLE" }, + { + re: /^set\s+(?:session\s+|local\s+)?session\s+authorization\b/i, + label: "SET SESSION AUTHORIZATION", + }, + { + re: /^reset\s+(?:role|search_path|session\s+authorization|all)\b/i, + label: "RESET session setting", + }, +]; + +/** + * Return the session-setting statement labels found at STATEMENT LEVEL in a SQL + * file (empty when clean). The statement-reordering assist (`sql-order.ts`) + * treats variable `SET`/`RESET` as no-dependency bootstrap statements, so it can + * move them relative to the DDL they were meant to scope — silently changing the + * shadow state (e.g. an unqualified `CREATE TABLE` resolving into the wrong + * schema). The CLI uses this to fall back to raw, file-granular loading (which + * preserves the authored order) when a directory contains such statements + * (review P1). Keywords inside comments / string / dollar-quoted literals are + * NOT flagged (reuses the same literal mask as `findTransactionControl`). + */ +export function findSessionSettingStatements(sql: string): string[] { + const skeleton = maskLiteralsAndComments(sql); + const found: string[] = []; + for (const raw of skeleton.split(";")) { + const stmt = raw.trim(); + if (stmt === "") continue; + for (const { re, label } of SESSION_SETTING_RULES) { + if (re.test(stmt)) { + found.push(label); + break; + } + } + } + return found; +} + +/** + * Return the `ALTER DEFAULT PRIVILEGES` statements found at STATEMENT LEVEL + * (empty when clean). pg-topo classifies these in its `privileges` phase, which + * sorts AFTER object creation — but PostgreSQL applies a schema's default + * privileges to objects created AFTER the `ALTER DEFAULT PRIVILEGES` in authored + * order. Reordering can therefore move the statement past the `CREATE` it was + * meant to scope, so the shadow misses the implicit ACLs and the plan diffs the + * wrong grants. The CLI treats a directory containing one as a reorder barrier + * and falls back to raw, file-granular loading (review P2). Keywords inside + * comments / literals are ignored (same literal mask as the others). + */ +export function findDefaultPrivilegeStatements(sql: string): string[] { + const skeleton = maskLiteralsAndComments(sql); + const found: string[] = []; + for (const raw of skeleton.split(";")) { + if (/^\s*alter\s+default\s+privileges\b/i.test(raw)) { + found.push("ALTER DEFAULT PRIVILEGES"); + } + } + return found; +} + +/** The (literal-masked, trimmed) statements in `sql` for which `predicate` is + * true. The generic form behind the other scanners — used by the extension + * shadow precheck to find a handler's DDL/intent statements without a SQL + * grammar (same literal/comment mask, so keywords inside strings are ignored). */ +export function findMatchingStatements( + sql: string, + predicate: (maskedStatement: string) => boolean, +): string[] { + return maskLiteralsAndComments(sql) + .split(";") + .map((s) => s.trim()) + .filter((s) => s !== "" && predicate(s)); +} + +/** Cluster-global (not database-local) DDL: role lifecycle, role membership, and + * role metadata. `schema apply --scope database` refuses these (or skips them + * with `--skip-cluster-ddl`) because roles are shared across the cluster and are + * not the declarative source's to manage in that scope. Membership grants are + * distinguished from privilege grants by the absence of an `ON` target. */ +const CLUSTER_DDL_RULES: { re: RegExp; label: string }[] = [ + // `user(?!\s+mapping)` so `CREATE|ALTER|DROP USER MAPPING …` (a database-local + // FDW object, emitted by src/plan/rules/foreign.ts) is NOT misclassified as + // cluster-global role DDL — otherwise database-scope apply would reject them + // (or --skip-cluster-ddl would strip them) from pg-delta's own exports. `\s+` + // matches any whitespace run in the masked/normalized statement text. + { + re: /^\s*create\s+(role|user(?!\s+mapping)|group)\b/i, + label: "CREATE ROLE", + }, + { + re: /^\s*alter\s+(role|user(?!\s+mapping)|group)\b/i, + label: "ALTER ROLE", + }, + { re: /^\s*drop\s+(role|user(?!\s+mapping)|group)\b/i, label: "DROP ROLE" }, + { re: /^\s*comment\s+on\s+role\b/i, label: "COMMENT ON ROLE" }, + { + re: /^\s*security\s+label\b[\s\S]*\bon\s+role\b/i, + label: "SECURITY LABEL ON ROLE", + }, + { re: /^\s*grant\b(?![\s\S]*\bon\b)/i, label: "GRANT (role membership)" }, + { re: /^\s*revoke\b(?![\s\S]*\bon\b)/i, label: "REVOKE (role membership)" }, +]; + +/** Labels of cluster-global DDL statements found at statement level (empty when + * clean). Keywords inside comments / literals are ignored (same literal mask as + * the other scanners). */ +export function findClusterDdlStatements(sql: string): string[] { + const skeleton = maskLiteralsAndComments(sql); + const found: string[] = []; + for (const raw of skeleton.split(";")) { + const stmt = raw.trim(); + if (stmt === "") continue; + for (const { re, label } of CLUSTER_DDL_RULES) { + if (re.test(stmt)) { + found.push(label); + break; + } + } + } + return found; +} + +/** Partition `sql` into the statements that are NOT cluster-global DDL (`kept`, + * rejoined and ready to load) and the cluster-DDL statements removed + * (`skipped`, original text, for the skip ledger). Uses the block-aware + * `splitSqlStatements` so function bodies etc. are not mis-split. */ +export function stripClusterDdl(sql: string): { + kept: string; + skipped: string[]; +} { + const keptParts: string[] = []; + const skipped: string[] = []; + for (const stmt of splitSqlStatements(sql)) { + const masked = maskLiteralsAndComments(stmt).trim(); + if (masked !== "" && CLUSTER_DDL_RULES.some(({ re }) => re.test(masked))) { + skipped.push(stmt.trim()); + } else { + keptParts.push(masked === "" ? stmt : `${stmt};`); + } + } + return { kept: keptParts.join("\n"), skipped }; +} + +export interface SqlFile { + name: string; + sql: string; +} + +export interface LoadResult { + factBase: FactBase; + pgVersion: string; + diagnostics: Diagnostic[]; + rounds: number; +} + +export class ShadowLoadError extends Error { + constructor( + message: string, + readonly details: Diagnostic[], + ) { + super(message); + this.name = "ShadowLoadError"; + } +} + +/** A membership tuple used for snapshot comparison. */ +interface MembershipTuple { + role: string; + member: string; + admin_option: boolean; +} + +function serializeMembership(m: MembershipTuple): string { + return `${m.role}:${m.member}:${String(m.admin_option)}`; +} + +/** + * Best-effort undo of any cluster-level role / membership side effect that + * committed before the load threw (databaseScratch ONLY). Because `applyFile` + * commits per file, a CREATE ROLE / GRANT — or DO-block dynamic SQL that evaded + * the static preflight — survives a load that later throws (non-convergence, + * body-validation, DML, or the on-success leak comparison itself). Compares the + * current cluster state against the pre-load snapshot and reverses the delta: + * drop created roles, revoke added memberships, re-grant removed memberships. + * `applyFile` ROLLBACKs on failure, so `client` is in a clean (non-aborted) + * transaction state when this runs. Each restore statement is best-effort; a + * failure is reported as a `scratch_leak_unrestored` diagnostic rather than + * masking the original error. Successful restores contribute nothing. + */ +async function restoreScratchClusterState( + client: PoolClient, + rolesBefore: QueryResult | null, + membershipsBefore: QueryResult | null, +): Promise { + const diags: Diagnostic[] = []; + const rolesNow = await client.query( + `SELECT rolname FROM pg_roles ORDER BY 1`, + ); + const membershipsNow = await client.query(` + SELECT r1.rolname AS role, r2.rolname AS member, + m.admin_option + FROM pg_auth_members m + JOIN pg_roles r1 ON r1.oid = m.roleid + JOIN pg_roles r2 ON r2.oid = m.member + ORDER BY 1, 2`); + + const beforeRoleSet = new Set( + (rolesBefore?.rows ?? []).map((r) => (r as { rolname: string }).rolname), + ); + const createdRoles = rolesNow.rows + .map((r) => (r as { rolname: string }).rolname) + .filter((r) => !beforeRoleSet.has(r)); + + const beforeMemberMap = new Map( + (membershipsBefore?.rows ?? []).map((m) => [serializeMembership(m), m]), + ); + const nowMemberMap = new Map( + membershipsNow.rows.map((m) => [serializeMembership(m), m]), + ); + const addedMemberships = [...nowMemberMap.entries()] + .filter(([k]) => !beforeMemberMap.has(k)) + .map(([, m]) => m); + const removedMemberships = [...beforeMemberMap.entries()] + .filter(([k]) => !nowMemberMap.has(k)) + .map(([, m]) => m); + + const tryRestore = async (stmt: string, what: string): Promise => { + try { + await client.query(stmt); + } catch (err) { + diags.push({ + code: "scratch_leak_unrestored", + severity: "error", + message: `${what}: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }; + + // Revoke added memberships first (a created role may hold them), then drop + // created roles, then re-grant memberships the load removed. + for (const m of addedMemberships) { + await tryRestore( + `REVOKE ${qid(m.role)} FROM ${qid(m.member)}`, + `could not revoke leaked membership ${m.role} FROM ${m.member}`, + ); + } + for (const role of createdRoles) { + await tryRestore( + `DROP ROLE IF EXISTS ${qid(role)}`, + `could not drop leaked role ${role}`, + ); + } + for (const m of removedMemberships) { + await tryRestore( + `GRANT ${qid(m.role)} TO ${qid(m.member)}${m.admin_option ? " WITH ADMIN OPTION" : ""}`, + `could not restore revoked membership ${m.role} TO ${m.member}`, + ); + } + return diags; +} + +export async function loadSqlFiles( + files: SqlFile[], + shadow: Pool, + options: { + maxRounds?: number; + mode?: "databaseScratch" | "isolatedCluster"; + /** Extractor for the loaded shadow state. Defaults to the raw core + * `extract`; a profile-aware caller passes its `ctx.extract` so the shadow + * desired state is projected with the SAME handlers as the target (review + * P1 — SQL-file workflows must match the profile-aware DB-to-DB path). */ + extract?: (pool: Pool, options?: ExtractOptions) => Promise; + /** Assumed schemas the caller PRE-SEEDED into the shadow (Phase 2b): the + * emptiness guard below excludes them from its count so a deliberately + * seeded shadow (auth.users under --profile supabase) is not rejected as + * "not empty". Only these schemas are exempt — an unexpected object + * anywhere else still fails the guard. */ + seededSchemas?: string[]; + /** Encoded stable ids of the routines the Phase 2b seed ACTUALLY created, + * mapped to each routine's seeded `pg_get_functiondef` text. Scopes the + * post-load body-validation leniency: a routine is treated as + * "seeded platform code" (warn on a wonky body) only when its overload-safe + * encoded identity is in this map AND its current def is unchanged from the + * seeded def. A user-authored routine in a seeded schema — a new overload, + * or a CREATE OR REPLACE that changes the body — is NOT in (or no longer + * matches) this map and THROWS (Codex #329). When OMITTED (direct library + * callers), leniency falls back to the coarser `seededSchemas` name check + * for backward compatibility; the CLI always passes this once it seeds. */ + seededRoutines?: ReadonlyMap; + /** Escalate a USER routine's post-load body-validation failure back to a + * fatal error (default `false`). By default a user routine whose body fails + * the `check_function_bodies = on` re-lint is reported as a loud WARNING and + * the load proceeds: Postgres already accepted it under check-off (which + * pg-delta's own apply executor emits in every plan preamble), so refusing + * to read it back would be pg-delta imposing stricter validation than + * Postgres and would block round-tripping any schema that relies on + * check-off. Set to `true` (CLI `--strict-function-bodies`) to restore the + * fatal gate for CI. Only class-3 (user-schema) failures honour this flag — + * a routine in a seeded schema that is NOT an unchanged seed always throws + * (Codex #329), and an unchanged seeded routine always warns. */ + strictFunctionBodies?: boolean; + } = {}, +): Promise { + // Rounds scale with dependency DEPTH, not file count: each round resolves + // every file whose dependencies now exist. A deterministic, convergent load + // therefore needs at most `files.length` rounds (worst case — a fully + // reverse-ordered linear chain resolves one file per round); the zero-progress + // check below fails genuine non-convergence (missing object, cycle) IMMEDIATELY + // with the real per-file errors. So `maxRounds` is purely an oscillation + // backstop for non-deterministic SQL, NOT a depth limit — it must scale with + // the file count (floor 25 preserves the small-schema default). A fixed 25 + // used to wrongly fail any chain deeper than 25 that was still making progress. + const maxRounds = options.maxRounds ?? Math.max(files.length + 1, 25); + const mode = options.mode ?? "databaseScratch"; + const extractShadow = options.extract ?? extract; + + // the shadow must be empty — verify by observation. Schemas the caller + // pre-seeded (Phase 2b assumed schemas) are exempt: they were deliberately + // populated before this load, and `<> ALL(ARRAY[]::text[])` is TRUE for every + // row when nothing was seeded, so the default (unseeded) path is unchanged. + const seededSchemas = options.seededSchemas ?? []; + const seededRoutines = options.seededRoutines; + const strictFunctionBodies = options.strictFunctionBodies ?? false; + // isolatedCluster shadows may already carry a platform baseline (e.g. the + // Supabase CLI declarative seam). The empty guard is for co-located scratch + // databases only — requiring emptiness there prevents accidental loads into + // a non-scratch database on a shared cluster. + if (mode !== "isolatedCluster") { + const preexisting = await shadow.query( + ` + SELECT count(*)::int AS n FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg\\_%' + AND n.nspname <> ALL($1::text[])`, + [seededSchemas], + ); + if ((preexisting.rows[0] as { n: number }).n > 0) { + throw new ShadowLoadError("shadow database is not empty", []); + } + } + + // reject files that manage their own transaction (review finding 6): an + // explicit COMMIT/BEGIN/SAVEPOINT/… would break the per-file atomic wrapper + // below, letting partial DDL commit before a later statement fails. + const txnControlDiags: Diagnostic[] = []; + for (const file of files) { + const offenders = findTransactionControl(file.sql); + if (offenders.length > 0) { + txnControlDiags.push({ + code: "transaction_control", + severity: "error", + message: `${file.name}: declarative SQL must not contain transaction-control statements (found: ${[...new Set(offenders)].join(", ")})`, + }); + } + } + if (txnControlDiags.length > 0) { + throw new ShadowLoadError( + `declarative files must not manage transactions — ${txnControlDiags.length} file(s) contain transaction-control statements`, + txnControlDiags, + ); + } + + // cluster-DDL preflight (databaseScratch ONLY): role / membership DDL leaks + // into the shared cluster because each file commits (BEGIN/COMMIT in + // applyFile). Refuse it BEFORE executing anything so a committed CREATE ROLE + // / GRANT can never survive. This is a static (regex-masked) precheck; DO-block + // dynamic SQL that evades it is caught + reversed by the post-load snapshot + // comparison and the best-effort restore below. isolatedCluster mode manages + // cluster state legitimately and skips this preflight entirely. + if (mode === "databaseScratch") { + const clusterDdlDiags: Diagnostic[] = []; + for (const file of files) { + const offenders = findClusterDdlStatements(file.sql); + if (offenders.length > 0) { + clusterDdlDiags.push({ + code: "cluster_ddl_in_scratch_mode", + severity: "error", + message: `${file.name}: declarative SQL must not contain cluster-level DDL in databaseScratch mode (found: ${[...new Set(offenders)].join(", ")}) — use an isolated-cluster shadow for shared objects`, + }); + } + } + if (clusterDdlDiags.length > 0) { + throw new ShadowLoadError( + `declarative files contain cluster-level DDL not allowed in databaseScratch mode — ${clusterDdlDiags.length} file(s) affected; use an isolated-cluster shadow for shared objects`, + clusterDdlDiags, + ); + } + } + + // snapshot pg_roles + pg_auth_members before loading (databaseScratch only) + const rolesBefore = + mode === "databaseScratch" + ? await shadow.query(`SELECT rolname FROM pg_roles ORDER BY 1`) + : null; + const membershipsBefore = + mode === "databaseScratch" + ? await shadow.query(` + SELECT r1.rolname AS role, r2.rolname AS member, + m.admin_option + FROM pg_auth_members m + JOIN pg_roles r1 ON r1.oid = m.roleid + JOIN pg_roles r2 ON r2.oid = m.member + ORDER BY 1, 2`) + : null; + + // bounded retry rounds at file granularity (fail-safe ordering) + let pending = [...files].sort((a, b) => (a.name < b.name ? -1 : 1)); + let rounds = 0; + // body-validation warnings for SEEDED-schema routines (populated below, + // outside the try/finally so the final result can merge them in). + let seededBodyWarnings: Diagnostic[] = []; + // the most recent round's per-file failures, retained so a budget-exhaustion + // error can report WHY each still-pending file failed (review P1 #2). + let lastFailures: Array<{ file: SqlFile; message: string }> = []; + // per-file count of CONSECUTIVE rounds a file failed with the SAME message, so + // a stuck / non-converged error can say "failed identically in N round(s)" — + // a file whose error never changes is a genuine missing dependency (or cycle), + // not something more rounds will resolve. + const failStreak = new Map(); + let bootstrapMembershipStrip: { + roles: ReadonlySet; + member: string; + } | null = null; + const client = await shadow.connect(); + try { + await client.query(`SET check_function_bodies = off`); + // PG 16+: CREATE ROLE no longer auto-grants the creator membership in the + // new role. Supabase's non-superuser `postgres` (CREATEROLE) then fails + // `CREATE SCHEMA … AUTHORIZATION new_role` with "must be able to SET ROLE" + // unless createrole_self_grant includes `set`. Best-effort — the GUC is + // absent on PG < 16, where creators still receive membership automatically. + // + // Those bootstrap memberships must NOT survive into the extracted desired + // state: planning them yields `GRANT role TO postgres WITH ADMIN OPTION`, + // which fails on apply with "ADMIN option cannot be granted back to your + // own grantor" (the applier is already the CREATE ROLE grantor). + let createroleSelfGrant = false; + try { + await client.query( + `SELECT set_config('createrole_self_grant', 'set, inherit', false)`, + ); + createroleSelfGrant = true; + } catch { + /* PG < 16 or GUC unavailable */ + } + const rolesBeforeSelfGrant = + createroleSelfGrant && mode === "isolatedCluster" + ? await client.query(`SELECT rolname FROM pg_roles ORDER BY 1`) + : null; + while (pending.length > 0) { + // Budget exhausted with files still pending: fail LOUD, never fall through + // to extraction with a partially loaded shadow (review P1 #2). The SQL-file + // frontend's contract is all-or-error — a caller must never receive a fact + // base that silently omits declarative files that did not converge. + if (rounds >= maxRounds) { + throw new ShadowLoadError( + `shadow load did not converge within maxRounds=${maxRounds}: ${pending.length} file(s) still pending (${pending.map((f) => f.name).join(", ")})`, + pending.map((f) => { + const failure = lastFailures.find((x) => x.file.name === f.name); + return { + code: "max_rounds_exceeded", + severity: "error", + message: failure + ? `${f.name}: ${failure.message}` + : `${f.name}: still pending after ${rounds} round(s)`, + }; + }), + ); + } + rounds++; + const failures: Array<{ file: SqlFile; message: string }> = []; + const next: SqlFile[] = []; + for (const file of pending) { + try { + await applyFile(client, file.sql); + } catch (error) { + // A ShadowLoadError from applyFile is a deterministic policy refusal + // (mixed non-transactional file, or a non-allowlisted non-transactional + // statement) — retrying in a later round can never make it succeed, so + // surface it immediately with its own message instead of deferring it + // until the round budget or a "stuck" round wraps it. + if (error instanceof ShadowLoadError) throw error; + const message = describeFileFailure(file.sql, error); + const prev = failStreak.get(file.name); + failStreak.set(file.name, { + message, + count: + prev !== undefined && prev.message === message + ? prev.count + 1 + : 1, + }); + failures.push({ file, message }); + next.push(file); + } + } + if (next.length === pending.length) { + // no progress: stuck — inspect for mutual-FK situation, then fail loud + const mutualFkHint = detectMutualFk(failures) + ? " Tip: if two tables reference each other with inline REFERENCES clauses, split one foreign key into a separate ALTER TABLE … ADD CONSTRAINT statement." + : ""; + throw new ShadowLoadError( + `shadow load stuck after ${rounds} round(s): ${next.length} file(s) cannot apply${mutualFkHint}`, + failures.map((f) => { + const streak = failStreak.get(f.file.name); + const streakNote = + streak !== undefined && streak.count > 1 + ? ` (failed identically in ${streak.count} round(s) — likely a genuine missing dependency, not ordering)` + : ""; + return { + code: "stuck_statement", + severity: "error", + message: `${f.file.name}: ${f.message}${streakNote}`, + }; + }), + ); + } + lastFailures = failures; + pending = next; + } + + // Capture createrole_self_grant bootstrap grants for post-extract stripping. + // REVOKE is insufficient: PG may leave an ADMIN membership whose grantor is + // the role that conferred CREATEROLE (not CURRENT_USER), which the applier + // cannot revoke — yet planning that membership yields a failing + // `GRANT … TO WITH ADMIN OPTION` on apply. + if (rolesBeforeSelfGrant !== null) { + const rolesAfterSelfGrant = await client.query( + `SELECT rolname FROM pg_roles ORDER BY 1`, + ); + const beforeSet = new Set( + rolesBeforeSelfGrant.rows.map( + (r) => (r as { rolname: string }).rolname, + ), + ); + const createdRoles = rolesAfterSelfGrant.rows + .map((r) => (r as { rolname: string }).rolname) + .filter((r) => !beforeSet.has(r)); + const me = await client.query<{ u: string }>(`SELECT current_user AS u`); + bootstrapMembershipStrip = { + roles: new Set(createdRoles), + member: me.rows[0]!.u, + }; + } + + // shared-object isolation: role/membership leakage is an error in databaseScratch mode + if (mode === "databaseScratch") { + const rolesAfter = await client.query( + `SELECT rolname FROM pg_roles ORDER BY 1`, + ); + const beforeRoleSet = new Set( + (rolesBefore?.rows ?? []).map( + (r) => (r as { rolname: string }).rolname, + ), + ); + const afterRoleSet = new Set( + rolesAfter.rows.map((r) => (r as { rolname: string }).rolname), + ); + // symmetric: a CREATE ROLE (after∖before) AND a DROP ROLE (before∖after) + // are both cluster-level side effects in shared-scratch mode (review P1). + const createdRoles = [...afterRoleSet].filter( + (r) => !beforeRoleSet.has(r), + ); + const droppedRoles = [...beforeRoleSet].filter( + (r) => !afterRoleSet.has(r), + ); + if (createdRoles.length > 0 || droppedRoles.length > 0) { + const parts = [ + createdRoles.length > 0 ? `created: ${createdRoles.join(", ")}` : "", + droppedRoles.length > 0 ? `dropped: ${droppedRoles.join(", ")}` : "", + ].filter(Boolean); + throw new ShadowLoadError( + `declarative files changed cluster-level roles (${parts.join("; ")}) — use an isolated-cluster shadow for shared objects`, + [...createdRoles, ...droppedRoles].map((r) => ({ + code: "shared_object_leak", + severity: "error", + subject: { kind: "role", name: r }, + message: `role ${r} changed out of the shadow database`, + })), + ); + } + + // membership snapshot comparison: detect GRANT role_a TO role_b leaks + const membershipsAfter = await client.query(` + SELECT r1.rolname AS role, r2.rolname AS member, + m.admin_option + FROM pg_auth_members m + JOIN pg_roles r1 ON r1.oid = m.roleid + JOIN pg_roles r2 ON r2.oid = m.member + ORDER BY 1, 2`); + // symmetric over the serialized rows (which include admin_option): a GRANT + // (after∖before), a REVOKE (before∖after), and an admin_option change (which + // appears as one of each) are all cluster-level side effects (review P1). + const beforeMemberSet = new Set( + (membershipsBefore?.rows ?? []).map(serializeMembership), + ); + const afterMemberSet = new Set( + membershipsAfter.rows.map(serializeMembership), + ); + const grants = membershipsAfter.rows.filter( + (m) => !beforeMemberSet.has(serializeMembership(m)), + ); + const revokes = (membershipsBefore?.rows ?? []).filter( + (m) => !afterMemberSet.has(serializeMembership(m)), + ); + if (grants.length > 0 || revokes.length > 0) { + const describe = ( + m: MembershipTuple, + verb: "GRANT" | "REVOKE", + ): string => + `${verb} ${m.role} ${verb === "GRANT" ? "TO" : "FROM"} ${m.member}${m.admin_option ? " WITH ADMIN OPTION" : ""}`; + const descriptions = [ + ...grants.map((m) => describe(m, "GRANT")), + ...revokes.map((m) => describe(m, "REVOKE")), + ]; + throw new ShadowLoadError( + `declarative files modified cluster-level membership (${descriptions.join(", ")}) — use an isolated-cluster shadow for shared objects`, + descriptions.map((d) => ({ + code: "shared_object_leak", + severity: "error", + message: `membership leak: ${d}`, + })), + ); + } + } + + // body validation: re-run routine definitions with checks ON. + // `check_function_bodies` only validates sql/plpgsql bodies — per the + // Postgres docs, it "has no effect on ... functions written in languages + // other than SQL and PL/pgSQL, whose bodies are not checked". Re-running a + // non-sql/plpgsql routine (internal/c, or any other procedural language) + // therefore adds no coverage, and can actively break the load: e.g. + // `CREATE TYPE ... AS RANGE (...)` auto-creates `LANGUAGE internal` + // constructor/support functions, and re-running those as + // `CREATE OR REPLACE FUNCTION ... LANGUAGE internal` fails with + // "permission denied for language internal" for a non-superuser role. + // `identity_args` reuses the EXACT `format_type(unnest(proargtypes))` + // expression extraction uses (src/extract/routines.ts) so the encoded + // `StableId` reconstructed per row matches the seed's `seededRoutines` keys + // byte-for-byte — the leniency gate is by overload-safe identity, not name. + // format_type's output is search_path-sensitive, and extraction runs under + // the canonical `search_path = pg_catalog` (everything else comes back + // schema-qualified) — so this query must run under the SAME path or a + // user-type arg (`hstore` vs `public.hstore`) breaks the byte-for-byte key + // match. Scope the canonical path to this query alone via a transaction: + // body re-validation below must keep the session's own path so bodies + // resolve as they would at apply time. + await client.query(`BEGIN`); + await client.query(`SET LOCAL search_path TO 'pg_catalog'`); + const defs = await client.query(` + SELECT n.nspname AS nspname, p.proname AS proname, p.prokind AS prokind, + ARRAY(SELECT format_type(t.t, NULL) + FROM unnest(p.proargtypes) WITH ORDINALITY AS t(t, ord) + ORDER BY t.ord)::text[] AS identity_args, + pg_get_functiondef(p.oid) AS def + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + JOIN pg_language l ON l.oid = p.prolang + WHERE p.prokind IN ('f', 'p') + AND l.lanname IN ('sql', 'plpgsql') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e')`); + await client.query(`COMMIT`); + await client.query(`SET check_function_bodies = on`); + const bodyErrors: Diagnostic[] = []; + const bodyWarnings: Diagnostic[] = []; + for (const row of defs.rows as { + nspname: string; + proname: string; + prokind: string; + identity_args: string[]; + def: string; + }[]) { + try { + await client.query(row.def); + } catch (error) { + const message = `${row.nspname}.${row.proname}: ${error instanceof Error ? error.message : String(error)}`; + // Three-way classification of a post-load body-validation failure: + // + // 1. SEEDED, UNCHANGED (identity + def byte-match a seed; or, when + // `seededRoutines` is omitted by a direct library caller, any routine + // whose schema NAME is seeded — the coarse legacy fallback): WARNING + // with the distinct `invalid_seeded_routine_body` code. Seeded platform + // routines are reference-only on both sides of the diff (they cancel, + // so a wonky seeded body cannot corrupt the plan) and are not the + // user's code to fail their apply on. Surfaced (not swallowed) because + // such a failure has previously exposed a real engine bug in + // platform-code reconstruction. NEVER escalated by strict mode. + // + // 2. SEEDED SCHEMA, NOT AN UNCHANGED SEED (a new overload, or a + // CREATE OR REPLACE that changed the body): FATAL. Assumed-schema + // facts are reference-only in the diff, so a declared change here would + // be a silent no-op — failing loud is a coverage guarantee (Codex + // #329). Ignores strict mode (always throws). + // + // 3. USER ROUTINE (schema NOT seeded): WARNING by default. Postgres + // accepted it under check-off (which pg-delta's own apply executor + // emits), so apply will faithfully materialise exactly what was + // declared — refusing to read it back would be pg-delta imposing + // stricter validation than Postgres. Escalated to FATAL only under + // `strictFunctionBodies` (CLI `--strict-function-bodies`). + const inSeededSchema = seededSchemas.includes(row.nspname); + const isUnchangedSeed = + seededRoutines === undefined + ? inSeededSchema + : ((): boolean => { + const id: StableId = { + kind: row.prokind === "p" ? "procedure" : "function", + schema: row.nspname, + name: row.proname, + args: (row.identity_args as string[]).map(String), + }; + const seededDef = seededRoutines.get(encodeId(id)); + return seededDef !== undefined && seededDef === row.def; + })(); + if (isUnchangedSeed) { + // class 1 + bodyWarnings.push({ + code: "invalid_seeded_routine_body", + severity: "warning", + message, + }); + } else if (inSeededSchema) { + // class 2 — always fatal (Codex #329) + bodyErrors.push({ + code: "invalid_routine_body", + severity: "error", + message, + }); + } else if (strictFunctionBodies) { + // class 3 under strict opt-in — fatal + bodyErrors.push({ + code: "invalid_routine_body", + severity: "error", + message, + }); + } else { + // class 3 default — loud warning, load proceeds + bodyWarnings.push({ + code: "invalid_routine_body", + severity: "warning", + message, + }); + } + } + } + if (bodyErrors.length > 0) { + throw new ShadowLoadError( + `${bodyErrors.length} routine bod${bodyErrors.length === 1 ? "y" : "ies"} failed validation`, + bodyErrors, + ); + } + seededBodyWarnings = bodyWarnings; + + // DML rejection by observation: any MANAGED USER table with rows fails. + // "User table" must mean the SAME thing the diff path manages, so reuse the + // extraction scope predicate (USER_SCHEMA_FILTER drops pg_catalog / + // information_schema / pg_toast / pg_temp) and exclude extension-owned + // relations (pg_depend deptype 'e'). Otherwise a declarative file that + // installs an extension whose CREATE EXTENSION seeds internal config rows — + // or a platform object — is wrongly rejected as if the user wrote DML (P2). + const tables = await client.query(` + SELECT n.nspname AS schema, c.relname AS name + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND ${USER_SCHEMA_FILTER} + AND ${notExtensionMember("pg_class", "c.oid")}`); + const populated: string[] = []; + for (const row of tables.rows as { schema: string; name: string }[]) { + const qualified = `"${row.schema.replaceAll('"', '""')}"."${row.name.replaceAll('"', '""')}"`; + const r = await client.query( + `SELECT EXISTS (SELECT 1 FROM ${qualified} LIMIT 1) AS has`, + ); + if ((r.rows[0] as { has: boolean }).has) populated.push(qualified); + } + if (populated.length > 0) { + throw new ShadowLoadError( + `declarative files must not contain data statements — rows found in managed user table(s): ${populated.join(", ")}`, + populated.map((t) => ({ + code: "data_statement", + severity: "error", + message: `managed user table ${t} contains rows after loading the declarative files`, + })), + ); + } + } catch (err) { + // Best-effort containment of any cluster-level leak that committed before the + // throw (databaseScratch only): per-file COMMIT means a CREATE ROLE / GRANT — + // or DO-block dynamic SQL that evaded the static preflight, detected by the + // on-success snapshot comparison above — survives an aborted load. Reverse it + // BEFORE the finally releases the pooled client. `applyFile` ROLLBACKs on + // failure, so `client` is in a clean transaction state here. + if (mode === "databaseScratch") { + const restoreDiags = await restoreScratchClusterState( + client, + rolesBefore, + membershipsBefore, + ); + if (restoreDiags.length > 0 && err instanceof ShadowLoadError) { + err.details.push(...restoreDiags); + } + } + throw err; + } finally { + // restore the GUC even when load fails early (before the on-success reset + // at the body-validation step) — otherwise the pooled client returns with + // check_function_bodies still off (review P2). + await client.query(`RESET check_function_bodies`).catch(() => {}); + client.release(); + } + + // provenance tag: mark the fact base as originating from SQL files. The + // extractor is profile-aware when the caller supplied one (handler-aware + // projection), else the raw core extractor. + const result = await extractShadow(shadow, { source: "sqlFiles" }); + let factBase = result.factBase; + if ( + bootstrapMembershipStrip !== null && + bootstrapMembershipStrip.roles.size > 0 + ) { + const strip = bootstrapMembershipStrip; + const drop = (id: StableId): boolean => + id.kind === "membership" && + strip.roles.has(id.role) && + id.member === strip.member; + const facts = factBase.facts().filter((f) => !drop(f.id)); + const kept = new Set(facts.map((f) => encodeId(f.id))); + const edges = [...factBase.edges].filter( + (e) => kept.has(encodeId(e.from)) && kept.has(encodeId(e.to)), + ); + factBase = buildFactBase( + facts, + edges, + factBase.source, + factBase.referenceOnly, + ); + } + return { + factBase, + pgVersion: result.pgVersion, + diagnostics: [...result.diagnostics, ...seededBodyWarnings], + rounds, + }; +} + +/** + * Heuristic: detect whether stuck files are likely suffering from a mutual + * inline FK cycle (two CREATE TABLEs each referencing the other's table inline). + * + * We look for: ≥2 stuck files whose PG errors mention "relation … does not + * exist" or "foreign key constraint … references table" against a table name + * that another stuck file would create. + */ +function detectMutualFk( + failures: Array<{ file: SqlFile; message: string }>, +): boolean { + if (failures.length < 2) return false; + + // Extract table names that each file attempts to CREATE TABLE + const tablePattern = + /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"[^"]+"|[\w.]+)/gi; + const fkErrorPattern = + /relation "([^"]+)" does not exist|foreign key constraint .* references table "([^"]+)"/i; + + const filesThatCreate = new Map>(); + for (const f of failures) { + const names = new Set(); + let m: RegExpExecArray | null; + tablePattern.lastIndex = 0; + while ((m = tablePattern.exec(f.file.sql)) !== null) { + // strip schema prefix and quotes for simple matching + const raw = m[0] + .replace(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?/i, "") + .trim(); + const bare = + raw + .replace(/^"([^"]+)"$/, "$1") + .split(".") + .pop() ?? raw; + names.add(bare.toLowerCase()); + } + filesThatCreate.set(f.file.name, names); + } + + // Check whether any stuck file's error mentions a table that another stuck + // file would create (i.e. cross-file unresolved reference) + const allCreated = new Set(); + for (const names of filesThatCreate.values()) { + for (const n of names) allCreated.add(n); + } + + for (const f of failures) { + const em = fkErrorPattern.exec(f.message); + if (!em) continue; + const missing = (em[1] ?? em[2] ?? "").toLowerCase().split(".").pop() ?? ""; + if (missing && allCreated.has(missing)) return true; + } + + return false; +} diff --git a/packages/pg-delta/src/frontends/prune-sql-files.test.ts b/packages/pg-delta/src/frontends/prune-sql-files.test.ts new file mode 100644 index 000000000..4a90396e9 --- /dev/null +++ b/packages/pg-delta/src/frontends/prune-sql-files.test.ts @@ -0,0 +1,108 @@ +/** + * pruneStaleSqlFiles removes orphaned `.sql` files from a re-exported directory + * so `schema apply --dir` cannot reload a dropped object's stale file (PR #307 + * review P2). It only DELETES files the previous export OWNED (recorded in the + * manifest's `files` list); files it never owned are reported as `unmanaged` + * (and left on disk) unless `pruneUnmanaged` is set. Non-SQL files and files in + * the keep set are never touched. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pruneStaleSqlFiles } from "./prune-sql-files.ts"; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "pgdn-prune-")); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function write(rel: string, body = "-- sql\n"): string { + const full = resolve(root, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, body, "utf8"); + return full; +} + +describe("pruneStaleSqlFiles", () => { + test("removes previously-owned .sql files not in the keep set, recursively", () => { + const keepFile = write("schemas/app/tables/kept.sql"); + const staleFile = write("schemas/app/tables/dropped.sql"); + const staleNested = write("cluster/roles.sql"); + const previouslyOwned = new Set([keepFile, staleFile, staleNested]); + + const { removed, unmanaged } = pruneStaleSqlFiles( + root, + new Set([keepFile]), + previouslyOwned, + false, + ); + + expect(removed.sort()).toEqual([staleNested, staleFile].sort()); + expect(unmanaged).toEqual([]); + expect(existsSync(keepFile)).toBe(true); + expect(existsSync(staleFile)).toBe(false); + expect(existsSync(staleNested)).toBe(false); + }); + + test("flags unmanaged files (never owned) without deleting them", () => { + const handwritten = write("schemas/app/handwritten.sql"); + // previouslyOwned undefined => pre-feature / hand-authored dir. + const { removed, unmanaged } = pruneStaleSqlFiles( + root, + new Set(), + undefined, + false, + ); + expect(removed).toEqual([]); + expect(unmanaged).toEqual([handwritten]); + expect(existsSync(handwritten)).toBe(true); + }); + + test("only files present in previouslyOwned are deleted; others are unmanaged", () => { + const owned = write("owned.sql"); + const foreign = write("foreign.sql"); + const { removed, unmanaged } = pruneStaleSqlFiles( + root, + new Set(), + new Set([owned]), + false, + ); + expect(removed).toEqual([owned]); + expect(unmanaged).toEqual([foreign]); + expect(existsSync(owned)).toBe(false); + expect(existsSync(foreign)).toBe(true); + }); + + test("pruneUnmanaged deletes unmanaged files too and returns them under removed", () => { + const handwritten = write("schemas/app/handwritten.sql"); + const { removed, unmanaged } = pruneStaleSqlFiles( + root, + new Set(), + undefined, + true, + ); + expect(removed).toEqual([handwritten]); + expect(unmanaged).toEqual([]); + expect(existsSync(handwritten)).toBe(false); + }); + + test("never touches non-.sql files", () => { + const readme = write("README.md", "# notes\n"); + const stale = write("schemas/app/x.sql"); + pruneStaleSqlFiles(root, new Set(), new Set([stale]), false); + expect(existsSync(readme)).toBe(true); + expect(existsSync(stale)).toBe(false); + }); + + test("returns empty for a directory that does not exist yet (first export)", () => { + expect( + pruneStaleSqlFiles(resolve(root, "missing"), new Set(), undefined, false), + ).toEqual({ removed: [], unmanaged: [] }); + }); +}); diff --git a/packages/pg-delta/src/frontends/prune-sql-files.ts b/packages/pg-delta/src/frontends/prune-sql-files.ts new file mode 100644 index 000000000..b8d74c15e --- /dev/null +++ b/packages/pg-delta/src/frontends/prune-sql-files.ts @@ -0,0 +1,70 @@ +/** + * Prune orphaned `.sql` files from a declarative export directory. + * + * `schema export` writes one file per managed object. When it re-exports into a + * directory that already holds files from a PREVIOUS export, only the paths in + * the new file set are overwritten — a file for an object the source no longer + * has is left behind. `schema apply --dir` loads the directory recursively, so + * those stale files would reintroduce dropped objects/grants into the desired + * shadow state (PR #307 review P2). Removing them before writing keeps the + * exported directory a faithful mirror of the source. + * + * The pruner only DELETES files the previous export OWNED (the manifest's + * `files` list, resolved to absolute paths in `previouslyOwned`). A `.sql` file + * it never owned — hand-authored SQL an operator dropped into the directory, or + * everything in a pre-feature / manifest-less dir — is reported as `unmanaged` + * and left on disk; the caller refuses the export rather than silently deleting + * it (an unmanaged file is a real hazard because `schema apply --dir` loads the + * whole tree). `pruneUnmanaged` opts into deleting the unmanaged files too. + * + * Only `.sql` files are considered; non-SQL files the operator placed in the + * directory are never touched. + */ +import { readdirSync, rmSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Scan every `*.sql` file under `outRoot` whose absolute path is not in `keep`. + * A file present in `previouslyOwned` is a stale owned file and is DELETED + * (returned under `removed`); any other `.sql` is `unmanaged` and left on disk + * unless `pruneUnmanaged` is true (then it is deleted too and moved to + * `removed`, and `unmanaged` comes back empty). `previouslyOwned` is `undefined` + * when the previous manifest is absent or recorded no `files` list — then every + * out-of-set `.sql` is unmanaged. A missing `outRoot` (first export) scans + * nothing. + */ +export function pruneStaleSqlFiles( + outRoot: string, + keep: ReadonlySet, + previouslyOwned: ReadonlySet | undefined, + pruneUnmanaged: boolean, +): { removed: string[]; unmanaged: string[] } { + let entries: string[]; + try { + entries = readdirSync(outRoot, { recursive: true }) as string[]; + } catch { + return { removed: [], unmanaged: [] }; // directory does not exist yet + } + const removed: string[] = []; + const unmanaged: string[] = []; + for (const entry of entries) { + if (!entry.endsWith(".sql")) continue; + const full = resolve(outRoot, entry); + if (keep.has(full)) continue; + try { + if (!statSync(full).isFile()) continue; + } catch { + continue; // vanished between readdir and stat — ignore + } + if (previouslyOwned?.has(full)) { + rmSync(full); + removed.push(full); + } else if (pruneUnmanaged) { + rmSync(full); + removed.push(full); + } else { + unmanaged.push(full); + } + } + return { removed, unmanaged }; +} diff --git a/packages/pg-delta/src/frontends/render-plan-files.test.ts b/packages/pg-delta/src/frontends/render-plan-files.test.ts new file mode 100644 index 000000000..b3040d01c --- /dev/null +++ b/packages/pg-delta/src/frontends/render-plan-files.test.ts @@ -0,0 +1,300 @@ +/** + * renderPlanFiles (pure): reads a Plan artifact and produces one or more + * dbmate-friendly SQL file bodies, splitting on the SAME segment boundaries + * `apply()` uses at execution time (src/apply/apply.ts::segmentActions), so + * rendered files reflect exactly how the plan would be executed. + */ +import { describe, expect, test } from "bun:test"; +import type { Action, Plan } from "../plan/plan.ts"; +import { renderPlanFiles } from "./render-plan-files.ts"; + +function action(overrides: Partial): Action { + return { + sql: "SELECT 1", + verb: "create", + produces: [], + consumes: [], + destroys: [], + releases: [], + transactionality: "transactional", + lockClass: "none", + newSegmentBefore: false, + dataLoss: "none", + rewriteRisk: false, + ...overrides, + } as Action; +} + +function makePlan(overrides: Partial): Plan { + return { + formatVersion: 1, + engineVersion: "test", + source: { fingerprint: "a" }, + target: { fingerprint: "b" }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: {}, + }, + ...overrides, + } as Plan; +} + +describe("renderPlanFiles", () => { + test("single segment: one file with preamble + statements", () => { + const plan = makePlan({ + preamble: [{ name: "check_function_bodies", value: "off" }], + actions: [ + action({ sql: "CREATE SCHEMA foo" }), + action({ sql: "CREATE TABLE foo.bar (id integer);" }), + ], + }); + + const result = renderPlanFiles(plan, { allowDrops: false }); + + expect(result.changes).toBe(true); + expect(result.files).toHaveLength(1); + expect(result.files[0]!.suffix).toBeNull(); + expect(result.files[0]!.transactional).toBe(true); + expect(result.files[0]!.actionCount).toBe(2); + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "set local check_function_bodies = off; + + CREATE SCHEMA foo; + + CREATE TABLE foo.bar (id integer); + " + `); + }); + + test("multi segment: nonTransactional action splits into _1/_2 with transaction=false header", () => { + const plan = makePlan({ + preamble: [{ name: "check_function_bodies", value: "off" }], + actions: [ + action({ sql: "CREATE TABLE foo (id integer)" }), + action({ + sql: "CREATE INDEX CONCURRENTLY foo_idx ON foo (id)", + transactionality: "nonTransactional", + }), + ], + }); + + const result = renderPlanFiles(plan, { allowDrops: false }); + + expect(result.changes).toBe(true); + expect(result.files).toHaveLength(2); + + expect(result.files[0]!.suffix).toBe("_1"); + expect(result.files[0]!.transactional).toBe(true); + expect(result.files[0]!.actionCount).toBe(1); + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "set local check_function_bodies = off; + + CREATE TABLE foo (id integer); + " + `); + + expect(result.files[1]!.suffix).toBe("_2"); + expect(result.files[1]!.transactional).toBe(false); + expect(result.files[1]!.actionCount).toBe(1); + expect(result.files[1]!.contents).toMatchInlineSnapshot(` + "-- pg-delta: transaction=false + set check_function_bodies = off; + + CREATE INDEX CONCURRENTLY foo_idx ON foo (id); + + reset all; + " + `); + }); + + test("commitBoundaryAfter closes a segment, starting a new one after it", () => { + const plan = makePlan({ + preamble: [], + actions: [ + action({ + sql: "ALTER TYPE color ADD VALUE 'blue'", + transactionality: "commitBoundaryAfter", + }), + action({ sql: "CREATE TABLE uses_color (c color)" }), + ], + }); + + const result = renderPlanFiles(plan, { allowDrops: false }); + + expect(result.files).toHaveLength(2); + expect(result.files[0]!.suffix).toBe("_1"); + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "ALTER TYPE color ADD VALUE 'blue'; + " + `); + expect(result.files[1]!.suffix).toBe("_2"); + expect(result.files[1]!.contents).toMatchInlineSnapshot(` + "CREATE TABLE uses_color (c color); + " + `); + }); + + test("drop action without allowDrops throws naming the offending SQL", () => { + const plan = makePlan({ + actions: [action({ sql: "DROP TABLE foo", verb: "drop" })], + }); + + expect(() => renderPlanFiles(plan, { allowDrops: false })).toThrow( + /DROP TABLE foo/, + ); + }); + + test("drop action with allowDrops renders normally", () => { + const plan = makePlan({ + actions: [action({ sql: "DROP TABLE foo", verb: "drop" })], + }); + + const result = renderPlanFiles(plan, { allowDrops: true }); + + expect(result.changes).toBe(true); + expect(result.files).toHaveLength(1); + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "DROP TABLE foo; + " + `); + }); + + test("destructive non-drop action without allowDrops throws (gates on dataLoss, not just verb)", () => { + const plan = makePlan({ + actions: [ + action({ + sql: "ALTER TABLE foo ALTER COLUMN c TYPE new_enum USING c::text::new_enum", + verb: "alter", + dataLoss: "destructive", + }), + ], + }); + + expect(() => renderPlanFiles(plan, { allowDrops: false })).toThrow( + /destructive action/, + ); + expect(renderPlanFiles(plan, { allowDrops: true }).files).toHaveLength(1); + }); + + test("non-destructive drop-verb action (e.g. cron unschedule) is still gated by allowDrops", () => { + const plan = makePlan({ + actions: [ + action({ + sql: "select cron.unschedule('nightly')", + verb: "drop", + dataLoss: "none", + }), + ], + }); + + expect(() => renderPlanFiles(plan, { allowDrops: false })).toThrow( + /drop action/, + ); + expect(renderPlanFiles(plan, { allowDrops: true }).files).toHaveLength(1); + }); + + test("transactional segment scopes preamble with SET LOCAL (dies at COMMIT, no session leak)", () => { + const plan = makePlan({ + preamble: [ + { name: "search_path", value: "pg_catalog" }, + { name: "check_function_bodies", value: "off" }, + ], + actions: [action({ sql: "CREATE SCHEMA foo" })], + }); + + const [file] = renderPlanFiles(plan, { allowDrops: false }).files; + + // a transactional file runs inside dbmate's BEGIN/COMMIT, so SET LOCAL + // reverts at COMMIT — a reused runner session does not inherit the settings. + expect(file!.transactional).toBe(true); + expect(file!.contents).toContain("set local check_function_bodies = off;"); + // search_path is NOT pinned in rendered files: the DDL is already fully + // qualified so the pin is redundant, and a third-party runner (dbmate) + // executes its own UNqualified bookkeeping (INSERT INTO schema_migrations) + // in the same transaction — pinning search_path=pg_catalog would misresolve + // it to pg_catalog.schema_migrations and break the migration. + expect(file!.contents).not.toContain("search_path"); + }); + + test("non-transactional segment resets the preamble at end of file (no session leak)", () => { + const plan = makePlan({ + preamble: [ + { name: "search_path", value: "pg_catalog" }, + { name: "check_function_bodies", value: "off" }, + ], + actions: [ + action({ + sql: "CREATE INDEX CONCURRENTLY foo_idx ON foo (id)", + transactionality: "nonTransactional", + }), + ], + }); + + const [file] = renderPlanFiles(plan, { allowDrops: false }).files; + + // SET LOCAL is a no-op outside a transaction, so a non-transactional file + // must use plain SET and RESET at the end so the settings do not persist on + // the runner's session (mirrors apply()'s RESET ALL after standalone DDL). + // search_path is filtered out (see transactional test above), so only + // check_function_bodies is set — but RESET ALL still cleans it up. + expect(file!.transactional).toBe(false); + expect(file!.contents).toContain("set check_function_bodies = off;"); + expect(file!.contents).not.toContain("search_path"); + expect(file!.contents).not.toContain("set local"); + expect(file!.contents.trimEnd().endsWith("reset all;")).toBe(true); + }); + + test("does not emit any search_path preamble line (fully-qualified DDL makes it redundant; third-party runners like dbmate share the transaction with unqualified bookkeeping)", () => { + const plan = makePlan({ + preamble: [ + { name: "search_path", value: "pg_catalog" }, + { name: "check_function_bodies", value: "off" }, + ], + actions: [action({ sql: "CREATE TABLE public.widgets (id integer)" })], + }); + + const [file] = renderPlanFiles(plan, { allowDrops: false }).files; + + expect(file!.contents).not.toContain("search_path"); + expect(file!.contents).toContain("set local check_function_bodies = off;"); + // the redundancy claim: the create is already schema-qualified. + expect(file!.contents).toContain("CREATE TABLE public.widgets"); + }); + + test("empty plan: no changes, no files", () => { + const plan = makePlan({ actions: [] }); + + const result = renderPlanFiles(plan, { allowDrops: false }); + + expect(result.changes).toBe(false); + expect(result.files).toHaveLength(0); + }); + + test("semicolon normalization: with and without trailing ; both yield exactly one", () => { + const plan = makePlan({ + actions: [ + action({ sql: "CREATE SCHEMA already_terminated;" }), + action({ sql: "CREATE SCHEMA not_terminated" }), + action({ sql: "CREATE SCHEMA trailing_whitespace; \n\n" }), + ], + }); + + const result = renderPlanFiles(plan, { allowDrops: false }); + + expect(result.files[0]!.contents).toMatchInlineSnapshot(` + "CREATE SCHEMA already_terminated; + + CREATE SCHEMA not_terminated; + + CREATE SCHEMA trailing_whitespace; + " + `); + }); +}); diff --git a/packages/pg-delta/src/frontends/render-plan-files.ts b/packages/pg-delta/src/frontends/render-plan-files.ts new file mode 100644 index 000000000..8ab243b65 --- /dev/null +++ b/packages/pg-delta/src/frontends/render-plan-files.ts @@ -0,0 +1,117 @@ +/** + * Pure plan-to-SQL-files renderer. + * + * Splits a plan's actions into dbmate-friendly `.sql` file bodies along the + * SAME segment boundaries `apply()` uses at execution time + * (src/apply/apply.ts::segmentActions), so a rendered file set reflects + * exactly how the plan would be executed — including which statements share + * a transaction and which run standalone (nonTransactional / + * commitBoundaryAfter). + */ +import { segmentActions } from "../apply/apply.ts"; +import type { Plan } from "../plan/plan.ts"; + +export interface RenderPlanFilesOptions { + /** allow destructive actions to be rendered. Off by default: rendering a + * plan that drops or rewrites data without acknowledging it is a common way + * to ship a destructive migration by accident. Gates BOTH `drop`-verb actions + * AND any action the planner marked `dataLoss: "destructive"`. */ + allowDrops: boolean; +} + +export interface RenderedPlanFile { + /** null for a single-segment plan (`.sql`); "_1", "_2", … in + * execution order when the plan splits into multiple segments. */ + suffix: string | null; + contents: string; + transactional: boolean; + actionCount: number; +} + +export interface RenderPlanFilesResult { + /** false only when the plan has zero actions. */ + changes: boolean; + files: RenderedPlanFile[]; +} + +/** Normalize action SQL to end with exactly one semicolon (action.sql may or + * may not already have a trailing `;`, and may have trailing whitespace). */ +function terminate(sql: string): string { + const trimmed = sql.trimEnd(); + return trimmed.endsWith(";") ? trimmed : `${trimmed};`; +} + +export function renderPlanFiles( + plan: Plan, + opts: RenderPlanFilesOptions, +): RenderPlanFilesResult { + if (plan.actions.length === 0) { + return { changes: false, files: [] }; + } + + if (!opts.allowDrops) { + const offender = plan.actions.find( + (a) => a.verb === "drop" || a.dataLoss === "destructive", + ); + if (offender !== undefined) { + const why = + offender.verb === "drop" ? "a drop action" : "a destructive action"; + throw new Error( + `render: plan contains ${why}, refusing without --allow-drops: ${offender.sql}`, + ); + } + } + + const segments = segmentActions(plan.actions); + const multi = segments.length > 1; + + const files: RenderedPlanFile[] = segments.map((segment, index) => { + const header = segment.transactional + ? "" + : "-- pg-delta: transaction=false\n"; + const statements: string[] = []; + // Emit the preamble session settings EXCEPT search_path. A rendered file's + // DDL is already fully schema-qualified (extraction canonicalizes to + // pg_catalog before deparse), so pinning search_path is redundant for the + // migration statements — and it actively breaks third-party runners. dbmate + // (the platform's production deploy path) appends its OWN bookkeeping, + // `INSERT INTO schema_migrations ...` (UNqualified), inside the SAME + // transaction as this file; a pinned `search_path = pg_catalog` would + // resolve that insert to pg_catalog.schema_migrations and fail the + // migration. apply() keeps the pin on its own connection (no third-party + // bookkeeping shares it). check_function_bodies is retained — function + // bodies may legitimately need it and it does not affect name resolution of + // the runner's insert. + // + // Scope the remaining settings so a reused runner session (sequential + // migration runners share a connection) does not silently inherit them — + // mirroring apply() (src/apply/apply.ts): SET LOCAL inside a transactional + // segment (reverts at COMMIT), plain SET + a trailing RESET ALL around a + // standalone non-transactional action (SET LOCAL is a no-op outside a + // transaction). A transactional dbmate file runs inside its own BEGIN/COMMIT. + const settings = plan.preamble.filter((s) => s.name !== "search_path"); + for (const setting of settings) { + statements.push( + segment.transactional + ? `set local ${setting.name} = ${setting.value};` + : `set ${setting.name} = ${setting.value};`, + ); + } + for (let i = segment.start; i < segment.end; i++) { + statements.push(terminate(plan.actions[i]!.sql)); + } + // A non-transactional file cannot rely on COMMIT to drop its SETs, so reset + // them explicitly at the end (only when a setting was actually emitted). + if (!segment.transactional && settings.length > 0) { + statements.push("reset all;"); + } + return { + suffix: multi ? `_${index + 1}` : null, + contents: `${header}${statements.join("\n\n")}\n`, + transactional: segment.transactional, + actionCount: segment.end - segment.start, + }; + }); + + return { changes: true, files }; +} diff --git a/packages/pg-delta/src/frontends/schema-export.ts b/packages/pg-delta/src/frontends/schema-export.ts new file mode 100644 index 000000000..865b133d6 --- /dev/null +++ b/packages/pg-delta/src/frontends/schema-export.ts @@ -0,0 +1,164 @@ +/** + * Library frontend: build a declarative schema export (SQL files + manifest + * metadata) from a live pool. No fs / argv / process I/O — callers write the + * returned files themselves (see CLI `writeExportFiles`). + */ +import type { Pool } from "pg"; +import type { Diagnostic } from "../core/diagnostic.ts"; +import { + type IntegrationProfile, + resolveProfile, + type ResolveProfileOptions, +} from "../integrations/profile.ts"; +import { flattenPolicy } from "../policy/policy.ts"; +import { reconstructManagedView } from "../policy/reconstruct.ts"; +import type { ManagementScope } from "../policy/view.ts"; +import { exportSqlFiles, type ExportGrouping } from "./export-sql-files.ts"; +import type { ExportManifest } from "./export-manifest.ts"; +import type { SqlFile } from "./load-sql-files.ts"; +import type { SqlFormatOptions } from "./sql-format/index.ts"; + +export type { ManagementScope }; + +export interface BuildSchemaExportOptions { + /** Integration profile to resolve against the pool (default: caller must pass + * one — typically `rawProfile` or `supabaseProfile`). */ + profile: IntegrationProfile; + /** Management scope of the export. Default: `"database"`. */ + scope?: ManagementScope; + /** Secret redaction mode. Default: `true`. */ + redactSecrets?: boolean; + /** Extra resolveProfile options (restrictToApplier, baselineDir, …). */ + resolveOptions?: Omit; + layout?: "by-object" | "ordered" | "grouped"; + grouping?: ExportGrouping; + /** Pretty-print options; omit for raw renderer output. */ + format?: SqlFormatOptions; + /** Default owner whose OWNER TO stays implicit under database scope. + * - a role name → that role + * - `null` → verbose (every OWNER TO) + * - omit → profile defaultOwner, else `datdba` */ + defaultOwner?: string | null; + /** Soft warnings (e.g. default-owner vs connection-role mismatch). */ + onWarning?: (message: string) => void; +} + +export interface SchemaExportResult { + files: SqlFile[]; + diagnostics: Diagnostic[]; + /** Metadata to stamp into `.pgdelta-export.json` (or pass to planSchemaFiles). */ + manifest: ExportManifest & { + redactSecrets: boolean; + scope: ManagementScope; + }; +} + +/** + * Extract, project (policy → scope), and render declarative SQL files for a + * live database — the same pipeline as `pgdelta schema export`, without writing + * to disk. + */ +export async function buildSchemaExport( + pool: Pool, + options: BuildSchemaExportOptions, +): Promise { + const exportScope: ManagementScope = options.scope ?? "database"; + const redactSecrets = options.redactSecrets ?? true; + const layout = options.layout ?? "by-object"; + + const ctx = await resolveProfile(pool, options.profile, { + ...options.resolveOptions, + redactSecrets, + }); + + const { factBase, diagnostics } = await ctx.extract(pool, { redactSecrets }); + + const assumed = ctx.planOptions.policy + ? flattenPolicy(ctx.planOptions.policy) + : undefined; + + let resolvedDefaultOwner: string | null = null; + if (exportScope === "database") { + if (options.defaultOwner === null) { + resolvedDefaultOwner = null; + } else if ( + options.defaultOwner !== undefined && + options.defaultOwner !== "" + ) { + resolvedDefaultOwner = options.defaultOwner; + } else { + const profileDefault = assumed?.defaultOwner; + if (profileDefault !== undefined) { + resolvedDefaultOwner = profileDefault; + } else { + const r = await pool.query<{ owner: string }>( + `SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = current_database()`, + ); + resolvedDefaultOwner = r.rows[0]?.owner ?? null; + } + } + } + + // reconstruct BEFORE onWarning: resolveProfile keeps the caller-owned policy + // by reference, so a warning callback that mutates filters must not run until + // the managed view is already sealed (pre-V1 order: resolveView then warn). + const scopedView = reconstructManagedView(factBase, { + policy: ctx.planOptions.policy, + capability: ctx.planOptions.capability, + baseline: ctx.planOptions.baseline, + scope: exportScope, + ...(resolvedDefaultOwner !== null + ? { defaultOwner: resolvedDefaultOwner } + : {}), + }); + if (exportScope === "database" && resolvedDefaultOwner !== null) { + const cu = (await pool.query<{ u: string }>(`SELECT current_user AS u`)) + .rows[0]?.u; + if (cu !== undefined && cu !== resolvedDefaultOwner) { + options.onWarning?.( + `the resolved default owner "${resolvedDefaultOwner}" differs from the export ` + + `connection role "${cu}"; ownership of its objects will be left implicit (no OWNER TO). ` + + `Apply this directory connecting as "${resolvedDefaultOwner}", or re-export with ` + + `defaultOwner "${cu}" / defaultOwner null (verbose).`, + ); + } + } + const scopeAssumedRoles = + exportScope === "database" + ? factBase + .facts() + .filter((f) => f.id.kind === "role") + .map((f) => (f.id as { name: string }).name) + : []; + const assumedSchemas = assumed?.assumedSchemas ?? []; + const assumedRoles = [...(assumed?.assumedRoles ?? []), ...scopeAssumedRoles]; + + const files = exportSqlFiles(scopedView, { + layout, + ...(options.grouping !== undefined ? { grouping: options.grouping } : {}), + ...(options.format !== undefined ? { format: options.format } : {}), + ...(assumedSchemas.length > 0 ? { assumedSchemas } : {}), + ...(assumedRoles.length > 0 ? { assumedRoles } : {}), + ...(ctx.planOptions.intentRules !== undefined + ? { intentRules: ctx.planOptions.intentRules } + : {}), + ...(options.onWarning !== undefined + ? { onWarning: options.onWarning } + : {}), + }); + + const exportProfileId = ctx.planOptions.profile?.id; + const manifest: SchemaExportResult["manifest"] = { + redactSecrets, + scope: exportScope, + ...(exportProfileId !== undefined ? { profile: exportProfileId } : {}), + ...(ctx.baseline !== undefined + ? { baselineDigest: ctx.baseline.digest } + : {}), + ...(exportScope === "database" + ? { defaultOwner: resolvedDefaultOwner } + : {}), + }; + + return { files, diagnostics, manifest }; +} diff --git a/packages/pg-delta/src/frontends/schema-files.test.ts b/packages/pg-delta/src/frontends/schema-files.test.ts new file mode 100644 index 000000000..0cf03de6b --- /dev/null +++ b/packages/pg-delta/src/frontends/schema-files.test.ts @@ -0,0 +1,155 @@ +/** + * Pure guards for declarative schema files (empty/comment-only refusal, + * database-scope cluster-DDL preflight, manifest reconciliation). + */ +import { describe, expect, test } from "bun:test"; +import { + prepareSchemaFiles, + reconcileSchemaManifest, + SchemaFrontendError, +} from "./schema-plan.ts"; +import type { ExportManifest } from "./export-manifest.ts"; +import type { SqlFile } from "./load-sql-files.ts"; + +function files(entries: Record): SqlFile[] { + return Object.entries(entries).map(([name, sql]) => ({ name, sql })); +} + +describe("prepareSchemaFiles", () => { + test("refuses empty / comment-only input (would drop-all)", () => { + const r = prepareSchemaFiles(files({ "c.sql": "-- just a comment\n" }), { + scope: "database", + skipClusterDdl: false, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toContain("no executable SQL found"); + }); + + test("refuses cluster DDL in database scope without skipClusterDdl", () => { + const r = prepareSchemaFiles( + files({ + "roles.sql": "CREATE ROLE app;\n", + "t.sql": "CREATE TABLE public.t (id int);\n", + }), + { scope: "database", skipClusterDdl: false }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toContain("cluster DDL"); + }); + + test("refuses an all-cluster-DDL dir after skipClusterDdl", () => { + const r = prepareSchemaFiles( + files({ "roles.sql": "CREATE ROLE app;\nALTER ROLE app WITH LOGIN;\n" }), + { scope: "database", skipClusterDdl: true }, + ); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.message).toContain("no executable database-scope SQL remains"); + } + }); + + test("keeps non-cluster SQL when skipClusterDdl leaves real statements", () => { + const r = prepareSchemaFiles( + files({ + "1.sql": "CREATE ROLE app;\nCREATE TABLE public.t (id int);\n", + }), + { scope: "database", skipClusterDdl: true }, + ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.skipped.length).toBeGreaterThan(0); + expect(r.files.map((f) => f.sql).join("")).toContain("CREATE TABLE"); + } + }); + + test("accepts a normal database-scope dir", () => { + expect( + prepareSchemaFiles( + files({ "t.sql": "CREATE TABLE public.t (id int);\n" }), + { + scope: "database", + skipClusterDdl: false, + }, + ).ok, + ).toBe(true); + }); +}); + +describe("reconcileSchemaManifest", () => { + test("rejects profile / scope / redaction / baseline / default-owner mismatches", () => { + const manifest: ExportManifest = { + profile: "supabase", + scope: "database", + redactSecrets: true, + baselineDigest: "aaaa", + defaultOwner: "postgres", + }; + + expect(() => + reconcileSchemaManifest(manifest, { + profileId: "raw", + scope: "database", + redactSecrets: true, + baselineDigest: "aaaa", + }), + ).toThrow(SchemaFrontendError); + + expect(() => + reconcileSchemaManifest(manifest, { + profileId: "supabase", + scope: "cluster", + redactSecrets: true, + baselineDigest: "aaaa", + }), + ).toThrow(/scope/); + + expect(() => + reconcileSchemaManifest(manifest, { + profileId: "supabase", + scope: "database", + redactSecrets: false, + baselineDigest: "aaaa", + }), + ).toThrow(/redact/); + + expect(() => + reconcileSchemaManifest(manifest, { + profileId: "supabase", + scope: "database", + redactSecrets: true, + baselineDigest: "bbbb", + }), + ).toThrow(/baseline/); + + // Matching values succeed and return the reconciled options. + const ok = reconcileSchemaManifest(manifest, { + profileId: "supabase", + scope: "database", + redactSecrets: true, + baselineDigest: "aaaa", + }); + expect(ok).toMatchObject({ + profileId: "supabase", + scope: "database", + redactSecrets: true, + baselineDigest: "aaaa", + defaultOwner: "postgres", + }); + }); + + test("defaults scope/profile/redaction from the manifest when flags are omitted", () => { + const ok = reconcileSchemaManifest( + { + profile: "supabase", + scope: "cluster", + redactSecrets: false, + baselineDigest: "digest1", + }, + {}, + ); + expect(ok.profileId).toBe("supabase"); + expect(ok.scope).toBe("cluster"); + expect(ok.redactSecrets).toBe(false); + expect(ok.baselineDigest).toBe("digest1"); + }); +}); diff --git a/packages/pg-delta/src/frontends/schema-plan.ts b/packages/pg-delta/src/frontends/schema-plan.ts new file mode 100644 index 000000000..37a1cc7e6 --- /dev/null +++ b/packages/pg-delta/src/frontends/schema-plan.ts @@ -0,0 +1,576 @@ +/** + * Library frontend: plan declarative SQL files against a live target using a + * supplied shadow pool. No argv / stdout / process.exit — callers own both + * pools (`pool.end()`) and any co-located shadow cleanup. + */ +import type { Pool } from "pg"; +import type { ApplyOptions } from "../apply/apply.ts"; +import type { Diagnostic } from "../core/diagnostic.ts"; +import type { StableId } from "../core/stable-id.ts"; +import type { ExtractOptions, ExtractResult } from "../extract/extract.ts"; +import { + type IntegrationProfile, + resolveProfile, + type ResolveProfileOptions, + type ResolvedProfile, +} from "../integrations/profile.ts"; +import { plan, type Plan, type PlanOptions } from "../plan/plan.ts"; +import type { RenameMode } from "../plan/renames.ts"; +import { flattenPolicy } from "../policy/policy.ts"; +import type { ManagementScope } from "../policy/view.ts"; +import { scanTokens } from "./sql-format/tokenizer.ts"; +import type { ExportManifest } from "./export-manifest.ts"; +import { + findClusterDdlStatements, + findDefaultPrivilegeStatements, + findMatchingStatements, + findSessionSettingStatements, + loadSqlFiles, + ShadowLoadError, + stripClusterDdl, + type SqlFile, +} from "./load-sql-files.ts"; +import { deriveAssumedSchemaSeed } from "./seed-assumed-schemas.ts"; +import { + analyzeForShadow, + ReorderUnavailableError, + type OrderedSqlFile, + type ShadowLoadCycle, +} from "./sql-order.ts"; + +export class SchemaFrontendError extends Error { + constructor(message: string) { + super(message); + this.name = "SchemaFrontendError"; + } +} + +/** Discriminated result of {@link prepareSchemaFiles}. */ +export type PreparedSchemaFiles = + | { ok: true; files: SqlFile[]; skipped: { file: string; stmt: string }[] } + | { ok: false; message: string }; + +export interface PrepareSchemaFilesOptions { + scope: ManagementScope; + skipClusterDdl?: boolean; + /** Label used in refusal messages (e.g. a directory path). */ + label?: string; +} + +/** + * Validate declarative SQL files for planning: refuse empty/comment-only input + * (would build an empty shadow and drop-all) and enforce the database-scope + * cluster-DDL policy. + */ +export function prepareSchemaFiles( + input: SqlFile[], + options: PrepareSchemaFilesOptions, +): PreparedSchemaFiles { + const label = options.label ?? "input"; + const skipClusterDdl = options.skipClusterDdl === true; + let files = input; + const hasExecutableSql = (fs: SqlFile[]): boolean => + fs.some((f) => scanTokens(f.sql).length > 0); + + if (!hasExecutableSql(files)) { + return { + ok: false, + message: `no executable SQL found under ${label} (${files.length} file(s), all missing/empty/comment-only). Refusing to apply an empty desired state (it would drop every managed object on the target).`, + }; + } + + const skipped: { file: string; stmt: string }[] = []; + if (options.scope === "database") { + const offenders = files + .map((f) => ({ name: f.name, labels: findClusterDdlStatements(f.sql) })) + .filter((x) => x.labels.length > 0); + if (offenders.length > 0) { + if (!skipClusterDdl) { + const detail = offenders + .map(({ name, labels }) => ` ${name}: ${labels.join(", ")}`) + .join("\n"); + return { + ok: false, + message: + `scope database does not manage cluster-global roles, but found cluster DDL:\n${detail}\n` + + `Use scope cluster (with an isolated shadow) to manage roles, or skipClusterDdl to skip these statements.`, + }; + } + files = files.map((f) => { + const { kept, skipped: sk } = stripClusterDdl(f.sql); + for (const s of sk) { + skipped.push({ file: f.name, stmt: s.split("\n")[0] ?? "" }); + } + return { ...f, sql: kept }; + }); + if (!hasExecutableSql(files)) { + return { + ok: false, + message: `after skipClusterDdl, no executable database-scope SQL remains under ${label}. Refusing to apply an empty desired state (it would drop every managed object on the target).`, + }; + } + } + } + return { ok: true, files, skipped }; +} + +export interface ReconcileSchemaManifestFlags { + profileId?: string; + scope?: ManagementScope; + redactSecrets?: boolean; + /** Digest the caller's resolved profile will subtract; compared to the + * manifest when both are present. Pass the key (even as `undefined`) to + * enable the baseline check after profile resolution. */ + baselineDigest?: string | undefined; +} + +export interface ReconciledSchemaOptions { + profileId: string | undefined; + scope: ManagementScope; + redactSecrets: boolean; + baselineDigest: string | undefined; + defaultOwner: string | null | undefined; +} + +/** + * Reconcile caller flags with an export manifest. Fail closed on any + * contradiction (profile, scope, redaction, baseline). When a flag is omitted, + * the manifest value (if any) wins; otherwise defaults apply (scope=database, + * redactSecrets=true). + */ +export function reconcileSchemaManifest( + manifest: ExportManifest | undefined, + flags: ReconcileSchemaManifestFlags, +): ReconciledSchemaOptions { + // profile + if ( + flags.profileId !== undefined && + manifest?.profile !== undefined && + flags.profileId !== manifest.profile + ) { + throw new SchemaFrontendError( + `profile "${flags.profileId}" contradicts the export manifest profile (${manifest.profile}); re-export or drop the profile override.`, + ); + } + const profileId = flags.profileId ?? manifest?.profile; + + // scope + if ( + flags.scope !== undefined && + manifest?.scope !== undefined && + flags.scope !== manifest.scope + ) { + throw new SchemaFrontendError( + `scope ${flags.scope} contradicts the export manifest scope (${manifest.scope}); re-export or drop the scope override.`, + ); + } + const scope: ManagementScope = flags.scope ?? manifest?.scope ?? "database"; + + // redaction — fail closed when both sides disagree + if ( + flags.redactSecrets !== undefined && + manifest?.redactSecrets !== undefined && + flags.redactSecrets !== manifest.redactSecrets + ) { + throw new SchemaFrontendError( + `redactSecrets=${flags.redactSecrets} contradicts the export manifest (redactSecrets=${manifest.redactSecrets}); re-export or drop the redaction override.`, + ); + } + const redactSecrets = flags.redactSecrets ?? manifest?.redactSecrets ?? true; + + // baseline — only when the caller supplies a resolved digest to check + // (`"baselineDigest" in flags`). The pre-resolve pass omits the key so a + // stamped digest is not treated as "profile declares none". + if (manifest !== undefined && "baselineDigest" in flags) { + const stamped = manifest.baselineDigest; + const resolved = flags.baselineDigest; + if (stamped !== resolved) { + if (stamped !== undefined && resolved !== undefined) { + throw new SchemaFrontendError( + `baseline mismatch: the export manifest was produced with baseline digest ${stamped.slice(0, 12)} ` + + `but the profile now resolves to ${resolved.slice(0, 12)}.`, + ); + } + if (stamped !== undefined) { + throw new SchemaFrontendError( + `baseline mismatch: the export manifest was produced with baseline digest ${stamped.slice(0, 12)} ` + + `but the profile now declares NO baseline.`, + ); + } + throw new SchemaFrontendError( + `baseline mismatch: the profile declares a baseline (digest ${resolved?.slice(0, 12)}) but the export manifest ` + + `was produced with NONE.`, + ); + } + } + + return { + profileId, + scope, + redactSecrets, + baselineDigest: manifest?.baselineDigest ?? flags.baselineDigest, + defaultOwner: manifest?.defaultOwner, + }; +} + +export interface PlanSchemaFilesOptions { + profile: IntegrationProfile; + /** Explicit scope; reconciled against manifest when both are set. Default: manifest or `"database"`. */ + scope?: ManagementScope; + /** Export manifest from the directory (or buildSchemaExport). */ + manifest?: ExportManifest; + redactSecrets?: boolean; + skipClusterDdl?: boolean; + /** Load mode: dedicated cluster allows role DDL. Default: false (databaseScratch). */ + isolatedShadow?: boolean; + /** Seed assumed-schema objects into a co-located (fresh) shadow before load. */ + seedAssumedSchemas?: boolean; + renames?: RenameMode; + acceptRenames?: Array<{ from: StableId; to: StableId }>; + resolveOptions?: Omit; + strictFunctionBodies?: boolean; + /** Statement-reorder assist. Default: true. */ + reorder?: boolean; + /** Soft warnings (reorder fallback, ADP caveat, …). */ + onWarning?: (message: string) => void; + /** Optional rewrite of a ShadowLoadError after reorder (CLI attaches file:line). */ + onShadowLoadError?: ( + error: ShadowLoadError, + ctx: { + orderedFiles: OrderedSqlFile[] | null; + cycles: ShadowLoadCycle[]; + originalSqlByName: Map; + }, + ) => Error; +} + +export interface PlanSchemaFilesResult { + plan: Plan; + loadDiagnostics: Diagnostic[]; + targetDiagnostics: Diagnostic[]; + skipped: { file: string; stmt: string }[]; + /** Same resolved profile bundles for a subsequent `apply()`. */ + applyOptions: ApplyOptions; + planOptions: PlanOptions; + extract: (pool: Pool, options?: ExtractOptions) => Promise; + /** Resolved default owner under database scope (undefined = verbose / N/A). */ + defaultOwner?: string; + scope: ManagementScope; + redactSecrets: boolean; + /** The resolved profile (handlers, baseline meta, …). */ + profile: ResolvedProfile; +} + +/** + * Plan declarative SQL files against a target using a supplied shadow pool. + * Does not apply and does not end either pool. + */ +export async function planSchemaFiles( + targetPool: Pool, + shadowPool: Pool, + inputFiles: SqlFile[], + options: PlanSchemaFilesOptions, +): Promise { + // First-pass reconcile without baseline (profile not resolved yet). + const pre = reconcileSchemaManifest(options.manifest, { + profileId: options.profile.id, + ...(options.scope !== undefined ? { scope: options.scope } : {}), + ...(options.redactSecrets !== undefined + ? { redactSecrets: options.redactSecrets } + : {}), + }); + + if (pre.scope === "cluster" && options.isolatedShadow !== true) { + throw new SchemaFrontendError( + "scope cluster manages cluster-global roles and must run against a dedicated shadow cluster; pass isolatedShadow: true.", + ); + } + + const prepared = prepareSchemaFiles(inputFiles, { + scope: pre.scope, + skipClusterDdl: options.skipClusterDdl === true, + }); + if (!prepared.ok) { + throw new SchemaFrontendError(prepared.message); + } + const files = prepared.files; + + const redactSecrets = pre.redactSecrets; + const ctx = await resolveProfile(targetPool, options.profile, { + ...options.resolveOptions, + redactSecrets, + }); + + // Second-pass: baseline digest now known. + reconcileSchemaManifest(options.manifest, { + profileId: options.profile.id, + scope: pre.scope, + redactSecrets, + baselineDigest: ctx.baseline?.digest, + }); + + const scope = pre.scope; + + // Default owner (database scope): stamped role must match target connection. + let applyDefaultOwner: string | undefined; + if (scope === "database") { + const mdo = options.manifest?.defaultOwner; + if (typeof mdo === "string") { + const cu = ( + await targetPool.query<{ u: string }>(`SELECT current_user AS u`) + ).rows[0]?.u; + if (cu !== mdo) { + throw new SchemaFrontendError( + `the export's default owner "${mdo}" does not match the target ` + + `connection role "${cu}". Objects the export left implicitly owned by "${mdo}" ` + + `would reload owned by "${cu}", producing spurious ownership drift.`, + ); + } + const scu = ( + await shadowPool.query<{ u: string }>(`SELECT current_user AS u`) + ).rows[0]?.u; + if (scu !== mdo) { + throw new SchemaFrontendError( + `the export's default owner "${mdo}" does not match the shadow ` + + `connection role "${scu}". Objects the export left implicitly owned by "${mdo}" ` + + `would load into the shadow owned by "${scu}", producing spurious ownership drift.`, + ); + } + applyDefaultOwner = mdo; + } else { + // null (verbose) or absent (pre-feature / hand-authored): no suppression. + applyDefaultOwner = undefined; + if (mdo === undefined && options.manifest !== undefined) { + // manifest present but field absent — note only when we have a manifest + // without the field (pre-feature). Hand-authored (no manifest) is silent. + if (!("defaultOwner" in options.manifest)) { + options.onWarning?.( + "the directory records no default owner, so it is applied verbose " + + "(all ownership honored as written). Re-export with the current pg-delta to " + + "record a default owner.", + ); + } + } + } + } + + // Extension shadow precheck + for (const handler of ctx.handlers) { + const precheck = handler.shadowPrecheck; + if (precheck === undefined) continue; + const matched = files.filter( + (f) => + findMatchingStatements(f.sql, (s) => precheck.matchesStatement(s)) + .length > 0, + ); + if (matched.length === 0) continue; + const verdict = await precheck.capable((sql) => + shadowPool.query(sql).then((r) => r.rows), + ); + if (!verdict.capable) { + throw new SchemaFrontendError( + `${matched.length} file(s) contain ${handler.extension} statements ` + + `(${matched.map((f) => f.name).join(", ")}) but the shadow database cannot ` + + `execute them: ${verdict.reason}.`, + ); + } + } + + const targetResult = await ctx.extract(targetPool, { redactSecrets }); + + const assumedTargetRoles = + scope === "database" + ? targetResult.factBase + .facts() + .filter((f) => f.id.kind === "role") + .map((f) => (f.id as { name: string }).name) + : []; + + let seededSchemas: string[] = []; + let seededRoutines = new Map(); + if (options.seedAssumedSchemas === true) { + const flatProfile = ctx.planOptions.policy + ? flattenPolicy(ctx.planOptions.policy) + : undefined; + const profileAssumedSchemas = flatProfile?.assumedSchemas ?? []; + if (profileAssumedSchemas.length > 0) { + const seed = deriveAssumedSchemaSeed(targetResult.factBase, { + ...(ctx.planOptions.policy ? { policy: ctx.planOptions.policy } : {}), + ...(ctx.planOptions.capability + ? { capability: ctx.planOptions.capability } + : {}), + ...(ctx.planOptions.baseline + ? { baseline: ctx.planOptions.baseline } + : {}), + assumedSchemas: profileAssumedSchemas, + assumedRoles: [ + ...(flatProfile?.assumedRoles ?? []), + ...assumedTargetRoles, + ], + ...(ctx.susetGucs !== undefined ? { susetGucs: ctx.susetGucs } : {}), + }); + if (seed.sql !== "") { + const seedClient = await shadowPool.connect(); + try { + // Same PG 16+ CREATEROLE non-superuser grant as loadSqlFiles: seed SQL + // may CREATE SCHEMA … AUTHORIZATION for assumed owners. + try { + await seedClient.query( + `SELECT set_config('createrole_self_grant', 'set, inherit', false)`, + ); + } catch { + /* PG < 16 or GUC unavailable */ + } + await seedClient.query(seed.sql); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new SchemaFrontendError( + `Failed to seed the co-located shadow with the target's assumed-schema objects: ${msg}`, + ); + } finally { + seedClient.release(); + } + seededSchemas = seed.schemas; + seededRoutines = seed.seededRoutines; + } + } + } + + const reorder = options.reorder !== false; + let orderedFiles: OrderedSqlFile[] | null = null; + let cycles: ShadowLoadCycle[] = []; + let loadInput: SqlFile[] = files; + if (reorder) { + let analyzed: Awaited> | null = null; + try { + analyzed = await analyzeForShadow(files); + } catch (err) { + if (!(err instanceof ReorderUnavailableError)) throw err; + options.onWarning?.( + "reorder assist unavailable (optional peer @supabase/pg-topo not installed). Loading files raw at file granularity.", + ); + } + if (analyzed !== null) { + const parseErrors = analyzed.diagnostics.filter( + (d) => d.code === "PARSE_ERROR" || d.code === "DISCOVERY_ERROR", + ); + const sessionSettingFiles = files.filter( + (f) => findSessionSettingStatements(f.sql).length > 0, + ); + const defaultPrivFiles = + options.manifest === undefined + ? files.filter( + (f) => findDefaultPrivilegeStatements(f.sql).length > 0, + ) + : []; + + if ( + parseErrors.length > 0 || + sessionSettingFiles.length > 0 || + defaultPrivFiles.length > 0 + ) { + const reasons: string[] = []; + if (parseErrors.length > 0) { + reasons.push( + `pg-topo could not parse ${parseErrors.length} input(s) — reordering would silently drop them`, + ); + } + if (sessionSettingFiles.length > 0) { + reasons.push( + `session-setting statements in ${sessionSettingFiles + .map((f) => f.name) + .join(", ")} must not be reordered`, + ); + } + if (defaultPrivFiles.length > 0) { + reasons.push( + `ALTER DEFAULT PRIVILEGES in ${defaultPrivFiles + .map((f) => f.name) + .join(", ")} must not be reordered past the objects it scopes`, + ); + } + options.onWarning?.( + `reorder assist disabled — ${reasons.join("; ")}. Loading files raw at file granularity.`, + ); + } else { + orderedFiles = analyzed.files; + cycles = analyzed.cycles; + loadInput = analyzed.files; + } + } + } + + if (orderedFiles === null) { + const adpFiles = files.filter( + (f) => findDefaultPrivilegeStatements(f.sql).length > 0, + ); + if (adpFiles.length > 0) { + options.onWarning?.( + "raw loading may apply ALTER DEFAULT PRIVILEGES AFTER objects created in the same load, so objects relying on ADP-implicit default grants may not receive them. Grant those privileges explicitly (as schema export does).", + ); + } + } + + const originalSqlByName = new Map(files.map((f) => [f.name, f.sql])); + + let loadResult; + try { + loadResult = await loadSqlFiles(loadInput, shadowPool, { + extract: (p, o) => ctx.extract(p, { ...o, redactSecrets }), + ...(seededSchemas.length > 0 ? { seededSchemas, seededRoutines } : {}), + strictFunctionBodies: options.strictFunctionBodies === true, + ...(options.isolatedShadow === true + ? { mode: "isolatedCluster" as const } + : {}), + }); + } catch (error) { + if (error instanceof ShadowLoadError && orderedFiles) { + if (options.onShadowLoadError !== undefined) { + throw options.onShadowLoadError(error, { + orderedFiles, + cycles, + originalSqlByName, + }); + } + } + throw error; + } + + const planOptions: PlanOptions = { + renames: options.renames ?? "off", + scope, + ...(options.acceptRenames !== undefined && options.acceptRenames.length > 0 + ? { acceptRenames: options.acceptRenames } + : {}), + ...ctx.planOptions, + ...(assumedTargetRoles.length > 0 + ? { + assumedRoles: [ + ...(ctx.planOptions.assumedRoles ?? []), + ...assumedTargetRoles, + ], + } + : {}), + ...(applyDefaultOwner !== undefined + ? { defaultOwner: applyDefaultOwner } + : {}), + }; + + const thePlan = plan(targetResult.factBase, loadResult.factBase, planOptions); + + return { + plan: thePlan, + loadDiagnostics: loadResult.diagnostics, + targetDiagnostics: targetResult.diagnostics, + skipped: prepared.skipped, + applyOptions: ctx.applyOptions, + planOptions, + extract: ctx.extract, + ...(applyDefaultOwner !== undefined + ? { defaultOwner: applyDefaultOwner } + : {}), + scope, + redactSecrets, + profile: ctx, + }; +} diff --git a/packages/pg-delta/src/frontends/seed-assumed-schemas.test.ts b/packages/pg-delta/src/frontends/seed-assumed-schemas.test.ts new file mode 100644 index 000000000..b169991da --- /dev/null +++ b/packages/pg-delta/src/frontends/seed-assumed-schemas.test.ts @@ -0,0 +1,341 @@ +/** + * deriveAssumedSchemaSeed unit pins (no Docker). Guards the Phase 2b seed + * derivation and the two silent-failure modes the design review (Fable) flagged: + * - the seed plan must NOT re-project (no policy / no referenceOnly), or the + * diff would skip every seed fact → a silently EMPTY seed; + * - extension members must be excluded (they can't be CREATEd standalone). + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { flattenPolicy, type Policy } from "../policy/policy.ts"; +import { supabasePolicy } from "../policy/supabase.ts"; +import { deriveAssumedSchemaSeed } from "./seed-assumed-schemas.ts"; + +const f = (id: StableId, payload: Fact["payload"] = {}): Fact => ({ + id, + payload, +}); + +const supabaseAssumedSchemas = + flattenPolicy(supabasePolicy).assumedSchemas ?? []; + +const schemaAuth: StableId = { kind: "schema", name: "auth" }; +const schemaPublic: StableId = { kind: "schema", name: "public" }; +const schemaApp: StableId = { kind: "schema", name: "app" }; + +describe("deriveAssumedSchemaSeed", () => { + test("seeds an assumed schema and NOT a user schema (non-empty; Q6b/Q6f pin)", () => { + // auth is an assumed (system) schema → reference-only → seeded. + // app / public are user-managed → NOT seeded. If a regression forwarded the + // policy (or referenceOnly) INTO the seed plan, resolveView would re-mark + // `auth` reference-only, the diff would skip it, and this would go EMPTY — + // which is exactly the silent failure this assertion catches. It ALSO fails + // if a future committed supabase baseline subtracts `auth`, forcing whoever + // lands it to revisit Phase 2b (see supabase.ts baseline TODO). + const target = buildFactBase( + [f(schemaPublic), f(schemaApp), f(schemaAuth)], + [], + ); + const seed = deriveAssumedSchemaSeed(target, { + policy: supabasePolicy, + assumedSchemas: supabaseAssumedSchemas, + assumedRoles: [], + }); + expect(seed.sql).toContain('CREATE SCHEMA "auth"'); + expect(seed.sql).not.toContain('"app"'); + expect(seed.schemas).toEqual(["auth"]); + expect(seed.facts).toBe(1); + }); + + test("excludes extension members (can't be CREATEd standalone)", () => { + const ext: StableId = { kind: "extension", name: "someext" }; + const memberFn: StableId = { + kind: "function", + schema: "auth", + name: "member_fn", + args: [], + }; + const target = buildFactBase( + [ + f(schemaAuth), + f(ext), + f(memberFn, { + def: `CREATE FUNCTION "auth"."member_fn"() RETURNS void LANGUAGE sql AS $$ $$`, + }), + ], + [{ from: memberFn, to: ext, kind: "memberOfExtension" }], + ); + const seed = deriveAssumedSchemaSeed(target, { + policy: supabasePolicy, + assumedSchemas: supabaseAssumedSchemas, + assumedRoles: [], + }); + // the assumed schema is seeded; the extension member is not. + expect(seed.sql).toContain('CREATE SCHEMA "auth"'); + expect(seed.sql).not.toContain("member_fn"); + expect(seed.facts).toBe(1); + }); + + test("a diff-time baseline containing the assumed schema does NOT empty the seed", () => { + // The seed answers the SUPERSET question — "what platform objects must + // exist for user SQL to elaborate in the shadow" — so it must derive from + // the RAW target, BEFORE the diff-time baseline subtraction. A baseline that + // contains `auth` (as a real platform baseline would) must NOT remove auth + // from the seed, or a co-located apply of a user dir referencing auth.users + // could not load. (Codex #323 finding 3: the seed used to forward the + // baseline into resolveView, silently emptying the seed.) + const target = buildFactBase([f(schemaPublic), f(schemaAuth)], []); + const baseline = buildFactBase([f(schemaAuth)], []); + const seed = deriveAssumedSchemaSeed(target, { + policy: supabasePolicy, + assumedSchemas: supabaseAssumedSchemas, + assumedRoles: [], + baseline, + }); + expect(seed.sql).toContain('CREATE SCHEMA "auth"'); + expect(seed.facts).toBe(1); + }); + + test("raw profile (no assumed schemas) seeds nothing", () => { + const target = buildFactBase([f(schemaPublic), f(schemaApp)], []); + const seed = deriveAssumedSchemaSeed(target, { + assumedSchemas: [], + assumedRoles: [], + }); + expect(seed).toEqual({ + sql: "", + facts: 0, + schemas: [], + seededRoutines: new Map(), + }); + }); + + // Unit C: a seeded routine carrying a superuser-only `SET` clause cannot be + // CREATEd by a non-superuser applier at all (SQLSTATE 42501 at CREATE time), + // and platform ADP entries fail the same way. The routine's `SET` clause lives + // inside its semantic `def` payload; the parallel `_configGucs` key is a + // NON-semantic structured duplicate of the SET GUC NAMES (populated from + // `pg_proc.proconfig` at extract time, `_`-prefixed ⇒ dropped from hash + diff) + // that lets the seed DECIDE which routines to skip WITHOUT reading SQL text. A + // custom profile that assumes `platform` marks its facts reference-only, so + // they land in the seed. + const platformPolicy: Policy = { + id: "test-platform", + filter: [ + { + match: { any: [{ schema: "platform" }, { name: "platform" }] }, + action: "exclude", + }, + ], + assumedSchemas: ["platform"], + }; + const schemaPlatform: StableId = { kind: "schema", name: "platform" }; + const noisyFn: StableId = { + kind: "function", + schema: "platform", + name: "noisy", + args: [], + }; + const tidyFn: StableId = { + kind: "function", + schema: "platform", + name: "tidy", + args: [], + }; + // Mirrors pg_get_functiondef output: SET clauses are header lines before `AS`. + const noisyDef = + `CREATE OR REPLACE FUNCTION platform.noisy()\n` + + ` RETURNS integer\n` + + ` LANGUAGE sql\n` + + ` SET search_path TO 'public'\n` + + ` SET log_min_messages TO 'fatal'\n` + + `AS $function$SELECT 1$function$`; + const tidyDef = + `CREATE OR REPLACE FUNCTION platform.tidy()\n` + + ` RETURNS integer\n` + + ` LANGUAGE sql\n` + + ` SET search_path TO 'public'\n` + + `AS $function$SELECT 1$function$`; + const routinePayload = ( + def: string, + configGucs: string[], + ): Fact["payload"] => ({ + def, + returnType: "integer", + argSignature: "", + language: "sql", + isWindow: false, + // Structured GUC names from proconfig; `_`-prefixed ⇒ non-semantic (excluded + // from hash + diff), used only for the seed skip decision. + _configGucs: configGucs, + }); + const adpFact: Fact = { + id: { + kind: "defaultPrivilege", + role: "admin", + schema: "platform", + objtype: "r", + grantee: "reader", + }, + payload: { privileges: ["SELECT"], grantable: [] }, + }; + // Snapshot target: a single routine + ADP, so the no-option path can be pinned + // byte-identical (routine def rendered verbatim, ADP omitted). + const platformTarget = () => + buildFactBase( + [ + f(schemaPlatform), + f( + noisyFn, + routinePayload(noisyDef, ["search_path", "log_min_messages"]), + ), + adpFact, + ], + [], + ); + + test("susetGucs excludes the whole offending routine (not a stripped copy); a search_path-only routine is seeded intact; ADP omitted", () => { + const target = buildFactBase( + [ + f(schemaPlatform), + f( + noisyFn, + routinePayload(noisyDef, ["search_path", "log_min_messages"]), + ), + f(tidyFn, routinePayload(tidyDef, ["search_path"])), + adpFact, + ], + [], + ); + const seed = deriveAssumedSchemaSeed(target, { + policy: platformPolicy, + assumedSchemas: ["platform"], + assumedRoles: ["admin", "reader"], + susetGucs: new Set(["log_min_messages"]), + }); + // the WHOLE offending routine is absent — no trace of a stripped version. + expect(seed.sql).not.toContain("noisy"); + expect(seed.sql).not.toContain("log_min_messages"); + // a routine whose only proconfig GUC is user-context (search_path) is kept. + expect(seed.sql).toContain("platform.tidy()"); + expect(seed.sql).toContain("SET search_path TO 'public'"); + // ADP is unconditionally omitted (no member-of-role at replay). + expect(seed.sql).not.toContain("ALTER DEFAULT PRIVILEGES"); + }); + + test("a fact depending on the excluded routine is excluded transitively", () => { + const dependentFn: StableId = { + kind: "function", + schema: "platform", + name: "dependent", + args: [], + }; + const dependentDef = + `CREATE OR REPLACE FUNCTION platform.dependent()\n` + + ` RETURNS integer\n` + + ` LANGUAGE sql\n` + + `AS $function$SELECT platform.noisy()$function$`; + const target = buildFactBase( + [ + f(schemaPlatform), + f( + noisyFn, + routinePayload(noisyDef, ["search_path", "log_min_messages"]), + ), + f(dependentFn, routinePayload(dependentDef, [])), + ], + [{ from: dependentFn, to: noisyFn, kind: "depends" }], + ); + const seed = deriveAssumedSchemaSeed(target, { + policy: platformPolicy, + assumedSchemas: ["platform"], + assumedRoles: [], + susetGucs: new Set(["log_min_messages"]), + }); + // the excluded routine AND its dependent are both gone; the schema remains. + expect(seed.sql).not.toContain("noisy"); + expect(seed.sql).not.toContain("dependent"); + expect(seed.sql).toContain('CREATE SCHEMA "platform"'); + }); + + test("a container fact excluded via depends does not orphan its CHILD facts (structural cascade)", () => { + // A view depends (via `depends` edge) on the SUSET-GUC routine, so the + // depends-edge fixpoint excludes the view. Parent/child containment is NOT + // a `depends` edge (it's structural, via Fact.parent — see fact.ts), so + // without a structural cascade the view's column child survives the flat + // `keptFacts.filter` and buildFactBase throws "references missing parent" + // because its parent (the view) was excluded. + const reportsView: StableId = { + kind: "view", + schema: "platform", + name: "reports", + }; + const reportsIdColumn: StableId = { + kind: "column", + schema: "platform", + table: "reports", + name: "id", + }; + const target = buildFactBase( + [ + f(schemaPlatform), + f( + noisyFn, + routinePayload(noisyDef, ["search_path", "log_min_messages"]), + ), + { id: reportsView, parent: schemaPlatform, payload: {} }, + { id: reportsIdColumn, parent: reportsView, payload: {} }, + ], + [{ from: reportsView, to: noisyFn, kind: "depends" }], + ); + // Before the fix this call throws: + // FactBase: fact column|platform|reports|id references missing parent view|platform|reports + const seed = deriveAssumedSchemaSeed(target, { + policy: platformPolicy, + assumedSchemas: ["platform"], + assumedRoles: [], + susetGucs: new Set(["log_min_messages"]), + }); + // the excluded routine AND the dependent view AND its column child are all + // gone; the schema remains. + expect(seed.sql).not.toContain("noisy"); + expect(seed.sql).not.toContain("reports"); + expect(seed.sql).toContain('CREATE SCHEMA "platform"'); + }); + + test("without susetGucs: routine def retained verbatim (byte-identical), ADP still omitted", () => { + const seed = deriveAssumedSchemaSeed(platformTarget(), { + policy: platformPolicy, + assumedSchemas: ["platform"], + assumedRoles: ["admin", "reader"], + }); + expect(seed.sql).toMatchInlineSnapshot(` + "SET check_function_bodies = off; + + CREATE SCHEMA "platform"; + + CREATE OR REPLACE FUNCTION platform.noisy() + RETURNS integer + LANGUAGE sql + SET search_path TO 'public' + SET log_min_messages TO 'fatal' + AS $function$SELECT 1$function$; + + RESET check_function_bodies; + " + `); + }); + + test("no policy → nothing is reference-only → seeds nothing", () => { + // assumedSchemas is non-empty but without a policy resolveView is the + // identity projection, so no fact is reference-only and the seed is empty. + const target = buildFactBase([f(schemaPublic), f(schemaAuth)], []); + const seed = deriveAssumedSchemaSeed(target, { + assumedSchemas: ["auth"], + assumedRoles: [], + }); + expect(seed.sql).toBe(""); + expect(seed.facts).toBe(0); + }); +}); diff --git a/packages/pg-delta/src/frontends/seed-assumed-schemas.ts b/packages/pg-delta/src/frontends/seed-assumed-schemas.ts new file mode 100644 index 000000000..ae0824ad2 --- /dev/null +++ b/packages/pg-delta/src/frontends/seed-assumed-schemas.ts @@ -0,0 +1,280 @@ +/** + * Phase 2b (#41): derive the SQL that seeds a co-located shadow database with a + * target's ASSUMED-SCHEMA objects (e.g. `auth.users` under `--profile + * supabase`), so a user declarative dir that references those platform objects + * can load into the otherwise-empty shadow. + * + * The managed view (`resolveView`) marks two disjoint categories reference-only + * in ONE set: assumed-schema objects (`auth.users`) and extension members + * (`net.http_get`). We seed only the former: + * - extension members can't be `CREATE`d standalone — `CREATE EXTENSION` owns + * their lifecycle — and they don't NEED seeding: they are reference-only on + * the target (diff) side, and `diff.ts` skips a fact reference-only on EITHER + * side, so a shadow that lacks them plans no spurious DROP. A user file that + * references a member is served by the user's own `CREATE EXTENSION`. + * - assumed-schema EXTENSIONS themselves (a system extension whose install + * schema is assumed, e.g. `pg_graphql` in `graphql`) are NOT members, so + * they land in the seed as `CREATE EXTENSION` and materialize their members + * for free — the same-cluster shadow has the shared libraries. + * + * Symmetry: after seed + user load, the shadow is re-extracted through the SAME + * profile, so the seeded objects come back reference-only and cancel against the + * target's reference-only copies in `plan()` — nothing leaks into the diff. + * + * Baseline is deliberately NOT applied here (Codex #323 finding 3). The seed is + * the SUPERSET question — "what platform objects must exist for the user SQL to + * elaborate in the shadow" — whereas the diff is the SUBSET question — "what do + * we manage". Only the diff subtracts the baseline. A baseline routinely + * CONTAINS the assumed-schema objects (e.g. `auth.users`), so subtracting it + * before the reference-only marking would silently empty the seed and a + * co-located apply of a user dir referencing those objects could not load. The + * profile's baseline is still accepted in `opts` so callers pass their resolved + * options uniformly, but it is intentionally ignored for seed derivation. + * + * Non-superuser replay (Unit C): real Supabase Cloud hands users a privileged + * NON-superuser `postgres`, so the seed must CREATE cleanly as a non-superuser. + * Two fact classes cannot and are SKIPPED WHOLE (the engine never edits SQL text + * — the fact is omitted, not rewritten): + * - `defaultPrivilege` (ADP): `ALTER DEFAULT PRIVILEGES FOR ROLE ` requires + * membership in , which the applier lacks; an ADP entry only governs + * FUTURE creation by so nothing a user file creates can depend on it. + * - a routine whose proconfig SETs a SUSET (superuser-context) GUC (e.g. + * `SET log_min_messages TO 'fatal'`): Postgres validates proconfig at CREATE + * time, so a non-superuser cannot create it AT ALL (SQLSTATE 42501). Detected + * via `susetGucs` ∩ the routine's structured `_configGucs` (from + * `pg_proc.proconfig`) — never by parsing the `def`. The one real occurrence + * on the Supabase surface is `realtime.list_changes`. + * A seeded fact is reference-only on both sides, so its absence is symmetric and + * cancels in the diff. If a user file genuinely references a skipped routine the + * load fails LOUDLY at file:line with a precise missing-object error — acceptable + * (user code essentially never calls these internal platform RPCs, and a clear + * error beats rewritten SQL). `susetGucs` absent ⇒ nothing is skipped for this + * reason (byte-identical behavior). Any fact that DEPENDS on a skipped routine is + * transitively skipped too (it could not replay against a missing dependency). + */ +import { buildFactBase, type Fact, type FactBase } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { plan } from "../plan/plan.ts"; +import { renderPlanSql } from "../plan/render-sql.ts"; +import type { ApplierCapability } from "../policy/capability.ts"; +import { type Policy, resolveView } from "../policy/policy.ts"; +import { extensionMemberReferenceOnly } from "../policy/view.ts"; + +export interface AssumedSchemaSeed { + /** Replayable SQL that creates the assumed-schema objects; `""` when nothing + * needs seeding (raw profile, or the view kept no assumed-schema facts). */ + sql: string; + /** Number of facts materialized by the seed (for progress logging + tests). */ + facts: number; + /** Distinct assumed-schema names actually seeded. Passed to `loadSqlFiles` so + * its shadow-emptiness guard ignores exactly what we pre-populated. */ + schemas: string[]; + /** Encoded stable ids of the routine facts the seed ACTUALLY created + * (function / procedure / aggregate), mapped to each routine's seeded + * `def` (the `pg_get_functiondef` text carried on the fact payload). Passed + * to `loadSqlFiles` so its post-load body-validation pass can tell a seeded + * platform routine (warn on a wonky reconstruction) from a USER-authored + * routine that merely lives in a seeded schema NAME — the latter, including a + * new overload or a CREATE OR REPLACE of a seeded routine, must fail loudly + * (Codex #329). Scoping by full overload-safe identity (not schema name) is + * why the encoded id is used; the def value guards against an OR-REPLACE that + * keeps the identity but changes the body. */ + seededRoutines: Map; +} + +const EMPTY: AssumedSchemaSeed = { + sql: "", + facts: 0, + schemas: [], + seededRoutines: new Map(), +}; + +/** Assumed-schema name a fact belongs to: a schema fact IS its own schema; any + * schema-qualified fact carries `schema`. (Satellites — `target`-shaped ids — + * are hard-pruned by `resolveView`, so they never reach the seed set.) */ +function assumedSchemaOf(id: StableId): string | undefined { + if (id.kind === "schema") return (id as { name: string }).name; + return (id as { schema?: string }).schema; +} + +/** GUC names a routine's proconfig SETs, from the non-semantic `_configGucs` + * payload key (populated at extract time from `pg_proc.proconfig`). Empty when + * the routine SETs nothing. Read for the seed skip decision only — never used + * in the hash or diff (the key is `_`-prefixed). */ +function configGucsOf(fact: Fact): string[] { + const v = fact.payload["_configGucs"]; + return Array.isArray(v) + ? v.filter((x): x is string => typeof x === "string") + : []; +} + +export function deriveAssumedSchemaSeed( + targetFb: FactBase, + opts: { + policy?: Policy; + capability?: ApplierCapability; + /** The profile's diff-time baseline. Accepted so callers can pass their + * resolved options uniformly, but INTENTIONALLY NOT applied to seed + * derivation — see the module doc (Codex #323 finding 3). */ + baseline?: FactBase; + assumedSchemas: string[]; + /** Policy assumed roles PLUS the target's own role names — same cluster, so + * every owner/grant role reference in the seed is present at replay. */ + assumedRoles: string[]; + /** Names of GUCs whose `pg_settings.context` requires superuser (queried from + * the TARGET, which shares the co-located shadow's cluster). A seeded routine + * whose structured `_configGucs` intersects this set carries a SET header + * clause that a non-superuser applier cannot CREATE (42501), so the whole + * routine fact — and anything depending on it — is OMITTED from the seed (see + * the module doc). Membership is CONTEXT-driven, not name-driven, so a + * user-context GUC like `search_path` is structurally absent and never + * triggers a skip. Absent ⇒ nothing skipped for this reason. */ + susetGucs?: ReadonlySet; + }, +): AssumedSchemaSeed { + // raw profile (no assumed schemas): nothing is platform-external, no seed. + if (opts.assumedSchemas.length === 0) return EMPTY; + + // NB: opts.baseline is deliberately NOT passed — the seed derives from the RAW + // target so baseline-identical platform objects (auth.users) stay in the view + // and get seeded. See the module doc (Codex #323 finding 3). + const view = resolveView(targetFb, opts.policy, opts.capability); + const members = extensionMemberReferenceOnly(targetFb); + const seedIds = new Set( + [...view.referenceOnly].filter((id) => !members.has(id)), + ); + if (seedIds.size === 0) return EMPTY; + + // Omit default-privilege (ADP) facts entirely: `ALTER DEFAULT PRIVILEGES FOR + // ROLE ` needs membership in , which a non-superuser applier lacks, and + // an ADP entry has no possible dependents (see the module doc). Applies to ALL + // roles — even an applier-owned ADP is unnecessary for elaboration. + const keptFacts = view + .facts() + .filter( + (f) => seedIds.has(encodeId(f.id)) && f.id.kind !== "defaultPrivilege", + ); + const keptIds = new Set(keptFacts.map((f) => encodeId(f.id))); + + // Skip whole routines that carry a superuser-only SET header clause (they can't + // be CREATEd by a non-superuser), decided from structured `_configGucs` — never + // by editing the `def` SQL text. Then cascade exclusion to a fixpoint over TWO + // relations: + // - `depends` edges: any kept fact DEPENDING on a skipped one (it can't + // replay against a missing dependency); + // - structural containment (`Fact.parent`): a container fact's CHILD facts + // (e.g. a view's columns) are not linked by a `depends` edge at all — they + // are linked by `Fact.parent` — so excluding the container without also + // excluding its descendants would leave orphaned children in `seedFacts`; + // the flat filter below would let them through and `buildFactBase` would + // hard-throw "references missing parent". + // The two relations interact: `pg_depend` endpoints can resolve to a + // COLUMN-level id (src/extract/dependencies.ts's `resolved` CTE, + // `objsubid > 0`), so a `depends` edge can point AT a column. A column + // excluded structurally (because its parent view was excluded) can therefore + // be the very fact another kept fact `depends` on, which the edge pass must + // then pick up — hence a combined fixpoint, not one pass of each. + const excluded = new Set(); + const suset = opts.susetGucs; + if (suset && suset.size > 0) { + for (const fct of keptFacts) { + if ( + (fct.id.kind === "function" || fct.id.kind === "procedure") && + configGucsOf(fct).some((g) => suset.has(g)) + ) { + excluded.add(encodeId(fct.id)); + } + } + if (excluded.size > 0) { + const parentOf = new Map(); + for (const fct of keptFacts) { + if (fct.parent !== undefined) { + parentOf.set(encodeId(fct.id), encodeId(fct.parent)); + } + } + let changed = true; + while (changed) { + changed = false; + for (const e of view.edges) { + if (e.kind !== "depends") continue; + const from = encodeId(e.from); + if ( + keptIds.has(from) && + !excluded.has(from) && + excluded.has(encodeId(e.to)) + ) { + excluded.add(from); + changed = true; + } + } + for (const fct of keptFacts) { + const encoded = encodeId(fct.id); + if (excluded.has(encoded)) continue; + let ancestor = parentOf.get(encoded); + while (ancestor !== undefined) { + if (excluded.has(ancestor)) { + excluded.add(encoded); + changed = true; + break; + } + ancestor = parentOf.get(ancestor); + } + } + } + } + } + + const seedFacts = + excluded.size > 0 + ? keptFacts.filter((f) => !excluded.has(encodeId(f.id))) + : keptFacts; + if (seedFacts.length === 0) return EMPTY; + const finalIds = new Set(seedFacts.map((f) => encodeId(f.id))); + const seedEdges = [...view.edges].filter( + (e) => finalIds.has(encodeId(e.from)) && finalIds.has(encodeId(e.to)), + ); + + // CRITICAL (Fable review Q6b): the seed plan must NOT re-project. + // - `buildFactBase(...)` is called WITHOUT the 4th `referenceOnly` arg, and + // - `plan(...)` is called WITHOUT a `policy`, + // because either would re-mark every seed fact reference-only and the diff + // would skip all of them — a SILENT empty seed. We want a from-empty CREATE + // for each assumed object. `assumedSchemas`/`assumedRoles` are forwarded only + // so the requirement guard exempts references to objects OUTSIDE the seed set + // (an extension member, a platform role) exactly as the export path does. + const assumedOnlyFb = buildFactBase(seedFacts, seedEdges, view.source); + const seedPlan = plan(buildFactBase([], []), assumedOnlyFb, { + renames: "off", + assumedSchemas: opts.assumedSchemas, + assumedRoles: opts.assumedRoles, + }); + if (seedPlan.actions.length === 0) return EMPTY; + + const schemas = [ + ...new Set( + seedFacts + .map((f) => assumedSchemaOf(f.id)) + .filter((s): s is string => s !== undefined), + ), + ]; + // Overload-safe identity map of the routines the seed actually created, keyed + // by encoded id → seeded `def`. Consumed by `loadSqlFiles`'s body-validation + // pass to scope leniency to genuinely-seeded routines (see the field doc). + const seededRoutines = new Map(); + for (const f of seedFacts) { + if ( + f.id.kind === "function" || + f.id.kind === "procedure" || + f.id.kind === "aggregate" + ) { + const def = f.payload["def"]; + seededRoutines.set(encodeId(f.id), typeof def === "string" ? def : ""); + } + } + return { + sql: renderPlanSql(seedPlan), + facts: seedFacts.length, + schemas, + seededRoutines, + }; +} diff --git a/packages/pg-delta/src/frontends/shadow.ts b/packages/pg-delta/src/frontends/shadow.ts new file mode 100644 index 000000000..cc29a6768 --- /dev/null +++ b/packages/pg-delta/src/frontends/shadow.ts @@ -0,0 +1,115 @@ +/** + * Co-located shadow provisioning (quick mode). + * + * When declarative apply runs WITHOUT an explicit shadow URL, the shadow + * database is created on the TARGET's own cluster: a fresh, throwaway database + * named `pgdelta_shadow__`, used to elaborate the declarative files, + * then dropped. Co-locating with the target means the shadow shares the + * target's cluster-global roles and extension availability. + * + * Safety: only `database` scope is permitted for co-located shadows. The + * shadow is a SEPARATE database, so the target database itself is never + * written. Only databases we created (tracked by name) are dropped. + * + * Callers own every Pool they open against the returned URL and must + * `pool.end()` before calling `cleanup()`. + */ +import pg from "pg"; + +/** Swap the database name in a connection URL, preserving everything else. */ +export function withDatabaseName(url: string, dbname: string): string { + const u = new URL(url); + u.pathname = `/${encodeURIComponent(dbname)}`; + return u.toString(); +} + +function quoteIdent(name: string): string { + return `"${name.replaceAll('"', '""')}"`; +} + +function makePool(url: string): pg.Pool { + const pool = new pg.Pool({ connectionString: url, max: 5 }); + pool.on("error", () => { + // idle client errors (server restart) must not crash the process + }); + return pool; +} + +/** Only ever created/dropped by us. */ +const SHADOW_PREFIX = "pgdelta_shadow_"; + +export interface CoLocatedShadow { + /** connection URL to the freshly-created shadow database */ + url: string; + /** database name (for logging) */ + name: string; + /** drop the shadow database (no-op when `keep`), best-effort. */ + cleanup(): Promise; +} + +export interface ProvisionCoLocatedShadowOptions { + /** keep the shadow database after the run (debugging) instead of dropping it */ + keep?: boolean; + /** unique suffix source; injectable for deterministic tests */ + uniqueSuffix?: string; +} + +/** + * Create a throwaway shadow database on the TARGET's cluster and return its URL + * plus a cleanup that drops it. Throws {@link ShadowProvisionError} if the + * connecting role lacks CREATEDB. + */ +export async function provisionCoLocatedShadow( + targetUrl: string, + opts: ProvisionCoLocatedShadowOptions = {}, +): Promise { + const suffix = + opts.uniqueSuffix ?? + `${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`; + const name = `${SHADOW_PREFIX}${suffix}`; + + const maint = makePool(targetUrl); + try { + const probe = await maint.query<{ can: boolean }>( + `SELECT (rolcreatedb OR rolsuper) AS can FROM pg_roles WHERE rolname = current_user`, + ); + if (probe.rows[0]?.can !== true) { + throw new ShadowProvisionError( + "the connecting role lacks CREATEDB on the target cluster, so a co-located shadow cannot be created; pass an explicit shadow URL to a dedicated empty database instead.", + ); + } + await maint.query(`CREATE DATABASE ${quoteIdent(name)} TEMPLATE template0`); + } finally { + await maint.end(); + } + + return { + url: withDatabaseName(targetUrl, name), + name, + cleanup: async () => { + if (opts.keep === true) return; + const m = makePool(targetUrl); + try { + await m.query( + `DROP DATABASE IF EXISTS ${quoteIdent(name)} WITH (FORCE)`, + ); + } catch { + // swallow — cleanup must never mask the caller's own outcome + } finally { + await m.end(); + } + }, + }; +} + +export class ShadowProvisionError extends Error { + constructor(message: string) { + super(message); + this.name = "ShadowProvisionError"; + } +} + +/** Type guard so callers can render provisioning errors without a stack. */ +export function isShadowProvisionError(e: unknown): e is ShadowProvisionError { + return e instanceof ShadowProvisionError; +} diff --git a/packages/pg-delta/src/frontends/snapshot-file.ts b/packages/pg-delta/src/frontends/snapshot-file.ts new file mode 100644 index 000000000..7a2262570 --- /dev/null +++ b/packages/pg-delta/src/frontends/snapshot-file.ts @@ -0,0 +1,48 @@ +/** + * Snapshot file frontend: persist and restore a FactBase as a JSON file on + * the local filesystem. The byte format is fully owned by core/snapshot.ts + * (format-version + digest); this module adds only the file I/O layer. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { deserializeSnapshot, serializeSnapshot } from "../core/snapshot.ts"; +import type { FactBase } from "../core/fact.ts"; + +/** + * Serialize `fb` and write it to `path`. The pgVersion string is stored in + * the snapshot so the reader can surface a mismatch warning when the + * environment has moved on. + * + * Writes synchronously (atomic-enough for CLI tools; swap-on-write is a + * future hardening step). + */ +export function saveSnapshot( + fb: FactBase, + pgVersion: string, + path: string, + redactSecrets?: boolean, + /** the profile the snapshot was captured under: a declared id, `null` + * (captured raw), or omitted (leave the field ABSENT — legacy). */ + profile?: string | null, +): void { + const json = serializeSnapshot(fb, { + pgVersion, + ...(redactSecrets !== undefined ? { redactSecrets } : {}), + ...(profile !== undefined ? { profile } : {}), + }); + writeFileSync(path, json, "utf8"); +} + +/** + * Read and deserialize a snapshot from `path`. + * Throws if the file does not exist, is invalid JSON, has an unsupported + * format version, or fails its content-hash check. + */ +export function loadSnapshot(path: string): { + factBase: FactBase; + pgVersion: string; + redactSecrets?: boolean; + profile?: string | null; +} { + const json = readFileSync(path, "utf8"); + return deserializeSnapshot(json); +} diff --git a/packages/pg-delta/src/core/plan/sql-format/constants.ts b/packages/pg-delta/src/frontends/sql-format/constants.ts similarity index 100% rename from packages/pg-delta/src/core/plan/sql-format/constants.ts rename to packages/pg-delta/src/frontends/sql-format/constants.ts diff --git a/packages/pg-delta/src/core/plan/sql-format/format-comment-literals.test.ts b/packages/pg-delta/src/frontends/sql-format/format-comment-literals.test.ts similarity index 91% rename from packages/pg-delta/src/core/plan/sql-format/format-comment-literals.test.ts rename to packages/pg-delta/src/frontends/sql-format/format-comment-literals.test.ts index cec303f83..a8f3e441f 100644 --- a/packages/pg-delta/src/core/plan/sql-format/format-comment-literals.test.ts +++ b/packages/pg-delta/src/frontends/sql-format/format-comment-literals.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { formatSqlStatements } from "../sql-format.ts"; +import { formatSqlStatements } from "./index.ts"; import { scanTokens } from "./tokenizer.ts"; function extractCommentLiteral(statement: string): string { @@ -14,7 +14,7 @@ function extractCommentLiteral(statement: string): string { let literalStart = isToken.end; while ( literalStart < statement.length && - /\s/.test(statement[literalStart]) + /\s/.test(statement[literalStart]!) ) { literalStart += 1; } @@ -73,20 +73,20 @@ this wrapper guarantees the seamless operation of all existing auth.can() checks maxWidth: 80, }); - expect(extractCommentLiteral(first)).toMatchInlineSnapshot(` + expect(extractCommentLiteral(first!)).toMatchInlineSnapshot(` "' Enhanced wrapper method for the primary auth.can() function. Utilize this wrapper to specifically check for project-related permissions. '" `); - expect(extractCommentLiteral(second)).toMatchInlineSnapshot(` + expect(extractCommentLiteral(second!)).toMatchInlineSnapshot(` "' Enhanced wrapper method for the primary auth.can() function. Utilize this wrapper to specifically check for project-related permissions. This method does not require _organization_id parameter. '" `); - expect(extractCommentLiteral(third)).toMatchInlineSnapshot(` + expect(extractCommentLiteral(third!)).toMatchInlineSnapshot(` "' Enhanced wrapper method for the primary auth.can() function. With the introduction of the _project_id parameter into auth.can(), this wrapper guarantees the seamless operation of all existing auth.can() checks. diff --git a/packages/pg-delta/src/core/plan/sql-format/format-functions.test.ts b/packages/pg-delta/src/frontends/sql-format/format-functions.test.ts similarity index 86% rename from packages/pg-delta/src/core/plan/sql-format/format-functions.test.ts rename to packages/pg-delta/src/frontends/sql-format/format-functions.test.ts index 718d90ec5..61bc7c11a 100644 --- a/packages/pg-delta/src/core/plan/sql-format/format-functions.test.ts +++ b/packages/pg-delta/src/frontends/sql-format/format-functions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { formatSqlStatements } from "../sql-format.ts"; +import { formatSqlStatements } from "./index.ts"; describe("function formatting", () => { test("single unnamed param, RETURNS void", () => { @@ -124,4 +124,19 @@ describe("function formatting", () => { AS $function$SELECT x$function$" `); }); + + test("BEGIN ATOMIC body is not shredded into separate statements", () => { + // A SQL-standard function body has bare, unquoted statement-separating + // semicolons. Formatting must keep it as ONE statement (no extra outputs, + // no per-fragment trailing semicolons) or the export is invalid SQL. + const sql = + "CREATE FUNCTION public.two_steps() RETURNS integer LANGUAGE sql\n" + + "BEGIN ATOMIC\n SELECT 1;\n SELECT 2;\nEND"; + const results = formatSqlStatements([sql]); + expect(results).toHaveLength(1); + expect(results[0]).toContain("BEGIN ATOMIC"); + expect(results[0]).toContain("SELECT 1;"); + expect(results[0]).toContain("SELECT 2;"); + expect(results[0]).toContain("END"); + }); }); diff --git a/packages/pg-delta/src/frontends/sql-format/format-index-rule.test.ts b/packages/pg-delta/src/frontends/sql-format/format-index-rule.test.ts new file mode 100644 index 000000000..58b13388c --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/format-index-rule.test.ts @@ -0,0 +1,52 @@ +/** + * Regressions for the `--format-options` export path (PR #307 Codex review): + * - rewrite-rule bodies (`DO ALSO ( …; … )`) must not be split on the + * semicolons inside their parentheses; + * - index `INCLUDE (…)` must keep its closing paren when a WHERE/WITH/ + * TABLESPACE clause follows; + * - index `NULLS NOT DISTINCT` must survive when such a clause follows. + * + * No Docker required (pure formatter). + */ +import { describe, expect, test } from "bun:test"; +import { formatSqlStatements } from "./index.ts"; +import { splitSqlStatements } from "./format-utils.ts"; + +describe("rule body splitting", () => { + test("a DO ALSO (…; …) rule body is not split on its inner semicolons", () => { + const sql = + "CREATE RULE log_insert AS ON INSERT TO public.t DO ALSO " + + "(INSERT INTO public.log VALUES (1); UPDATE public.counts SET n = n + 1)"; + expect(splitSqlStatements(sql)).toEqual([sql]); + }); + + test("formatSqlStatements keeps a multi-command rule as one statement", () => { + const sql = + "CREATE RULE log_insert AS ON INSERT TO public.t DO ALSO " + + "(INSERT INTO public.log VALUES (1); UPDATE public.counts SET n = n + 1)"; + const results = formatSqlStatements([sql]); + expect(results).toHaveLength(1); + expect(results[0]).toContain("INSERT INTO public.log"); + expect(results[0]).toContain("UPDATE public.counts"); + }); +}); + +describe("index formatting edge cases", () => { + test("INCLUDE (...) keeps its closing paren before a WHERE clause", () => { + const sql = "CREATE INDEX idx ON public.t (a) INCLUDE (b) WHERE (a > 0)"; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("INCLUDE (b)"); + expect(result).toContain("WHERE"); + // the column 'b' must not leak into the WHERE clause / lose its paren + expect(result).not.toContain("INCLUDE (b\n"); + expect(result).not.toMatch(/INCLUDE \(b\b(?!\))/); + }); + + test("NULLS NOT DISTINCT survives when a WHERE clause follows", () => { + const sql = + "CREATE UNIQUE INDEX idx ON public.t (a) NULLS NOT DISTINCT WHERE (a IS NOT NULL)"; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("NULLS NOT DISTINCT"); + expect(result).toContain("WHERE"); + }); +}); diff --git a/packages/pg-delta/src/frontends/sql-format/format-keyword-type.test.ts b/packages/pg-delta/src/frontends/sql-format/format-keyword-type.test.ts new file mode 100644 index 000000000..a17305e05 --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/format-keyword-type.test.ts @@ -0,0 +1,46 @@ +/** + * Regressions for the `--format-options` export path (PR #307 Codex review): + * a schema-qualified user type whose final component is a non-reserved keyword + * (e.g. `public.cost`, `public.generated`) was mis-read as a clause / boundary + * keyword, so the formatter split the type name and produced invalid SQL. A + * keyword that is the tail of a qualified name (preceded by `.`) is an + * identifier, not a keyword. + * + * No Docker required (pure formatter). + */ +import { describe, expect, test } from "bun:test"; +import { formatSqlStatements } from "./index.ts"; + +describe("keyword-like qualified type names survive formatting", () => { + test("function RETURNS public.cost is not split on the COST keyword", () => { + const sql = + `CREATE FUNCTION public.f() RETURNS public.cost ` + + `LANGUAGE sql COST 100 AS $function$SELECT NULL::public.cost$function$`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("public.cost"); + expect(result).not.toMatch(/RETURNS\s+public\.\s*$/m); + }); + + test("table column of type public.generated keeps the type name", () => { + const sql = `CREATE TABLE public.t (\n a integer,\n b public.generated NOT NULL\n)`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("public.generated"); + expect(result).not.toMatch(/public\.\s+GENERATED/); + }); + + test("trigger EXECUTE FUNCTION public.execute() is not split on the qualified tail", () => { + const sql = + `CREATE TRIGGER tr AFTER INSERT ON public.t ` + + `FOR EACH ROW EXECUTE FUNCTION public.execute()`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("public.execute()"); + expect(result).not.toMatch(/public\.\s*$/m); + }); + + test("FDW HANDLER public.handler keeps the qualified handler name", () => { + const sql = `CREATE FOREIGN DATA WRAPPER w HANDLER public.handler`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("public.handler"); + expect(result).not.toMatch(/HANDLER\s+public\.\s*$/m); + }); +}); diff --git a/packages/pg-delta/src/frontends/sql-format/format-lowercase-coverage.test.ts b/packages/pg-delta/src/frontends/sql-format/format-lowercase-coverage.test.ts new file mode 100644 index 000000000..b0d58650f --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/format-lowercase-coverage.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; +import { formatSqlStatements } from "./index.ts"; + +describe("lowercase coverage formatting", () => { + test("normalizes contextual keywords while preserving protected payloads", () => { + const statements = [ + "CREATE EVENT TRIGGER prevent_drop ON sql_drop WHEN TAG IN ('DROP TABLE', 'DROP SCHEMA') EXECUTE FUNCTION public.prevent_drop_fn();", + "CREATE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql STABLE AS $function$SELECT coalesce(nullif(current_setting('request.jwt.claim.sub', true), ''), (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub'))::uuid$function$;", + "COMMENT ON FUNCTION public.fn() IS E'line 1 \\' still quoted\\nline 2';", + "CREATE COLLATION public.test (LOCALE = 'en_US', DETERMINISTIC = false, provider = icu);", + ]; + + const formatted = formatSqlStatements(statements, { + keywordCase: "lower", + maxWidth: 140, + }); + + const normalized = [formatted[0]!, formatted[1]!, formatted[3]!].map( + (value) => value.replace(/\s+/g, " ").trim(), + ); + expect(normalized).toMatchInlineSnapshot(` + [ + "create event trigger prevent_drop on sql_drop when tag in ('DROP TABLE', 'DROP SCHEMA') execute function public.prevent_drop_fn()", + "create function auth.uid() returns uuid language sql stable AS $function$SELECT coalesce(nullif(current_setting('request.jwt.claim.sub', true), ''), (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub'))::uuid$function$", + "create collation public.test ( locale = 'en_US', deterministic = false, provider = icu )", + ] + `); + + expect(formatted[2]).toMatchInlineSnapshot( + `"comment on function public.fn() is E'line 1 \\' still quoted\\nline 2'"`, + ); + }); + + test("fails safe: malformed protected literals skip casing but still wrap", () => { + const statements = [ + "COMMENT ON FUNCTION public.fn() IS E'unterminated \\'", + "ALTER TABLE auth.audit_log_entries ENABLE ROW LEVEL SECURITY;", + ]; + + const formatted = formatSqlStatements(statements, { + keywordCase: "lower", + maxWidth: 40, + }); + + // Malformed statement: casing skipped (stays uppercase) but wrapping still applies + expect(formatted[0]!.replace(/\s+/g, " ").trim()).toMatchInlineSnapshot( + `"COMMENT ON FUNCTION public.fn() IS E'unterminated \\'"`, + ); + + expect(formatted[1]!.replace(/\s+/g, " ").trim()).toMatchInlineSnapshot( + `"alter table auth.audit_log_entries enable row level security"`, + ); + }); + + test("a QUOTED object name does not shield the following keyword from casing", () => { + // quoted identifiers produce no scanner token, so the object-name + // heuristic used to mark the NEXT word (ENABLE / ON) as the "name" and + // protect it from casing — the middleware dogfood surfaced this on every + // quoted-relation ALTER and CREATE POLICY. + const formatted = formatSqlStatements( + [ + `ALTER TABLE "public"."users" ENABLE ROW LEVEL SECURITY;`, + `CREATE POLICY "p_sel" ON "public"."users" FOR SELECT TO "authenticated" USING (true);`, + ], + { keywordCase: "lower" }, + ); + const normalized = formatted.map((v) => v.replace(/\s+/g, " ").trim()); + expect(normalized[0]).toBe( + `alter table "public"."users" enable row level security`, + ); + expect(normalized[1]).toBe( + `create policy "p_sel" on "public"."users" for select to "authenticated" using (true)`, + ); + }); + + test("privilege + index-ordering vocabulary lower-cases (MAINTAIN, TRUNCATE, DESC, NULLS LAST)", () => { + const formatted = formatSqlStatements( + [ + `GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "public"."t" TO "r";`, + `CREATE INDEX t_a_idx ON public.t USING btree (a DESC NULLS LAST);`, + ], + { keywordCase: "lower" }, + ); + const normalized = formatted.map((v) => v.replace(/\s+/g, " ").trim()); + expect(normalized[0]).toContain( + "grant delete, insert, maintain, references, select, trigger, truncate, update", + ); + expect(normalized[1]).toContain("(a desc nulls last)"); + }); + + test("lowercases all ALTER DEFAULT PRIVILEGES object-type keywords", () => { + const statements = [ + "ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public GRANT ALL ON TABLES TO app_reader;", + "ALTER DEFAULT PRIVILEGES FOR ROLE app_user GRANT ALL ON SEQUENCES TO app_reader;", + "ALTER DEFAULT PRIVILEGES FOR ROLE app_user GRANT ALL ON ROUTINES TO PUBLIC;", + "ALTER DEFAULT PRIVILEGES FOR ROLE app_user GRANT ALL ON TYPES TO PUBLIC;", + "ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA api GRANT ALL ON SCHEMAS TO app_admin;", + "ALTER DEFAULT PRIVILEGES FOR ROLE app_user REVOKE ALL ON SEQUENCES FROM app_reader;", + "ALTER DEFAULT PRIVILEGES FOR ROLE app_user REVOKE ALL ON TYPES FROM PUBLIC;", + ]; + + const formatted = formatSqlStatements(statements, { + keywordCase: "lower", + }); + + const normalized = formatted.map((v) => v.replace(/\s+/g, " ").trim()); + expect(normalized).toMatchInlineSnapshot(` + [ + "alter default privileges for role app_user in schema public grant all on tables to app_reader", + "alter default privileges for role app_user grant all on sequences to app_reader", + "alter default privileges for role app_user grant all on routines to public", + "alter default privileges for role app_user grant all on types to public", + "alter default privileges for role app_user in schema api grant all on schemas to app_admin", + "alter default privileges for role app_user revoke all on sequences from app_reader", + "alter default privileges for role app_user revoke all on types from public", + ] + `); + }); + + test("lowercases PUBLIC in standalone GRANT/REVOKE statements", () => { + const statements = [ + "GRANT ALL ON SCHEMA public TO PUBLIC;", + "GRANT EXECUTE ON FUNCTION public.my_fn() TO PUBLIC;", + "REVOKE ALL ON SCHEMA public FROM PUBLIC;", + "GRANT USAGE ON TYPE public.my_type TO PUBLIC;", + ]; + + const formatted = formatSqlStatements(statements, { + keywordCase: "lower", + }); + + const normalized = formatted.map((v) => v.replace(/\s+/g, " ").trim()); + expect(normalized).toMatchInlineSnapshot(` + [ + "grant all on schema public to public", + "grant execute on function public.my_fn() to public", + "revoke all on schema public from public", + "grant usage on type public.my_type to public", + ] + `); + }); + + test("preserves full CHECK clause text while casing surrounding structure", () => { + const [formatted] = formatSqlStatements( + [ + "ALTER TABLE public.t ADD CONSTRAINT c CHECK (State IN ('ON','OFF')) NO INHERIT;", + ], + { keywordCase: "lower" }, + ); + + expect(formatted!.replace(/\s+/g, " ").trim()).toMatchInlineSnapshot( + `"alter table public.t add constraint c check (State IN ('ON','OFF')) no inherit"`, + ); + }); +}); diff --git a/packages/pg-delta/src/frontends/sql-format/format-matview.test.ts b/packages/pg-delta/src/frontends/sql-format/format-matview.test.ts new file mode 100644 index 000000000..24e0cffb3 --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/format-matview.test.ts @@ -0,0 +1,37 @@ +/** + * Regressions for materialized-view formatting (PR #307 Codex review): + * - a quoted/qualified matview name was skipped by scanTokens, so the storage + * `WITH (...)` clause (and the name) were dropped (#3499812840); + * - with `preserveViewBodies:false` the body is unprotected, and the scanner + * treated every `AS`/`WITH` inside the SELECT (column alias, `WITH NO DATA`) + * as a matview clause, shredding the query (#3499812830). + * + * No Docker required (pure formatter). + */ +import { describe, expect, test } from "bun:test"; +import { formatSqlStatements } from "./index.ts"; + +describe("materialized view formatting", () => { + test("quoted/qualified name keeps the storage WITH clause", () => { + const sql = `CREATE MATERIALIZED VIEW "s"."v" WITH (fillfactor = 70) AS SELECT 1`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain(`"s"."v"`); + expect(result).toContain("fillfactor"); + }); + + test("unprotected SELECT body is not shredded on AS/WITH", () => { + const sql = `CREATE MATERIALIZED VIEW v AS SELECT 1 AS a WITH NO DATA`; + const [result] = formatSqlStatements([sql], { + preserveViewBodies: false, + }); + expect(result).toContain("SELECT 1 AS a"); + expect(result).toContain("WITH NO DATA"); + }); + + test("USING and TABLESPACE clauses (pre-AS) are preserved", () => { + const sql = `CREATE MATERIALIZED VIEW v USING heap TABLESPACE ts AS SELECT 1`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("USING heap"); + expect(result).toContain("TABLESPACE ts"); + }); +}); diff --git a/packages/pg-delta/src/frontends/sql-format/format-quoted-names.test.ts b/packages/pg-delta/src/frontends/sql-format/format-quoted-names.test.ts new file mode 100644 index 000000000..253c0cbcb --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/format-quoted-names.test.ts @@ -0,0 +1,89 @@ +/** + * Regressions for the `--format-options` export path (PR #307 Codex review): + * the catalog renderer double-quotes object names, but `scanTokens` skips + * double-quoted identifiers, so positional `tokens[N]` indexing landed PAST the + * name onto the first clause keyword — and the formatter then dropped the clause + * that followed the name, producing invalid SQL. Each formatter must locate the + * (quoted) name from the raw statement before slicing clauses. + * + * No Docker required (pure formatter). + */ +import { describe, expect, test } from "bun:test"; +import { formatSqlStatements } from "./index.ts"; + +describe("formatting preserves clauses after a quoted object name", () => { + test("trigger keeps its event/table clause", () => { + const sql = + `CREATE TRIGGER "tr" AFTER INSERT ON "public"."t" ` + + `FOR EACH ROW EXECUTE FUNCTION "public"."f"()`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("AFTER INSERT ON"); + expect(result).toContain(`"public"."t"`); + expect(result).toContain("EXECUTE FUNCTION"); + }); + + test("foreign server keeps FOREIGN DATA WRAPPER", () => { + const sql = + `CREATE SERVER "srv" FOREIGN DATA WRAPPER "postgres_fdw" ` + + `OPTIONS (host 'h', dbname 'd')`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain(`FOREIGN DATA WRAPPER "postgres_fdw"`); + expect(result).toContain("OPTIONS"); + }); + + test("foreign server keeps an unquoted keyword-like FDW name (e.g. options)", () => { + // the FDW name `options` is an unquoted non-reserved keyword; it must not be + // mistaken for an OPTIONS clause start (which would drop the wrapper name). + const sql = `CREATE SERVER srv FOREIGN DATA WRAPPER options OPTIONS (host 'h')`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("FOREIGN DATA WRAPPER options"); + expect(result).not.toContain('"AS'); + // still exactly one OPTIONS clause (the real one), name not swallowed + expect(result).toContain("OPTIONS"); + expect(result).toContain("host 'h'"); + }); + + test("subscription keeps CONNECTION conninfo", () => { + const sql = + `CREATE SUBSCRIPTION "sub" CONNECTION 'host=h dbname=d' ` + + `PUBLICATION "pub" WITH (connect = false)`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("CONNECTION 'host=h dbname=d'"); + expect(result).toContain(`PUBLICATION "pub"`); + }); + + test("foreign-data wrapper keeps its HANDLER/OPTIONS clauses", () => { + const sql = `CREATE FOREIGN DATA WRAPPER "w" OPTIONS (debug 'true')`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain("OPTIONS"); + expect(result).toContain("debug"); + }); + + test("language keeps its HANDLER clause", () => { + const sql = `CREATE LANGUAGE "plx" HANDLER "public"."plx_handler"`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain(`HANDLER "public"."plx_handler"`); + }); + + test("ALTER TABLE keeps its ADD COLUMN action", () => { + const sql = `ALTER TABLE "public"."users" ADD COLUMN "a" integer`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain(`ALTER TABLE "public"."users"`); + // the action keyword must not be stranded on the header line + expect(result).toContain("ADD COLUMN"); + }); + + test("ALTER TABLE keeps a keyword-only action (ENABLE ROW LEVEL SECURITY)", () => { + const sql = `ALTER TABLE "public"."users" ENABLE ROW LEVEL SECURITY`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain(`ALTER TABLE "public"."users"`); + expect(result).toContain("ENABLE ROW LEVEL SECURITY"); + }); + + test("ALTER MATERIALIZED VIEW keeps its OWNER TO action", () => { + const sql = `ALTER MATERIALIZED VIEW "public"."mv" OWNER TO "postgres"`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain(`ALTER MATERIALIZED VIEW "public"."mv"`); + expect(result).toContain("OWNER TO"); + }); +}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-stress.test.ts b/packages/pg-delta/src/frontends/sql-format/format-stress.test.ts similarity index 99% rename from packages/pg-delta/src/core/plan/sql-format/format-stress.test.ts rename to packages/pg-delta/src/frontends/sql-format/format-stress.test.ts index 9afc46d8c..f1ab0440f 100644 --- a/packages/pg-delta/src/core/plan/sql-format/format-stress.test.ts +++ b/packages/pg-delta/src/frontends/sql-format/format-stress.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { formatSqlStatements } from "../sql-format.ts"; +import { formatSqlStatements } from "./index.ts"; describe("stress tests", () => { test("recursive CTE view with dollar identifiers and window functions", () => { diff --git a/packages/pg-delta/src/frontends/sql-format/format-utils.test.ts b/packages/pg-delta/src/frontends/sql-format/format-utils.test.ts new file mode 100644 index 000000000..1d3595efa --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/format-utils.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { DEFAULT_OPTIONS } from "./constants.ts"; +import { + formatColumnList, + formatKeyValueItems, + formatListItems, + indentString, + splitLeadingComments, + splitSqlStatements, +} from "./format-utils.ts"; + +describe("splitSqlStatements", () => { + it("splits by semicolons", () => { + const result = splitSqlStatements("SELECT 1;SELECT 2"); + expect(result).toEqual(["SELECT 1", "SELECT 2"]); + }); + + it("ignores semicolons inside quotes", () => { + const result = splitSqlStatements("SELECT ';' FROM foo"); + expect(result).toEqual(["SELECT ';' FROM foo"]); + }); + + it("ignores semicolons inside comments", () => { + const result = splitSqlStatements("SELECT 1 -- semi; here\nFROM foo"); + expect(result).toEqual(["SELECT 1 -- semi; here\nFROM foo"]); + }); + + it("does not split inside a BEGIN ATOMIC routine body", () => { + // pg_get_functiondef emits SQL-standard bodies as `BEGIN ATOMIC ...; ...; END` + // whose statement-separating semicolons are NOT inside quotes or dollar tags. + // The body must stay one statement. + const sql = + "CREATE FUNCTION f() RETURNS int LANGUAGE sql\n" + + "BEGIN ATOMIC\n SELECT 1;\n SELECT 2;\nEND"; + expect(splitSqlStatements(sql)).toEqual([sql]); + }); + + it("treats CASE ... END inside a BEGIN ATOMIC body as part of the body", () => { + const sql = + "CREATE FUNCTION f(x int) RETURNS int LANGUAGE sql\n" + + "BEGIN ATOMIC\n SELECT CASE WHEN x > 0 THEN 1 ELSE 0 END;\nEND"; + expect(splitSqlStatements(sql)).toEqual([sql]); + }); + + it("still splits a standalone statement that ends with CASE ... END", () => { + const result = splitSqlStatements("SELECT CASE WHEN x THEN 1 END;SELECT 2"); + expect(result).toEqual(["SELECT CASE WHEN x THEN 1 END", "SELECT 2"]); + }); +}); + +describe("splitLeadingComments", () => { + it("separates leading comment lines from body", () => { + const input = "-- comment\n-- another\nSELECT 1"; + const result = splitLeadingComments(input); + expect(result.commentLines).toEqual(["-- comment", "-- another"]); + expect(result.body).toBe("SELECT 1"); + }); + + it("returns empty commentLines when no comments", () => { + const result = splitLeadingComments("SELECT 1"); + expect(result.commentLines).toEqual([]); + expect(result.body).toBe("SELECT 1"); + }); +}); + +describe("formatColumnList", () => { + it("formats column definitions with alignment", () => { + const content = "id integer, name text, description varchar(255)"; + const result = formatColumnList(content, DEFAULT_OPTIONS); + expect(result).not.toBeNull(); + expect(result?.length).toBe(3); + // Each line should be indented + for (const line of result ?? []) { + expect(line).toMatch(/^\s+/); + } + }); + + it("returns null for empty content", () => { + expect(formatColumnList("", DEFAULT_OPTIONS)).toBeNull(); + }); +}); + +describe("formatKeyValueItems", () => { + it("formats key=value items with alignment", () => { + const items = ["a = 1", "long_key = 2"]; + const result = formatKeyValueItems(items, DEFAULT_OPTIONS); + expect(result.length).toBe(2); + // Both should be indented + for (const line of result) { + expect(line).toMatch(/^\s+/); + } + }); +}); + +describe("formatListItems", () => { + it("applies trailing comma style", () => { + const result = formatListItems(["a", "b", "c"], " ", "trailing"); + expect(result).toEqual([" a,", " b,", " c"]); + }); + + it("applies leading comma style", () => { + const result = formatListItems(["a", "b", "c"], " ", "leading"); + expect(result).toEqual([" a", " , b", " , c"]); + }); +}); + +describe("indentString", () => { + it("returns correct number of spaces", () => { + expect(indentString(0)).toBe(""); + expect(indentString(2)).toBe(" "); + expect(indentString(4)).toBe(" "); + }); +}); diff --git a/packages/pg-delta/src/core/plan/sql-format/format-utils.ts b/packages/pg-delta/src/frontends/sql-format/format-utils.ts similarity index 76% rename from packages/pg-delta/src/core/plan/sql-format/format-utils.ts rename to packages/pg-delta/src/frontends/sql-format/format-utils.ts index bc439a874..1f3e0f78b 100644 --- a/packages/pg-delta/src/core/plan/sql-format/format-utils.ts +++ b/packages/pg-delta/src/frontends/sql-format/format-utils.ts @@ -6,10 +6,45 @@ export function splitSqlStatements(sql: string): string[] { const statements: string[] = []; let buffer = ""; + // Suppress statement splitting where a `;` is NOT a statement separator: + // 1. inside parentheses — a multi-command rewrite-rule body + // `DO ALSO ( INSERT …; UPDATE … )` from pg_get_ruledef() carries inner + // `;` at paren depth > 0 (tracked via walkSql's depth); + // 2. inside a SQL-standard `BEGIN ATOMIC … END` routine body — those + // semicolons are bare (not in quotes/dollar tags/parens), so we track + // block nesting at the code level: `BEGIN ATOMIC` and `CASE` open a + // block, `END` closes one. + // A `;` only separates statements at paren depth 0 AND block depth 0. + // Reserved words used as identifiers are double-quoted by the catalog + // pretty-printers, so they never reach this keyword scan. + let blockDepth = 0; + let word = ""; + let prevKeyword = ""; + const finalizeWord = (): void => { + if (word.length === 0) return; + const upper = word.toUpperCase(); + if (upper === "ATOMIC" && prevKeyword === "BEGIN") { + blockDepth += 1; + } else if (upper === "CASE") { + blockDepth += 1; + } else if (upper === "END" && blockDepth > 0) { + blockDepth -= 1; + } + prevKeyword = upper; + word = ""; + }; + walkSql( sql, - (_index, char) => { - if (char === ";") { + (_index, char, depth) => { + if (isWordChar(char)) { + word += char; + buffer += char; + return true; + } + // a non-word top-level char terminates the word that just accumulated + finalizeWord(); + if (char === ";" && depth === 0 && blockDepth === 0) { const trimmed = trimOuterBlankLines(buffer); if (trimmed.length > 0) { statements.push(trimmed); @@ -21,12 +56,16 @@ export function splitSqlStatements(sql: string): string[] { return true; }, { + trackDepth: true, onSkipped: (chunk) => { + // a quoted/commented/dollar segment also ends the current word + finalizeWord(); buffer += chunk; }, }, ); + finalizeWord(); const trailing = trimOuterBlankLines(buffer); if (trailing.length > 0) { statements.push(trailing); @@ -43,11 +82,11 @@ export function splitLeadingComments(statement: string): { const commentLines: string[] = []; let index = 0; - while ( - index < lines.length && - (lines[index].trim().startsWith("--") || lines[index].trim() === "") - ) { - commentLines.push(lines[index]); + while (index < lines.length) { + const line = lines[index]; + if (line === undefined) break; + if (!line.trim().startsWith("--") && line.trim() !== "") break; + commentLines.push(line); index += 1; } @@ -57,10 +96,14 @@ export function splitLeadingComments(statement: string): { function trimOuterBlankLines(text: string): string { const lines = text.split(/\r?\n/); - while (lines.length > 0 && lines[0].trim().length === 0) { + while (lines.length > 0) { + const first = lines[0]; + if (first === undefined || first.trim().length !== 0) break; lines.shift(); } - while (lines.length > 0 && lines[lines.length - 1].trim().length === 0) { + while (lines.length > 0) { + const last = lines[lines.length - 1]; + if (last === undefined || last.trim().length !== 0) break; lines.pop(); } return lines.join("\n"); @@ -91,8 +134,8 @@ export function formatColumnList( const lines: string[] = []; for (let index = 0; index < items.length; index += 1) { - const item = items[index].trim(); - const column = parsed[index]; + const item = items[index]!.trim(); + const column = parsed[index]!; let line = item; if (column) { @@ -160,7 +203,7 @@ export function parseDefinitionItem( } name = trimmed.slice(0, i); } else { - while (i < trimmed.length && isWordChar(trimmed[i])) { + while (i < trimmed.length && isWordChar(trimmed[i]!)) { i += 1; } name = trimmed.slice(0, i); @@ -180,7 +223,7 @@ export function parseDefinitionItem( } let restStart = i; - while (restStart < trimmed.length && /\s/.test(trimmed[restStart])) { + while (restStart < trimmed.length && /\s/.test(trimmed[restStart]!)) { restStart += 1; } if (restStart >= trimmed.length) return null; @@ -204,6 +247,10 @@ export function parseDefinitionItem( for (const token of tokens) { if (token.depth !== 0) continue; + // a keyword that is the tail of a schema-qualified name (preceded by `.`) is + // part of the type, not a column-tail boundary — e.g. a column of type + // `public.generated` must not split on the GENERATED keyword (review P2). + if (rest[token.start - 1] === ".") continue; if (boundaryKeywords.has(token.upper)) { boundaryIndex = token.start; break; @@ -212,13 +259,13 @@ export function parseDefinitionItem( let typeEnd = boundaryIndex === null ? trimmed.length : restStart + boundaryIndex; - while (typeEnd > restStart && /\s/.test(trimmed[typeEnd - 1])) { + while (typeEnd > restStart && /\s/.test(trimmed[typeEnd - 1]!)) { typeEnd -= 1; } let tailStart = typeEnd; if (boundaryIndex !== null) { tailStart = restStart + boundaryIndex; - while (tailStart < trimmed.length && /\s/.test(trimmed[tailStart])) { + while (tailStart < trimmed.length && /\s/.test(trimmed[tailStart]!)) { tailStart += 1; } } @@ -255,7 +302,7 @@ export function formatKeyValueItems( ); return parsed.map((entry, index) => { - let line = items[index].trim(); + let line = items[index]!.trim(); if (entry) { let key = entry.key; if (options.alignKeyValues) { @@ -341,7 +388,7 @@ export function formatMixedItems( ); return parsed.map((entry, index) => { - let line = items[index].trim(); + let line = items[index]!.trim(); if (entry) { let key = entry.key; if (options.alignKeyValues) { diff --git a/packages/pg-delta/src/frontends/sql-format/formatters.ts b/packages/pg-delta/src/frontends/sql-format/formatters.ts new file mode 100644 index 000000000..4fbd958d4 --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/formatters.ts @@ -0,0 +1,1053 @@ +import { + formatColumnList, + formatKeyValueItems, + formatListItems, + formatMixedItems, + indentString, + joinHeaderAndClauses, +} from "./format-utils.ts"; +import { + findClausePositions, + findTopLevelParen, + identifierEnd, + qualifiedNameEnd, + scanTokens, + sliceClauses, + splitByCommas, +} from "./tokenizer.ts"; +import type { NormalizedOptions, Token } from "./types.ts"; + +// ── Module-level keyword sets (hoisted to avoid per-call allocations) ──────── + +const DOMAIN_CLAUSE_KEYWORDS = new Set(["COLLATE", "DEFAULT", "CHECK"]); + +const FUNCTION_CLAUSE_KEYWORDS = new Set([ + "RETURNS", + "LANGUAGE", + "TRANSFORM", + "WINDOW", + "IMMUTABLE", + "STABLE", + "VOLATILE", + "LEAKPROOF", + "CALLED", + "STRICT", + "SECURITY", + "PARALLEL", + "COST", + "ROWS", + "SUPPORT", + "SET", + "AS", +]); + +const POLICY_CLAUSE_KEYWORDS = new Set(["FOR", "TO", "USING", "WITH"]); + +const TRIGGER_CLAUSE_KEYWORDS = new Set([ + "BEFORE", + "AFTER", + "INSTEAD", + "FOR", + "WHEN", + "EXECUTE", +]); +const EVENT_TRIGGER_CLAUSE_KEYWORDS = new Set(["ON", "WHEN", "EXECUTE"]); + +const INDEX_CLAUSE_KEYWORDS = new Set(["WHERE", "WITH", "TABLESPACE"]); + +const LANGUAGE_CLAUSE_KEYWORDS = new Set(["HANDLER", "INLINE", "VALIDATOR"]); + +// pre-AS matview clauses (USING , TABLESPACE) must be preserved +// as their own header clauses; otherwise sliceClauses drops the text before the +// first recognized clause (the AS body), silently changing the access method. +const MATVIEW_CLAUSE_KEYWORDS = new Set(["USING", "TABLESPACE", "WITH", "AS"]); + +const SUBSCRIPTION_CLAUSE_KEYWORDS = new Set([ + "CONNECTION", + "PUBLICATION", + "WITH", +]); + +const FDW_CLAUSE_KEYWORDS = new Set(["HANDLER", "VALIDATOR", "OPTIONS"]); + +const EXPANDABLE_KEYWORDS = new Set(["OPTIONS", "WITH", "SET"]); + +// ── Formatters ─────────────────────────────────────────────────────────────── + +export function formatCreateDomain( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 2) return null; + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "DOMAIN") { + return null; + } + + // Domain has a special NOT NULL compound clause that findClausePositions + // can't handle generically, so we keep a custom scan here. + const clauseStarts: number[] = []; + for (let i = 0; i < tokens.length; i += 1) { + const tok = tokens[i]; + if (tok === undefined) continue; + if (tok.depth !== 0) continue; + + const upper = tok.upper; + if (DOMAIN_CLAUSE_KEYWORDS.has(upper)) { + clauseStarts.push(tok.start); + continue; + } + if ( + upper === "NOT" && + tokens[i + 1]?.upper === "NULL" && + tokens[i + 1]?.depth === 0 + ) { + clauseStarts.push(tok.start); + i += 1; + } + } + + if (clauseStarts.length === 0) return null; + clauseStarts.sort((a, b) => a - b); + + const prefix = statement.slice(0, clauseStarts[0]).trim(); + const clauses = sliceClauses(statement, clauseStarts); + + return joinHeaderAndClauses(prefix, clauses, options); +} + +export function formatCreateEnum( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 4) return null; + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "TYPE") { + return null; + } + + const enumToken = tokens.find( + (token, index) => + token.upper === "ENUM" && tokens[index - 1]?.upper === "AS", + ); + if (!enumToken) return null; + + const parens = findTopLevelParen(statement, enumToken.end); + if (!parens) return null; + const { open, close } = parens; + + const header = statement.slice(0, open).trim(); + const content = statement.slice(open + 1, close).trim(); + const suffix = statement.slice(close + 1).trim(); + + const items = splitByCommas(content); + const indent = indentString(options.indent); + const listLines = formatListItems(items, indent, options.commaStyle); + + const lines = [`${header} (`, ...listLines, `)${suffix ? ` ${suffix}` : ""}`]; + return lines.join("\n"); +} + +export function formatCreateCompositeType( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + { + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "TYPE") { + return null; + } + } + + const asToken = tokens.find((token) => token.upper === "AS"); + if (!asToken) return null; + const asIndex = tokens.indexOf(asToken); + const nextToken = tokens[asIndex + 1]; + if (nextToken?.upper === "ENUM" || nextToken?.upper === "RANGE") { + return null; + } + const parens = findTopLevelParen(statement, asToken.end); + if (!parens) return null; + + const { open, close } = parens; + const header = statement.slice(0, open).trim(); + const content = statement.slice(open + 1, close).trim(); + const suffix = statement.slice(close + 1).trim(); + + const formattedColumns = formatColumnList(content, options); + if (!formattedColumns) return null; + + const lines = [ + `${header} (`, + ...formattedColumns, + `)${suffix ? ` ${suffix}` : ""}`, + ]; + return lines.join("\n"); +} + +export function formatCreateTable( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + if (tokens[0]?.upper !== "CREATE") return null; + + const tableToken = tokens.find((token, index) => { + if (token.upper !== "TABLE") return false; + if (index > 0 && tokens[index - 1]?.upper === "RETURNS") return false; + return true; + }); + if (!tableToken) return null; + + const parens = findTopLevelParen(statement, tableToken.end); + if (!parens) return null; + + const { open, close } = parens; + const hasPartitionBeforeColumns = tokens.some( + (token) => + token.depth === 0 && token.upper === "PARTITION" && token.start < open, + ); + if (hasPartitionBeforeColumns) return null; + const header = statement.slice(0, open).trim(); + const content = statement.slice(open + 1, close).trim(); + const suffix = statement.slice(close + 1).trim(); + + const formattedColumns = formatColumnList(content, options); + if (!formattedColumns) return null; + + const lines = [ + `${header} (`, + ...formattedColumns, + `)${suffix ? ` ${suffix}` : ""}`, + ]; + return lines.join("\n"); +} + +export function formatCreateRange( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 4) return null; + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "TYPE") { + return null; + } + + const rangeToken = tokens.find( + (token, index) => + token.upper === "RANGE" && tokens[index - 1]?.upper === "AS", + ); + if (!rangeToken) return null; + + const parens = findTopLevelParen(statement, rangeToken.end); + if (!parens) return null; + const { open, close } = parens; + + const header = statement.slice(0, open).trim(); + const content = statement.slice(open + 1, close).trim(); + const suffix = statement.slice(close + 1).trim(); + + const items = splitByCommas(content); + if (items.length === 0) return null; + + const formattedItems = formatKeyValueItems(items, options); + const lines = [ + `${header} (`, + ...formattedItems, + `)${suffix ? ` ${suffix}` : ""}`, + ]; + return lines.join("\n"); +} + +export function formatCreateCollation( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "COLLATION") { + return null; + } + + const parens = findTopLevelParen(statement, t1.end); + if (!parens) return null; + const { open, close } = parens; + + const header = statement.slice(0, open).trim(); + const content = statement.slice(open + 1, close).trim(); + const suffix = statement.slice(close + 1).trim(); + + const items = splitByCommas(content); + if (items.length === 0) return null; + + const formattedItems = formatKeyValueItems(items, options); + const lines = [ + `${header} (`, + ...formattedItems, + `)${suffix ? ` ${suffix}` : ""}`, + ]; + return lines.join("\n"); +} + +export function formatCreateFunction( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + if (tokens[0]?.upper !== "CREATE") return null; + + let cursor = 1; + if ( + tokens[cursor]?.upper === "OR" && + tokens[cursor + 1]?.upper === "REPLACE" + ) { + cursor += 2; + } + const objectToken = tokens[cursor]; + if ( + !objectToken || + (objectToken.upper !== "FUNCTION" && objectToken.upper !== "PROCEDURE") + ) { + return null; + } + + const parens = findTopLevelParen(statement, objectToken.end); + if (!parens) return null; + const { open, close } = parens; + + const header = statement.slice(0, open).trim(); + const argContent = statement.slice(open + 1, close).trim(); + const postArgs = statement.slice(close + 1).trim(); + + const indent = indentString(options.indent); + const lines: string[] = []; + + if (argContent.length === 0) { + lines.push(`${header}()`); + } else { + const formattedArgs = formatColumnList(argContent, options); + if (formattedArgs) { + lines.push(`${header} (`, ...formattedArgs, `)`); + } else { + lines.push(`${header}(${argContent})`); + } + } + + if (postArgs.length === 0) { + return lines.join("\n"); + } + + // Function/procedure has special compound clauses (NOT LEAKPROOF, placeholders) + // that require a custom scan rather than the generic findClausePositions. + const postTokens = scanTokens(postArgs); + const clauseStarts: number[] = []; + + for (let i = 0; i < postTokens.length; i += 1) { + const tok = postTokens[i]; + if (tok === undefined) continue; + if (tok.depth !== 0) continue; + // a token that is the tail of a schema-qualified name (preceded by `.`) is + // an identifier, not a clause keyword — e.g. a RETURNS type `public.cost` + // must not be split on the COST function-clause keyword (review P2). + if (postArgs[tok.start - 1] === ".") continue; + + if (tok.upper === "NOT" && postTokens[i + 1]?.upper === "LEAKPROOF") { + clauseStarts.push(tok.start); + i += 1; + continue; + } + + if (FUNCTION_CLAUSE_KEYWORDS.has(tok.upper)) { + clauseStarts.push(tok.start); + continue; + } + if (tok.value.startsWith("__PGDELTA_PLACEHOLDER_")) { + clauseStarts.push(tok.start); + } + } + + if (clauseStarts.length === 0) { + lines[lines.length - 1] += ` ${postArgs}`; + return lines.join("\n"); + } + + clauseStarts.sort((a, b) => a - b); + + const beforeFirstClause = postArgs.slice(0, clauseStarts[0]).trim(); + if (beforeFirstClause.length > 0) { + lines[lines.length - 1] += ` ${beforeFirstClause}`; + } + + const clauses = sliceClauses(postArgs, clauseStarts); + for (const clause of clauses) { + const clauseTokens = scanTokens(clause); + const ct0 = clauseTokens[0]; + const ct1 = clauseTokens[1]; + if ( + clauseTokens.length >= 2 && + ct0 !== undefined && + ct1 !== undefined && + ct0.upper === "RETURNS" && + ct1.upper === "TABLE" + ) { + const tableParens = findTopLevelParen(clause, ct1.end); + if (tableParens) { + const innerContent = clause + .slice(tableParens.open + 1, tableParens.close) + .trim(); + const afterTable = clause.slice(tableParens.close + 1).trim(); + + if (innerContent.length > 0) { + const formattedCols = formatColumnList(innerContent, { + ...options, + indent: options.indent * 2, + }); + if (formattedCols) { + lines.push( + `${indent}RETURNS TABLE (`, + ...formattedCols, + `${indent})`, + ); + } else { + lines.push(`${indent}RETURNS TABLE (${innerContent})`); + } + } else { + lines.push(`${indent}RETURNS TABLE ()`); + } + if (afterTable.length > 0) { + lines[lines.length - 1] += ` ${afterTable}`; + } + continue; + } + } + + lines.push(`${indent}${clause}`); + } + + return lines.join("\n"); +} + +export function formatCreatePolicy( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "POLICY") { + return null; + } + + // Policy has special WITH CHECK handling that requires a custom scan. + const clauseStarts: number[] = []; + for (let i = 2; i < tokens.length; i += 1) { + const tok = tokens[i]; + if (tok === undefined) continue; + if (tok.depth !== 0) continue; + const upper = tok.upper; + + if ( + upper === "AS" && + (tokens[i + 1]?.upper === "PERMISSIVE" || + tokens[i + 1]?.upper === "RESTRICTIVE") + ) { + clauseStarts.push(tok.start); + continue; + } + if (upper === "WITH" && tokens[i + 1]?.upper === "CHECK") { + clauseStarts.push(tok.start); + continue; + } + if (upper === "WITH") continue; + if (POLICY_CLAUSE_KEYWORDS.has(upper)) { + clauseStarts.push(tok.start); + } + } + + if (clauseStarts.length === 0) return null; + clauseStarts.sort((a, b) => a - b); + + const header = statement.slice(0, clauseStarts[0]).trim(); + const clauses = sliceClauses(statement, clauseStarts); + + return joinHeaderAndClauses(header, clauses, options); +} + +export function formatCreateTrigger( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + if (tokens[0]?.upper !== "CREATE") return null; + + let triggerIndex = -1; + for (let i = 1; i < Math.min(5, tokens.length); i += 1) { + if (tokens[i]?.upper === "TRIGGER") { + triggerIndex = i; + break; + } + } + if (triggerIndex === -1) return null; + + const triggerToken = tokens[triggerIndex]; + if (triggerToken === undefined) return null; + // the (possibly quoted) trigger name follows TRIGGER; scan the raw statement + // for its end rather than tokens[triggerIndex + 1], which is the next clause + // keyword when the name is quoted (scanTokens drops quoted identifiers). + const headerEnd = identifierEnd(statement, triggerToken.end); + const rest = statement.slice(headerEnd).trim(); + const header = statement.slice(0, headerEnd).trim(); + + if (rest.length === 0) return null; + + const restTokens = scanTokens(rest); + const clauseKeywords = + tokens[triggerIndex - 1]?.upper === "EVENT" + ? EVENT_TRIGGER_CLAUSE_KEYWORDS + : TRIGGER_CLAUSE_KEYWORDS; + const positions = findClausePositions(rest, restTokens, clauseKeywords); + if (positions.length === 0) return null; + + const clauses = sliceClauses(rest, positions); + return joinHeaderAndClauses(header, clauses, options); +} + +export function formatCreateIndex( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + if (tokens[0]?.upper !== "CREATE") return null; + + let indexIndex = -1; + for (let i = 1; i < Math.min(4, tokens.length); i += 1) { + if (tokens[i]?.upper === "INDEX") { + indexIndex = i; + break; + } + } + if (indexIndex === -1) return null; + + const indexToken = tokens[indexIndex]; + if (indexToken === undefined) return null; + const parens = findTopLevelParen(statement, indexToken.end); + if (!parens) return null; + + let headerEnd = parens.close + 1; + + const rawAfter = statement.slice(headerEnd); + // `afterParens` is trimmed for tokenizing; remember the leading whitespace so + // offsets computed against it map back to the original string. + const leadWs = rawAfter.length - rawAfter.trimStart().length; + const afterParens = rawAfter.trim(); + const afterTokens = scanTokens(afterParens); + const afterToken0 = afterTokens[0]; + if ( + afterTokens.length > 0 && + afterToken0 !== undefined && + afterToken0.upper === "INCLUDE" + ) { + const includeParens = findTopLevelParen(afterParens, afterToken0.end); + if (includeParens) { + // include the trimmed leading whitespace, or headerEnd lands BEFORE the + // INCLUDE list's closing paren and cuts the `)` off (review P2). + headerEnd = headerEnd + leadWs + includeParens.close + 1; + } + } + + const restText = statement.slice(headerEnd).trim(); + if (restText.length === 0) return null; + + const restTokens = scanTokens(restText); + const positions = findClausePositions( + restText, + restTokens, + INDEX_CLAUSE_KEYWORDS, + ); + if (positions.length === 0) return null; + + // Text between the column/INCLUDE list and the first recognized clause is an + // index modifier such as `NULLS NOT DISTINCT` — keep it on the header line. + // sliceClauses() drops everything before positions[0], so without this the + // modifier would be silently lost, changing the index semantics (review P2). + const firstClauseStart = positions[0] ?? restText.length; + const modifier = restText.slice(0, firstClauseStart).trim(); + const header = statement.slice(0, headerEnd).trim(); + const headerWithModifier = + modifier.length > 0 ? `${header} ${modifier}` : header; + const clauses = sliceClauses(restText, positions); + return joinHeaderAndClauses(headerWithModifier, clauses, options); +} + +export function formatAlterTable( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + { + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "ALTER" || t1.upper !== "TABLE") { + return null; + } + } + + let cursor = 2; + if ( + tokens[cursor]?.upper === "IF" && + tokens[cursor + 1]?.upper === "EXISTS" + ) { + cursor += 2; + } + if (tokens[cursor]?.upper === "ONLY") { + cursor += 1; + } + + if (cursor >= tokens.length) return null; + + // `tokens[cursor - 1]` is the last prefix keyword (ALTER/TABLE, or IF EXISTS / + // ONLY). The object name starts after it and may be double-quoted, which + // `scanTokens` drops — so find its true end from the raw statement rather than + // by positional token indexing (which would land on the action keyword). + const lastPrefix = tokens[cursor - 1]; + if (lastPrefix === undefined) return null; + const headerEnd = qualifiedNameEnd(statement, lastPrefix.end); + const header = statement.slice(0, headerEnd).trim(); + const action = statement.slice(headerEnd).trim(); + + if (action.length === 0) return null; + + const indent = indentString(options.indent); + return `${header}\n${indent}${action}`; +} + +export function formatCreateAggregate( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + { + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "AGGREGATE") { + return null; + } + } + + // Find the argument list parentheses first (e.g. array_cat_agg(anycompatiblearray)) + const aggT1 = tokens[1]; + if (aggT1 === undefined) return null; + const argParens = findTopLevelParen(statement, aggT1.end); + if (!argParens) return null; + + // Find the options parentheses after the argument list + const optParens = findTopLevelParen(statement, argParens.close + 1); + if (!optParens) return null; + + const { open, close } = optParens; + const header = statement.slice(0, open).trim(); + const content = statement.slice(open + 1, close).trim(); + const suffix = statement.slice(close + 1).trim(); + + const items = splitByCommas(content); + if (items.length === 0) return null; + + const formattedItems = formatMixedItems(items, options); + const lines = [ + `${header} (`, + ...formattedItems, + `)${suffix ? ` ${suffix}` : ""}`, + ]; + return lines.join("\n"); +} + +export function formatCreateLanguage( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + if (tokens[0]?.upper !== "CREATE") return null; + + // Find LANGUAGE token (may be preceded by TRUSTED) + let langIndex = -1; + for (let i = 1; i < Math.min(4, tokens.length); i += 1) { + if (tokens[i]?.upper === "LANGUAGE") { + langIndex = i; + break; + } + } + if (langIndex === -1) return null; + + const langToken = tokens[langIndex]; + if (langToken === undefined) return null; + // (possibly quoted) language name follows LANGUAGE — scan the raw statement + // for its end (tokens[langIndex + 1] is the next keyword when name is quoted). + const headerEnd = identifierEnd(statement, langToken.end); + const rest = statement.slice(headerEnd).trim(); + const header = statement.slice(0, headerEnd).trim(); + + if (rest.length === 0) return null; + + const restTokens = scanTokens(rest); + const positions = findClausePositions( + rest, + restTokens, + LANGUAGE_CLAUSE_KEYWORDS, + ); + if (positions.length === 0) return null; + + const clauses = sliceClauses(rest, positions); + return joinHeaderAndClauses(header, clauses, options); +} + +export function formatCreateMaterializedView( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 4) return null; + if (tokens[0]?.upper !== "CREATE") return null; + + // Find MATERIALIZED VIEW sequence + let viewIndex = -1; + for (let i = 1; i < Math.min(5, tokens.length); i += 1) { + if ( + tokens[i]?.upper === "MATERIALIZED" && + tokens[i + 1]?.upper === "VIEW" + ) { + viewIndex = i + 1; // point to VIEW + break; + } + } + if (viewIndex === -1) return null; + + // Name (possibly quoted/qualified, e.g. "s"."v") follows VIEW. scanTokens + // drops quoted identifiers, so locate the name's end on the raw statement + // rather than via token indexing (which would land on the WITH/AS clause and + // drop the storage options + name). + const viewToken = tokens[viewIndex]; + if (viewToken === undefined) return null; + const nameEnd = qualifiedNameEnd(statement, viewToken.end); + const rest = statement.slice(nameEnd).trim(); + const header = statement.slice(0, nameEnd).trim(); + + if (rest.length === 0) return null; + + // The matview body after AS is split on clause keywords only when it has been + // PROTECTED (preserveViewBodies, the default) — it then appears as a single + // placeholder token. Without protection the raw SELECT contains its own + // `AS` (column aliases) and `WITH` (CTEs / `WITH NO DATA`) which are not + // matview clauses; splitting on them shreds the query, so fall back to generic + // formatting (review P2). + const restTokens = scanTokens(rest); + const hasProtectedBody = restTokens.some((t) => + t.value.startsWith("__PGDELTA_PLACEHOLDER_"), + ); + if (!hasProtectedBody) return null; + + const clauseStarts: number[] = []; + for (let i = 0; i < restTokens.length; i += 1) { + const rtok = restTokens[i]; + if (rtok === undefined) continue; + if (rtok.depth !== 0) continue; + if (rest[rtok.start - 1] === ".") continue; // qualified-name tail + if (MATVIEW_CLAUSE_KEYWORDS.has(rtok.upper)) { + clauseStarts.push(rtok.start); + } + // Handle placeholder for protected view body + if (rtok.value.startsWith("__PGDELTA_PLACEHOLDER_")) { + clauseStarts.push(rtok.start); + } + } + + if (clauseStarts.length === 0) return null; + clauseStarts.sort((a, b) => a - b); + + const clauses = sliceClauses(rest, clauseStarts); + return joinHeaderAndClauses(header, clauses, options); +} + +export function formatCreateSubscription( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + { + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "SUBSCRIPTION") { + return null; + } + } + + // (possibly quoted) name follows SUBSCRIPTION (tokens[1]); scan the raw + // statement for its end, since tokens[2] is the first clause keyword + // (CONNECTION) when the name is quoted (scanTokens drops quoted identifiers). + const subToken = tokens[1]; + if (subToken === undefined) return null; + const headerEnd = identifierEnd(statement, subToken.end); + const rest = statement.slice(headerEnd).trim(); + const header = statement.slice(0, headerEnd).trim(); + + if (rest.length === 0) return null; + + const restTokens = scanTokens(rest); + const positions = findClausePositions( + rest, + restTokens, + SUBSCRIPTION_CLAUSE_KEYWORDS, + ); + if (positions.length === 0) return null; + + const clauses = sliceClauses(rest, positions); + return joinHeaderAndClauses(header, clauses, options, expandOptionsClause); +} + +export function formatCreateFDW( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 5) return null; + if (tokens[0]?.upper !== "CREATE") return null; + + // Must be CREATE FOREIGN DATA WRAPPER (not CREATE SERVER ... FOREIGN DATA WRAPPER) + if ( + tokens[1]?.upper !== "FOREIGN" || + tokens[2]?.upper !== "DATA" || + tokens[3]?.upper !== "WRAPPER" + ) { + return null; + } + + // (possibly quoted) name follows WRAPPER (tokens[3]); scan the raw statement + // for its end (tokens[4] is the next keyword when the name is quoted). + const wrapperToken = tokens[3]; + if (wrapperToken === undefined) return null; + const headerEnd = identifierEnd(statement, wrapperToken.end); + const rest = statement.slice(headerEnd).trim(); + const header = statement.slice(0, headerEnd).trim(); + + if (rest.length === 0) return null; + + const restTokens = scanTokens(rest); + const positions = findClausePositions(rest, restTokens, FDW_CLAUSE_KEYWORDS); + if (positions.length === 0) return null; + + const clauses = sliceClauses(rest, positions); + return joinHeaderAndClauses(header, clauses, options, expandOptionsClause); +} + +export function formatCreateServer( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + { + const t0 = tokens[0]; + const t1 = tokens[1]; + if (t0 === undefined || t1 === undefined) return null; + if (t0.upper !== "CREATE" || t1.upper !== "SERVER") { + return null; + } + } + + // (possibly quoted) name follows SERVER (tokens[1]); scan the raw statement + // for its end, since tokens[2] is the first clause keyword (FOREIGN) when the + // name is quoted (scanTokens drops quoted identifiers). + const serverToken = tokens[1]; + if (serverToken === undefined) return null; + const headerEnd = identifierEnd(statement, serverToken.end); + const rest = statement.slice(headerEnd).trim(); + const header = statement.slice(0, headerEnd).trim(); + + if (rest.length === 0) return null; + + // Server has a multi-keyword clause (FOREIGN DATA WRAPPER) requiring custom scan + const restTokens = scanTokens(rest); + const clauseStarts: number[] = []; + + for (let i = 0; i < restTokens.length; i += 1) { + const rtok = restTokens[i]; + if (rtok === undefined) continue; + if (rtok.depth !== 0) continue; + const upper = rtok.upper; + if (upper === "TYPE" || upper === "VERSION" || upper === "OPTIONS") { + clauseStarts.push(rtok.start); + continue; + } + // Handle FOREIGN DATA WRAPPER as a clause start + if ( + upper === "FOREIGN" && + restTokens[i + 1]?.upper === "DATA" && + restTokens[i + 2]?.upper === "WRAPPER" + ) { + clauseStarts.push(rtok.start); + // Skip the FDW NAME that follows WRAPPER: an unquoted, non-reserved name + // (e.g. `FOREIGN DATA WRAPPER options`) is itself tokenized and would be + // misread as a TYPE/VERSION/OPTIONS clause start, dropping the name and + // producing invalid SQL. identifierEnd handles quoted and unquoted names; + // advance past every token that falls within DATA/WRAPPER/ (review P2). + const wrapper = restTokens[i + 2]; + if (wrapper !== undefined) { + const nameEnd = identifierEnd(rest, wrapper.end); + while ( + i + 1 < restTokens.length && + (restTokens[i + 1] as Token).start < nameEnd + ) { + i += 1; + } + } + } + } + + if (clauseStarts.length === 0) return null; + clauseStarts.sort((a, b) => a - b); + + const clauses = sliceClauses(rest, clauseStarts); + return joinHeaderAndClauses(header, clauses, options, expandOptionsClause); +} + +export function formatAlterGeneric( + statement: string, + tokens: Token[], + options: NormalizedOptions, +): string | null { + if (tokens.length < 3) return null; + if (tokens[0]?.upper !== "ALTER") return null; + + // Already handled by formatAlterTable + if (tokens[1]?.upper === "TABLE") return null; + + // Map of ALTER types to the number of type-keyword tokens + // e.g. ALTER DOMAIN = 1, ALTER FOREIGN DATA WRAPPER = 3, ALTER MATERIALIZED VIEW = 2 + let typeTokenCount = 0; + const t1 = tokens[1]?.upper; + + if (t1 === "DOMAIN" || t1 === "SUBSCRIPTION" || t1 === "SERVER") { + typeTokenCount = 1; + } else if (t1 === "MATERIALIZED" && tokens[2]?.upper === "VIEW") { + typeTokenCount = 2; + } else if (t1 === "FOREIGN") { + if (tokens[2]?.upper === "TABLE") { + typeTokenCount = 2; + } else if (tokens[2]?.upper === "DATA" && tokens[3]?.upper === "WRAPPER") { + typeTokenCount = 3; + } else { + return null; + } + } else if (t1 === "EVENT" && tokens[2]?.upper === "TRIGGER") { + typeTokenCount = 2; + } else { + return null; + } + + // cursor now points to the first token after the type keywords + let cursor = 1 + typeTokenCount; + + // Skip IF EXISTS + if ( + tokens[cursor]?.upper === "IF" && + tokens[cursor + 1]?.upper === "EXISTS" + ) { + cursor += 2; + } + + if (cursor >= tokens.length) return null; + + // The name (may be schema-qualified and double-quoted) starts after the last + // prefix keyword `tokens[cursor - 1]`. Find its true end from the raw + // statement: `scanTokens` drops quoted identifiers, so positional token + // indexing would land on the action keyword and strand it on the header line. + const lastPrefix = tokens[cursor - 1]; + if (lastPrefix === undefined) return null; + const headerEnd = qualifiedNameEnd(statement, lastPrefix.end); + const header = statement.slice(0, headerEnd).trim(); + const action = statement.slice(headerEnd).trim(); + + if (action.length === 0) return null; + + const indent = indentString(options.indent); + const expandedLines = expandOptionsClause(action, indent, options); + return [header, ...expandedLines].join("\n"); +} + +/** + * If a clause contains a parenthesized options list (e.g. OPTIONS(...), WITH(...), SET(...)) + * and it has multiple comma-separated items, expand them one per line. + * Returns an array of properly indented lines that should be pushed directly into the output. + * + * Also used as a `clauseTransform` callback for `joinHeaderAndClauses`. + */ +function expandOptionsClause( + clause: string, + baseIndent: string, + options: NormalizedOptions, +): string[] { + const clauseTokens = scanTokens(clause); + if (clauseTokens.length === 0) return [`${baseIndent}${clause}`]; + + const clauseToken0 = clauseTokens[0]; + if (clauseToken0 === undefined) return [`${baseIndent}${clause}`]; + const firstUpper = clauseToken0.upper; + if (!EXPANDABLE_KEYWORDS.has(firstUpper)) { + return [`${baseIndent}${clause}`]; + } + + const parens = findTopLevelParen(clause, clauseToken0.end); + if (!parens) return [`${baseIndent}${clause}`]; + + const { open, close } = parens; + const content = clause.slice(open + 1, close).trim(); + const suffix = clause.slice(close + 1).trim(); + const keyword = clause.slice(0, open).trim(); + + const items = splitByCommas(content); + if (items.length <= 1) return [`${baseIndent}${clause}`]; + + const innerIndent = `${baseIndent}${indentString(options.indent)}`; + const formattedItems = formatMixedItems(items, options, innerIndent); + return [ + `${baseIndent}${keyword} (`, + ...formattedItems, + `${baseIndent})${suffix ? ` ${suffix}` : ""}`, + ]; +} + +export function formatGeneric( + statement: string, + _tokens: Token[], + _options: NormalizedOptions, +): string { + return statement.trim(); +} diff --git a/packages/pg-delta/src/frontends/sql-format/index.ts b/packages/pg-delta/src/frontends/sql-format/index.ts new file mode 100644 index 000000000..5336ebcb6 --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/index.ts @@ -0,0 +1,151 @@ +import { DEFAULT_OPTIONS } from "./constants.ts"; +import { splitLeadingComments, splitSqlStatements } from "./format-utils.ts"; +import { + formatAlterGeneric, + formatAlterTable, + formatCreateAggregate, + formatCreateCollation, + formatCreateCompositeType, + formatCreateDomain, + formatCreateEnum, + formatCreateFDW, + formatCreateFunction, + formatCreateIndex, + formatCreateLanguage, + formatCreateMaterializedView, + formatCreatePolicy, + formatCreateRange, + formatCreateServer, + formatCreateSubscription, + formatCreateTable, + formatCreateTrigger, + formatGeneric, +} from "./formatters.ts"; +import { applyKeywordCase } from "./keyword-case.ts"; +import { protectSegments, restorePlaceholders } from "./protect.ts"; +import { scanTokens } from "./tokenizer.ts"; +import type { NormalizedOptions, SqlFormatOptions } from "./types.ts"; +import { wrapStatement } from "./wrap.ts"; + +export type { SqlFormatOptions, CommaStyle } from "./types.ts"; + +export function formatSqlStatements( + statements: string[], + options: SqlFormatOptions = {}, +): string[] { + const resolved = normalizeOptions(options); + const flattened = flattenStatements(statements); + return flattened + .map((statement) => formatStatement(statement, resolved)) + .filter((statement) => statement.length > 0); +} + +function normalizeOptions(options: SqlFormatOptions): NormalizedOptions { + const indent = + typeof options.indent === "number" && Number.isFinite(options.indent) + ? Math.max(0, Math.floor(options.indent)) + : DEFAULT_OPTIONS.indent; + const maxWidth = + typeof options.maxWidth === "number" && Number.isFinite(options.maxWidth) + ? Math.max(20, Math.floor(options.maxWidth)) + : DEFAULT_OPTIONS.maxWidth; + const keywordCase = + options.keywordCase === "upper" || + options.keywordCase === "lower" || + options.keywordCase === "preserve" + ? options.keywordCase + : DEFAULT_OPTIONS.keywordCase; + const commaStyle = + options.commaStyle === "leading" || options.commaStyle === "trailing" + ? options.commaStyle + : DEFAULT_OPTIONS.commaStyle; + + return { + keywordCase, + indent, + maxWidth, + commaStyle, + alignColumns: + typeof options.alignColumns === "boolean" + ? options.alignColumns + : DEFAULT_OPTIONS.alignColumns, + alignKeyValues: + typeof options.alignKeyValues === "boolean" + ? options.alignKeyValues + : DEFAULT_OPTIONS.alignKeyValues, + preserveRoutineBodies: + typeof options.preserveRoutineBodies === "boolean" + ? options.preserveRoutineBodies + : DEFAULT_OPTIONS.preserveRoutineBodies, + preserveViewBodies: + typeof options.preserveViewBodies === "boolean" + ? options.preserveViewBodies + : DEFAULT_OPTIONS.preserveViewBodies, + preserveRuleBodies: + typeof options.preserveRuleBodies === "boolean" + ? options.preserveRuleBodies + : DEFAULT_OPTIONS.preserveRuleBodies, + }; +} + +function flattenStatements(statements: string[]): string[] { + const output: string[] = []; + for (const statement of statements) { + for (const split of splitSqlStatements(statement)) { + if (split.trim().length > 0) { + output.push(split); + } + } + } + return output; +} + +function formatStatement( + statement: string, + options: NormalizedOptions, +): string { + const { commentLines, body } = splitLeadingComments(statement); + if (body.trim().length === 0) { + return commentLines.join("\n"); + } + + const protectedSegments = protectSegments(body, options); + const tokens = scanTokens(protectedSegments.text); + let formatted = + formatCreateDomain(protectedSegments.text, tokens, options) ?? + formatCreateEnum(protectedSegments.text, tokens, options) ?? + formatCreateCompositeType(protectedSegments.text, tokens, options) ?? + formatCreateTable(protectedSegments.text, tokens, options) ?? + formatCreateRange(protectedSegments.text, tokens, options) ?? + formatCreateCollation(protectedSegments.text, tokens, options) ?? + formatCreateFunction(protectedSegments.text, tokens, options) ?? + formatCreatePolicy(protectedSegments.text, tokens, options) ?? + formatCreateTrigger(protectedSegments.text, tokens, options) ?? + formatCreateIndex(protectedSegments.text, tokens, options) ?? + formatCreateAggregate(protectedSegments.text, tokens, options) ?? + formatCreateLanguage(protectedSegments.text, tokens, options) ?? + formatCreateMaterializedView(protectedSegments.text, tokens, options) ?? + formatCreateSubscription(protectedSegments.text, tokens, options) ?? + formatCreateFDW(protectedSegments.text, tokens, options) ?? + formatCreateServer(protectedSegments.text, tokens, options) ?? + formatAlterTable(protectedSegments.text, tokens, options) ?? + formatAlterGeneric(protectedSegments.text, tokens, options) ?? + formatGeneric(protectedSegments.text, tokens, options); + + if (!protectedSegments.skipCasing && options.keywordCase !== "preserve") { + formatted = applyKeywordCase(formatted, options); + } + + formatted = wrapStatement( + formatted, + options, + protectedSegments.noWrapPlaceholders, + ); + formatted = restorePlaceholders(formatted, protectedSegments.placeholders); + + if (commentLines.length > 0) { + return [...commentLines, formatted].join("\n"); + } + + return formatted; +} diff --git a/packages/pg-delta/src/core/plan/sql-format/keyword-case.test.ts b/packages/pg-delta/src/frontends/sql-format/keyword-case.test.ts similarity index 100% rename from packages/pg-delta/src/core/plan/sql-format/keyword-case.test.ts rename to packages/pg-delta/src/frontends/sql-format/keyword-case.test.ts diff --git a/packages/pg-delta/src/core/plan/sql-format/keyword-case.ts b/packages/pg-delta/src/frontends/sql-format/keyword-case.ts similarity index 90% rename from packages/pg-delta/src/core/plan/sql-format/keyword-case.ts rename to packages/pg-delta/src/frontends/sql-format/keyword-case.ts index eae7cdf06..e6b02ff89 100644 --- a/packages/pg-delta/src/core/plan/sql-format/keyword-case.ts +++ b/packages/pg-delta/src/frontends/sql-format/keyword-case.ts @@ -20,11 +20,13 @@ const STRUCTURAL_TOP_LEVEL_KEYWORDS = new Set([ "ALTER", "ALWAYS", "AS", + "ASC", "ATTACH", "ATTRIBUTE", "AUTHORIZATION", "BEFORE", "BY", + "CONCURRENTLY", "CACHE", "CALLED", "CANONICAL", @@ -48,6 +50,7 @@ const STRUCTURAL_TOP_LEVEL_KEYWORDS = new Set([ "DEFERRED", "DEFINER", "DELETE", + "DESC", "DESERIALFUNC", "DETACH", "DETERMINISTIC", @@ -100,8 +103,12 @@ const STRUCTURAL_TOP_LEVEL_KEYWORDS = new Set([ "LEVEL", "LIMIT", "LOCALE", + "FIRST", + "INCLUDE", + "LAST", "LOGGED", "LOGIN", + "MAINTAIN", "MATERIALIZED", "MAXVALUE", "MATCH", @@ -119,6 +126,7 @@ const STRUCTURAL_TOP_LEVEL_KEYWORDS = new Set([ "NOT", "NOTHING", "NULL", + "NULLS", "OF", "ON", "ONLY", @@ -182,6 +190,7 @@ const STRUCTURAL_TOP_LEVEL_KEYWORDS = new Set([ "TEMPORARY", "TO", "TRIGGER", + "TRUNCATE", "TRUSTED", "TYPE", "UNIQUE", @@ -271,13 +280,13 @@ export function applyKeywordCase( if (index < skipUntil) return true; while ( rangeIndex < protectedRanges.length && - protectedRanges[rangeIndex].end <= index + protectedRanges[rangeIndex]!.end <= index ) { rangeIndex += 1; } if (isWordChar(char)) { let end = index + 1; - while (end < statement.length && isWordChar(statement[end])) { + while (end < statement.length && isWordChar(statement[end]!)) { end += 1; } const word = statement.slice(index, end); @@ -310,23 +319,26 @@ function collectCaseableTokenStarts( const topLevelTokens: Array<{ token: Token; index: number }> = []; for (let i = 0; i < tokens.length; i += 1) { - if (tokens[i].depth === 0) { - topLevelTokens.push({ token: tokens[i], index: i }); + if (tokens[i]!.depth === 0) { + topLevelTokens.push({ token: tokens[i]!, index: i }); } } if (topLevelTokens.length === 0) return caseable; - const command = topLevelTokens[0].token.upper; + const command = topLevelTokens[0]!.token.upper; const scopedKeywords = getStatementScopedKeywords(topLevelTokens); const objectNameTokenIndexes = new Set(); for (let topIndex = 0; topIndex < topLevelTokens.length; topIndex += 1) { - if (isLikelyObjectNameToken(command, topLevelTokens, topIndex)) { - objectNameTokenIndexes.add(topLevelTokens[topIndex].index); + if ( + isLikelyObjectNameToken(command, topLevelTokens, topIndex) && + !quotedNameInGap(statement, topLevelTokens, topIndex) + ) { + objectNameTokenIndexes.add(topLevelTokens[topIndex]!.index); } } for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index]; + const token = tokens[index]!; const upper = token.upper; if (!STRUCTURAL_TOP_LEVEL_KEYWORDS.has(upper) && !scopedKeywords.has(upper)) continue; @@ -342,6 +354,27 @@ function collectCaseableTokenStarts( return caseable; } +/** + * True when a QUOTED identifier sits between the previous top-level word token + * and the candidate "object name" token. Quoted identifiers produce no scanner + * token, so the positional object-name heuristic would otherwise mark the word + * AFTER the real (quoted) name — `ENABLE` in + * `ALTER TABLE "s"."t" ENABLE ROW LEVEL SECURITY`, `ON` in + * `CREATE POLICY "p" ON …` — as the name and shield it from casing. When the + * gap carries a quote, the real name was quoted (and needs no protection), so + * no word token should be treated as the object name. + */ +function quotedNameInGap( + statement: string, + topLevelTokens: Array<{ token: Token; index: number }>, + topIndex: number, +): boolean { + if (topIndex === 0) return false; + const prev = topLevelTokens[topIndex - 1]!.token; + const candidate = topLevelTokens[topIndex]!.token; + return statement.slice(prev.end, candidate.start).includes('"'); +} + function isLikelyObjectNameToken( command: string, topLevelTokens: Array<{ token: Token; index: number }>, @@ -498,7 +531,7 @@ function findTopLevelIndex( keyword: string, ): number { for (let i = 0; i < topLevelTokens.length; i += 1) { - if (topLevelTokens[i].token.upper === keyword) return i; + if (topLevelTokens[i]!.token.upper === keyword) return i; } return -1; } @@ -562,7 +595,7 @@ function collectCheckClauseRanges( tokens.some((t) => t.depth === 0 && t.upper === "TRIGGER"); for (let i = 0; i < tokens.length; i += 1) { - const token = tokens[i]; + const token = tokens[i]!; if (token.upper === "CHECK") { const open = findImmediateParen(statement, token.end); @@ -642,11 +675,11 @@ function collectCreateOptionBlockAssignmentRanges( if (tokens[1]?.upper === "TYPE") { for (let i = 2; i < tokens.length; i += 1) { - if (tokens[i].depth !== 0 || tokens[i].upper !== "AS") continue; + if (tokens[i]!.depth !== 0 || tokens[i]!.upper !== "AS") continue; if (tokens[i + 1]?.depth !== 0 || tokens[i + 1]?.upper !== "RANGE") { continue; } - const parens = findTopLevelParen(statement, tokens[i + 1].end); + const parens = findTopLevelParen(statement, tokens[i + 1]!.end); if (parens) { collectAssignmentItemRanges( statement, @@ -764,7 +797,7 @@ function collectCreateDefinitionRanges( tokens[index + 1]?.upper !== "RANGE", ); if (asIndex !== -1) { - const parens = findTopLevelParen(statement, tokens[asIndex].end); + const parens = findTopLevelParen(statement, tokens[asIndex]!.end); if (parens) { collectDefinitionRangesFromParen( statement, @@ -795,12 +828,12 @@ function collectCreateDefinitionRanges( for (let i = 0; i < tokens.length - 1; i += 1) { if ( - tokens[i].depth === 0 && - tokens[i].upper === "RETURNS" && - tokens[i + 1].depth === 0 && - tokens[i + 1].upper === "TABLE" + tokens[i]!.depth === 0 && + tokens[i]!.upper === "RETURNS" && + tokens[i + 1]!.depth === 0 && + tokens[i + 1]!.upper === "TABLE" ) { - const parens = findTopLevelParen(statement, tokens[i + 1].end); + const parens = findTopLevelParen(statement, tokens[i + 1]!.end); if (parens) { collectDefinitionRangesFromParen( statement, @@ -847,7 +880,7 @@ function collectAlterDefinitionRanges( if (cursor >= tokens.length) return; for (let i = cursor; i < tokens.length; i += 1) { - const token = tokens[i]; + const token = tokens[i]!; if (token.depth !== 0) continue; const actionEnd = findNextTopLevelComma(statement, token.start); @@ -857,17 +890,17 @@ function collectAlterDefinitionRanges( let defIndex = i + 1; if ( tokens[defIndex]?.depth === 0 && - tokens[defIndex].upper === "COLUMN" + tokens[defIndex]!.upper === "COLUMN" ) { defIndex += 1; } if ( tokens[defIndex]?.depth === 0 && - tokens[defIndex].upper === "IF" && + tokens[defIndex]!.upper === "IF" && tokens[defIndex + 1]?.depth === 0 && - tokens[defIndex + 1].upper === "NOT" && + tokens[defIndex + 1]!.upper === "NOT" && tokens[defIndex + 2]?.depth === 0 && - tokens[defIndex + 2].upper === "EXISTS" + tokens[defIndex + 2]!.upper === "EXISTS" ) { defIndex += 3; } @@ -886,7 +919,7 @@ function collectAlterDefinitionRanges( if ( token.upper === "ALTER" && tokens[i + 1]?.depth === 0 && - tokens[i + 1].upper === "COLUMN" + tokens[i + 1]!.upper === "COLUMN" ) { const nameToken = tokens[i + 2]; if (!nameToken || nameToken.depth !== 0 || nameToken.start >= end) { @@ -895,7 +928,7 @@ function collectAlterDefinitionRanges( let typeTokenIndex = -1; for (let j = i + 3; j < tokens.length; j += 1) { - const candidate = tokens[j]; + const candidate = tokens[j]!; if (candidate.start >= end) break; if (candidate.depth !== 0) continue; if (candidate.upper === "TYPE") { @@ -914,14 +947,14 @@ function collectAlterDefinitionRanges( ranges.push({ start: nameToken.start, end: nameToken.end }); - const typeToken = tokens[typeTokenIndex]; + const typeToken = tokens[typeTokenIndex]!; let typeStart = typeToken.end; - while (typeStart < end && /\s/.test(statement[typeStart])) { + while (typeStart < end && /\s/.test(statement[typeStart]!)) { typeStart += 1; } let typeEnd = end; for (let j = typeTokenIndex + 1; j < tokens.length; j += 1) { - const candidate = tokens[j]; + const candidate = tokens[j]!; if (candidate.start >= end) break; if (candidate.depth !== 0) continue; if (ALTER_TYPE_BOUNDARY_KEYWORDS.has(candidate.upper)) { @@ -929,7 +962,7 @@ function collectAlterDefinitionRanges( break; } } - while (typeEnd > typeStart && /\s/.test(statement[typeEnd - 1])) { + while (typeEnd > typeStart && /\s/.test(statement[typeEnd - 1]!)) { typeEnd -= 1; } if (typeStart < typeEnd) { @@ -995,10 +1028,10 @@ function trimRange( let trimmedStart = start; let trimmedEnd = end; - while (trimmedStart < trimmedEnd && /\s/.test(content[trimmedStart])) { + while (trimmedStart < trimmedEnd && /\s/.test(content[trimmedStart]!)) { trimmedStart += 1; } - while (trimmedEnd > trimmedStart && /\s/.test(content[trimmedEnd - 1])) { + while (trimmedEnd > trimmedStart && /\s/.test(content[trimmedEnd - 1]!)) { trimmedEnd -= 1; } if (trimmedStart >= trimmedEnd) return null; @@ -1042,19 +1075,19 @@ function findTopLevelEquals(text: string): number { function previousNonSpace(text: string, index: number): string | null { let i = index; - while (i >= 0 && /\s/.test(text[i])) i -= 1; - return i >= 0 ? text[i] : null; + while (i >= 0 && /\s/.test(text[i]!)) i -= 1; + return i >= 0 ? (text[i] ?? null) : null; } function nextNonSpace(text: string, index: number): string | null { let i = index; - while (i < text.length && /\s/.test(text[i])) i += 1; - return i < text.length ? text[i] : null; + while (i < text.length && /\s/.test(text[i]!)) i += 1; + return i < text.length ? (text[i] ?? null) : null; } function findImmediateParen(statement: string, start: number): number { let index = start; - while (index < statement.length && /\s/.test(statement[index])) { + while (index < statement.length && /\s/.test(statement[index]!)) { index += 1; } return statement[index] === "(" ? index : -1; diff --git a/packages/pg-delta/src/core/plan/sql-format/protect.test.ts b/packages/pg-delta/src/frontends/sql-format/protect.test.ts similarity index 88% rename from packages/pg-delta/src/core/plan/sql-format/protect.test.ts rename to packages/pg-delta/src/frontends/sql-format/protect.test.ts index 4968a0365..db0967971 100644 --- a/packages/pg-delta/src/core/plan/sql-format/protect.test.ts +++ b/packages/pg-delta/src/frontends/sql-format/protect.test.ts @@ -1,7 +1,21 @@ import { describe, expect, it } from "bun:test"; import { DEFAULT_OPTIONS } from "./constants.ts"; +import { formatSqlStatements } from "./index.ts"; import { protectSegments, restorePlaceholders } from "./protect.ts"; +describe("placeholder collision safety", () => { + it("does not clobber an identifier equal to the placeholder token", () => { + // The original SQL contains the fixed placeholder prefix verbatim, so a + // global-replace restore with a fixed sentinel would rewrite the view NAME + // to the protected body. The collision-free sentinel prevents that. + const sql = `CREATE VIEW "__PGDELTA_PLACEHOLDER_0__" AS SELECT 1`; + const [result] = formatSqlStatements([sql]); + expect(result).toContain(`"__PGDELTA_PLACEHOLDER_0__"`); + expect(result).toContain("SELECT 1"); + expect(result).not.toContain(`"AS SELECT 1"`); + }); +}); + describe("protectSegments", () => { it("protects function body after AS", () => { const sql = "CREATE FUNCTION foo() RETURNS void AS $$ BEGIN NULL; END; $$"; diff --git a/packages/pg-delta/src/core/plan/sql-format/protect.ts b/packages/pg-delta/src/frontends/sql-format/protect.ts similarity index 85% rename from packages/pg-delta/src/core/plan/sql-format/protect.ts rename to packages/pg-delta/src/frontends/sql-format/protect.ts index de1aaed0a..18faa3d5b 100644 --- a/packages/pg-delta/src/core/plan/sql-format/protect.ts +++ b/packages/pg-delta/src/frontends/sql-format/protect.ts @@ -7,8 +7,22 @@ type ProtectState = { noWrapPlaceholders: Set; counter: number; skipCasing: boolean; + /** placeholder prefix guaranteed NOT to occur in the input, so + * `restorePlaceholders`' global replace only touches inserted tokens. */ + sentinel: string; }; +/** A placeholder prefix that does not appear anywhere in `statement`. Restore + * replaces placeholder tokens globally, so if the ORIGINAL SQL contained the + * fixed prefix verbatim (e.g. an identifier `"__PGDELTA_PLACEHOLDER_0__"`), + * that occurrence would be clobbered too. Extending the prefix until it is + * absent from the input makes the token collision-proof (review P2). */ +function collisionFreeSentinel(statement: string): string { + let sentinel = "__PGDELTA_PLACEHOLDER_"; + while (statement.includes(sentinel)) sentinel += "X_"; + return sentinel; +} + export function protectSegments( statement: string, options: NormalizedOptions, @@ -21,6 +35,7 @@ export function protectSegments( noWrapPlaceholders, counter: 0, skipCasing: false, + sentinel: collisionFreeSentinel(statement), }; if (options.preserveRoutineBodies) { @@ -56,7 +71,8 @@ function protectTailAfterAs( if (tokens.length === 0) return { text }; for (let i = 0; i < tokens.length; i += 1) { - if (tokens[i].upper !== "CREATE") continue; + const tok = tokens[i]!; + if (tok.upper !== "CREATE") continue; let cursor = i + 1; if ( @@ -91,7 +107,7 @@ function protectTailAfterAs( if (!asToken) continue; - const placeholder = makePlaceholder(state.counter); + const placeholder = makePlaceholder(state.sentinel, state.counter); state.counter += 1; state.placeholders.set(placeholder, text.slice(asToken.start)); state.noWrapPlaceholders.add(placeholder); @@ -108,7 +124,7 @@ function protectCommentLiteral( ): { text: string } { const tokens = scanTokens(text); if (tokens.length < 4) return { text }; - if (tokens[0].upper !== "COMMENT" || tokens[1].upper !== "ON") { + if (tokens[0]!.upper !== "COMMENT" || tokens[1]!.upper !== "ON") { return { text }; } @@ -118,7 +134,7 @@ function protectCommentLiteral( if (!isToken) return { text }; let literalStart = isToken.end; - while (literalStart < text.length && /\s/.test(text[literalStart])) { + while (literalStart < text.length && /\s/.test(text[literalStart]!)) { literalStart += 1; } if (literalStart >= text.length) return { text }; @@ -127,7 +143,7 @@ function protectCommentLiteral( return { text }; } - const first = text[literalStart]; + const first = text[literalStart]!; let quoteStart = -1; let isEscapeString = false; if (first === "'") { @@ -171,7 +187,7 @@ function protectCommentLiteral( return { text }; } - const placeholder = makePlaceholder(state.counter); + const placeholder = makePlaceholder(state.sentinel, state.counter); state.counter += 1; state.placeholders.set(placeholder, text.slice(literalStart, literalEnd)); const updated = `${text.slice(0, literalStart)}${placeholder}${text.slice(literalEnd)}`; @@ -289,7 +305,7 @@ function protectDollarQuotes( const start = i; const end = text.indexOf(tag, i + tag.length); if (end !== -1) { - const placeholder = makePlaceholder(state.counter); + const placeholder = makePlaceholder(state.sentinel, state.counter); state.counter += 1; state.placeholders.set( placeholder, @@ -332,6 +348,6 @@ function isKeywordBoundary(statement: string, token: Token): boolean { return isBoundary(before) && isBoundary(after); } -function makePlaceholder(index: number): string { - return `__PGDELTA_PLACEHOLDER_${index}__`; +function makePlaceholder(sentinel: string, index: number): string { + return `${sentinel}${index}__`; } diff --git a/packages/pg-delta/src/core/plan/sql-format/sql-scanner.test.ts b/packages/pg-delta/src/frontends/sql-format/sql-scanner.test.ts similarity index 100% rename from packages/pg-delta/src/core/plan/sql-format/sql-scanner.test.ts rename to packages/pg-delta/src/frontends/sql-format/sql-scanner.test.ts diff --git a/packages/pg-delta/src/core/plan/sql-format/sql-scanner.ts b/packages/pg-delta/src/frontends/sql-format/sql-scanner.ts similarity index 87% rename from packages/pg-delta/src/core/plan/sql-format/sql-scanner.ts rename to packages/pg-delta/src/frontends/sql-format/sql-scanner.ts index 61be27010..bb8476ac9 100644 --- a/packages/pg-delta/src/core/plan/sql-format/sql-scanner.ts +++ b/packages/pg-delta/src/frontends/sql-format/sql-scanner.ts @@ -29,16 +29,6 @@ type WalkSqlOptions = { * called once with the full sequence. */ onSkipped?: (chunk: string) => void; - /** - * Called once for each top-level double-quoted identifier, after its closing - * quote, with the index of the opening quote (`start`), the index just past - * the closing quote (`end`), and the parenthesis depth at the quote. - * - * Lets callers treat a quoted identifier (e.g. `"my-trigger"`) as an atomic - * token even though its interior is reported via `onSkipped`. Returning - * `false` stops the walk early, like `onTopLevel`. - */ - onQuotedIdentifier?: (start: number, end: number, depth: number) => boolean; }; /** @@ -85,7 +75,7 @@ export function isEscapeStringQuoteStart( export function readDollarTag(text: string, start: number): string | null { if (text[start] !== "$") return null; let i = start + 1; - while (i < text.length && isWordChar(text[i])) { + while (i < text.length && isWordChar(text[i]!)) { i += 1; } if (text[i] === "$") { @@ -114,12 +104,10 @@ export function walkSql( const trackDepth = options?.trackDepth ?? false; const startIndex = options?.startIndex ?? 0; const onSkipped = options?.onSkipped; - const onQuotedIdentifier = options?.onQuotedIdentifier; let inSingleQuote = false; let singleQuoteEscapeMode = false; let inDoubleQuote = false; - let doubleQuoteStart = -1; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; @@ -127,7 +115,7 @@ export function walkSql( let i = startIndex; while (i < text.length) { - const char = text[i]; + const char = text[i]!; const next = text[i + 1]; // --- Inside line comment --- @@ -199,12 +187,6 @@ export function walkSql( continue; } inDoubleQuote = false; - onSkipped?.(char); - if (onQuotedIdentifier?.(doubleQuoteStart, i + 1, depth) === false) { - return; - } - i += 1; - continue; } onSkipped?.(char); i += 1; @@ -233,7 +215,6 @@ export function walkSql( } if (char === '"') { inDoubleQuote = true; - doubleQuoteStart = i; onSkipped?.(char); i += 1; continue; diff --git a/packages/pg-delta/src/core/plan/sql-format/tokenizer.test.ts b/packages/pg-delta/src/frontends/sql-format/tokenizer.test.ts similarity index 76% rename from packages/pg-delta/src/core/plan/sql-format/tokenizer.test.ts rename to packages/pg-delta/src/frontends/sql-format/tokenizer.test.ts index 140d55e80..cd79246d2 100644 --- a/packages/pg-delta/src/core/plan/sql-format/tokenizer.test.ts +++ b/packages/pg-delta/src/frontends/sql-format/tokenizer.test.ts @@ -15,26 +15,10 @@ describe("scanTokens", () => { const tokens = scanTokens("fn(a, b)"); const inner = tokens.filter((t) => t.depth > 0); expect(inner.length).toBe(2); - expect(inner[0].value).toBe("a"); - expect(inner[0].depth).toBe(1); - expect(inner[1].value).toBe("b"); - expect(inner[1].depth).toBe(1); - }); - - it("emits a single token for a double-quoted identifier", () => { - const tokens = scanTokens('CREATE TRIGGER "send-chat-push" AFTER'); - expect(tokens).toEqual([ - { value: "CREATE", upper: "CREATE", start: 0, end: 6, depth: 0 }, - { value: "TRIGGER", upper: "TRIGGER", start: 7, end: 14, depth: 0 }, - { - value: '"send-chat-push"', - upper: '"SEND-CHAT-PUSH"', - start: 15, - end: 31, - depth: 0, - }, - { value: "AFTER", upper: "AFTER", start: 32, end: 37, depth: 0 }, - ]); + expect(inner[0]!.value).toBe("a"); + expect(inner[0]!.depth).toBe(1); + expect(inner[1]!.value).toBe("b"); + expect(inner[1]!.depth).toBe(1); }); it("ignores content in quotes, comments, and dollar-quotes", () => { diff --git a/packages/pg-delta/src/frontends/sql-format/tokenizer.ts b/packages/pg-delta/src/frontends/sql-format/tokenizer.ts new file mode 100644 index 000000000..bdc019400 --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-format/tokenizer.ts @@ -0,0 +1,202 @@ +import { isWordChar, walkSql } from "./sql-scanner.ts"; +import type { Token } from "./types.ts"; + +/** + * Index just past a single SQL identifier (quoted or unquoted) at/after `from`, + * skipping leading whitespace. `scanTokens` drops double-quoted identifiers, so + * positional `tokens[N]` indexing lands PAST a quoted object name onto the next + * clause keyword; a formatter that needs the name's true end (to slice the + * header before the first clause) scans the raw statement with this instead. + */ +export function identifierEnd(statement: string, from: number): number { + let i = from; + while (i < statement.length && /\s/.test(statement[i]!)) i += 1; + if (statement[i] === '"') { + i += 1; + while (i < statement.length) { + if (statement[i] === '"') { + if (statement[i + 1] === '"') { + i += 2; // escaped "" inside a quoted identifier + continue; + } + return i + 1; // closing quote + } + i += 1; + } + return i; // unterminated — caller still gets a sane bound + } + while (i < statement.length && isWordChar(statement[i]!)) i += 1; + return i; +} + +/** + * Index just past a possibly schema-qualified identifier (`a`, `a.b`, `"s"."v"`) + * at/after `from`. Built on {@link identifierEnd}; follows a `.` chain. Used by + * formatters whose object name is qualified and may be quoted (e.g. a + * materialized view), which positional token indexing mishandles because + * `scanTokens` drops quoted identifiers. + */ +export function qualifiedNameEnd(statement: string, from: number): number { + let i = identifierEnd(statement, from); + while (statement[i] === ".") { + i = identifierEnd(statement, i + 1); + } + return i; +} + +export function scanTokens(statement: string): Token[] { + const tokens: Token[] = []; + let skipUntil = -1; + + walkSql( + statement, + (index, char, depth) => { + if (index < skipUntil) return true; + if (char === "(" || char === ")") return true; + if (isWordChar(char)) { + let end = index + 1; + while (end < statement.length && isWordChar(statement[end]!)) { + end += 1; + } + const value = statement.slice(index, end); + tokens.push({ + value, + upper: value.toUpperCase(), + start: index, + end, + depth, + }); + skipUntil = end; + } + return true; + }, + { trackDepth: true }, + ); + + return tokens; +} + +export function findTopLevelParen( + statement: string, + startIndex: number, +): { open: number; close: number } | null { + let result: { open: number; close: number } | null = null; + let openIndex: number | null = null; + + walkSql( + statement, + (index, char, depth) => { + if (char === "(") { + if (depth === 0) { + openIndex = index; + } + return true; + } + if (char === ")") { + if (depth === 0 && openIndex !== null) { + result = { open: openIndex, close: index }; + return false; + } + } + return true; + }, + { trackDepth: true, startIndex }, + ); + + return result; +} + +/** + * Collect the starting positions of top-level clause keywords in a token list. + * Returns a sorted array of character offsets (Token.start values). + * + * `text` is the string the tokens were scanned from: a keyword token that is the + * tail of a schema-qualified name (preceded by `.`, e.g. the `execute` in + * `EXECUTE FUNCTION public.execute()` or the `handler` in `HANDLER + * public.handler`) is an identifier, not a clause start, and is skipped. + */ +export function findClausePositions( + text: string, + tokens: Token[], + keywords: Set, +): number[] { + const positions: number[] = []; + for (let i = 0; i < tokens.length; i += 1) { + const tok = tokens[i]!; + if (tok.depth !== 0) continue; + if (text[tok.start - 1] === ".") continue; // qualified-name tail, not a clause + if (keywords.has(tok.upper)) { + positions.push(tok.start); + } + } + positions.sort((a, b) => a - b); + return positions; +} + +/** + * Advance a cursor past a possibly schema-qualified name (e.g. `public.my_table`). + * Returns the new cursor position (pointing to the first token after the name). + */ +export function skipQualifiedName( + statement: string, + tokens: Token[], + cursor: number, +): number { + let c = cursor + 1; + while (c < tokens.length) { + const curr = tokens[c]!; + const prev = tokens[c - 1]!; + if (curr.start !== prev.end + 1 || statement[prev.end] !== ".") break; + c += 1; + } + return c; +} + +/** + * Slice a text into clause strings given sorted clause-start positions. + * Returns trimmed, non-empty clause strings. + */ +export function sliceClauses(text: string, positions: number[]): string[] { + const clauses: string[] = []; + for (let i = 0; i < positions.length; i += 1) { + const start = positions[i]; + const end = positions[i + 1] ?? text.length; + const clause = text.slice(start, end).trim(); + if (clause.length > 0) clauses.push(clause); + } + return clauses; +} + +export function splitByCommas(content: string): string[] { + const items: string[] = []; + let buffer = ""; + + walkSql( + content, + (_index, char, depth) => { + if (char === "(" || char === ")") { + buffer += char; + return true; + } + if (char === "," && depth === 0) { + items.push(buffer); + buffer = ""; + return true; + } + buffer += char; + return true; + }, + { + trackDepth: true, + onSkipped: (chunk) => { + buffer += chunk; + }, + }, + ); + + if (buffer.length > 0) { + items.push(buffer); + } + + return items.map((item) => item.trim()).filter((item) => item.length > 0); +} diff --git a/packages/pg-delta/src/core/plan/sql-format/types.ts b/packages/pg-delta/src/frontends/sql-format/types.ts similarity index 100% rename from packages/pg-delta/src/core/plan/sql-format/types.ts rename to packages/pg-delta/src/frontends/sql-format/types.ts diff --git a/packages/pg-delta/src/core/plan/sql-format/wrap.test.ts b/packages/pg-delta/src/frontends/sql-format/wrap.test.ts similarity index 95% rename from packages/pg-delta/src/core/plan/sql-format/wrap.test.ts rename to packages/pg-delta/src/frontends/sql-format/wrap.test.ts index 295007a2c..25abe0c0b 100644 --- a/packages/pg-delta/src/core/plan/sql-format/wrap.test.ts +++ b/packages/pg-delta/src/frontends/sql-format/wrap.test.ts @@ -17,7 +17,7 @@ describe("wrapStatement", () => { const result = wrapStatement(long, opts, noWrap); const lines = result.split("\n"); expect(lines.length).toBeGreaterThan(1); - expect(lines[0].length).toBeLessThanOrEqual(40); + expect(lines[0]!.length).toBeLessThanOrEqual(40); }); it("does not wrap comment lines regardless of length", () => { @@ -53,7 +53,7 @@ describe("wrapStatement", () => { const lines = result.split("\n"); expect(lines.length).toBeGreaterThan(1); // The second line should start with a keyword like MATCH or ON - const secondLineTrimmed = lines[1].trim(); + const secondLineTrimmed = lines[1]!.trim(); expect(secondLineTrimmed).toMatch( /^(MATCH|ON|FOREIGN|CHECK|REFERENCES|DEFERRABLE|INITIALLY)/, ); @@ -105,7 +105,7 @@ describe("wrapStatement", () => { expect(l.trim().length).toBeGreaterThan(0); } // First line should keep "ON FUNCTION" together - expect(lines[0].trim()).toMatch(/^ON FUNCTION/); + expect(lines[0]!.trim()).toMatch(/^ON FUNCTION/); }); it("breaks after commas when within maxWidth (one clause per line)", () => { @@ -113,7 +113,7 @@ describe("wrapStatement", () => { const result = wrapStatement("a, b", { ...opts, maxWidth: 3 }, noWrap); const lines = result.split("\n"); expect(lines.length).toBe(2); - expect(lines[0].trimEnd()).toBe("a,"); - expect(lines[1].trim()).toBe("b"); + expect(lines[0]!.trimEnd()).toBe("a,"); + expect(lines[1]!.trim()).toBe("b"); }); }); diff --git a/packages/pg-delta/src/core/plan/sql-format/wrap.ts b/packages/pg-delta/src/frontends/sql-format/wrap.ts similarity index 97% rename from packages/pg-delta/src/core/plan/sql-format/wrap.ts rename to packages/pg-delta/src/frontends/sql-format/wrap.ts index 3fdcfd510..630f1b292 100644 --- a/packages/pg-delta/src/core/plan/sql-format/wrap.ts +++ b/packages/pg-delta/src/frontends/sql-format/wrap.ts @@ -115,9 +115,9 @@ function getPreviousWord(text: string, beforeIndex: number): string | null { while (end >= 0 && (text[end] === " " || text[end] === "\t")) { end -= 1; } - if (end < 0 || !isWordChar(text[end])) return null; + if (end < 0 || !isWordChar(text[end]!)) return null; let start = end; - while (start > 0 && isWordChar(text[start - 1])) { + while (start > 0 && isWordChar(text[start - 1]!)) { start -= 1; } return text.slice(start, end + 1).toUpperCase(); @@ -157,9 +157,9 @@ function findWrapPosition(text: string, maxWidth: number): number { // Check if the next word is a preferred keyword const nextWordStart = index + 1; - if (nextWordStart < text.length && isWordChar(text[nextWordStart])) { + if (nextWordStart < text.length && isWordChar(text[nextWordStart]!)) { let wordEnd = nextWordStart + 1; - while (wordEnd < text.length && isWordChar(text[wordEnd])) { + while (wordEnd < text.length && isWordChar(text[wordEnd]!)) { wordEnd += 1; } const word = text.slice(nextWordStart, wordEnd).toUpperCase(); diff --git a/packages/pg-delta/src/frontends/sql-order.test.ts b/packages/pg-delta/src/frontends/sql-order.test.ts new file mode 100644 index 000000000..4630defbb --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-order.test.ts @@ -0,0 +1,308 @@ +/** + * Unit tests for the statement-reordering assist (`orderForShadow`). + * + * The assist is advisory: it splits the user's SQL files into one-statement + * units and topologically pre-sorts them so the parser-free shadow loader + * ([`load-sql-files.ts`](./load-sql-files.ts)) converges in fewer rounds. It is + * NEVER trusted for correctness — Postgres elaborates the shadow (principle P1) + * — so the only hard guarantees it must keep are structural: + * + * - one statement per output `SqlFile`, fed straight into `loadSqlFiles`; + * - a zero-padded ordinal name prefix so the loader's per-round lexicographic + * `name` sort reproduces topo order (D4 option `a`); + * - every input statement preserved exactly once — including statements + * pg-topo cannot classify (`UNKNOWN`) and statements trapped in a cycle; + * - statement text carried verbatim; + * - deterministic output for the same input. + * + * No Docker required (pg-topo is a pure WASM parser; no shadow DB is loaded). + */ +import { describe, expect, test } from "bun:test"; +import { + __setPgTopoImporterForTests, + analyzeForShadow, + canReorder, + orderForShadow, + ReorderParseError, + ReorderUnavailableError, + type OrderedSqlFile, +} from "./sql-order.ts"; +import type { SqlFile } from "./load-sql-files.ts"; + +const file = (name: string, sql: string): SqlFile => ({ name, sql }); + +const ORDINAL_PREFIX = /^\d+__/; + +describe("orderForShadow — split + topological pre-sort", () => { + test("reorders an intra-file VIEW-before-TABLE into TABLE-before-VIEW", async () => { + // a single file authored in the wrong internal order + const files = [ + file( + "schema.sql", + "create view public.v as select id from public.t;\n" + + "create table public.t(id int primary key);", + ), + ]; + + const ordered = await orderForShadow(files); + + // split into one statement per SqlFile + expect(ordered).toHaveLength(2); + for (const out of ordered) { + expect(out.sql.match(/;/g) ?? []).toHaveLength(1); + } + + // the TABLE now precedes the VIEW + expect(ordered[0]?.sql.toLowerCase()).toContain("create table public.t"); + expect(ordered[1]?.sql.toLowerCase()).toContain("create view public.v"); + }); + + test("ordinal name prefix makes lexicographic name sort equal topo order", async () => { + const files = [ + file( + "schema.sql", + "create view public.v as select id from public.t;\n" + + "create table public.t(id int primary key);", + ), + ]; + + const ordered = await orderForShadow(files); + + // every name is ordinal-prefixed and references the original file + for (const out of ordered) { + expect(out.name).toMatch(ORDINAL_PREFIX); + expect(out.name).toContain("schema.sql"); + } + + // re-sorting by name (what the loader does each round) preserves topo order + const byName = [...ordered].sort((a, b) => (a.name < b.name ? -1 : 1)); + expect(byName.map((f) => f.sql)).toEqual(ordered.map((f) => f.sql)); + + // ordinals are zero-padded to a consistent width so the sort is stable + const prefixes = ordered.map((f) => f.name.match(/^(\d+)__/)?.[1] ?? ""); + const widths = new Set(prefixes.map((p) => p.length)); + expect(widths.size).toBe(1); + }); + + test("carries provenance back to the original file + statement index", async () => { + const files = [ + file( + "schema.sql", + "create view public.v as select id from public.t;\n" + + "create table public.t(id int primary key);", + ), + ]; + + const ordered: OrderedSqlFile[] = await orderForShadow(files); + + for (const out of ordered) { + expect(out.provenance.filePath).toBe("schema.sql"); + expect(typeof out.provenance.statementIndex).toBe("number"); + } + // the TABLE was authored second in the file → statementIndex 1 + const table = ordered.find((f) => /create table/i.test(f.sql)); + expect(table?.provenance.statementIndex).toBe(1); + }); + + test("preserves every statement exactly once, including UNKNOWN classes", async () => { + const files = [ + file("a.sql", "create table public.t(id int primary key);"), + // pg-topo classifies VACUUM as UNKNOWN — it must still be carried through + file("b.sql", "vacuum public.t;"), + ]; + + const ordered = await orderForShadow(files); + + expect(ordered).toHaveLength(2); + const sqls = ordered.map((f) => f.sql.trim().toLowerCase()).sort(); + expect(sqls).toEqual([ + "create table public.t(id int primary key);", + "vacuum public.t;", + ]); + }); + + test("preserves cycle members instead of dropping them", async () => { + // inline mutual FK across two files → a shadow-load cycle. The assist cannot + // resolve it, but must still emit both statements so the loader can surface + // its real Postgres error (and, later, the mutual-FK hint). + const files = [ + file( + "a.sql", + "create table public.a(id int primary key, b_id int references public.b(id));", + ), + file( + "b.sql", + "create table public.b(id int primary key, a_id int references public.a(id));", + ), + ]; + + const ordered = await orderForShadow(files); + + expect(ordered).toHaveLength(2); + const names = ordered.map((f) => f.provenance.filePath).sort(); + expect(names).toEqual(["a.sql", "b.sql"]); + }); + + test("carries statement text verbatim", async () => { + const sql = "create table public.t ( id int primary key );"; + const files = [file("a.sql", sql)]; + + const ordered = await orderForShadow(files); + + expect(ordered).toHaveLength(1); + // whitespace/formatting is preserved exactly (modulo the trailing splitter) + expect(ordered[0]?.sql).toContain("create table public.t"); + }); + + test("is deterministic for the same input", async () => { + const files = [ + file("schema.sql", "create schema app;"), + file("view.sql", "create view app.v as select id from app.t;"), + file("table.sql", "create table app.t(id int primary key);"), + ]; + + const first = await orderForShadow(files); + const second = await orderForShadow(files); + + expect(second.map((f) => f.name)).toEqual(first.map((f) => f.name)); + expect(second.map((f) => f.sql)).toEqual(first.map((f) => f.sql)); + }); + + test("returns an empty list for empty input", async () => { + expect(await orderForShadow([])).toEqual([]); + }); +}); + +describe("orderForShadow — must not silently drop unparseable statements", () => { + test("throws ReorderParseError when pg-topo cannot parse an input (would shrink the file set)", async () => { + // pg-topo returns an empty statement list for a whole-content PARSE_ERROR, + // so the offending file vanishes from the ordered output. The convenience + // API discards diagnostics, so a raw `orderForShadow` caller would silently + // build an INCOMPLETE desired state — the invariant: no caller may receive a + // silently-shrunk file set. + const files = [ + file("good.sql", "create table public.t(id int primary key);"), + file("bad.sql", "this is definitely not valid sql !!!;"), + ]; + + let thrown: unknown; + try { + await orderForShadow(files); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(ReorderParseError); + expect((thrown as Error).message).toContain("bad.sql"); + // points the caller at the graceful-degradation escape hatch + expect((thrown as Error).message).toMatch(/analyzeForShadow|raw/i); + expect((thrown as ReorderParseError).diagnostics.length).toBeGreaterThan(0); + }); + + test("does not throw when every statement parses", async () => { + const ordered = await orderForShadow([ + file("t.sql", "create table public.t(id int primary key);"), + file("v.sql", "create view public.v as select id from public.t;"), + ]); + expect(ordered).toHaveLength(2); + }); +}); + +describe("orderForShadow — degradation when pg-topo is absent", () => { + test("throws a typed ReorderUnavailableError with an install hint", async () => { + __setPgTopoImporterForTests(() => { + throw new Error("Cannot find module '@supabase/pg-topo'"); + }); + try { + let thrown: unknown; + try { + await orderForShadow([file("a.sql", "create table public.t(id int);")]); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(ReorderUnavailableError); + expect((thrown as Error).message).toContain("@supabase/pg-topo"); + expect((thrown as Error).message).toMatch(/install|add/i); + } finally { + __setPgTopoImporterForTests(null); + } + }); + + test("canReorder() reports false when absent, true when present", async () => { + __setPgTopoImporterForTests(() => { + throw new Error("Cannot find module '@supabase/pg-topo'"); + }); + expect(await canReorder()).toBe(false); + + __setPgTopoImporterForTests(null); + expect(await canReorder()).toBe(true); + }); +}); + +describe("analyzeForShadow — cycle surfacing (D6)", () => { + test("reports cycle members mapped back to real provenance", async () => { + // inline mutual FK across two files → a shadow-load cycle pg-topo detects + const files = [ + file( + "a.sql", + "create table public.a(id int primary key, b_id int references public.b(id));", + ), + file( + "b.sql", + "create table public.b(id int primary key, a_id int references public.a(id));", + ), + ]; + + const { files: ordered, cycles } = await analyzeForShadow(files); + + // files are still the full single-statement set (Slice 1 contract intact) + expect(ordered).toHaveLength(2); + + // a cycle is surfaced, with members mapped to the ORIGINAL file names + expect(cycles.length).toBeGreaterThan(0); + const memberFiles = cycles + .flatMap((c) => c.members.map((m) => m.filePath)) + .sort(); + expect(memberFiles).toEqual(["a.sql", "b.sql"]); + // members carry their statement index; none reference the synthetic + for (const cycle of cycles) { + for (const member of cycle.members) { + expect(member.filePath).not.toMatch(/^$/); + expect(typeof member.statementIndex).toBe("number"); + } + } + }); + + test("reports no cycles for an acyclic schema", async () => { + const { cycles } = await analyzeForShadow([ + file("t.sql", "create table public.t(id int primary key);"), + file("v.sql", "create view public.v as select id from public.t;"), + ]); + expect(cycles).toEqual([]); + }); +}); + +describe("analyzeForShadow — pg-topo diagnostics (lint)", () => { + test("surfaces an UNKNOWN_STATEMENT_CLASS diagnostic mapped to its file", async () => { + const { diagnostics } = await analyzeForShadow([ + file("t.sql", "create table public.t(id int primary key);"), + file("vacuum.sql", "vacuum public.t;"), + ]); + const unknown = diagnostics.find( + (d) => d.code === "UNKNOWN_STATEMENT_CLASS", + ); + expect(unknown).toBeDefined(); + expect(unknown?.location?.filePath).toBe("vacuum.sql"); + }); + + test("has no error-class diagnostics for a clean acyclic schema", async () => { + const { diagnostics } = await analyzeForShadow([ + file("t.sql", "create table public.t(id int primary key);"), + file("v.sql", "create view public.v as select id from public.t;"), + ]); + expect( + diagnostics.some( + (d) => d.code === "CYCLE_DETECTED" || d.code === "PARSE_ERROR", + ), + ).toBe(false); + }); +}); diff --git a/packages/pg-delta/src/frontends/sql-order.ts b/packages/pg-delta/src/frontends/sql-order.ts new file mode 100644 index 000000000..4cf6fdd10 --- /dev/null +++ b/packages/pg-delta/src/frontends/sql-order.ts @@ -0,0 +1,318 @@ +/** + * Statement-reordering assist for shadow loading (target-architecture §4.4.1). + * + * This is the OPT-IN enhancement layered above the parser-free core loader + * ([`load-sql-files.ts`](./load-sql-files.ts)). It splits the user's SQL files + * into one-statement units and topologically pre-sorts them via `@supabase/pg-topo` + * so the loader's defer-and-retry rounds converge faster and deterministically. + * + * Trust posture (the whole reason this lives in its own subpath): + * - The assist is **advisory**. Correctness comes from the split + the shadow's + * real Postgres rounds, never from trusting pg-topo's order (principle P1): + * the worst it can do is fail to build the shadow — a visible error BEFORE + * extraction — it can never corrupt the extracted desired state. + * - The core lib and `loadSqlFiles` MUST stay parser-free / WASM-free. So + * `@supabase/pg-topo` is imported with a **guarded dynamic `import()`** and is + * declared an `optionalPeerDependency`. Merely importing the core never pulls + * the WASM parser; only calling into this subpath does. + * + * Structural guarantees (D4): + * - exactly one statement per output `SqlFile`, so the existing `loadSqlFiles` + * becomes statement-granular with zero core change; + * - a zero-padded ordinal `name` prefix (`0007__schema/users.sql`) so the + * loader's per-round lexicographic `name` sort reproduces topo order; + * - every input statement preserved **exactly once** — including statements + * pg-topo classes as `UNKNOWN` and statements trapped in a cycle (pg-topo's + * `ordered` is a total order, so cycle members arrive at a best-effort + * position rather than being dropped); + * - statement text carried **verbatim**; + * - **deterministic** output for the same input. + */ +import type { AnalyzeOptions, ObjectRef, StatementId } from "@supabase/pg-topo"; +import type { SqlFile } from "./load-sql-files.ts"; + +/** Provenance back to the authored source, so a caller can render + * `schema/users.sql:line:col` after stripping the ordinal name prefix. */ +export interface StatementProvenance { + /** The original `SqlFile.name` this statement came from. */ + filePath: string; + /** Index of the statement within its original file (0-based). */ + statementIndex: number; + /** Byte offset of the statement in the original file content, when pg-topo + * resolves it (used for line:column rendering). */ + sourceOffset?: number; +} + +/** A single-statement `SqlFile` carrying provenance. Assignable to `SqlFile`, + * so the array can be fed straight into `loadSqlFiles` — the loader reads only + * `name`/`sql` and is blind to the extra field (and to whether input was + * sorted at all). */ +export interface OrderedSqlFile extends SqlFile { + provenance: StatementProvenance; +} + +export interface OrderForShadowOptions { + /** Objects the shadow already provides (e.g. extension-owned), passed through + * to pg-topo so they are not flagged as unresolved. Optional; the lowest-risk + * default is none — round-retry + diagnostics handle externals. */ + externalProviders?: ObjectRef[]; +} + +/** + * A shadow-load cycle the assist statically detected (e.g. inline mutual FK). + * Advisory only — the assist never decides correctness; this is annotation a + * caller can attach to a real (Postgres-driven) stuck error (D6). + */ +export interface ShadowLoadCycle { + /** The statements forming the cycle, in cycle order, mapped back to source. */ + members: StatementProvenance[]; + /** pg-topo object keys involved in the cycle (e.g. `table:public.a`), if any. */ + objectKeys: string[]; +} + +/** A pg-topo static-analysis diagnostic, mapped back to the original source. + * Surfaced for proactive authoring (`schema lint`) — advisory, never consulted + * on the apply path. */ +export interface ShadowOrderDiagnostic { + /** pg-topo diagnostic code (e.g. `UNKNOWN_STATEMENT_CLASS`, `CYCLE_DETECTED`). */ + code: string; + message: string; + /** Source location of the offending statement, when pg-topo provides one. */ + location?: StatementProvenance; +} + +/** Result of analyzing files for shadow loading: the reordered single-statement + * files, any statically-detected shadow-load cycles, and the raw pg-topo + * diagnostics (for lint). */ +export interface ShadowOrderResult { + files: OrderedSqlFile[]; + cycles: ShadowLoadCycle[]; + diagnostics: ShadowOrderDiagnostic[]; +} + +/** + * Thrown when the reordering assist is invoked but `@supabase/pg-topo` is not + * installed. Carries the exact install command plus the escape hatch (call + * `loadSqlFiles` directly for raw file-granular loading). + */ +export class ReorderUnavailableError extends Error { + constructor(cause?: unknown) { + super( + "The statement reordering assist requires the optional peer " + + "'@supabase/pg-topo', which is not installed. Install it with " + + "`pnpm add @supabase/pg-topo` (or `bun add @supabase/pg-topo`), or call " + + "`loadSqlFiles` directly to load SQL files at file granularity without " + + "reordering.", + ); + this.name = "ReorderUnavailableError"; + if (cause !== undefined) { + (this as { cause?: unknown }).cause = cause; + } + } +} + +/** + * Thrown by the convenience {@link orderForShadow} when `@supabase/pg-topo` + * cannot parse one or more inputs. pg-topo returns an empty statement list for a + * whole-content parse failure, so the offending file would silently vanish from + * the ordered output. `orderForShadow` returns ONLY files (no diagnostics + * channel), so a silent shrink would leave a library caller building an + * INCOMPLETE desired state — and destructive drops when it diffs. Throwing keeps + * the invariant that no caller can receive a silently-shrunk file set; a caller + * that prefers graceful degradation (the CLI's fall-back-to-raw path) calls + * {@link analyzeForShadow} directly and inspects its diagnostics instead. + */ +export class ReorderParseError extends Error { + /** The PARSE_ERROR / DISCOVERY_ERROR diagnostics that caused the shrink. */ + readonly diagnostics: ShadowOrderDiagnostic[]; + constructor(diagnostics: ShadowOrderDiagnostic[]) { + const locations = [ + ...new Set( + diagnostics + .map((d) => d.location?.filePath) + .filter((f): f is string => f !== undefined), + ), + ]; + super( + `The statement reordering assist could not parse ${diagnostics.length} ` + + `input(s)${locations.length > 0 ? ` (${locations.join(", ")})` : ""} — ` + + `reordering would silently drop them and shrink the desired state. Fix the ` + + `SQL, or call analyzeForShadow(...) and inspect its diagnostics to degrade ` + + `to raw file loading (loadSqlFiles) instead.`, + ); + this.name = "ReorderParseError"; + this.diagnostics = diagnostics; + } +} + +type PgTopoModule = typeof import("@supabase/pg-topo"); + +/** The dynamic importer, behind an indirection so tests can simulate the + * pg-topo-absent path without uninstalling the workspace dependency. */ +let importPgTopo: () => Promise = () => + import("@supabase/pg-topo"); + +/** + * @internal Test-only seam. Pass an importer that rejects/throws to exercise the + * degradation path; pass `null` to restore the real dynamic `import()`. + */ +export function __setPgTopoImporterForTests( + importer: (() => Promise) | null, +): void { + importPgTopo = importer ?? (() => import("@supabase/pg-topo")); +} + +async function loadPgTopo(): Promise { + try { + return await importPgTopo(); + } catch (cause) { + throw new ReorderUnavailableError(cause); + } +} + +/** + * Resolve whether the reordering assist can run (i.e. `@supabase/pg-topo` is + * importable). Lets a caller that prefers silent fallback probe instead of + * catching `ReorderUnavailableError`. + */ +export async function canReorder(): Promise { + try { + await loadPgTopo(); + return true; + } catch { + return false; + } +} + +/** + * Analyze `files` for shadow loading: split them into one-statement units, + * topologically pre-sort them, and surface any statically-detected shadow-load + * cycles. Returns the reordered single-statement `OrderedSqlFile`s (each with a + * zero-padded ordinal `name` prefix and provenance) plus the cycles. + * + * Both outputs are advisory — Postgres still elaborates the shadow (P1). The + * cycles let a caller annotate a real (Postgres-driven) stuck load (D6). + * + * @throws {ReorderUnavailableError} when `@supabase/pg-topo` is not installed. + */ +export async function analyzeForShadow( + files: SqlFile[], + options: OrderForShadowOptions = {}, +): Promise { + if (files.length === 0) { + return { files: [], cycles: [], diagnostics: [] }; + } + + const { analyzeAndSort } = await loadPgTopo(); + + // pg-topo addresses each input by a synthetic `` path; map back to + // the original SqlFile name for provenance. + const sql = files.map((f) => f.sql); + const analyzeOptions: AnalyzeOptions | undefined = + options.externalProviders === undefined + ? undefined + : { externalProviders: options.externalProviders }; + const { ordered, diagnostics, graph } = await analyzeAndSort( + sql, + analyzeOptions, + ); + + const toProvenance = (id: StatementId): StatementProvenance => { + const inputIndex = parseInputIndex(id.filePath); + const originalName = + inputIndex !== null && inputIndex < files.length + ? (files[inputIndex] as SqlFile).name + : id.filePath; + return { + filePath: originalName, + statementIndex: id.statementIndex, + // omit `sourceOffset` when pg-topo did not resolve it (exactOptionalPropertyTypes) + ...(id.sourceOffset === undefined + ? {} + : { sourceOffset: id.sourceOffset }), + }; + }; + + // zero-pad ordinals to a fixed width so lexicographic name sort == topo order + // even past 9 / 99 statements (the loader re-sorts `pending` by name each + // round). `ordered` is already a total order (pg-topo never drops a statement, + // including UNKNOWN classes and cycle members), so this is a 1:1 remap. + const width = String(Math.max(ordered.length - 1, 0)).length; + const orderedFiles: OrderedSqlFile[] = ordered.map((node, index) => { + const provenance = toProvenance(node.id); + const ordinal = String(index).padStart(width, "0"); + return { + name: `${ordinal}__${provenance.filePath}`, + sql: node.sql, + provenance, + }; + }); + + // map pg-topo's cycle groups (statement ids, in cycle order) to provenance, + // pulling the object keys from the matching CYCLE_DETECTED diagnostic. + const cycleDiagnostics = diagnostics.filter( + (d) => d.code === "CYCLE_DETECTED", + ); + const cycles: ShadowLoadCycle[] = graph.cycleGroups.map((group) => { + const head = group[0]; + const diagnostic = cycleDiagnostics.find( + (d) => + d.statementId !== undefined && + head !== undefined && + d.statementId.filePath === head.filePath && + d.statementId.statementIndex === head.statementIndex, + ); + const objectKeys = diagnostic?.details?.["cycleObjectKeys"]; + return { + members: group.map(toProvenance), + objectKeys: Array.isArray(objectKeys) + ? objectKeys.filter((k): k is string => typeof k === "string") + : [], + }; + }); + + // map every pg-topo diagnostic back to source for `schema lint`. + const orderDiagnostics: ShadowOrderDiagnostic[] = diagnostics.map((d) => ({ + code: d.code, + message: d.message, + ...(d.statementId === undefined + ? {} + : { location: toProvenance(d.statementId) }), + })); + + return { files: orderedFiles, cycles, diagnostics: orderDiagnostics }; +} + +/** + * Split `files` into one-statement units and topologically pre-sort them for + * shadow loading. Returns single-statement `OrderedSqlFile`s in topo order, each + * with a zero-padded ordinal `name` prefix and provenance back to the source. + * + * Thin wrapper over {@link analyzeForShadow} for callers that only need the + * files (the statically-detected cycles are discarded). + * + * @throws {ReorderUnavailableError} when `@supabase/pg-topo` is not installed. + */ +export async function orderForShadow( + files: SqlFile[], + options: OrderForShadowOptions = {}, +): Promise { + const result = await analyzeForShadow(files, options); + // A whole-content parse failure yields no statements, so the input vanishes + // from `result.files`. This convenience wrapper has no diagnostics channel, so + // it must refuse rather than hand back a silently-shrunk set (the same codes + // the CLI degrade-to-raw path keys on in schema-plan.ts). + const parseErrors = result.diagnostics.filter( + (d) => d.code === "PARSE_ERROR" || d.code === "DISCOVERY_ERROR", + ); + if (parseErrors.length > 0) { + throw new ReorderParseError(parseErrors); + } + return result.files; +} + +/** Parse the `i` out of pg-topo's synthetic `` file path. */ +function parseInputIndex(filePath: string): number | null { + const match = /^$/.exec(filePath); + return match ? Number.parseInt(match[1] as string, 10) : null; +} diff --git a/packages/pg-delta/src/index.ts b/packages/pg-delta/src/index.ts index 2c0b6301c..cc640e7a8 100644 --- a/packages/pg-delta/src/index.ts +++ b/packages/pg-delta/src/index.ts @@ -1,58 +1,141 @@ /** - * @supabase/pg-delta - PostgreSQL migrations made easy - * - * This module exports the public API for the pg-delta library. + * @supabase/pg-delta — clean-room rebuild per docs/architecture/target-architecture.md. + * Public API per §4.5; the complete vocabulary is listed here and reviewed + * in API-REVIEW.md (stage-9 deliverable 8). */ -// Catalog model and extraction -export { - Catalog, - createEmptyCatalog, - extractCatalog, -} from "./core/catalog.model.ts"; -export type { CatalogSnapshot } from "./core/catalog.snapshot.ts"; -export { - deserializeCatalog, - serializeCatalog, - stringifyCatalogSnapshot, -} from "./core/catalog.snapshot.ts"; +// ── core primitives ────────────────────────────────────────────────────────── +export { NotImplementedError, type Diagnostic } from "./core/diagnostic.ts"; +export { + encodeId, + parseId, + type StableId, + type FactKind, +} from "./core/stable-id.ts"; +export { + canonicalize, + contentHash, + type Payload, + type ContentHash, +} from "./core/hash.ts"; +export { + buildFactBase, + FactBase, + type Fact, + type DependencyEdge, + type EdgeKind, +} from "./core/fact.ts"; +export { serializeSnapshot, deserializeSnapshot } from "./core/snapshot.ts"; +export { diff, type Delta } from "./core/diff.ts"; -// Declarative schema export -export { exportDeclarativeSchema } from "./core/export/index.ts"; -export type { - DeclarativeSchemaOutput, - FileCategory, - FileEntry, - FileMetadata, -} from "./core/export/types.ts"; +// ── extract ────────────────────────────────────────────────────────────────── +export { + extract, + ExtractionTimeoutError, + type ExtractResult, +} from "./extract/extract.ts"; -// Integrations -export type { IntegrationDSL } from "./core/integrations/integration-dsl.ts"; +// ── plan ───────────────────────────────────────────────────────────────────── +export { + plan, + ENGINE_VERSION, + type Plan, + type Action, + type PlanOptions, + type SafetyReport, +} from "./plan/plan.ts"; +export { serializePlan, parsePlan } from "./plan/artifact.ts"; +export { type RenameCandidate, type RenameMode } from "./plan/renames.ts"; +export { type LockClass } from "./plan/locks.ts"; -// Plan operations -export type { Change } from "./core/change.types.ts"; -export { applyPlan } from "./core/plan/apply.ts"; -export type { CatalogInput } from "./core/plan/create.ts"; -export { createPlan } from "./core/plan/create.ts"; -export type { - RenderedPlanFile, - RenderPlanSqlOptions, -} from "./core/plan/render.ts"; -export { - flattenPlanStatements, +// ── apply ──────────────────────────────────────────────────────────────────── +export { + apply, + type ApplyReport, + type ApplyOptions, + type ActionStatus, +} from "./apply/apply.ts"; + +// ── proof ──────────────────────────────────────────────────────────────────── +export { provePlan, type ProofVerdict } from "./proof/prove.ts"; + +// ── frontends ──────────────────────────────────────────────────────────────── +export { + loadSqlFiles, + ShadowLoadError, + type SqlFile, + type LoadResult, +} from "./frontends/load-sql-files.ts"; +export { + exportSqlFiles, + type ExportOptions, +} from "./frontends/export-sql-files.ts"; +export { saveSnapshot, loadSnapshot } from "./frontends/snapshot-file.ts"; +export { + EXPORT_MANIFEST_FILE, + readExportManifest, + writeExportManifest, + type ExportManifest, +} from "./frontends/export-manifest.ts"; +export { + buildSchemaExport, + type BuildSchemaExportOptions, + type SchemaExportResult, + type ManagementScope, +} from "./frontends/schema-export.ts"; +export { + planSchemaFiles, + prepareSchemaFiles, + reconcileSchemaManifest, + SchemaFrontendError, + type PlanSchemaFilesOptions, + type PlanSchemaFilesResult, + type PreparedSchemaFiles, +} from "./frontends/schema-plan.ts"; +export { renderPlanFiles, - renderPlanSql, -} from "./core/plan/render.ts"; -export type { SqlFormatOptions } from "./core/plan/sql-format.ts"; -export { formatSqlStatements } from "./core/plan/sql-format.ts"; -export type { - CreatePlanOptions, - ExecutionBoundaryReason, - MigrationUnit, - Plan, - TransactionMode, -} from "./core/plan/types.ts"; -export { UnorderableCycleError } from "./core/sort/unorderable-cycle-error.ts"; + type RenderPlanFilesOptions, + type RenderPlanFilesResult, + type RenderedPlanFile, +} from "./frontends/render-plan-files.ts"; +export { + provisionCoLocatedShadow, + ShadowProvisionError, + isShadowProvisionError, + withDatabaseName, + type CoLocatedShadow, + type ProvisionCoLocatedShadowOptions, +} from "./frontends/shadow.ts"; +export { + factMatches, + deltaMatches, + filterDeltas, + flattenPolicy, + validatePolicy, + type Policy, + type Predicate, + type FilterRule, + type SerializeRule, +} from "./policy/policy.ts"; +export { + subtractBaseline, + loadBaselineFile, + type LoadedBaseline, + resolveBaseline, +} from "./policy/baseline.ts"; +export { supabasePolicy } from "./policy/supabase.ts"; -// Postgres config -export { createManagedPool } from "./core/postgres-config.ts"; +// ── integrations (the safe, profile-scoped path) ───────────────────────────── +// The headline managed-view API: resolve a profile against a source pool, then +// route extract / plan / prove / apply through the resolved option bundles so +// they reconstruct the same view (plan == prove == apply). The full surface +// (handlers, capability probing, custom-profile building blocks) lives on the +// `@supabase/pg-delta/integrations` subpath. +export { + resolveProfile, + rawProfile, + supabaseProfile, + type IntegrationProfile, + type ResolvedProfile, + type ResolveProfileOptions, +} from "./integrations/index.ts"; diff --git a/packages/pg-delta/src/integrations/index.ts b/packages/pg-delta/src/integrations/index.ts new file mode 100644 index 000000000..72643ba50 --- /dev/null +++ b/packages/pg-delta/src/integrations/index.ts @@ -0,0 +1,27 @@ +/** + * Integration profile API (the `@supabase/pg-delta/integrations` subpath). + * + * The safe, supported surface for managing a profile-scoped view: resolve a + * profile against a source pool, then route extract / plan / prove / apply + * through the resolved option bundles. Prefer this over composing the low-level + * helpers by hand. + */ +export type { + IntegrationProfile, + ResolveProfileOptions, + ResolvedProfile, +} from "./profile.ts"; +export { rawProfile, resolveProfile } from "./profile.ts"; +export { SUPABASE_EXTENSION_HANDLERS, supabaseProfile } from "./supabase.ts"; + +// Building blocks, re-exported for advanced composition (custom profiles). +export type { + CaptureResult, + ExtensionHandler, + HandlerContext, +} from "../extract/handler.ts"; +export { pgCronHandler, pgPartmanHandler } from "../policy/extensions/index.ts"; +export { + type ApplierCapability, + probeApplierCapability, +} from "../policy/capability.ts"; diff --git a/packages/pg-delta/src/integrations/profile.test.ts b/packages/pg-delta/src/integrations/profile.test.ts new file mode 100644 index 000000000..8506bb6e6 --- /dev/null +++ b/packages/pg-delta/src/integrations/profile.test.ts @@ -0,0 +1,172 @@ +/** + * Unit tests for the integration profile (src/integrations/profile.ts). + * No Docker: the only DB touch is `probeApplierCapability` / pgMajor, mocked. + * + * The profile is the single object that owns "what state may the engine manage?" + * — it resolves policy + capability + baseline ONCE against a source pool and + * bakes them into plan/prove/apply option bundles, so all three reconstruct the + * SAME managed view (plan == prove == apply) by construction. + */ +import { describe, expect, test } from "bun:test"; +import type { Pool } from "pg"; +import { buildFactBase } from "../core/fact.ts"; +import { supabasePolicy } from "../policy/supabase.ts"; +import type { IntegrationProfile } from "./profile.ts"; +import { rawProfile, resolveProfile } from "./profile.ts"; +import { supabaseProfile } from "./supabase.ts"; + +/** A mock pool: capability probe + server_version_num are the only queries. */ +function mockPool(opts: { + superuser?: boolean; + memberOf?: string[]; + versionNum?: number; +}): Pool { + return { + // biome-ignore lint: minimal pg.Pool stand-in for unit tests + query: async (sql: string) => { + if (sql.includes("server_version_num")) { + return { rows: [{ v: opts.versionNum ?? 170004 }] }; + } + return { + rows: [ + { + role: "applier", + is_superuser: opts.superuser ?? false, + member_of: opts.memberOf ?? [], + }, + ], + }; + }, + } as unknown as Pool; +} + +describe("resolveProfile", () => { + test("rawProfile composes an unrestricted, handler-free view", async () => { + const ctx = await resolveProfile(mockPool({}), rawProfile); + expect(ctx.id).toBe("raw"); + expect(ctx.planOptions.policy).toBeUndefined(); + expect(ctx.planOptions.capability).toBeUndefined(); + expect(ctx.planOptions.baseline).toBeUndefined(); + expect(typeof ctx.proveOptions.reextract).toBe("function"); + expect(typeof ctx.applyOptions.reextract).toBe("function"); + }); + + test("supabaseProfile carries the Supabase policy into all three bundles", async () => { + const ctx = await resolveProfile(mockPool({}), supabaseProfile); + expect(ctx.id).toBe("supabase"); + expect(ctx.planOptions.policy).toBe(supabasePolicy); + expect(ctx.proveOptions.policy).toBe(supabasePolicy); + // baseline is unset on the v1 Supabase policy → resolves cleanly to none + expect(ctx.planOptions.baseline).toBeUndefined(); + expect(ctx.applyOptions.baseline).toBeUndefined(); + }); + + test("planOptions carries the profile id so plan() can stamp the artifact", async () => { + const supa = await resolveProfile(mockPool({}), supabaseProfile); + expect(supa.planOptions.profile).toEqual({ id: "supabase" }); + const raw = await resolveProfile(mockPool({}), rawProfile); + expect(raw.planOptions.profile).toEqual({ id: "raw" }); + }); + + test("restrictToApplier probes capability and threads it consistently", async () => { + const ctx = await resolveProfile( + mockPool({ superuser: false }), + supabaseProfile, + { + restrictToApplier: true, + }, + ); + expect(ctx.planOptions.capability).toBeDefined(); + expect(ctx.planOptions.capability?.isSuperuser).toBe(false); + // the SAME capability object is shared with the proof bundle (plan == prove) + expect(ctx.proveOptions.capability).toBe(ctx.planOptions.capability); + }); + + test("without restrictToApplier, capability stays unrestricted (no probe)", async () => { + const ctx = await resolveProfile(mockPool({}), supabaseProfile); + expect(ctx.planOptions.capability).toBeUndefined(); + expect(ctx.proveOptions.capability).toBeUndefined(); + }); + + test("an explicit baseline override is threaded into all three bundles + stamped", async () => { + // a caller (library / test) can supply a pre-loaded LoadedBaseline for a + // profile with no policy-declared baseline. The engine option is its + // FactBase; the digest is stamped on planOptions.baselineMeta + ctx.baseline. + const factBase = buildFactBase( + [{ id: { kind: "schema", name: "platform" }, payload: {} }], + [], + ); + const baseline = { factBase, digest: factBase.rootHash }; + const ctx = await resolveProfile(mockPool({}), rawProfile, { baseline }); + expect(ctx.planOptions.baseline).toBe(factBase); + expect(ctx.proveOptions.baseline).toBe(factBase); + expect(ctx.applyOptions.baseline).toBe(factBase); + expect(ctx.planOptions.baselineMeta?.digest).toBe(factBase.rootHash); + expect(ctx.baseline?.digest).toBe(factBase.rootHash); + }); + + test("an explicit baseline override wins over a policy-declared baseline name", async () => { + // profile whose policy declares a baseline NAME (which would resolve from + // the committed baselines dir); the explicit override replaces it without + // touching the dir, so a missing committed snapshot never even matters. + const factBase = buildFactBase( + [{ id: { kind: "schema", name: "x" }, payload: {} }], + [], + ); + const override = { factBase, digest: factBase.rootHash }; + const profile: IntegrationProfile = { + id: "p", + handlers: [], + policy: { + id: "pol", + baseline: "nonexistent-committed-baseline", + filter: [], + }, + }; + const ctx = await resolveProfile(mockPool({}), profile, { + baseline: override, + }); + expect(ctx.planOptions.baseline).toBe(factBase); + }); + + test("rejects a baseline whose redaction mode differs from the command's", async () => { + // a baseline captured redacted, applied by a command extracting unredacted + // (or vice versa) would silently stop subtracting — fail loud. + const factBase = buildFactBase( + [{ id: { kind: "schema", name: "platform" }, payload: {} }], + [], + ); + const baseline = { + factBase, + digest: factBase.rootHash, + redactSecrets: true, + }; + let err: unknown; + try { + await resolveProfile(mockPool({}), rawProfile, { + baseline, + redactSecrets: false, + }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/redactSecrets/); + }); + + test("skipBaseline resolves handlers only — a missing declared baseline does not fail", async () => { + // snapshot/drift capture handler-aware facts and never subtract a baseline; + // a profile that DECLARES a baseline (e.g. the file this snapshot is about to + // write) must not make resolution fail loading a not-yet-existent file. + const profile: IntegrationProfile = { + id: "p", + handlers: [], + baselinePath: "/no/such/baseline-snapshot.json", + }; + const ctx = await resolveProfile(mockPool({}), profile, { + skipBaseline: true, + }); + expect(ctx.baseline).toBeUndefined(); + expect(ctx.planOptions.baseline).toBeUndefined(); + }); +}); diff --git a/packages/pg-delta/src/integrations/profile.ts b/packages/pg-delta/src/integrations/profile.ts new file mode 100644 index 000000000..a046a85e6 --- /dev/null +++ b/packages/pg-delta/src/integrations/profile.ts @@ -0,0 +1,281 @@ +/** + * Integration profile (docs/architecture/managed-view-architecture.md; + * docs/architecture/extension-intent.md §2). + * + * The profile is the ONE module that answers "what state is this engine allowed + * to manage?" — instead of asking every caller to remember the same sequence of + * helper calls (handler-aware extraction, policy, baseline, capability, proof + * re-extraction, apply fingerprint reconstruction). + * + * It is split into a STATIC declaration (`IntegrationProfile` — handlers + + * policy, pure data) and a RUNTIME-resolved context (`ResolvedProfile`). + * `resolveProfile` resolves capability + baseline ONCE against a source pool and + * bakes policy + capability + baseline into the plan / prove / apply option + * bundles, so all three reconstruct the SAME managed view — `plan == prove == + * apply` holds by construction (shared option identity), not by comment. + */ +import type { Pool } from "pg"; +import type { ApplyOptions } from "../apply/apply.ts"; +import { + extract, + type ExtractOptions, + type ExtractResult, +} from "../extract/extract.ts"; +import type { ExtensionHandler } from "../extract/handler.ts"; +import type { PlanOptions } from "../plan/plan.ts"; +import { buildIntentRuleIndex } from "../plan/rules.ts"; +import type { ProveOptions } from "../proof/prove.ts"; +import { + type LoadedBaseline, + loadBaselineFile, + resolveBaseline, +} from "../policy/baseline.ts"; +import { probeApplierCapability } from "../policy/capability.ts"; +import { flattenPolicy, type Policy } from "../policy/policy.ts"; + +/** Static, declarative profile: the handlers and policy that define a managed + * view. Pure data — no live connection. Compose your own, or use the presets + * (`supabaseProfile`, `rawProfile`). */ +export interface IntegrationProfile { + readonly id: string; + /** Extension handlers, run inside `extract`'s snapshot-bound transaction. */ + readonly handlers: readonly ExtensionHandler[]; + /** Policy supplying scope-filter + serialize rules (and an optional declared + * baseline name resolved at `resolveProfile` time). */ + readonly policy?: Policy; + /** Absolute path to an external baseline snapshot (`pgdelta snapshot` file). + * Stays pure data — the FactBase is loaded ONCE at `resolveProfile` time + * (the snapshot self-verifies its digest on load). Set by the CLI when a + * custom profile file declares `"baseline": "./…"` (resolved relative to the + * profile file's directory). Wins over a policy-declared baseline NAME. */ + readonly baselinePath?: string; +} + +export interface ResolveProfileOptions { + /** Probe the source pool's applier capability and restrict the managed view to + * operations that applier can execute (e.g. drop superuser-only FDW ACLs). */ + restrictToApplier?: boolean; + /** Directory to resolve a policy's declared baseline snapshot from (defaults + * to the committed `src/policy/baselines/`). */ + baselineDir?: string; + /** Explicit pre-loaded baseline. The engine-level override seam (library + * callers / tests); wins over both `profile.baselinePath` and a policy-declared + * baseline name. */ + baseline?: LoadedBaseline; + /** The redaction mode of the extraction this profile will drive. Validated + * against the resolved baseline's recorded mode — a mismatch throws, because + * redacted vs unredacted payloads hash differently and the baseline would + * silently stop subtracting. Defaults to `true` (redacted), matching the CLI + * default. */ + redactSecrets?: boolean; + /** Skip ALL baseline resolution (profile-declared file, policy-declared name, + * and the explicit override). For commands that use only the profile's + * handler-aware EXTRACTION and never subtract a baseline — `snapshot` (which + * CAPTURES the baseline a profile declares, so it must not require that file + * to already exist — the chicken-and-egg) and `drift` (raw snapshot-vs-live + * comparison). `ctx.baseline`, `planOptions.baseline`, and `baselineMeta` stay + * undefined. */ + skipBaseline?: boolean; +} + +/** A profile resolved against a live source pool: a handler-aware extractor plus + * plan / prove / apply option bundles that all carry the same policy + + * capability + baseline. */ +export interface ResolvedProfile { + readonly id: string; + /** The profile's extension handlers (exposed so a caller — e.g. `schema + * apply`'s shadow precheck — can inspect them without re-opening the profile). */ + readonly handlers: readonly ExtensionHandler[]; + /** Handler-aware extraction (core + this profile's handlers, same snapshot). + * A plain function field, not a method: callers pass it around by value + * (`ctx.extract ?? extract`), and it never relies on `this`. */ + readonly extract: ( + pool: Pool, + options?: ExtractOptions, + ) => Promise; + /** Metadata of the baseline in effect (undefined when none), for stamping a + * plan artifact / export manifest and reconciling it at apply/prove time. */ + readonly baseline?: { + readonly digest: string; + readonly redactSecrets?: boolean; + readonly path?: string; + }; + readonly planOptions: PlanOptions; + readonly proveOptions: ProveOptions; + readonly applyOptions: ApplyOptions; + /** Superuser-context (`pg_settings.context = 'superuser'`) GUC names, probed + * from the live connection. Present ONLY when the profile's policy declares + * `assumedSchemas` AND the connected role is NOT a superuser; consumed by + * `deriveAssumedSchemaSeed`'s `susetGucs` option to skip a co-located-shadow + * seed routine whose `SET ` header clause a non-superuser applier + * cannot REPLAY (Postgres validates proconfig at CREATE time against the + * creating role, SQLSTATE 42501). A superuser applier needs no stripping. */ + readonly susetGucs?: ReadonlySet; +} + +async function probePgMajor(pool: Pool): Promise { + const res = await pool.query( + `SELECT current_setting('server_version_num')::int AS v`, + ); + return Math.floor((res.rows[0] as { v: number }).v / 10000); +} + +/** + * Resolve a profile against the SOURCE pool: probe capability (if requested) and + * the declared baseline (if any) once, then hand back option bundles whose + * policy / capability / baseline are shared by reference across plan, prove, and + * apply. Re-extraction for proof and the apply fingerprint gate is the SAME + * handler-aware extractor, so the projected view never diverges. + */ +export async function resolveProfile( + pool: Pool, + profile: IntegrationProfile, + options: ResolveProfileOptions = {}, +): Promise { + const { handlers, policy } = profile; + + const capability = options.restrictToApplier + ? await probeApplierCapability(pool) + : undefined; + + // Superuser-context (SUSET) GUCs: a real Supabase-Cloud `postgres` is a + // privileged NON-superuser, so a seeded routine's `SET TO …` + // header clause (e.g. realtime.list_changes' `SET log_min_messages`) fails to + // REPLAY (42501) when the co-located shadow's seed (`deriveAssumedSchemaSeed`) + // is played back by that role — Postgres validates proconfig at CREATE time + // against the creating role. The clause is never semantically compared (the + // seeded routine re-extracts reference-only and cancels in the diff), so it is + // safe to strip from the seed. Only relevant when the profile's policy + // actually declares `assumedSchemas` (nothing to seed otherwise), and only + // when the applier is NOT a superuser (a superuser needs no stripping — the + // seed's routine replays as-is). Reuses the capability probed above when + // `restrictToApplier` was requested; otherwise probes locally without + // threading that probe into `planOptions`/`capability` (those stay governed + // strictly by `restrictToApplier`). The `pool` this resolves against is the + // same connection `schema apply` extracts the target from, which shares the + // co-located shadow's cluster + role, so its GUC catalog and role are + // authoritative for the shadow. Gated on the FLATTENED policy's + // `assumedSchemas` (not the policy's own field) — a policy can inherit + // `assumedSchemas` via `extends`, and `cmdSchemaApply` seeds from the + // flattened set, so the probe must be gated on the same aggregate. + let susetGucs: ReadonlySet | undefined; + if (policy !== undefined && flattenPolicy(policy).assumedSchemas.length > 0) { + const applier = capability ?? (await probeApplierCapability(pool)); + if (!applier.isSuperuser) { + const susetRows = await pool.query<{ name: string }>( + `SELECT name FROM pg_settings WHERE context = 'superuser'`, + ); + susetGucs = new Set(susetRows.rows.map((r) => r.name)); + } + } + + // Baseline precedence: an explicit pre-loaded override (options.baseline) wins, + // then a profile-declared file (baselinePath), then a policy-declared NAME + // resolved against the committed baselines dir. Each yields a LoadedBaseline + // carrying facts + digest + redaction mode. resolveBaseline only probes pgMajor + // when the policy actually declares a baseline, so the common no-baseline path + // pays nothing. + let loaded: LoadedBaseline | undefined; + if (options.skipBaseline) { + loaded = undefined; + } else if (options.baseline !== undefined) { + loaded = options.baseline; + } else if (profile.baselinePath !== undefined) { + loaded = loadBaselineFile(profile.baselinePath); + } else if (policy?.baseline !== undefined) { + loaded = resolveBaseline(policy, { + pgMajor: await probePgMajor(pool), + ...(options.baselineDir !== undefined + ? { dir: options.baselineDir } + : {}), + }); + } + + // Redaction guard: a baseline captured in a DIFFERENT redaction mode than this + // command's extraction hashes its secret-bearing facts differently, so it would + // silently stop subtracting them (the platform objects the operator asked to + // hide would reappear). Fail loud instead. Default both sides to redacted. + if (loaded !== undefined) { + const baselineMode = loaded.redactSecrets ?? true; + const commandMode = options.redactSecrets ?? true; + if (baselineMode !== commandMode) { + throw new Error( + `baseline ${loaded.path ?? loaded.digest.slice(0, 12)} was captured with ` + + `redactSecrets=${baselineMode}, but this command extracts with ` + + `redactSecrets=${commandMode}; mismatched redaction makes baseline facts ` + + `hash differently so the baseline would silently stop subtracting. ` + + `Re-capture the baseline in the matching mode ` + + `(pgdelta snapshot ${commandMode ? "" : "--unsafe-show-secrets "}--profile …).`, + ); + } + } + + // The engine option is a plain FactBase; the digest/redaction metadata travels + // separately (planOptions.baselineMeta + ResolvedProfile.baseline). + const baseline = loaded?.factBase; + + const profileExtract = ( + p: Pool, + extractOptions: ExtractOptions = {}, + ): Promise => extract(p, { ...extractOptions, handlers }); + + // fold the handlers' intent replay rules (pg_cron jobs, …) into a resolver + // index. Only `plan()` needs it (prove never re-plans; apply only replays + // artifact SQL), and it holds functions so it is NEVER serialized onto the + // plan artifact — apply/prove reconstruct it from the same profile. + const intentRules = buildIntentRuleIndex(handlers); + + // omit undefined keys: under exactOptionalPropertyTypes an explicit + // `policy: undefined` is not assignable to an optional `policy?` field. The + // SAME view-projection values are shared by reference across all three + // bundles, so plan == prove == apply by construction. + const view = { + ...(policy !== undefined ? { policy } : {}), + ...(capability !== undefined ? { capability } : {}), + ...(baseline !== undefined ? { baseline } : {}), + }; + + // baseline metadata for artifact/manifest stamping + apply/prove reconciliation + const baselineMeta = + loaded !== undefined + ? { + digest: loaded.digest, + ...(loaded.redactSecrets !== undefined + ? { redactSecrets: loaded.redactSecrets } + : {}), + ...(loaded.path !== undefined ? { path: loaded.path } : {}), + } + : undefined; + + return { + id: profile.id, + handlers, + extract: profileExtract, + ...(baselineMeta !== undefined ? { baseline: baselineMeta } : {}), + ...(susetGucs !== undefined ? { susetGucs } : {}), + // stamp the profile id on planOptions so plan() records it on the artifact; + // apply/prove then reconstruct this view without the operator repeating + // --profile (P2 follow-up). baselineMeta stamps the baseline DIGEST on the + // artifact so apply/prove fail loud on a swapped/edited baseline. + planOptions: { + ...view, + profile: { id: profile.id }, + ...(loaded !== undefined + ? { baselineMeta: { digest: loaded.digest } } + : {}), + ...(intentRules.size > 0 ? { intentRules } : {}), + }, + proveOptions: { ...view, reextract: (p) => profileExtract(p) }, + applyOptions: { + ...(baseline !== undefined ? { baseline } : {}), + reextract: (p) => profileExtract(p), + }, + }; +} + +/** The identity profile: no handlers, no policy — the raw view a generic user + * or a test sees. The default when no integration is selected. */ +export const rawProfile: IntegrationProfile = { + id: "raw", + handlers: [], +}; diff --git a/packages/pg-delta/src/integrations/supabase.ts b/packages/pg-delta/src/integrations/supabase.ts new file mode 100644 index 000000000..783edf30a --- /dev/null +++ b/packages/pg-delta/src/integrations/supabase.ts @@ -0,0 +1,26 @@ +/** + * The Supabase integration profile: the handlers + policy that define the + * managed view for a Supabase-hosted PostgreSQL database. + * + * This is purely a COMPOSITION — the handler mechanism (pg_partman, …) and the + * policy DSL are generic; the Supabase profile selects a set of them. Selecting + * `supabaseProfile` (or `--profile supabase` on the CLI) is the one safe, + * discoverable way to get Supabase semantics; nothing else should hand-assemble + * the recipe. + */ +import type { ExtensionHandler } from "../extract/handler.ts"; +import { pgCronHandler, pgPartmanHandler } from "../policy/extensions/index.ts"; +import { supabasePolicy } from "../policy/supabase.ts"; +import type { IntegrationProfile } from "./profile.ts"; + +/** The stateful-extension handlers the Supabase integration composes. */ +export const SUPABASE_EXTENSION_HANDLERS: readonly ExtensionHandler[] = [ + pgPartmanHandler, + pgCronHandler, +]; + +export const supabaseProfile: IntegrationProfile = { + id: "supabase", + handlers: SUPABASE_EXTENSION_HANDLERS, + policy: supabasePolicy, +}; diff --git a/packages/pg-delta/src/plan/aggregate-options.test.ts b/packages/pg-delta/src/plan/aggregate-options.test.ts new file mode 100644 index 000000000..0d7a4c2f5 --- /dev/null +++ b/packages/pg-delta/src/plan/aggregate-options.test.ts @@ -0,0 +1,116 @@ +/** + * CREATE AGGREGATE must reproduce every pg_aggregate option, not just + * SFUNC/STYPE/FINALFUNC/INITCOND/HYPOTHETICAL (PR #299 review, + * supabase/pg-toolbelt). Before this, the extra options (COMBINEFUNC, + * SERIALFUNC, DESERIALFUNC, the moving-aggregate set, FINALFUNC_MODIFY, + * FINALFUNC_EXTRA, SORTOP, PARALLEL) were never captured/rendered, so an + * aggregate that differed only in those options was recreated wrong (or, since + * they were absent from the payload, not recreated at all). Pure rule/diff + * level — no DB. Aggregates are drop+create, so each option must render in the + * CREATE. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const aggId: StableId = { + kind: "aggregate", + schema: "app", + name: "agg", + args: ["integer"], +}; +const aggFact = (extra: Record): Fact => ({ + id: aggId, + parent: { kind: "schema", name: "app" }, + payload: { + aggKind: "n", + numDirectArgs: 0, + sfunc: "app.sf", + stype: "integer", + finalfunc: null, + initcond: null, + combinefunc: null, + serialfunc: null, + deserialfunc: null, + msfunc: null, + minvfunc: null, + mstype: null, + mfinalfunc: null, + minitcond: null, + finalfuncExtra: false, + mfinalfuncExtra: false, + finalfuncModify: "r", + mfinalfuncModify: "r", + sspace: 0, + msspace: 0, + sortop: null, + parallel: "u", + ...extra, + }, +}); +const base = (extra: Fact[]) => buildFactBase([schemaFact, ...extra], []); + +describe("CREATE AGGREGATE option rendering", () => { + const create = (extra: Record): string => + plan(base([]), base([aggFact(extra)])) + .actions.map((a) => a.sql) + .join("\n"); + + test("renders COMBINEFUNC / SERIALFUNC / DESERIALFUNC", () => { + const sql = create({ + combinefunc: "app.cf", + serialfunc: "app.serf", + deserialfunc: "app.deserf", + }); + expect(sql).toContain("COMBINEFUNC = app.cf"); + expect(sql).toContain("SERIALFUNC = app.serf"); + expect(sql).toContain("DESERIALFUNC = app.deserf"); + }); + + test("renders the moving-aggregate set", () => { + const sql = create({ + msfunc: "app.msf", + minvfunc: "app.minvf", + mstype: "integer", + mfinalfunc: "app.mff", + minitcond: "0", + msspace: 16, + }); + expect(sql).toContain("MSFUNC = app.msf"); + expect(sql).toContain("MINVFUNC = app.minvf"); + expect(sql).toContain("MSTYPE = integer"); + expect(sql).toContain("MFINALFUNC = app.mff"); + expect(sql).toContain("MINITCOND = '0'"); + expect(sql).toContain("MSSPACE = 16"); + }); + + test("renders FINALFUNC_EXTRA / FINALFUNC_MODIFY / SSPACE", () => { + const sql = create({ + finalfunc: "app.ff", + finalfuncExtra: true, + finalfuncModify: "w", + sspace: 64, + }); + expect(sql).toContain("FINALFUNC = app.ff"); + expect(sql).toContain("FINALFUNC_EXTRA"); + expect(sql).toContain("FINALFUNC_MODIFY = READ_WRITE"); + expect(sql).toContain("SSPACE = 64"); + }); + + test("renders SORTOP and PARALLEL", () => { + const sql = create({ sortop: "OPERATOR(pg_catalog.>)", parallel: "s" }); + expect(sql).toContain("SORTOP = OPERATOR(pg_catalog.>)"); + expect(sql).toContain("PARALLEL = SAFE"); + }); + + test("default FINALFUNC_MODIFY / PARALLEL are omitted", () => { + const sql = create({ finalfunc: "app.ff" }); + expect(sql).not.toContain("FINALFUNC_MODIFY"); + expect(sql).not.toContain("PARALLEL"); + }); +}); diff --git a/packages/pg-delta/src/plan/artifact.test.ts b/packages/pg-delta/src/plan/artifact.test.ts new file mode 100644 index 000000000..04d7ad89b --- /dev/null +++ b/packages/pg-delta/src/plan/artifact.test.ts @@ -0,0 +1,114 @@ +/** Plan artifact v1: lossless round-trip + version refusals (stage 6). */ +import { describe, expect, test } from "bun:test"; +import { parsePlan, serializePlan } from "./artifact.ts"; +import { ENGINE_VERSION, type Plan } from "./plan.ts"; + +const samplePlan: Plan = { + formatVersion: 1, + engineVersion: ENGINE_VERSION, + source: { fingerprint: "a".repeat(64) }, + target: { fingerprint: "b".repeat(64) }, + preamble: [{ name: "check_function_bodies", value: "off" }], + filteredDeltas: [], + renameCandidates: [], + deltas: [ + { + verb: "add", + fact: { + id: { kind: "schema", name: "app" }, + payload: { owner: "test", big: 9223372036854775807n }, + }, + }, + ], + actions: [ + { + sql: 'CREATE SCHEMA "app" AUTHORIZATION "test"', + verb: "create", + produces: [{ kind: "schema", name: "app" }], + consumes: [{ kind: "role", name: "test" }], + destroys: [], + releases: [], + transactionality: "transactional", + lockClass: "none", + newSegmentBefore: false, + dataLoss: "none", + rewriteRisk: false, + }, + ], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 0, + lockClasses: { none: 1 }, + }, +}; + +describe("plan artifact v1", () => { + test("round-trips losslessly, including bigint payload values", () => { + const parsed = parsePlan(serializePlan(samplePlan)); + expect(parsed).toEqual(samplePlan); + const delta = parsed.deltas[0]; + if (delta?.verb !== "add") throw new Error("expected add delta"); + expect(typeof delta.fact.payload["big"]).toBe("bigint"); + }); + + test("round-trips an inlined applier capability (follow-up 2)", () => { + const withCapability: Plan = { + ...samplePlan, + capability: { + role: "app_owner", + isSuperuser: false, + memberOf: ["app_owner", "readers"], + }, + }; + const parsed = parsePlan(serializePlan(withCapability)); + expect(parsed.capability).toEqual({ + role: "app_owner", + isSuperuser: false, + memberOf: ["app_owner", "readers"], + }); + }); + + test("round-trips the stamped integration profile id (P2 follow-up)", () => { + const withProfile: Plan = { ...samplePlan, profile: { id: "supabase" } }; + const parsed = parsePlan(serializePlan(withProfile)); + expect(parsed.profile).toEqual({ id: "supabase" }); + }); + + test("a profile-less plan (direct library plan()) parses (profile undefined)", () => { + const parsed = parsePlan(serializePlan(samplePlan)); + expect(parsed.profile).toBeUndefined(); + }); + + test("round-trips the stamped redaction mode so apply/prove re-extract identically", () => { + // a plan produced with --unsafe-show-secrets fingerprints over unredacted + // secrets; the artifact must carry redactSecrets:false so apply/prove + // re-extract the target the same way (otherwise the fingerprint gate fails). + const unsafe: Plan = { ...samplePlan, redactSecrets: false }; + expect(parsePlan(serializePlan(unsafe)).redactSecrets).toBe(false); + expect(parsePlan(serializePlan(samplePlan)).redactSecrets).toBeUndefined(); + }); + + test("rejects unknown formatVersion", () => { + const mangled = serializePlan(samplePlan).replace( + '"formatVersion": 1', + '"formatVersion": 2', + ); + expect(() => parsePlan(mangled)).toThrow(/unsupported formatVersion 2/); + }); + + test("rejects a foreign engineVersion", () => { + const mangled = serializePlan(samplePlan).replace( + `"engineVersion": "${ENGINE_VERSION}"`, + '"engineVersion": "99.0.0"', + ); + expect(() => parsePlan(mangled)).toThrow(/produced by engine 99\.0\.0/); + }); + + test("rejects non-JSON and structurally broken artifacts", () => { + expect(() => parsePlan("not json")).toThrow(/not valid JSON/); + expect(() => + parsePlan(`{"formatVersion": 1, "engineVersion": "${ENGINE_VERSION}"}`), + ).toThrow(/missing actions/); + }); +}); diff --git a/packages/pg-delta/src/plan/artifact.ts b/packages/pg-delta/src/plan/artifact.ts new file mode 100644 index 000000000..45755e867 --- /dev/null +++ b/packages/pg-delta/src/plan/artifact.ts @@ -0,0 +1,71 @@ +/** + * Plan artifact v1 (stage 6 deliverable 1): a plan is a durable, + * version-tagged JSON document that round-trips losslessly. `apply` + * accepts the artifact, never a bare SQL list, and refuses artifacts + * whose formatVersion/engineVersion it does not understand (the version + * check itself lives in apply.ts; this module owns the byte format). + * + * Payload values can contain bigints (sequence bounds); they are encoded + * as {"$bigint": "…"} exactly like fact snapshots (stage 1). + */ +import { ENGINE_VERSION, type Plan } from "./plan.ts"; + +function replacer(_key: string, value: unknown): unknown { + if (typeof value === "bigint") return { $bigint: value.toString() }; + return value; +} + +function reviver(_key: string, value: unknown): unknown { + if ( + typeof value === "object" && + value !== null && + "$bigint" in value && + typeof (value as { $bigint: unknown }).$bigint === "string" && + Object.keys(value).length === 1 + ) { + return BigInt((value as { $bigint: string }).$bigint); + } + return value; +} + +export function serializePlan(thePlan: Plan): string { + return JSON.stringify(thePlan, replacer, 2); +} + +export function parsePlan(json: string): Plan { + let parsed: unknown; + try { + parsed = JSON.parse(json, reviver); + } catch (error) { + throw new Error( + `plan artifact: not valid JSON — ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (typeof parsed !== "object" || parsed === null) { + throw new Error("plan artifact: expected a JSON object"); + } + const artifact = parsed as Partial; + if (artifact.formatVersion !== 1) { + throw new Error( + `plan artifact: unsupported formatVersion ${String(artifact.formatVersion)} (this engine reads 1)`, + ); + } + if (typeof artifact.engineVersion !== "string") { + throw new Error("plan artifact: missing engineVersion"); + } + if (artifact.engineVersion !== ENGINE_VERSION) { + throw new Error( + `plan artifact: produced by engine ${artifact.engineVersion}, this engine is ${ENGINE_VERSION} — re-plan`, + ); + } + if (!Array.isArray(artifact.actions) || !Array.isArray(artifact.deltas)) { + throw new Error("plan artifact: missing actions/deltas"); + } + if ( + artifact.source?.fingerprint === undefined || + artifact.target?.fingerprint === undefined + ) { + throw new Error("plan artifact: missing source/target fingerprints"); + } + return artifact as Plan; +} diff --git a/packages/pg-delta/src/plan/assumed-schema-requirement.test.ts b/packages/pg-delta/src/plan/assumed-schema-requirement.test.ts new file mode 100644 index 000000000..a8aafc6ac --- /dev/null +++ b/packages/pg-delta/src/plan/assumed-schema-requirement.test.ts @@ -0,0 +1,98 @@ +/** + * Missing-requirement guard for objects WITHIN an assumed schema. No Docker. + * + * A managed object can depend on something that lives in an assumed schema — + * e.g. a user trigger on `auth.users`, or a column of an extension type in + * `extensions`. The guard must satisfy those that are genuinely present at + * apply time WITHOUT exempting a desired-side reference to an assumed-schema + * object the target does NOT have (PR #307 review #3499413404): the latter must + * fail at PLAN time rather than at apply against a missing relation. + * + * The decisive signal: + * - present on the target → kept reference-only in `source` → `source.has` is + * true → satisfied (the in-schema exemption never even runs); + * - external to the managed view (e.g. an extension member, hard-pruned from + * both sides) → not in `desired` → ambient, satisfied; + * - kept in `desired` (reference-only) but absent from `source` → the desired + * side wants something the target lacks → NOT exempt → throws. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { buildActionGraph } from "./internal.ts"; +import type { Action } from "./plan.ts"; + +const authUsers: StableId = { kind: "table", schema: "auth", name: "users" }; + +function consumerAction(consume: StableId): Action { + return { + sql: `CREATE TRIGGER t ON ${(consume as { schema: string }).schema}.users`, + verb: "create", + produces: [], + consumes: [consume], + destroys: [], + releases: [], + transactionality: "transactional", + lockClass: "shareRowExclusive", + newSegmentBefore: false, + dataLoss: "none", + rewriteRisk: false, + }; +} + +function fact(id: StableId): Fact { + return { id, payload: {} }; +} + +function run( + source: ReturnType, + desired: ReturnType, + assumedSchemas: Set, +): void { + buildActionGraph( + [consumerAction(authUsers)], + new Map(), + new Map(), + source, + desired, + new Set(), // renameActionIndices + new Set(), // assumedRoleNames + assumedSchemas, + ); +} + +describe("missing-requirement guard: objects within assumed schemas", () => { + test("throws when absent from source and the schema is NOT assumed", () => { + const source = buildFactBase([], []); + const desired = buildFactBase([fact(authUsers)], []); + expect(() => run(source, desired, new Set())).toThrow( + /missing requirement/, + ); + }); + + test("throws when kept in the desired view but absent from source, even if the schema IS assumed", () => { + // the desired side references an assumed-schema object the TARGET lacks + // (e.g. a brand-new `auth.extra`) — apply would fail, so fail at plan time. + const source = buildFactBase([], []); + const desired = buildFactBase([fact(authUsers)], []); + expect(() => run(source, desired, new Set(["auth"]))).toThrow( + /missing requirement/, + ); + }); + + test("is exempt when the object is present on the target (reference-only in source)", () => { + // resolveView keeps a present platform table as reference-only in BOTH sides, + // so source.has is true and the requirement is satisfied directly. + const source = buildFactBase([fact(authUsers)], []); + const desired = buildFactBase([fact(authUsers)], []); + expect(() => run(source, desired, new Set(["auth"]))).not.toThrow(); + }); + + test("is exempt when the object is external to the managed view (e.g. an extension member) in an assumed schema", () => { + // hard-pruned from both sides (not in source, not in desired) — genuinely + // ambient (present at apply via its extension). + const source = buildFactBase([], []); + const desired = buildFactBase([], []); + expect(() => run(source, desired, new Set(["auth"]))).not.toThrow(); + }); +}); diff --git a/packages/pg-delta/src/plan/column-order.test.ts b/packages/pg-delta/src/plan/column-order.test.ts new file mode 100644 index 000000000..1324225b3 --- /dev/null +++ b/packages/pg-delta/src/plan/column-order.test.ts @@ -0,0 +1,95 @@ +/** + * Table column ORDER is desired state (row layout): a from-empty `CREATE TABLE` + * must render its columns in declared positional order, not the encoded-id + * (name) order that drives the default action tie-break. Before the fix a table + * declared `(z int, b int, a int)` reconstructed as `(a, b, z)` — a silent + * column reorder that changes `SELECT *`, positional INSERTs, and the row-type + * layout, and is invisible to the hash (so the proof can't see it). + * + * Two facets, both pinned here (pure rule/diff level — no DB): + * 1. the from-empty CREATE renders columns in declared `_position` order; + * 2. `_position` is NON-semantic — two fact bases identical except column + * declaration ORDER hash-equal, so an order-only reshuffle on an EXISTING + * table stays undiffable BY DESIGN (mirrors the composite `_position` field). + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "s" }, + payload: { owner: "test" }, +}; +const tableId: StableId = { kind: "table", schema: "s", name: "t" }; +const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "s" }, + payload: { + persistence: "p", + rowSecurity: false, + forceRowSecurity: false, + replicaIdentity: "d", + replicaIdentityIndex: null, + partitionKey: null, + partitionBound: null, + parentTable: null, + reloptions: null, + }, +}; +const columnFact = (name: string, type: string, position: number): Fact => ({ + id: { kind: "column", schema: "s", table: "t", name }, + parent: tableId, + payload: { + _position: position, + type, + notNull: false, + identity: null, + collation: null, + generatedExpr: null, + }, +}); + +describe("table column order", () => { + test("from-empty CREATE TABLE renders columns in declared position order", () => { + // declared order is NOT alphabetical: z(1) < b(2) < a(3). Alphabetical would + // put a first. + const facts: Fact[] = [ + schemaFact, + tableFact, + columnFact("z", "integer", 1), + columnFact("b", "integer", 2), + columnFact("a", "integer", 3), + ]; + const sql = plan(buildFactBase([], []), buildFactBase(facts, [])) + .actions.map((a) => a.sql) + .find((s) => s.startsWith(`CREATE TABLE "s"."t"`)); + expect(sql).toMatchInlineSnapshot( + `"CREATE TABLE "s"."t" ("z" integer, "b" integer, "a" integer)"`, + ); + }); + + test("_position is non-semantic: order-only differences hash-equal", () => { + const ascending = buildFactBase( + [ + schemaFact, + tableFact, + columnFact("a", "integer", 1), + columnFact("b", "integer", 2), + ], + [], + ); + const swapped = buildFactBase( + [ + schemaFact, + tableFact, + columnFact("a", "integer", 2), + columnFact("b", "integer", 1), + ], + [], + ); + // identical except the declared column ORDER → equal root hash (the field is + // `_`-prefixed, so it never enters the canonical hash or the diff). + expect(swapped.rootHash).toBe(ascending.rootHash); + }); +}); diff --git a/packages/pg-delta/src/plan/composite-attribute-order.test.ts b/packages/pg-delta/src/plan/composite-attribute-order.test.ts new file mode 100644 index 000000000..811677915 --- /dev/null +++ b/packages/pg-delta/src/plan/composite-attribute-order.test.ts @@ -0,0 +1,50 @@ +/** + * Composite type attribute ORDER is desired state (row layout): a composite + * `CREATE TYPE … AS (…)` must render its attributes in declared positional + * order, not the encoded-id (name) order that `childrenOf` yields. Before the + * fix the attribute facts were rendered alphabetically, so a type declared + * `(wal, is_rls_enabled, subscription_ids, errors)` reconstructed as + * `(errors, is_rls_enabled, subscription_ids, wal)` — a silent column reorder + * that breaks composite-returning dependents. Pure rule/diff level — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "realtime" }, + payload: { owner: "test" }, +}; +const typeFact: Fact = { + id: { kind: "type", schema: "realtime", name: "wal_rls" }, + parent: { kind: "schema", name: "realtime" }, + payload: { variant: "composite" }, +}; +const typeId: StableId = { kind: "type", schema: "realtime", name: "wal_rls" }; +const attrFact = (name: string, type: string, position: number): Fact => ({ + id: { kind: "typeAttribute", schema: "realtime", type: "wal_rls", name }, + parent: typeId, + payload: { type, collation: null, _position: position }, +}); + +describe("composite attribute order", () => { + test("CREATE TYPE renders attributes in declared position order", () => { + // declared order is NOT alphabetical: wal(1) < is_rls_enabled(2) < + // subscription_ids(3) < errors(4). Alphabetical would put errors first. + const facts: Fact[] = [ + schemaFact, + typeFact, + attrFact("wal", "jsonb", 1), + attrFact("is_rls_enabled", "boolean", 2), + attrFact("subscription_ids", "uuid[]", 3), + attrFact("errors", "text[]", 4), + ]; + const sql = plan(buildFactBase([], []), buildFactBase(facts, [])) + .actions.map((a) => a.sql) + .find((s) => s.startsWith(`CREATE TYPE "realtime"."wal_rls"`)); + expect(sql).toMatchInlineSnapshot( + `"CREATE TYPE "realtime"."wal_rls" AS ("wal" jsonb, "is_rls_enabled" boolean, "subscription_ids" uuid[], "errors" text[])"`, + ); + }); +}); diff --git a/packages/pg-delta/src/plan/depends-requirement.test.ts b/packages/pg-delta/src/plan/depends-requirement.test.ts new file mode 100644 index 000000000..b548aa076 --- /dev/null +++ b/packages/pg-delta/src/plan/depends-requirement.test.ts @@ -0,0 +1,96 @@ +/** + * Missing-requirement invariant for DEPENDENCY edges (review P0-1). + * + * The planner's missing-requirement guard (internal.ts) covered `consumes` + * edges but NOT the build-order edges derived from a produced fact's `depends` + * edges: when a produced fact depends on something neither produced by the plan + * nor present in source, the produces-loop silently skipped it instead of + * failing. A policy that filters out the delta creating a dependency (while + * keeping a dependent) could therefore emit a migration that references a + * missing object — and the planner would not reject it. + * + * No Docker required — synthetic fact bases exercise the planner wiring. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import type { Policy } from "../policy/policy.ts"; +import { plan } from "./plan.ts"; + +const publicSchema: StableId = { kind: "schema", name: "public" }; +const hstore: StableId = { kind: "extension", name: "hstore" }; +const table: StableId = { kind: "table", schema: "public", name: "t" }; +const column: StableId = { + kind: "column", + schema: "public", + table: "t", + name: "h", +}; + +const f = ( + id: StableId, + parent?: StableId, + payload: Fact["payload"] = {}, +): Fact => (parent ? { id, parent, payload } : { id, payload }); + +describe("plan() — dependency-edge requirement invariant (P0-1)", () => { + test("a kept fact whose dependency's creation was filtered out fails loudly", () => { + const source = buildFactBase([f(publicSchema)], []); + // desired: create hstore, and a table+column whose type comes from hstore. + // The column DEPENDS on the extension (resolver resolves an extension-member + // type reference to the extension itself). + const desired = buildFactBase( + [ + f(publicSchema), + f(hstore, publicSchema, { schema: "public", relocatable: true }), + f(table, publicSchema, { persistence: "p" }), + f(column, table, { type: "hstore", notNull: false }), + ], + [ + { from: hstore, to: publicSchema, kind: "depends" }, + { from: column, to: hstore, kind: "depends" }, + ], + ); + + // policy excludes the extension's CREATE (its `add` delta), but keeps the + // table+column that need it. + const policy: Policy = { + id: "no-extension-creates", + filter: [ + { + match: { all: [{ verb: "add" }, { kind: "extension" }] }, + action: "exclude", + }, + ], + }; + + // RED before the fix: planner emits CREATE TABLE (h hstore) with no + // CREATE EXTENSION and does NOT throw. GREEN: the produced column depends on + // hstore, which is neither produced nor in source → missing requirement. + expect(() => plan(source, desired, { policy })).toThrow( + /missing requirement/, + ); + }); + + test("the same plan WITHOUT the filter succeeds (no false positive)", () => { + const source = buildFactBase([f(publicSchema)], []); + const desired = buildFactBase( + [ + f(publicSchema), + f(hstore, publicSchema, { schema: "public", relocatable: true }), + f(table, publicSchema, { persistence: "p" }), + f(column, table, { type: "hstore", notNull: false }), + ], + [ + { from: hstore, to: publicSchema, kind: "depends" }, + { from: column, to: hstore, kind: "depends" }, + ], + ); + + // hstore IS produced here, so the column's dependency is satisfied. + const thePlan = plan(source, desired); + expect(thePlan.actions.some((a) => /CREATE EXTENSION/i.test(a.sql))).toBe( + true, + ); + }); +}); diff --git a/packages/pg-delta/src/plan/domain-constraint-comment.test.ts b/packages/pg-delta/src/plan/domain-constraint-comment.test.ts new file mode 100644 index 000000000..3ed6e89b6 --- /dev/null +++ b/packages/pg-delta/src/plan/domain-constraint-comment.test.ts @@ -0,0 +1,88 @@ +/** + * A comment on a DOMAIN constraint must render `COMMENT ON CONSTRAINT … ON + * DOMAIN …`, not the table form (Codex review). The constraint id is shaped + * identically to a table constraint, so the satellite carries an `onDomain` + * flag (set at extraction) that the comment rule renders from. Pure — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; + +// --- domain + its CHECK constraint --- +const domainFact: Fact = { + id: { kind: "domain", schema: "app", name: "d" }, + parent: { kind: "schema", name: "app" }, + payload: { + baseType: "integer", + notNull: false, + default: null, + collation: null, + }, +}; +const domainConId: StableId = { + kind: "constraint", + schema: "app", + table: "d", + name: "d_check", +}; +const domainConFact: Fact = { + id: domainConId, + parent: { kind: "domain", schema: "app", name: "d" }, + payload: { def: "CHECK (VALUE > 0)", type: "c", validated: true }, +}; + +// --- table + its CHECK constraint (guard: must stay the table form) --- +const tableFact: Fact = { + id: { kind: "table", schema: "app", name: "t" }, + parent: { kind: "schema", name: "app" }, + payload: { owner: "test", persistence: "p" }, +}; +const tableConId: StableId = { + kind: "constraint", + schema: "app", + table: "t", + name: "t_check", +}; +const tableConFact: Fact = { + id: tableConId, + parent: { kind: "table", schema: "app", name: "t" }, + payload: { def: "CHECK (n > 0)", type: "c", validated: true }, +}; + +const comment = (target: StableId, onDomain: boolean): Fact => ({ + id: { kind: "comment", target }, + parent: target, + payload: onDomain ? { text: "chk", onDomain: true } : { text: "chk" }, +}); + +const base = (extra: Fact[]) => + buildFactBase( + [schemaFact, domainFact, domainConFact, tableFact, tableConFact, ...extra], + [], + ); + +describe("comment target for domain vs table constraints", () => { + test("domain constraint uses COMMENT ON CONSTRAINT … ON DOMAIN", () => { + const sql = plan(base([]), base([comment(domainConId, true)])).actions.map( + (a) => a.sql, + ); + expect(sql).toContain( + `COMMENT ON CONSTRAINT "d_check" ON DOMAIN "app"."d" IS 'chk'`, + ); + }); + + test("table constraint still uses the table form", () => { + const sql = plan(base([]), base([comment(tableConId, false)])).actions.map( + (a) => a.sql, + ); + expect(sql).toContain( + `COMMENT ON CONSTRAINT "t_check" ON "app"."t" IS 'chk'`, + ); + }); +}); diff --git a/packages/pg-delta/src/plan/extension-member-projection.test.ts b/packages/pg-delta/src/plan/extension-member-projection.test.ts new file mode 100644 index 000000000..2e148f864 --- /dev/null +++ b/packages/pg-delta/src/plan/extension-member-projection.test.ts @@ -0,0 +1,206 @@ +/** + * plan() applies the default extension-member projection (4b Stage 0). + * No Docker / database required. + * + * Extension members are projected OUT of the managed universe on BOTH sides + * before diffing (docs/archive/hardening-plan.md, "Item 4b"). This test + * injects a `memberOfExtension` edge synthetically — decoupled from the + * extractor flip (Stage 2) — so it pins the plan-side wiring on its own: a fact + * an extension owns must never become a planned action, and the plan's target + * fingerprint must reflect the member-excluded state. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { PlanOptions } from "./plan.ts"; +import type { Policy } from "../policy/policy.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaPublic: StableId = { kind: "schema", name: "public" }; +const extPgmq: StableId = { kind: "extension", name: "pgmq" }; +// a schema the extension owns; member roots are kept as roots here so removing +// them never orphans the extension fact (the extension is not their descendant) +const memberSchema: StableId = { kind: "schema", name: "pgmq_internal" }; + +const f = (id: StableId, parent?: StableId): Fact => + parent ? { id, parent, payload: {} } : { id, payload: {} }; + +// No skipAuthorization needed: facts have no owner payload → no owner edge → +// CREATE SCHEMA needs no role (owner edge is absent, not suppressed). +const opts: PlanOptions = {}; + +describe("plan() — default extension-member projection (4b Stage 0)", () => { + test("an extension-owned object never becomes a planned action", () => { + const source = buildFactBase( + [f(schemaPublic), f(extPgmq, schemaPublic)], + [], + ); + const desired = buildFactBase( + [f(schemaPublic), f(extPgmq, schemaPublic), f(memberSchema)], + [{ from: memberSchema, to: extPgmq, kind: "memberOfExtension" }], + ); + + const thePlan = plan(source, desired, opts); + + // The member is REFERENCE-ONLY (kept in the view so its satellites can be + // diffed, but the member object itself is never a create/drop/alter action + // and never a delta). It is present-at-apply via CREATE EXTENSION, so the + // honest target retains it — the fingerprint therefore folds it in (reference- + // only facts are part of rootHash), exactly like an assumed-schema object. + expect(thePlan.actions).toHaveLength(0); + expect(thePlan.deltas).toHaveLength(0); + }); + + test("a NON-member schema added in desired is still planned (no false suppression)", () => { + const userSchema: StableId = { kind: "schema", name: "app" }; + const source = buildFactBase([f(schemaPublic)], []); + const desired = buildFactBase([f(schemaPublic), f(userSchema)], []); + + const thePlan = plan(source, desired, opts); + + expect(thePlan.actions.length).toBeGreaterThan(0); + }); + + // A USER object created inside an extension-CREATED SCHEMA is NOT extension- + // managed: the extension owns the schema, but a table the user adds under it + // carries no memberOfExtension edge of its own. The member closure must stop + // at a schema-kind root's children so such a table (and its comment/grant + // satellites) diffs normally — otherwise the table is silently suppressed and + // its satellite crashes the requirement guard. + test("a user object under an extension-created schema is diffed (member closure stops at schema roots)", () => { + const ext: StableId = { kind: "extension", name: "myext" }; + const memberSchema2: StableId = { kind: "schema", name: "myext_s" }; + const userTable: StableId = { + kind: "table", + schema: "myext_s", + name: "t", + }; + const comment: StableId = { kind: "comment", target: userTable }; + const source = buildFactBase([f(schemaPublic)], []); + const desired = buildFactBase( + [ + f(schemaPublic), + { + id: ext, + parent: schemaPublic, + payload: { + schema: "myext_s", + relocatable: false, + }, + }, + f(memberSchema2), + { id: userTable, parent: memberSchema2, payload: { persistence: "p" } }, + { id: comment, parent: userTable, payload: { text: "user note" } }, + ], + [{ from: memberSchema2, to: ext, kind: "memberOfExtension" }], + ); + + // RED today: userTable is a descendant of the member schema → reference-only + // → its comment satellite is diffed and throws "missing requirement" because + // isExtensionMember(userTable) is false. After the fix: the table + comment + // are planned, ordered after CREATE EXTENSION. + const thePlan = plan(source, desired, opts); + const sql = thePlan.actions.map((a) => a.sql); + expect(sql.some((s) => /CREATE TABLE "myext_s"."t"/.test(s))).toBe(true); + expect(sql.some((s) => /COMMENT ON TABLE "myext_s"."t"/.test(s))).toBe( + true, + ); + }); + + // Guard the boundary the other way: a member TABLE's own descendants + // (columns/constraints) ARE extension-managed and stay reference-only — the + // closure extends through non-schema roots. + test("a member table's column stays reference-only (closure extends through table roots)", () => { + const ext2: StableId = { kind: "extension", name: "ext2" }; + const memberTable: StableId = { + kind: "table", + schema: "public", + name: "mt", + }; + const memberCol: StableId = { + kind: "column", + schema: "public", + table: "mt", + name: "c", + }; + const source = buildFactBase([f(schemaPublic)], []); + const desired = buildFactBase( + [ + f(schemaPublic), + { + id: ext2, + parent: schemaPublic, + payload: { + schema: "public", + relocatable: true, + }, + }, + f(memberTable, schemaPublic), + { id: memberCol, parent: memberTable, payload: { type: "integer" } }, + ], + [{ from: memberTable, to: ext2, kind: "memberOfExtension" }], + ); + + const thePlan = plan(source, desired, opts); + const sql = thePlan.actions.map((a) => a.sql); + // the member table and its column are never independently created + expect(sql.some((s) => /"public"\."mt"/.test(s))).toBe(false); + }); + + // The requirement guard exempts a consumed extension member because it is + // present-at-apply VIA its extension. That is only sound when the extension is + // actually produced by the plan or already on the target. A policy that + // filters the CREATE EXTENSION delta but keeps a member satellite must fail at + // PLAN time ("a filter may be hiding its creation"), not silently ship a plan + // that crashes at apply. + const ext7: StableId = { kind: "extension", name: "e7" }; + const memberSchema7: StableId = { kind: "schema", name: "es7" }; + const comment7: StableId = { kind: "comment", target: memberSchema7 }; + const withExtAndComment = () => + buildFactBase( + [ + f(schemaPublic), + { + id: ext7, + parent: schemaPublic, + payload: { schema: "es7", relocatable: false }, + }, + f(memberSchema7), + { id: comment7, parent: memberSchema7, payload: { text: "hi" } }, + ], + [{ from: memberSchema7, to: ext7, kind: "memberOfExtension" }], + ); + + test("member exemption requires the extension to be produced or present — else missing-requirement throws", () => { + const source = buildFactBase([f(schemaPublic)], []); + const desired = withExtAndComment(); + // policy verb rule: keep the extension fact in the view but FILTER its add + // delta, so no CREATE EXTENSION action is emitted while the member schema's + // comment survives. + const policy: Policy = { + id: "p", + filter: [ + { + match: { all: [{ kind: "extension" }, { verb: "add" }] }, + action: "exclude", + }, + ], + }; + // RED today: isExtensionMember(es7) is true on edge alone, so the orphan + // COMMENT ON is exempted and the plan validates. After the fix it throws. + expect(() => plan(source, desired, { policy })).toThrow( + /missing requirement/, + ); + }); + + test("member exemption holds when the extension IS produced in the plan (no false throw)", () => { + const source = buildFactBase([f(schemaPublic)], []); + const desired = withExtAndComment(); + // no filter → CREATE EXTENSION e7 is planned, so the comment on its member + // schema is legitimately exempt + ordered after it. + const thePlan = plan(source, desired, opts); + const sql = thePlan.actions.map((a) => a.sql); + expect(sql.some((s) => /CREATE EXTENSION "e7"/.test(s))).toBe(true); + expect(sql.some((s) => /COMMENT ON SCHEMA "es7"/.test(s))).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/plan/extension-relocatable.test.ts b/packages/pg-delta/src/plan/extension-relocatable.test.ts new file mode 100644 index 000000000..b70cc2f2d --- /dev/null +++ b/packages/pg-delta/src/plan/extension-relocatable.test.ts @@ -0,0 +1,59 @@ +/** + * The `CREATE EXTENSION … SCHEMA` clause is a PLAN-TIME decision based on the + * target schema's PRESENCE, not the extension's `relocatable` field: emit + * `SCHEMA s` iff `s` is present on the target or produced by this plan (order + * the extension after it), else emit the bare form so an extension that creates + * its own schema (pgmq) does not reference a not-yet-existing schema. + * + * No Docker required — synthetic fact bases exercise the rule + planner wiring. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const publicSchema: StableId = { kind: "schema", name: "public" }; +const f = (id: StableId, payload: Fact["payload"] = {}): Fact => ({ + id, + payload, +}); + +describe("extension SCHEMA clause derived from schema presence", () => { + test("an extension whose schema is neither present nor produced emits a bare CREATE", () => { + const pgmq: StableId = { kind: "extension", name: "pgmq" }; + const source = buildFactBase([f(publicSchema)], []); + // desired adds pgmq (installs its own `pgmq` schema). The `pgmq` schema is + // NOT a fact and is NOT produced by the plan → the extension must create it. + const desired = buildFactBase( + [f(publicSchema), f(pgmq, { schema: "pgmq", relocatable: false })], + [], + ); + // bare CREATE, no schema requirement, exactly one action (no guard throw). + const thePlan = plan(source, desired); + expect(thePlan.actions).toHaveLength(1); + expect(thePlan.actions[0]!.sql).toBe(`CREATE EXTENSION "pgmq"`); + }); + + test("an extension whose target schema is produced by the plan emits SCHEMA and is ordered after it", () => { + const hstore: StableId = { kind: "extension", name: "hstore" }; + const app: StableId = { kind: "schema", name: "app" }; + const source = buildFactBase([f(publicSchema)], []); + // desired adds a managed `app` schema AND hstore installed into it → the + // extension emits `SCHEMA app` and consumes it (ordered after CREATE SCHEMA). + const desired = buildFactBase( + [ + f(publicSchema), + f(app), + f(hstore, { schema: "app", relocatable: true }), + ], + [], + ); + const sqls = plan(source, desired).actions.map((a) => a.sql); + const schemaAt = sqls.findIndex((s) => /CREATE SCHEMA "app"/.test(s)); + const extAt = sqls.findIndex((s) => + /CREATE EXTENSION "hstore" SCHEMA "app"/.test(s), + ); + expect(schemaAt).toBeGreaterThanOrEqual(0); + expect(extAt).toBeGreaterThan(schemaAt); + }); +}); diff --git a/packages/pg-delta/src/plan/filtered-child-inlining.test.ts b/packages/pg-delta/src/plan/filtered-child-inlining.test.ts new file mode 100644 index 000000000..ce17dd150 --- /dev/null +++ b/packages/pg-delta/src/plan/filtered-child-inlining.test.ts @@ -0,0 +1,200 @@ +/** + * Unit tests for the policy-projection seam in action EMISSION (review P1 #1). + * No Docker / database required. + * + * Create rules inline child facts via `alsoProduces` (a column's DEFAULT, a + * partitioned table's columns, a composite type's attributes, a publication's + * relations). Emission must render against the PROJECTED plan target, not the + * full `desired` view — otherwise a child whose own `add` delta was filtered out + * by the policy is still rendered into the SQL and claimed as produced, which a + * non-proof apply path turns into managed drift. + * + * The companion invariant in buildActionGraph still consults the un-projected + * `desired` graph for missing-requirement detection (see internal.ts); this test + * pins ONLY the emission half of the seam. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { Payload } from "../core/hash.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; +import type { Policy } from "../policy/policy.ts"; + +const schemaApp: StableId = { kind: "schema", name: "app" }; +const tableT: StableId = { kind: "table", schema: "app", name: "t" }; +const colX: StableId = { + kind: "column", + schema: "app", + table: "t", + name: "x", +}; +const defX: StableId = { + kind: "default", + schema: "app", + table: "t", + name: "x", +}; + +function makeFact( + id: StableId, + payload: Payload = {}, + parent?: StableId, +): Fact { + return parent ? { id, parent, payload } : { id, payload }; +} + +describe("create-rule inlining respects the projected plan target (review P1 #1)", () => { + test("a filtered-out DEFAULT child is not inlined into ADD COLUMN", () => { + // source already has schema app + table app.t + const source = buildFactBase( + [makeFact(schemaApp), makeFact(tableT, {}, schemaApp)], + [], + ); + // desired adds column app.t.x WITH a DEFAULT 42 child + const desired = buildFactBase( + [ + makeFact(schemaApp), + makeFact(tableT, {}, schemaApp), + makeFact(colX, { type: "integer" }, tableT), + makeFact(defX, { expr: "42" }, colX), + ], + [], + ); + // policy keeps `add:column` but filters out `add:default` + const policy: Policy = { + id: "no-default-adds", + filter: [ + { + match: { all: [{ kind: "default" }, { verb: "add" }] }, + action: "exclude", + }, + ], + }; + + const p = plan(source, desired, { policy }); + + // exactly one action: the column add, rendered WITHOUT the filtered default + expect(p.actions).toHaveLength(1); + const action = p.actions[0]!; + expect(action.sql).toBe(`ALTER TABLE "app"."t" ADD COLUMN "x" integer`); + expect(action.sql).not.toContain("DEFAULT"); + // and the action must not claim to produce the filtered-out default fact + expect(action.produces.map((id) => encodeId(id))).not.toContain( + encodeId(defX), + ); + + // the filtered default add is still reported — drift the user chose not to + // manage is never silently absent (§3.9) + expect( + p.filteredDeltas.some( + (d) => d.verb === "add" && encodeId(d.fact.id) === encodeId(defX), + ), + ).toBe(true); + + // the plan target excludes the default, so source != target (a real plan) + expect(p.target.fingerprint).not.toBe(p.source.fingerprint); + }); + + test("an unfiltered DEFAULT child is still inlined (no policy regression)", () => { + const source = buildFactBase( + [makeFact(schemaApp), makeFact(tableT, {}, schemaApp)], + [], + ); + const desired = buildFactBase( + [ + makeFact(schemaApp), + makeFact(tableT, {}, schemaApp), + makeFact(colX, { type: "integer" }, tableT), + makeFact(defX, { expr: "42" }, colX), + ], + [], + ); + + const p = plan(source, desired); + + const action = p.actions[0]!; + expect(action.sql).toBe( + `ALTER TABLE "app"."t" ADD COLUMN "x" integer DEFAULT 42`, + ); + expect(action.produces.map((id) => encodeId(id))).toContain(encodeId(defX)); + }); +}); + +describe("default-privilege hygiene respects the projected plan target (review P1 #3)", () => { + test("a filtered grantee role + default ACL emit no REVOKE mentioning it", () => { + const rolePayload = (): Payload => ({ + superuser: false, + inherit: true, + createRole: false, + createDb: false, + login: false, + replication: false, + bypassRls: false, + config: [], + }); + const tablePayload = (): Payload => ({ + persistence: "p", + rowSecurity: false, + forceRowSecurity: false, + replicaIdentity: "d", + replicaIdentityIndex: null, + partitionKey: null, + partitionBound: null, + parentTable: null, + }); + const roleOwner: StableId = { kind: "role", name: "owner" }; + const roleG: StableId = { kind: "role", name: "g" }; + const dp: StableId = { + kind: "defaultPrivilege", + role: "owner", + schema: "app", + objtype: "r", + grantee: "g", + }; + + // source: just role owner + schema app + const source = buildFactBase( + [makeFact(roleOwner, rolePayload()), makeFact(schemaApp)], + [], + ); + // desired adds role g, table app.t (owned by owner), and a default ACL + // granting SELECT from owner to g + const desired = buildFactBase( + [ + makeFact(roleOwner, rolePayload()), + makeFact(roleG, rolePayload()), + makeFact(schemaApp), + makeFact(tableT, tablePayload(), schemaApp), + makeFact(dp, { privileges: ["SELECT"], grantable: [] }), + ], + [{ from: tableT, to: roleOwner, kind: "owner" }], + ); + + // policy filters BOTH the grantee role add and the default-privilege add + const policy: Policy = { + id: "drop-role-and-defacl", + filter: [ + { + match: { all: [{ kind: "role" }, { name: "g" }, { verb: "add" }] }, + action: "exclude", + }, + { + match: { all: [{ kind: "defaultPrivilege" }, { verb: "add" }] }, + action: "exclude", + }, + ], + }; + + // hygiene must read the PROJECTED target (where g and the default ACL are + // gone), not the unprojected `desired` — else it emits an impossible REVOKE + let p!: ReturnType; + expect(() => { + p = plan(source, desired, { policy, compact: false }); + }).not.toThrow(); + + // no emitted statement may mention the filtered-away grantee role + for (const action of p.actions) { + expect(action.sql).not.toContain('"g"'); + } + }); +}); diff --git a/packages/pg-delta/src/plan/function-body-transactionality.test.ts b/packages/pg-delta/src/plan/function-body-transactionality.test.ts new file mode 100644 index 000000000..c6cba615d --- /dev/null +++ b/packages/pg-delta/src/plan/function-body-transactionality.test.ts @@ -0,0 +1,74 @@ +/** + * Apply-segmentation false-positive guard (old function-operations.test.ts :: + * "keeps functions whose bodies embed non-transactional SQL text in one + * transactional unit"). This behavior is NOT corpus-observable — both a + * transactional and a (wrongly) non-transactional plan APPLY identically here, + * so a roundtrip cannot catch the regression. It is unit-tested instead. + * + * A CREATE FUNCTION whose dollar-quoted body merely CONTAINS the text of a + * non-transactional statement (CREATE INDEX CONCURRENTLY, VACUUM FULL, a + * work_mem SET, …) must stay `transactional`: the keywords are inert string + * payload, not statements the migration runs. Transactionality is declared + * per-rule (routines.ts never sets it → default transactional); this pins that + * the create action is never misclassified from its body text, and that + * segmentation keeps such functions in one transactional unit. + * + * The opposite direction — a GENUINE non-transactional action (CREATE INDEX + * CONCURRENTLY) IS segmented — is covered by tests/execution.test.ts. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { segmentActions } from "../apply/apply.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const base = (extra: Fact[]) => buildFactBase([schemaFact, ...extra], []); + +// a plpgsql function whose body TEXT embeds three non-transactional markers. +const fnId: StableId = { + kind: "function", + schema: "app", + name: "rebuild", + args: [], +}; +const fnFact: Fact = { + id: fnId, + parent: { kind: "schema", name: "app" }, + payload: { + def: `CREATE FUNCTION "app"."rebuild"() RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + -- these are just strings in the body, not statements this migration runs: + -- CREATE INDEX CONCURRENTLY, VACUUM FULL, SET work_mem + EXECUTE 'CREATE INDEX CONCURRENTLY rebuilt_idx ON app.t (x)'; + EXECUTE 'VACUUM FULL app.t'; + PERFORM set_config('work_mem', '256MB', true); +END +$$`, + }, +}; + +describe("function body transactionality (apply-segmentation guard)", () => { + test("a function whose body embeds non-transactional SQL text stays transactional", () => { + const actions = plan(base([]), base([fnFact])).actions; + const create = actions.find((a) => + /CREATE FUNCTION "app"\."rebuild"/.test(a.sql), + ); + expect(create).toBeDefined(); + expect(create?.transactionality).toBe("transactional"); + // no action in this plan may be classified non-transactional from body text + expect(actions.some((a) => a.transactionality === "nonTransactional")).toBe( + false, + ); + }); + + test("such a function plans into a single transactional segment", () => { + const actions = plan(base([]), base([fnFact])).actions; + const segments = segmentActions(actions); + expect(segments).toHaveLength(1); + expect(segments[0]?.transactional).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/plan/graph.ts b/packages/pg-delta/src/plan/graph.ts new file mode 100644 index 000000000..be6f84bfb --- /dev/null +++ b/packages/pg-delta/src/plan/graph.ts @@ -0,0 +1,109 @@ +/** + * One graph, one deterministic sort (target-architecture §3.6). + * Heap-based Kahn; a cycle throws with the full path — there is no repair + * subsystem and never will be (guardrail 4). + */ + +class MinHeap { + #items: number[] = []; + constructor(private readonly keyOf: (i: number) => string) {} + + get size(): number { + return this.#items.length; + } + + push(item: number): void { + this.#items.push(item); + let i = this.#items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if ( + this.keyOf(this.#items[parent] as number) <= + this.keyOf(this.#items[i] as number) + ) + break; + [this.#items[parent], this.#items[i]] = [ + this.#items[i] as number, + this.#items[parent] as number, + ]; + i = parent; + } + } + + pop(): number { + const top = this.#items[0] as number; + const last = this.#items.pop() as number; + if (this.#items.length > 0) { + this.#items[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1; + const r = l + 1; + let smallest = i; + if ( + l < this.#items.length && + this.keyOf(this.#items[l] as number) < + this.keyOf(this.#items[smallest] as number) + ) + smallest = l; + if ( + r < this.#items.length && + this.keyOf(this.#items[r] as number) < + this.keyOf(this.#items[smallest] as number) + ) + smallest = r; + if (smallest === i) break; + [this.#items[smallest], this.#items[i]] = [ + this.#items[i] as number, + this.#items[smallest] as number, + ]; + i = smallest; + } + } + return top; + } +} + +export function topoSort( + nodeCount: number, + edges: Array<[before: number, after: number]>, + tieKeyOf: (node: number) => string, + describe: (node: number) => string, +): number[] { + const adjacency: number[][] = Array.from({ length: nodeCount }, () => []); + const indegree = Array.from({ length: nodeCount }, () => 0); + const seen = new Set(); + for (const [u, v] of edges) { + if (u === v) continue; + const key = `${u}>${v}`; + if (seen.has(key)) continue; + seen.add(key); + (adjacency[u] as number[]).push(v); + indegree[v] = (indegree[v] as number) + 1; + } + + const heap = new MinHeap(tieKeyOf); + for (let i = 0; i < nodeCount; i++) if (indegree[i] === 0) heap.push(i); + + const order: number[] = []; + while (heap.size > 0) { + const node = heap.pop(); + order.push(node); + for (const next of adjacency[node] as number[]) { + indegree[next] = (indegree[next] as number) - 1; + if (indegree[next] === 0) heap.push(next); + } + } + + if (order.length !== nodeCount) { + // a cycle is a RULE BUG: report the cycle path, never repair (guardrail 4) + const inCycle = new Set(); + for (let i = 0; i < nodeCount; i++) + if ((indegree[i] as number) > 0) inCycle.add(i); + const cyclePath = [...inCycle].map(describe).join("\n "); + throw new Error( + `dependency cycle among ${inCycle.size} actions — this is a rule/emission bug, fix the rule (guardrail 4):\n ${cyclePath}`, + ); + } + return order; +} diff --git a/packages/pg-delta/src/plan/identity-options.test.ts b/packages/pg-delta/src/plan/identity-options.test.ts new file mode 100644 index 000000000..cb168c188 --- /dev/null +++ b/packages/pg-delta/src/plan/identity-options.test.ts @@ -0,0 +1,131 @@ +/** + * Identity columns must reproduce their backing sequence's options (PR #299 + * review, supabase/pg-toolbelt). The identity payload only carried + * {generation, sequence}, so a column declared + * `GENERATED … AS IDENTITY (START WITH 10 INCREMENT BY 5 …)` was recreated as a + * bare identity (default sequence parameters), and an options-only change + * planned nothing. Pure rule/diff level — no DB. + * + * Default sequence parameters still render as a bare `GENERATED … AS IDENTITY` + * so an ordinary identity column does not churn. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const tableId: StableId = { kind: "table", schema: "app", name: "t" }; +const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "app" }, + payload: { owner: "test", persistence: "p" }, +}; +const colId: StableId = { + kind: "column", + schema: "app", + table: "t", + name: "id", +}; +const colFact = (options: Record): Fact => ({ + id: colId, + parent: tableId, + payload: { + type: "integer", + notNull: false, + collation: null, + generatedExpr: null, + identity: { + generation: "a", + sequence: { schema: "app", name: "t_id_seq" }, + options: { + increment: "1", + start: "1", + minValue: "1", + maxValue: "2147483647", + cache: "1", + cycle: false, + ...options, + }, + }, + }, +}); +const base = (extra: Fact[]) => + buildFactBase([schemaFact, tableFact, ...extra], []); + +describe("identity column sequence options", () => { + test("non-default options render in the GENERATED … AS IDENTITY clause", () => { + const sql = plan(base([]), base([colFact({ increment: "5", start: "10" })])) + .actions.map((a) => a.sql) + .join("\n"); + expect(sql).toContain("GENERATED ALWAYS AS IDENTITY ("); + expect(sql).toContain("INCREMENT BY 5"); + expect(sql).toContain("START WITH 10"); + }); + + test("all-default options render a bare GENERATED … AS IDENTITY", () => { + const sql = plan(base([]), base([colFact({})])) + .actions.map((a) => a.sql) + .join("\n"); + expect(sql).toContain("GENERATED ALWAYS AS IDENTITY"); + expect(sql).not.toContain("INCREMENT BY"); + expect(sql).not.toContain("("); + }); + + test("an options-only change is ONE in-place ALTER COLUMN with chained SETs", () => { + const sql = plan( + base([colFact({})]), + base([colFact({ increment: "5", cache: "20" })]), + ).actions.map((a) => a.sql); + // combined into a single statement (chained SET clauses) — no RESTART, since + // neither bound moved + expect(sql).toContain( + `ALTER TABLE "app"."t" ALTER COLUMN "id" SET INCREMENT BY 5 SET CACHE 20`, + ); + }); + + test("a cycle flip alters in place", () => { + const sql = plan( + base([colFact({})]), + base([colFact({ cycle: true })]), + ).actions.map((a) => a.sql); + expect(sql).toContain(`ALTER TABLE "app"."t" ALTER COLUMN "id" SET CYCLE`); + }); + + test("moving both identity bounds emits ONE combined ALTER COLUMN (final-state valid) with RESTART", () => { + const sql = plan( + base([colFact({ minValue: "100", maxValue: "200", start: "100" })]), + base([colFact({ minValue: "1", maxValue: "50", start: "1" })]), + ).actions.map((a) => a.sql); + // exactly one identity-options statement — per-field statements would run + // `SET MAXVALUE 50` while MIN is still 100 (transient min>max) and fail. + const idAlters = sql.filter((s) => + /ALTER COLUMN "id" SET (MINVALUE|MAXVALUE|START)/.test(s), + ); + expect(idAlters).toHaveLength(1); + expect(idAlters[0]).toContain("SET MINVALUE 1"); + expect(idAlters[0]).toContain("SET MAXVALUE 50"); + expect(idAlters[0]).toContain("SET START WITH 1"); + expect(idAlters[0]).toContain("RESTART"); + }); + + test("an OVERLAPPING identity bound + START change must NOT RESTART a live counter", () => { + // MAX 100→200 (widen) + START 50→60 with MIN unchanged: ranges [1,100] and + // [1,200] overlap, so a live counter stays valid. RESTART would replay + // already-issued values → duplicate keys. Only a DISJOINT shift may RESTART. + const sql = plan( + base([colFact({ minValue: "1", maxValue: "100", start: "50" })]), + base([colFact({ minValue: "1", maxValue: "200", start: "60" })]), + ).actions.map((a) => a.sql); + const idAlters = sql.filter((s) => + /ALTER COLUMN "id" SET (MINVALUE|MAXVALUE|START)/.test(s), + ); + expect(idAlters).toHaveLength(1); + expect(idAlters[0]).toContain("SET MAXVALUE 200"); + expect(idAlters[0]).toContain("SET START WITH 60"); + expect(idAlters[0]).not.toContain("RESTART"); + }); +}); diff --git a/packages/pg-delta/src/plan/intent-plan.test.ts b/packages/pg-delta/src/plan/intent-plan.test.ts new file mode 100644 index 000000000..d17f0ef14 --- /dev/null +++ b/packages/pg-delta/src/plan/intent-plan.test.ts @@ -0,0 +1,210 @@ +/** + * End-to-end planner wiring for `extensionIntent` facts (docs/architecture/ + * extension-intent.md §4). A toy cron-shaped intent rule is supplied through + * `PlanOptions.intentRules` (what the resolved profile does in production), and + * we assert the generic planner turns intent deltas into replay actions: + * add → `select cron.schedule(...)`, ordered after schema DDL + * set → `unschedule` then `schedule` (replace, by key) + * remove → `select cron.unschedule(...)`, dataLoss none + * plus: renames:"auto" tolerates intent add/remove, and an intent delta with no + * registered rule fails loudly. No Docker — synthetic fact bases. + */ +import { describe, expect, test } from "bun:test"; +import { INTENT_UNKEYED } from "../core/diagnostic.ts"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; +import { + buildIntentRuleIndex, + type IntentKindRule, + type IntentRuleIndex, +} from "./rules.ts"; + +const f = ( + id: StableId, + parent?: StableId, + payload: Fact["payload"] = {}, +): Fact => (parent ? { id, parent, payload } : { id, payload }); + +const keyOf = (fact: Fact): string => + (fact.id as Extract).key; + +/** Toy pg_cron-shaped intent rule (the real one lands in A3). */ +const cronJobRule: IntentKindRule = { + payloadAttrs: ["schedule", "command", "database", "username", "active"], + create: (fact) => { + const p = fact.payload as { schedule: string; command: string }; + return [ + { + sql: `select cron.schedule('${keyOf(fact)}', '${p.schedule}', $$${p.command}$$)`, + }, + ]; + }, + drop: (fact) => ({ + sql: `select cron.unschedule('${keyOf(fact)}')`, + dataLoss: "none", + }), +}; + +const intentRules: IntentRuleIndex = buildIntentRuleIndex([ + { extension: "pg_cron", intentKinds: { job: cronJobRule } }, +]); + +const publicSchema: StableId = { kind: "schema", name: "public" }; +const pgCron: StableId = { kind: "extension", name: "pg_cron" }; +const table: StableId = { kind: "table", schema: "public", name: "t" }; +const job = (key: string): StableId => ({ + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key, +}); + +/** pg_cron present on both sides (never diffed) so the depends edge is valid. */ +const baseFacts: Fact[] = [ + f(publicSchema), + f(pgCron, publicSchema, { schema: "pg_catalog", relocatable: false }), +]; + +const jobFact = (key: string, schedule: string): Fact => + f(job(key), undefined, { + schedule, + command: "select 1", + database: "postgres", + username: "postgres", + active: true, + }); + +const indexOf = (actions: readonly { sql: string }[], needle: RegExp): number => + actions.findIndex((a) => needle.test(a.sql)); + +describe("plan() — extension intent wiring", () => { + test("add: emits cron.schedule, ordered after a schema table create", () => { + const source = buildFactBase(baseFacts, []); + const desired = buildFactBase( + [ + ...baseFacts, + f(table, publicSchema, { persistence: "p" }), + jobFact("nightly", "0 0 * * *"), + ], + [{ from: job("nightly"), to: pgCron, kind: "depends" }], + ); + + const thePlan = plan(source, desired, { intentRules }); + + const scheduleIdx = indexOf(thePlan.actions, /cron\.schedule\('nightly'/); + const tableIdx = indexOf(thePlan.actions, /CREATE TABLE/i); + expect(scheduleIdx).toBeGreaterThanOrEqual(0); + expect(tableIdx).toBeGreaterThanOrEqual(0); + // intent weight (90) is later than every schema kind → schedule sorts last + expect(scheduleIdx).toBeGreaterThan(tableIdx); + const scheduleAction = thePlan.actions[scheduleIdx]!; + expect(scheduleAction.verb).toBe("create"); + expect(scheduleAction.produces).toContainEqual(job("nightly")); + expect(scheduleAction.transactionality).toBe("transactional"); + expect(scheduleAction.lockClass).toBe("none"); + }); + + test("set: a schedule change replays as unschedule then schedule", () => { + const source = buildFactBase( + [...baseFacts, jobFact("nightly", "0 0 * * *")], + [{ from: job("nightly"), to: pgCron, kind: "depends" }], + ); + const desired = buildFactBase( + [...baseFacts, jobFact("nightly", "*/5 * * * *")], + [{ from: job("nightly"), to: pgCron, kind: "depends" }], + ); + + const thePlan = plan(source, desired, { intentRules }); + + const unscheduleIdx = indexOf( + thePlan.actions, + /cron\.unschedule\('nightly'/, + ); + const scheduleIdx = indexOf(thePlan.actions, /cron\.schedule\('nightly'/); + expect(unscheduleIdx).toBeGreaterThanOrEqual(0); + expect(scheduleIdx).toBeGreaterThanOrEqual(0); + // destroy-before-re-produce: unschedule the old job before scheduling the new + expect(unscheduleIdx).toBeLessThan(scheduleIdx); + expect(thePlan.actions[scheduleIdx]!.sql).toContain("*/5 * * * *"); + // no in-place ALTER action was emitted for the intent + expect(thePlan.actions).toHaveLength(2); + }); + + test("remove: emits cron.unschedule with no data loss", () => { + const source = buildFactBase( + [...baseFacts, jobFact("nightly", "0 0 * * *")], + [{ from: job("nightly"), to: pgCron, kind: "depends" }], + ); + const desired = buildFactBase(baseFacts, []); + + const thePlan = plan(source, desired, { intentRules }); + + expect(thePlan.actions).toHaveLength(1); + const drop = thePlan.actions[0]!; + expect(drop.sql).toContain("cron.unschedule('nightly')"); + expect(drop.verb).toBe("drop"); + expect(drop.dataLoss).toBe("none"); + }); + + test('renames:"auto" tolerates intent add/remove (no rename candidacy)', () => { + const source = buildFactBase( + [...baseFacts, jobFact("old", "0 0 * * *")], + [{ from: job("old"), to: pgCron, kind: "depends" }], + ); + const desired = buildFactBase( + [...baseFacts, jobFact("new", "0 0 * * *")], + [{ from: job("new"), to: pgCron, kind: "depends" }], + ); + + expect(() => + plan(source, desired, { intentRules, renames: "auto" }), + ).not.toThrow(); + const thePlan = plan(source, desired, { intentRules, renames: "auto" }); + // drop+create by key, never a rename + expect(thePlan.renameCandidates).toHaveLength(0); + expect( + indexOf(thePlan.actions, /cron\.unschedule\('old'/), + ).toBeGreaterThanOrEqual(0); + expect( + indexOf(thePlan.actions, /cron\.schedule\('new'/), + ).toBeGreaterThanOrEqual(0); + }); + + test("an intent delta with no registered rule fails loudly", () => { + const source = buildFactBase(baseFacts, []); + const desired = buildFactBase( + [...baseFacts, jobFact("nightly", "0 0 * * *")], + [{ from: job("nightly"), to: pgCron, kind: "depends" }], + ); + // no intentRules supplied → the resolver has no rule for pg_cron/job + expect(() => plan(source, desired)).toThrow(/no intent rule registered/); + }); + + test("a desired-side unkeyed-intent diagnostic aborts the plan", () => { + const source = buildFactBase(baseFacts, []); + const desired = buildFactBase(baseFacts, []); + desired.diagnostics.push({ + code: INTENT_UNKEYED, + severity: "warning", + message: + "cron job (command: delete from x) has no jobname and cannot be managed", + context: { ext: "pg_cron", intentKind: "job" }, + }); + expect(() => plan(source, desired, { intentRules })).toThrow( + /cannot key|unnamed|no jobname/i, + ); + }); + + test("a source-side unkeyed-intent diagnostic does NOT abort (unmanaged drift)", () => { + const source = buildFactBase(baseFacts, []); + source.diagnostics.push({ + code: INTENT_UNKEYED, + severity: "warning", + message: "cron job (command: delete from x) has no jobname", + context: { ext: "pg_cron", intentKind: "job" }, + }); + const desired = buildFactBase(baseFacts, []); + expect(() => plan(source, desired, { intentRules })).not.toThrow(); + }); +}); diff --git a/packages/pg-delta/src/plan/intent-rules.test.ts b/packages/pg-delta/src/plan/intent-rules.test.ts new file mode 100644 index 000000000..34d62e33d --- /dev/null +++ b/packages/pg-delta/src/plan/intent-rules.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { + buildIntentRuleIndex, + buildRuleResolver, + defaultRulesForId, + type IntentKindRule, +} from "./rules.ts"; + +/** A toy cron-shaped intent rule, matching the pg_cron slice's contract. */ +const cronJobRule: IntentKindRule = { + payloadAttrs: ["schedule", "command", "database", "username", "active"], + create: (fact) => { + const key = (fact.id as Extract).key; + return [{ sql: `select cron.schedule('${key}', '* * * * *', 'select 1')` }]; + }, + drop: (fact) => { + const key = (fact.id as Extract).key; + return { sql: `select cron.unschedule('${key}')`, dataLoss: "none" }; + }, +}; + +const handlers = [{ extension: "pg_cron", intentKinds: { job: cronJobRule } }]; + +const intentId: StableId = { + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "nightly", +}; + +const fact: Fact = { + id: intentId, + payload: { + schedule: "* * * * *", + command: "select 1", + database: "postgres", + username: "postgres", + active: true, + }, +}; + +const view = buildFactBase([fact], []); + +describe("intent rule resolver", () => { + test("resolves a registered intent kind to a KindRules with the replay SQL", () => { + const resolver = buildRuleResolver(buildIntentRuleIndex(handlers)); + const rules = resolver(intentId); + expect(rules.create(fact, view)[0]!.sql).toContain("cron.schedule"); + expect(rules.drop(fact).sql).toContain("cron.unschedule"); + }); + + test("marks every payload attr as replace (any change → drop+create)", () => { + const rules = buildRuleResolver(buildIntentRuleIndex(handlers))(intentId); + for (const attr of cronJobRule.payloadAttrs) { + expect(rules.attributes[attr]).toBe("replace"); + } + }); + + test("weight defaults to 90 — later than every schema kind", () => { + const rules = buildRuleResolver(buildIntentRuleIndex(handlers))(intentId); + expect(rules.weight).toBe(90); + }); + + test("injects lockClass none unless a spec overrides it", () => { + const rules = buildRuleResolver(buildIntentRuleIndex(handlers))(intentId); + expect(rules.create(fact, view)[0]!.lockClass).toBe("none"); + expect(rules.drop(fact).lockClass).toBe("none"); + }); + + test("omits rename/owner/defacl slots so the planner skips them", () => { + const rules = buildRuleResolver(buildIntentRuleIndex(handlers))(intentId); + expect(rules.rename).toBeUndefined(); + expect(rules.ownerAlterPrefix).toBeUndefined(); + expect(rules.defaclObjtype).toBeUndefined(); + expect(rules.metadata).toBeUndefined(); + }); + + test("throws for an unregistered intent kind", () => { + // no index at all + expect(() => defaultRulesForId(intentId)).toThrow( + /no intent rule registered/, + ); + // registered ext, unknown intentKind + const resolver = buildRuleResolver(buildIntentRuleIndex(handlers)); + expect(() => + resolver({ + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "queue", + key: "x", + }), + ).toThrow(/no intent rule registered/); + }); + + test("schema kinds still resolve through the static RULES table", () => { + const resolver = buildRuleResolver(buildIntentRuleIndex(handlers)); + expect(resolver({ kind: "table", schema: "public", name: "t" })).toBe( + defaultRulesForId({ kind: "table", schema: "public", name: "t" }), + ); + }); +}); diff --git a/packages/pg-delta/src/plan/internal.test.ts b/packages/pg-delta/src/plan/internal.test.ts new file mode 100644 index 000000000..459e0c7d5 --- /dev/null +++ b/packages/pg-delta/src/plan/internal.test.ts @@ -0,0 +1,564 @@ +/** + * Unit coverage for the default-ACL elision compaction pass (§3.6). Hand-built + * actions + fact base so the per-grantee rules are exercised without a database. + */ +import { describe, expect, test } from "bun:test"; +import type { ApplierCapability } from "../policy/capability.ts"; +import { buildFactBase, type DependencyEdge, type Fact } from "../core/fact.ts"; +import { type StableId } from "../core/stable-id.ts"; +import { + elideCoCreateRevokeBeforeGrant, + elideDefaultAclCreates, + foldCoCreateOwnership, +} from "./internal.ts"; +import type { Action } from "./plan.ts"; + +function mkAction(partial: Partial & { sql: string }): Action { + return { + verb: "create", + produces: [], + consumes: [], + destroys: [], + releases: [], + transactionality: "transactional", + lockClass: "none", + newSegmentBefore: false, + dataLoss: "none", + rewriteRisk: false, + ...partial, + }; +} + +const typeId = (name: string): StableId => ({ + kind: "type", + schema: "app", + name, +}); +const schemaId = (name: string): StableId => ({ kind: "schema", name }); +const roleId = (name: string): StableId => ({ kind: "role", name }); +const cap = (role: string): ApplierCapability => ({ + role, + isSuperuser: false, + memberOf: [role], +}); +const tableId = (name: string): StableId => ({ + kind: "table", + schema: "app", + name, +}); +const aclId = (target: StableId, grantee: string): StableId => ({ + kind: "acl", + target, + grantee, +}); + +/** Build the REVOKE + GRANT action pair the emitter produces for one acl fact. */ +function aclActions(target: StableId, grantee: string): Action[] { + const id = aclId(target, grantee); + return [ + mkAction({ + sql: `REVOKE ALL ... FROM ${grantee}`, + produces: [id], + consumes: [target], + }), + mkAction({ + sql: `GRANT ... TO ${grantee}`, + produces: [], + consumes: [id, target], + }), + ]; +} + +function aclFact( + target: StableId, + grantee: string, + privileges: string[], + grantable: string[] = [], + ownerDefault?: string[], +): Fact { + return { + id: aclId(target, grantee), + parent: target, + payload: { + privileges, + grantable, + ...(ownerDefault !== undefined ? { _ownerDefault: ownerDefault } : {}), + }, + }; +} + +const roleFact = (name: string): Fact => ({ + id: { kind: "role", name }, + payload: {}, +}); + +describe("elideDefaultAclCreates", () => { + test("elides owner and default-PUBLIC grants on a co-created type", () => { + const mood = typeId("mood"); + const facts: Fact[] = [ + { id: mood, payload: {} }, + roleFact("test"), + aclFact(mood, "PUBLIC", ["USAGE"]), + // owner has exactly the create-time default (USAGE for a type) → elidable + aclFact(mood, "test", ["USAGE"], [], ["USAGE"]), + ]; + const edges: DependencyEdge[] = [ + { from: mood, to: { kind: "role", name: "test" }, kind: "owner" }, + ]; + const desired = buildFactBase(facts, edges); + + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.mood ...", produces: [mood] }), + mkAction({ + sql: "ALTER TYPE app.mood OWNER TO test", + verb: "alter", + consumes: [mood, { kind: "role", name: "test" }], + }), + ...aclActions(mood, "PUBLIC"), + ...aclActions(mood, "test"), + ]; + + const kept = elideDefaultAclCreates(actions, desired); + expect(kept.map((a) => a.sql)).toEqual([ + "CREATE TYPE app.mood ...", + "ALTER TYPE app.mood OWNER TO test", + ]); + }); + + test("keeps the owner ACL group when the owner revoked a create-time default", () => { + // owner default for a table is the full set; here the owner kept everything + // EXCEPT UPDATE. Eliding the REVOKE/GRANT group would leave PostgreSQL's full + // create-time default in place, so UPDATE would wrongly come back (review P2). + const t = tableId("t"); + const ownerDefault = [ + "DELETE", + "INSERT", + "REFERENCES", + "SELECT", + "TRIGGER", + "TRUNCATE", + "UPDATE", + ]; + const desiredOwnerPrivs = ownerDefault.filter((p) => p !== "UPDATE"); + const facts: Fact[] = [ + { id: t, payload: {} }, + roleFact("test"), + aclFact(t, "test", desiredOwnerPrivs, [], ownerDefault), + ]; + const desired = buildFactBase(facts, [ + { from: t, to: { kind: "role", name: "test" }, kind: "owner" }, + ]); + const actions: Action[] = [ + mkAction({ sql: "CREATE TABLE app.t ...", produces: [t] }), + ...aclActions(t, "test"), + ]; + const kept = elideDefaultAclCreates(actions, desired); + expect(kept.map((a) => a.sql)).toContain("REVOKE ALL ... FROM test"); + expect(kept.map((a) => a.sql)).toContain("GRANT ... TO test"); + }); + + test("keeps the owner ACL group when an ALTER DEFAULT PRIVILEGES customizes the objtype", () => { + // desired owner == the built-in default, BUT an ADP reduces the owner default + // for new tables. Since a from-empty plan does not guarantee the table is + // created AFTER the ADP, the create-time owner ACL is ambiguous, so the + // REVOKE/GRANT group is load-bearing and must NOT be elided (review P2). + const t = tableId("t"); + const ownerDefault = [ + "DELETE", + "INSERT", + "REFERENCES", + "SELECT", + "TRIGGER", + "TRUNCATE", + "UPDATE", + ]; + const adp: StableId = { + kind: "defaultPrivilege", + role: "test", + schema: null, + objtype: "r", + grantee: "test", + }; + const facts: Fact[] = [ + { id: t, payload: {} }, + roleFact("test"), + aclFact(t, "test", ownerDefault, [], ownerDefault), + { + id: adp, + payload: { privileges: ownerDefault.filter((p) => p !== "UPDATE") }, + }, + ]; + const desired = buildFactBase(facts, [ + { from: t, to: { kind: "role", name: "test" }, kind: "owner" }, + ]); + const actions: Action[] = [ + mkAction({ sql: "CREATE TABLE app.t ...", produces: [t] }), + ...aclActions(t, "test"), + ]; + // capability's role is the ADP's defaclrole (the applier creates the object) + const kept = elideDefaultAclCreates(actions, desired, cap("test")); + expect(kept.map((a) => a.sql)).toContain("REVOKE ALL ... FROM test"); + expect(kept.map((a) => a.sql)).toContain("GRANT ... TO test"); + }); + + test("keeps a third-party grant on a co-created object", () => { + const mood = typeId("mood"); + const facts: Fact[] = [ + { id: mood, payload: {} }, + roleFact("test"), + roleFact("app_user"), + aclFact(mood, "app_user", ["USAGE"]), + ]; + const desired = buildFactBase(facts, [ + { from: mood, to: { kind: "role", name: "test" }, kind: "owner" }, + ]); + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.mood ...", produces: [mood] }), + ...aclActions(mood, "app_user"), + ]; + const kept = elideDefaultAclCreates(actions, desired); + expect(kept.map((a) => a.sql)).toContain("GRANT ... TO app_user"); + expect(kept).toHaveLength(3); + }); + + test("keeps a PUBLIC grant on a kind with no PUBLIC default (table)", () => { + const t = tableId("t"); + const facts: Fact[] = [ + { id: t, payload: {} }, + roleFact("test"), + aclFact(t, "PUBLIC", ["SELECT"]), + ]; + const desired = buildFactBase(facts, [ + { from: t, to: { kind: "role", name: "test" }, kind: "owner" }, + ]); + const actions: Action[] = [ + mkAction({ sql: "CREATE TABLE app.t ...", produces: [t] }), + ...aclActions(t, "PUBLIC"), + ]; + const kept = elideDefaultAclCreates(actions, desired); + expect(kept.map((a) => a.sql)).toContain("GRANT ... TO PUBLIC"); + }); + + test("keeps a non-default PUBLIC privilege set on a type (USAGE + something)", () => { + const mood = typeId("mood"); + const facts: Fact[] = [ + { id: mood, payload: {} }, + roleFact("test"), + // grant option present → not a default, keep it + aclFact(mood, "PUBLIC", ["USAGE"], ["USAGE"]), + ]; + const desired = buildFactBase(facts, [ + { from: mood, to: { kind: "role", name: "test" }, kind: "owner" }, + ]); + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.mood ...", produces: [mood] }), + ...aclActions(mood, "PUBLIC"), + ]; + const kept = elideDefaultAclCreates(actions, desired); + expect(kept.map((a) => a.sql)).toContain("GRANT ... TO PUBLIC"); + }); + + test("leaves ACLs on a pre-existing object untouched", () => { + const existing = typeId("existing"); + const facts: Fact[] = [ + { id: existing, payload: {} }, + roleFact("test"), + aclFact(existing, "PUBLIC", ["USAGE"]), + ]; + const desired = buildFactBase(facts, [ + { from: existing, to: { kind: "role", name: "test" }, kind: "owner" }, + ]); + // no CREATE for `existing` → target not co-created + const actions: Action[] = [...aclActions(existing, "PUBLIC")]; + const kept = elideDefaultAclCreates(actions, desired); + expect(kept).toHaveLength(2); + }); + + test("no-op when there is nothing to elide", () => { + const t = tableId("t"); + const desired = buildFactBase([{ id: t, payload: {} }], []); + const actions: Action[] = [ + mkAction({ sql: "CREATE TABLE app.t ...", produces: [t] }), + ]; + expect(elideDefaultAclCreates(actions, desired).map((a) => a.sql)).toEqual([ + "CREATE TABLE app.t ...", + ]); + }); +}); + +describe("foldCoCreateOwnership", () => { + test("folds a co-created schema + owner ALTER into CREATE SCHEMA AUTHORIZATION", () => { + const s = schemaId("myschema"); + const desired = buildFactBase( + [{ id: s, payload: {} }, roleFact("bob")], + [{ from: s, to: roleId("bob"), kind: "owner" }], + ); + const actions: Action[] = [ + mkAction({ sql: `CREATE SCHEMA "myschema"`, produces: [s] }), + mkAction({ + sql: `ALTER SCHEMA "myschema" OWNER TO "bob"`, + verb: "alter", + consumes: [s, roleId("bob")], + }), + ]; + // schema fold is always-on (syntactic equivalence), even with no capability + const kept = foldCoCreateOwnership(actions, desired); + expect(kept.map((a) => a.sql)).toEqual([ + `CREATE SCHEMA "myschema" AUTHORIZATION "bob"`, + ]); + }); + + test("does not fold a foreign-owner schema the restricted applier cannot set", () => { + // a restricted applier (`test`, not a superuser, not a member of `bob`) + // cannot run AUTHORIZATION bob NOR ALTER … OWNER TO bob. The fold's safety + // invariant must be local: do not collapse an ALTER we cannot prove the + // applier could execute (in the real pipeline emit's canSetOwner fail-fast + // runs first; this keeps the fold self-contained if called without it). + const s = schemaId("myschema"); + const desired = buildFactBase( + [{ id: s, payload: {} }, roleFact("bob")], + [{ from: s, to: roleId("bob"), kind: "owner" }], + ); + const actions: Action[] = [ + mkAction({ sql: `CREATE SCHEMA "myschema"`, produces: [s] }), + mkAction({ + sql: `ALTER SCHEMA "myschema" OWNER TO "bob"`, + verb: "alter", + consumes: [s, roleId("bob")], + }), + ]; + const kept = foldCoCreateOwnership(actions, desired, cap("test")); + expect(kept.map((a) => a.sql)).toEqual([ + `CREATE SCHEMA "myschema"`, + `ALTER SCHEMA "myschema" OWNER TO "bob"`, + ]); + }); + + test("elides an applier-redundant owner ALTER on a co-created type", () => { + const mood = typeId("mood"); + const desired = buildFactBase( + [{ id: mood, payload: {} }, roleFact("test")], + [{ from: mood, to: roleId("test"), kind: "owner" }], + ); + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.mood ...", produces: [mood] }), + mkAction({ + sql: "ALTER TYPE app.mood OWNER TO test", + verb: "alter", + consumes: [mood, roleId("test")], + }), + ]; + const kept = foldCoCreateOwnership(actions, desired, cap("test")); + expect(kept.map((a) => a.sql)).toEqual(["CREATE TYPE app.mood ..."]); + }); + + test("keeps a foreign-owner ALTER on a co-created type", () => { + const mood = typeId("mood"); + const desired = buildFactBase( + [{ id: mood, payload: {} }, roleFact("type_owner")], + [{ from: mood, to: roleId("type_owner"), kind: "owner" }], + ); + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.mood ...", produces: [mood] }), + mkAction({ + sql: "ALTER TYPE app.mood OWNER TO type_owner", + verb: "alter", + consumes: [mood, roleId("type_owner")], + }), + ]; + // applier is `test`, owner is `type_owner` → not a no-op, keep it + const kept = foldCoCreateOwnership(actions, desired, cap("test")); + expect(kept.map((a) => a.sql)).toEqual([ + "CREATE TYPE app.mood ...", + "ALTER TYPE app.mood OWNER TO type_owner", + ]); + }); + + test("leaves an owner change on a pre-existing object untouched", () => { + const mood = typeId("mood"); + const fresh = typeId("fresh"); + const desired = buildFactBase( + [{ id: mood, payload: {} }, { id: fresh, payload: {} }, roleFact("test")], + [ + { from: mood, to: roleId("test"), kind: "owner" }, + { from: fresh, to: roleId("test"), kind: "owner" }, + ], + ); + // a DIFFERENT object (`fresh`) is co-created so createActionOf is non-empty + // — this exercises the genuine not-co-created branch for `mood`, not the + // empty-map early return. + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.fresh ...", produces: [fresh] }), + // no CREATE for `mood` → target not co-created; this is a real owner change + mkAction({ + sql: "ALTER TYPE app.mood OWNER TO test", + verb: "alter", + consumes: [mood, roleId("test")], + releases: [roleId("old_owner")], + }), + ]; + const kept = foldCoCreateOwnership(actions, desired, cap("test")); + expect(kept.map((a) => a.sql)).toEqual([ + "CREATE TYPE app.fresh ...", + "ALTER TYPE app.mood OWNER TO test", + ]); + }); + + test("without capability, a non-schema owner ALTER stays (Rule 2 inert)", () => { + const mood = typeId("mood"); + const desired = buildFactBase( + [{ id: mood, payload: {} }, roleFact("test")], + [{ from: mood, to: roleId("test"), kind: "owner" }], + ); + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.mood ...", produces: [mood] }), + mkAction({ + sql: "ALTER TYPE app.mood OWNER TO test", + verb: "alter", + consumes: [mood, roleId("test")], + }), + ]; + const kept = foldCoCreateOwnership(actions, desired); + expect(kept.map((a) => a.sql)).toEqual([ + "CREATE TYPE app.mood ...", + "ALTER TYPE app.mood OWNER TO test", + ]); + }); +}); + +/** Build a defaultPrivilege fact (ALTER DEFAULT PRIVILEGES residue). */ +function defaultPrivilegeFact( + role: string, + schema: string | null, + objtype: string, + grantee: string, + privileges: string[], + grantable: string[] = [], +): Fact { + return { + id: { kind: "defaultPrivilege", role, schema, objtype, grantee }, + payload: { privileges, grantable }, + }; +} + +describe("elideCoCreateRevokeBeforeGrant", () => { + test("drops the leading REVOKE for a third-party grant with no default", () => { + const mood = typeId("mood"); + const desired = buildFactBase( + [ + { id: mood, payload: {} }, + roleFact("app_user"), + aclFact(mood, "app_user", ["USAGE"]), + ], + [], + ); + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.mood ...", produces: [mood] }), + ...aclActions(mood, "app_user"), + ]; + const kept = elideCoCreateRevokeBeforeGrant(actions, desired); + expect(kept.map((a) => a.sql)).toEqual([ + "CREATE TYPE app.mood ...", + "GRANT ... TO app_user", + ]); + }); + + test("keeps a REVOKE-only group (empty privileges)", () => { + const mood = typeId("mood"); + const id = aclId(mood, "PUBLIC"); + const desired = buildFactBase( + [{ id: mood, payload: {} }, aclFact(mood, "PUBLIC", [])], + [], + ); + const actions: Action[] = [ + mkAction({ sql: "CREATE TYPE app.mood ...", produces: [mood] }), + mkAction({ + sql: "REVOKE ALL ... FROM PUBLIC", + produces: [id], + consumes: [mood], + }), + ]; + const kept = elideCoCreateRevokeBeforeGrant(actions, desired); + expect(kept.map((a) => a.sql)).toContain("REVOKE ALL ... FROM PUBLIC"); + }); + + test("keeps the REVOKE when a potentially-active default grants a superset", () => { + const t = tableId("t"); + const desired = buildFactBase( + [ + { id: t, payload: {} }, + roleFact("anon"), + aclFact(t, "anon", ["SELECT"]), + // applier `test` has a default privilege granting SELECT+INSERT on + // tables in `app` to anon → REVOKE is load-bearing, keep it + defaultPrivilegeFact("test", "app", "r", "anon", ["SELECT", "INSERT"]), + ], + [], + ); + const actions: Action[] = [ + mkAction({ sql: "CREATE TABLE app.t ...", produces: [t] }), + ...aclActions(t, "anon"), + ]; + const kept = elideCoCreateRevokeBeforeGrant(actions, desired, cap("test")); + expect(kept.map((a) => a.sql)).toContain("REVOKE ALL ... FROM anon"); + }); + + test("keeps the REVOKE when a potentially-active default grants a grant option", () => { + const t = tableId("t"); + const desired = buildFactBase( + [ + { id: t, payload: {} }, + roleFact("anon"), + aclFact(t, "anon", ["SELECT"]), + // default grants SELECT WITH GRANT OPTION → plain GRANT would leave the + // grant option behind without the REVOKE + defaultPrivilegeFact("test", null, "r", "anon", ["SELECT"], ["SELECT"]), + ], + [], + ); + const actions: Action[] = [ + mkAction({ sql: "CREATE TABLE app.t ...", produces: [t] }), + ...aclActions(t, "anon"), + ]; + const kept = elideCoCreateRevokeBeforeGrant(actions, desired, cap("test")); + expect(kept.map((a) => a.sql)).toContain("REVOKE ALL ... FROM anon"); + }); + + test("drops the REVOKE when the only default's privileges are a subset", () => { + const t = tableId("t"); + const desired = buildFactBase( + [ + { id: t, payload: {} }, + roleFact("anon"), + aclFact(t, "anon", ["SELECT", "INSERT"]), + // default grants only SELECT (subset of explicit) and no grant option → + // the plain GRANT covers it, REVOKE is redundant + defaultPrivilegeFact("test", "app", "r", "anon", ["SELECT"]), + ], + [], + ); + const actions: Action[] = [ + mkAction({ sql: "CREATE TABLE app.t ...", produces: [t] }), + ...aclActions(t, "anon"), + ]; + const kept = elideCoCreateRevokeBeforeGrant(actions, desired, cap("test")); + expect(kept.map((a) => a.sql)).not.toContain("REVOKE ALL ... FROM anon"); + expect(kept.map((a) => a.sql)).toContain("GRANT ... TO anon"); + }); + + test("leaves ACLs on a pre-existing object untouched", () => { + const existing = typeId("existing"); + const desired = buildFactBase( + [ + { id: existing, payload: {} }, + roleFact("app_user"), + aclFact(existing, "app_user", ["USAGE"]), + ], + [], + ); + // no CREATE for `existing` → not co-created + const actions: Action[] = [...aclActions(existing, "app_user")]; + const kept = elideCoCreateRevokeBeforeGrant(actions, desired); + expect(kept).toHaveLength(2); + }); +}); diff --git a/packages/pg-delta/src/plan/internal.ts b/packages/pg-delta/src/plan/internal.ts new file mode 100644 index 000000000..acd50a61f --- /dev/null +++ b/packages/pg-delta/src/plan/internal.ts @@ -0,0 +1,1022 @@ +/** + * ActionGraph building blocks — the graph construction, deterministic tie-break, + * compaction passes, and safety report that the `finalizeActions` phase + * (./phases/action-graph.ts) composes. They depend only on explicit inputs plus + * module imports (encodeId, the rule table), never on planner-local state. + * + * `plan()` itself is now a thin orchestrator over four named phases + * (./phases/{change-set,replacement-expansion,action-emitter,action-graph}.ts): + * the rename/role-rename cancellation, replacement expansion, and the cohesive + * action-emission algorithm (with its producer/destroyer bookkeeping, kept local + * to the ActionEmitter phase) each live behind a phase boundary, so their + * invariants are testable in isolation behind the UNCHANGED public `plan()` API. + * + * Pure refactor: the corpus + differential prove the plans are state-equivalent. + */ +import type { FactBase } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { type ApplierCapability, canSetOwner } from "../policy/capability.ts"; +import { extensionMemberClosure } from "../policy/view.ts"; +import type { Action, SafetyReport } from "./plan.ts"; +import { ruleFlag } from "./rule-flags.ts"; +import { defaultRulesForId, type RulesForId } from "./rules.ts"; +import { schemaCreateSql } from "./rules/schemas.ts"; + +/** + * Build the action dependency graph (edges as `[fromIndex, toIndex]`) and check + * requirements. Build order comes from the DESIRED state's edges, teardown + * order from the SOURCE state's edges; a consumer of an id that neither this + * plan produces nor the target already has is a missing requirement (it throws, + * stage-5 deliverable 6). Reads only the emitted actions + the producer/ + * destroyer indexes + the two fact bases. + */ +export function buildActionGraph( + actions: readonly Action[], + producerOf: ReadonlyMap, + destroyerOf: ReadonlyMap, + source: FactBase, + desired: FactBase, + // actions that rename a subtree in place. A rename changes IDENTITY, not + // ownership: PostgreSQL preserves the owner OID across `ALTER … RENAME`, and a + // separate owner action handles any real change. So `owner` edges on a + // renamed subtree must NOT drive ordering through the rename — otherwise a + // table rename + owner-role rename deadlock each other (review P1 #2). + renameActionIndices: ReadonlySet = new Set(), + // role names the active policy assumes exist at apply time but does not manage + // (e.g. Supabase anon/authenticated). Treated like pg_*/PUBLIC by the + // missing-requirement guard: a kept `GRANT … TO ` whose role object is + // filtered out of the view is a valid grant target, not a stranded reference. + assumedRoleNames: ReadonlySet = new Set(), + // schema names the active policy assumes exist at apply time but does not + // manage (e.g. Supabase's `extensions`). Same idea as assumedRoleNames: a kept + // `CREATE EXTENSION … SCHEMA ` whose schema object is filtered out of + // the view is a valid dependency target, not a stranded reference. + assumedSchemaNames: ReadonlySet = new Set(), +): Array<[number, number]> { + const edges: Array<[number, number]> = []; + + // cache encoded -> StableId for ids we encounter + const parseKeyCache = new Map(); + const remember = (id: StableId): string => { + const key = encodeId(id); + parseKeyCache.set(key, id); + return key; + }; + + // A reference target that is present-at-apply but kept out of the managed + // view: built-in roles (pg_*/PUBLIC), policy-declared assumed roles, an + // assumed SCHEMA object, or an object WITHIN an assumed schema (e.g. a + // Supabase extension member in `extensions`). Such a target satisfies a + // `consumes` / `depends` requirement without being produced by the plan. + const isAmbient = (id: StableId): boolean => { + if (id.kind === "role") { + const name = (id as { name: string }).name; + if (name.startsWith("pg_") || name === "PUBLIC") return true; + if (assumedRoleNames.has(name)) return true; + } + if ( + id.kind === "schema" && + assumedSchemaNames.has((id as { name: string }).name) + ) { + return true; + } + const schema = (id as { schema?: string }).schema; + if (schema === undefined || !assumedSchemaNames.has(schema)) return false; + // An object in an assumed schema is ambient only when it is genuinely + // external to the managed view (e.g. an extension member, hard-pruned from + // both sides). If the DESIRED view KEEPS it (reference-only) yet it is absent + // from the target (`!source.has`, checked by the caller), the desired side is + // referencing something the target lacks — fail at plan time instead of + // exempting it and letting apply fail against a missing relation (review P2). + return !desired.has(id); + }; + + // Extension-member closures (member object OR non-satellite descendant → + // owning extension ids), computed ONCE per side. A member is reference-only + // (never produced/dropped by a standalone action) but is present-at-apply VIA + // its extension, so it can satisfy a consume/depends requirement — but ONLY + // when an owning extension is actually produced by this plan or already on the + // target. A member whose CREATE EXTENSION a policy filtered away is NOT + // present, so the guard must still fire (surfacing the missing reference at + // plan time, not apply time). Distinct from the assumed-schema `isAmbient` + // case, which never exempts a kept-but-absent object. + const desiredMemberClosure = extensionMemberClosure(desired); + const sourceMemberClosure = extensionMemberClosure(source); + const memberExtensionPresent = (memberKey: string): boolean => { + const exts = + desiredMemberClosure.get(memberKey) ?? sourceMemberClosure.get(memberKey); + return ( + exts !== undefined && + exts.some((ext) => producerOf.has(encodeId(ext)) || source.has(ext)) + ); + }; + + // alter actions indexed by their primary fact (opts.consumes[0]) + const alterersOf = new Map(); + actions.forEach((action, index) => { + if (action.verb !== "alter") return; + const primary = action.consumes[0]; + if (primary === undefined) return; + const key = encodeId(primary); + const list = alterersOf.get(key) ?? []; + list.push(index); + alterersOf.set(key, list); + }); + + // B1 temporary carve-out: an accepted ROLE rename destroys the old role id + // and produces the new one in the SAME action. A payload mutation such as + // ALTER POLICY … TO releases the old id and consumes that exact new id. Its + // consume edge correctly orders rename -> mutation; adding the generic + // release-before-destroy edge in the opposite direction would make a cycle. + // Keep this deliberately narrower than "release targets a rename": require + // one accepted role old->new pair represented by the destroyer action AND + // require this action to consume that pair's new id. I1's pre-diff payload + // normalization makes the redundant policy mutation disappear and removes + // this carve-out. + const releasesAcceptedRoleRename = ( + action: Action, + released: StableId, + destroyer: number, + ): boolean => { + if (released.kind !== "role" || !renameActionIndices.has(destroyer)) { + return false; + } + const renameAction = actions[destroyer] as Action; + const releasedKey = encodeId(released); + if (!renameAction.destroys.some((id) => encodeId(id) === releasedKey)) { + return false; + } + const consumedKeys = new Set(action.consumes.map((id) => encodeId(id))); + return renameAction.produces.some( + (id) => id.kind === "role" && consumedKeys.has(encodeId(id)), + ); + }; + + actions.forEach((action, index) => { + for (const id of action.releases) { + const destroyer = destroyerOf.get(remember(id)); + if (destroyer !== undefined && destroyer !== index) { + if (releasesAcceptedRoleRename(action, id, destroyer)) continue; + edges.push([index, destroyer]); + } + } + for (const id of action.consumes) { + const key = remember(id); + const producer = producerOf.get(key); + if (producer !== undefined && producer !== index) + edges.push([producer, index]); + const destroyer = destroyerOf.get(key); + // consumer-before-destroyer applies only when the id is NOT being + // re-produced; consumers of a replaced fact use the new one + if ( + destroyer !== undefined && + destroyer !== index && + producer === undefined + ) { + edges.push([index, destroyer]); + } + // A consumed EXTENSION MEMBER is reference-only (its object is never a + // create/drop action — CREATE/DROP EXTENSION materializes/removes it). A + // customization on it (a GRANT/COMMENT/SECURITY LABEL, whose satellite fact + // consumes the member as its parent) is sequenced relative to the + // extension: AFTER `CREATE EXTENSION` (the member appears with it) and + // BEFORE `DROP EXTENSION` (the member vanishes with it — REVOKE it while it + // still exists). + for (const ext of desiredMemberClosure.get(key) ?? []) { + const extProducer = producerOf.get(encodeId(ext)); + if (extProducer !== undefined && extProducer !== index) + edges.push([extProducer, index]); + } + for (const ext of sourceMemberClosure.get(key) ?? []) { + const extDestroyer = destroyerOf.get(encodeId(ext)); + if (extDestroyer !== undefined && extDestroyer !== index) + edges.push([index, extDestroyer]); + } + // the id must exist on the target before apply (source) or be + // produced by this plan; "it's in the desired state" is not enough — + // a policy filter can hide the delta that would have created it. Ambient + // targets (built-in/assumed roles, assumed schemas, objects within them) + // and extension members whose extension is actually present/produced + // satisfy the requirement. + if ( + producer === undefined && + !source.has(id) && + !isAmbient(id) && + !memberExtensionPresent(key) + ) { + throw new Error( + `missing requirement: action "${action.sql}" consumes ${key}, which neither exists on the target nor is produced by this plan${desired.has(id) ? " — a filter may be hiding its creation" : ""}`, + ); + } + } + // build order from the DESIRED state's dependency edges + const producesKeys = new Set(action.produces.map((id) => encodeId(id))); + for (const id of action.produces) { + remember(id); + if (!desired.has(id)) continue; + for (const edge of desired.outgoingEdges(id)) { + // a rename does not CREATE the owner edge (PG carries the owner across + // RENAME); ordering the new owner's producer before the rename would, + // paired with the source-side teardown edge below, form a cycle (P1 #2) + if (edge.kind === "owner" && renameActionIndices.has(index)) continue; + const targetKey = remember(edge.to); + const producer = producerOf.get(targetKey); + if (producer !== undefined && producer !== index) { + edges.push([producer, index]); + } else if (producer === undefined) { + // the dependency is kept but altered in place: create the dependent + // against its FINAL state (e.g. a view recreated after an enum's + // value-set migration). Skip alterers that consume what this action + // produces — there the alter needs the create first (REPLICA + // IDENTITY USING a new index). + for (const alterer of alterersOf.get(targetKey) ?? []) { + if (alterer === index) continue; + const altererConsumesProduct = ( + actions[alterer] as Action + ).consumes.some((c) => producesKeys.has(encodeId(c))); + if (!altererConsumesProduct) edges.push([alterer, index]); + } + // A produced fact's DEPENDS edge must resolve to something this plan + // produces or the target already has — "it's in the desired view" is + // NOT enough: a policy filter can hide the delta that would create the + // dependency, leaving a CREATE that references a missing object (P0-1). + // (An altered-in-place dependency is in `source`, so this never fires + // for it; built-in endpoints resolve to no edge at all.) + if ( + edge.kind === "depends" && + !source.has(edge.to) && + !isAmbient(edge.to) && + !memberExtensionPresent(targetKey) + ) { + throw new Error( + `missing requirement: action "${action.sql}" produces ${encodeId(id)}, ` + + `which depends on ${targetKey} — neither produced by this plan nor ` + + `present on the target${desired.has(edge.to) ? " (a filter may be hiding its creation)" : ""}`, + ); + } + } + } + } + // teardown order from the SOURCE state's dependency edges + const destroysKeys = new Set(action.destroys.map((id) => encodeId(id))); + for (const id of action.destroys) { + const key = remember(id); + // replace: destroy before re-produce. This applies even to ids with no + // source fact — DROP IDENTITY implicitly destroys the backing sequence + // (alsoDestroys), which a CREATE SEQUENCE of the same name re-produces + const reproducer = producerOf.get(key); + if (reproducer !== undefined && reproducer !== index) + edges.push([index, reproducer]); + if (!source.has(id)) continue; + for (const edge of source.edges) { + if (encodeId(edge.to) !== key) continue; + // symmetric to the produces side: a rename does not TEAR DOWN the owner + // edge, so an owner edge into the renamed subtree must not order the + // owner's dependent teardown before the rename (P1 #2 cycle) + if (edge.kind === "owner" && renameActionIndices.has(index)) continue; + const dependentKey = remember(edge.from); + const dependentDestroyer = destroyerOf.get(dependentKey); + if (dependentDestroyer !== undefined && dependentDestroyer !== index) { + edges.push([dependentDestroyer, index]); + } else if (dependentDestroyer === undefined && desired.has(edge.from)) { + if (producerOf.has(key)) continue; + // the desired state no longer carries this dependency: whatever + // alters the dependent (e.g. ALTER PUBLICATION … SET delisting a + // dropped table) releases it — order those alters first + const stillRequired = desired + .outgoingEdges(edge.from) + .some((e) => encodeId(e.to) === key); + if (!stillRequired) { + for (const alterer of alterersOf.get(dependentKey) ?? []) { + if (alterer !== index) edges.push([alterer, index]); + } + continue; + } + // a surviving fact depends on something this plan destroys, and + // nothing recreates the dependency: fail loudly (stage-5 deliverable 6) + throw new Error( + `missing requirement: ${dependentKey} survives but depends on ${key}, which this plan drops without recreating`, + ); + } + } + // a dependent's teardown precedes in-place alters of its dependencies + // (drop the view before migrating the enum its definition references); + // an alterer that releases something this action destroys is the + // opposite shape — releases ordering wins there + for (const edge of source.outgoingEdges(id)) { + const depKey = remember(edge.to); + if (destroyerOf.has(depKey)) continue; + for (const alterer of alterersOf.get(depKey) ?? []) { + if (alterer === index) continue; + const altererReleasesOurDestroy = ( + actions[alterer] as Action + ).releases.some((r) => destroysKeys.has(encodeId(r))); + if (!altererReleasesOurDestroy) edges.push([index, alterer]); + } + } + // child teardown precedes parent teardown + const fact = source.get(id); + if (fact?.parent !== undefined) { + const parentDestroyer = destroyerOf.get(remember(fact.parent)); + if (parentDestroyer !== undefined && parentDestroyer !== index) { + edges.push([index, parentDestroyer]); + } + } + } + }); + + return edges; +} + +/** + * Deterministic tie-break key for an action at index `i`: drops first + * (descending kind weight), then creates/alters (ascending weight), then by + * subject id, then by emission index (zero-padded so "10" sorts after "9" — + * multi-spec sequences like the enum value-set migration rely on it). + */ +export function actionTieKey( + actions: readonly Action[], + i: number, + // id-keyed resolver so an `extensionIntent` action ties on its DECLARED late + // weight (via the profile's intent rules) rather than the accidental 99 + // catch-fallback. Defaults to the no-intent resolver for direct callers/tests. + rulesForId: RulesForId = defaultRulesForId, + // Optional per-action override for the SUBJECT segment of the key. The + // ordering phase uses it to sort a table's ADD COLUMN creates by declared + // column position (pg_attribute.attnum) instead of column NAME, so from-empty + // CREATEs (and the folds that collapse them into the CREATE parens) render + // columns in declared order. Returns undefined to fall back to the encoded + // subject id (every other action is unaffected). + subjectKeyOf?: (subject: StableId, action: Action) => string | undefined, +): string { + const action = actions[i] as Action; + const subject = + action.produces[0] ?? action.destroys[0] ?? action.consumes[0]; + const weight = (() => { + if (subject === undefined) return 99; + try { + return rulesForId(subject).weight; + } catch { + return 99; + } + })(); + const phase = action.verb === "drop" ? "0" : "1"; + const w = action.verb === "drop" ? 99 - weight : weight; + const subjectKey = + (subject !== undefined ? subjectKeyOf?.(subject, action) : undefined) ?? + (subject ? encodeId(subject) : ""); + return `${phase}|${String(w).padStart(2, "0")}|${subjectKey}|${String(i).padStart(6, "0")}`; +} + +/** + * Compaction (§3.6): fold `ADD COLUMN` clauses into their bare `CREATE TABLE`. + * Safe iff every graph predecessor of the folded action sits at or before the + * target — i.e. no edge crosses the merge. Purely cosmetic: produces/consumes + * merge, so ordering semantics and the proof are unchanged. Mutates the target + * actions in place (as the inline version did) and returns the kept actions. + */ +export function compactColumnFolds( + orderedActions: readonly Action[], + order: readonly number[], + edges: ReadonlyArray<[number, number]>, + foldHints: ReadonlyArray<{ foldInto: StableId; clause: string } | undefined>, + acceptsFolds: readonly boolean[], + positionOf: readonly number[], + foldConstraints?: { exclude?: ReadonlySet }, +): Action[] { + const predecessorsOf = new Map(); + for (const [a, b] of edges) { + const list = predecessorsOf.get(b) ?? []; + list.push(a); + predecessorsOf.set(b, list); + } + const targetPosOf = new Map(); + orderedActions.forEach((action, pos) => { + for (const id of action.produces) { + const key = encodeId(id); + if (!targetPosOf.has(key)) targetPosOf.set(key, pos); + } + }); + const foldedPos = new Set(); + const effectivePosOf = new Map(); // orig idx -> post-fold pos + for (let pos = 0; pos < orderedActions.length; pos++) { + const origIndex = order[pos] as number; + const hint = foldHints[origIndex]; + if (hint === undefined) continue; + const action = orderedActions[pos] as Action; + if (action.newSegmentBefore || action.transactionality !== "transactional") + continue; + // Constraint fold hints (CONSTRAINT name clauses) apply ONLY under + // `foldConstraints` — the export-only mode whose output is loaded by the + // retry/reorder loader. In a regular diff plan the apply EXECUTOR runs + // actions in graph order, and folding an FK into a CREATE TABLE that + // precedes the referenced table's CREATE would fail — so constraint hints + // stay inert (data) unless the caller opted in. Cycle-participating FKs + // (the caller's `exclude` set) stay as ALTERs so the raw file loader keeps + // converging via the .fk.sql split. + const isConstraintFold = action.produces[0]?.kind === "constraint"; + if (isConstraintFold) { + if (foldConstraints === undefined) continue; + if (foldConstraints.exclude?.has(encodeId(action.produces[0]!))) { + continue; + } + } + const targetPos = targetPosOf.get(encodeId(hint.foldInto)); + if (targetPos === undefined || targetPos >= pos) continue; + const targetOrig = order[targetPos] as number; + if (!acceptsFolds[targetOrig] || foldedPos.has(targetPos)) continue; + const target = orderedActions[targetPos] as Action; + if (target.verb !== "create" || target.newSegmentBefore) continue; + // Column folds: ANY predecessor landing after the target vetoes the fold + // (apply-executor safety — no edge may cross the merge). + // + // Constraint folds are loaded by the retry/reorder loader, not the apply + // executor, so a crossing to another RELATION is tolerated: a VALIDATED FK's + // referenced table (or a backing index / type on some other relation) may be + // created by a LATER file and the loader reorders files to satisfy it. The + // one crossing a constraint fold must NOT tolerate is a SAME-TABLE column of + // its own fold target that was deferred to a later `ADD COLUMN` (its column + // fold crossed a domain-type edge, or it is a generated column that never + // hints) — folding the constraint inline would reference a column the CREATE + // TABLE does not yet declare, and no file reordering can repair that. An + // inlined column shares the target's effective position, so it never counts + // as "after" and passes naturally. + const foldTarget = hint.foldInto; + const crossesEdge = (predecessorsOf.get(origIndex) ?? []).some((p) => { + const pPos = effectivePosOf.get(p) ?? (positionOf[p] as number); + if (pPos <= targetPos) return false; + if (!isConstraintFold) return true; + const predAction = orderedActions[positionOf[p] as number] as Action; + return predAction.produces.some( + (id) => + id.kind === "column" && + foldTarget.kind === "table" && + id.schema === foldTarget.schema && + id.table === foldTarget.name, + ); + }); + if (crossesEdge) continue; + // fold: splice the clause into the CREATE's column list + target.sql = target.sql.endsWith("()") + ? `${target.sql.slice(0, -2)}(${hint.clause})` + : `${target.sql.slice(0, -1)}, ${hint.clause})`; + target.produces.push(...action.produces); + for (const id of action.consumes) { + if (!target.consumes.some((c) => encodeId(c) === encodeId(id))) + target.consumes.push(id); + } + if (action.dataLoss === "destructive") target.dataLoss = "destructive"; + target.rewriteRisk = target.rewriteRisk || action.rewriteRisk; + foldedPos.add(pos); + effectivePosOf.set(origIndex, targetPos); + } + return foldedPos.size > 0 + ? orderedActions.filter((_, pos) => !foldedPos.has(pos)) + : [...orderedActions]; +} + +/** + * Compaction (§3.6), redundant-drop elision: a replace renders as drop + create, + * but some kinds' create is self-resetting — `acl`'s `grantActions` leads with + * its own `REVOKE ALL … FROM grantee`, byte-identical to the replace's drop. The + * drop is then a redundant (idempotent) statement. This is GENERAL to every ACL + * privilege change, so it is prettified here in the cosmetic pass rather than + * special-cased per kind in the planner (correctness first, prettify later). + * + * Purely cosmetic + provably safe via LOCAL checks (no graph walk): remove a + * `drop` D destroying a single id I when a `create` P re-produces I with the + * SAME sql, and removing D cannot lose an ordering constraint — + * - D.consumes ⊆ P.consumes, so every producer that had to precede D also has + * to precede P (which remains); + * - nothing `releases` I (a releaser would order before D's destruction); + * - I has no children, so no child-teardown is ordered before D. + * Those exhaust the edge kinds that can point INTO a drop, so P inherits all of + * D's predecessors and the byte-identical statement reproduces D's effect. + */ +export function elideRedundantDrops( + actions: readonly Action[], + source: FactBase, +): Action[] { + // first create that produces each id, with its sql + consume set + const producerOf = new Map< + string, + { index: number; sql: string; consumes: Set } + >(); + actions.forEach((action, index) => { + if (action.verb !== "create") return; + for (const id of action.produces) { + const key = encodeId(id); + if (!producerOf.has(key)) { + producerOf.set(key, { + index, + sql: action.sql, + consumes: new Set(action.consumes.map(encodeId)), + }); + } + } + }); + const releasedIds = new Set(); + for (const action of actions) + for (const id of action.releases) releasedIds.add(encodeId(id)); + + const remove = new Set(); + actions.forEach((action, index) => { + if (action.verb !== "drop" || action.destroys.length !== 1) return; + const id = action.destroys[0] as StableId; + const key = encodeId(id); + const producer = producerOf.get(key); + if (producer === undefined || producer.index === index) return; + if (producer.sql !== action.sql) return; // not a byte-identical reproduce + if (releasedIds.has(key)) return; // a releaser is ordered before this drop + if (source.childrenOf(id).length > 0) return; // child-teardown precedes it + if (!action.consumes.every((c) => producer.consumes.has(encodeId(c)))) + return; // P would not inherit all of D's producer-predecessors + remove.add(index); + }); + + return remove.size > 0 + ? actions.filter((_, index) => !remove.has(index)) + : [...actions]; +} + +/** + * Compaction (§3.6): trim a redundant explicit DROP POLICY. The Bug-1 fix makes + * policy drops never fold (suppressible:false), so an explicit DROP POLICY is + * always emitted before the table drop. That is load-bearing when the policy + * references a SEPARATELY-dropped object (a view in its USING subquery, a role): + * PostgreSQL refuses to drop that object while the policy references it. But when + * the policy only references its own table (or undropped objects), PostgreSQL's + * implicit DROP TABLE cascade already removes the policy, so the explicit drop is + * redundant. + * + * Cosmetic + safe via LOCAL checks: remove a `drop` of a single policy P on table + * T when (a) some drop destroys T (DROP TABLE removes its policies by cascade), + * and (b) P is not load-bearing — every object P depends on that is ALSO being + * dropped lies within T's own drop subtree, so eliding P loses no ordering + * another drop relies on. A wrong "keep" is merely verbose; a wrong "elide" would + * surface as an unappliable plan in the corpus proof. + */ +export function elideCascadeSubsumedPolicyDrops( + actions: readonly Action[], + source: FactBase, +): Action[] { + const droppedIds = new Set(); + for (const action of actions) + if (action.verb === "drop") + for (const id of action.destroys) droppedIds.add(encodeId(id)); + + const inSubtree = (x: StableId, rootKey: string): boolean => { + let cur: StableId | undefined = x; + while (cur !== undefined) { + if (encodeId(cur) === rootKey) return true; + cur = source.get(cur)?.parent; + } + return false; + }; + + const remove = new Set(); + actions.forEach((action, index) => { + if (action.verb !== "drop" || action.destroys.length !== 1) return; + const id = action.destroys[0] as StableId; + if (id.kind !== "policy") return; + const table = source.get(id)?.parent; + if (table === undefined) return; + const tableKey = encodeId(table); + if (!droppedIds.has(tableKey)) return; // table survives → the drop is real + const loadBearing = source + .outgoingEdges(id) + .some( + (e) => droppedIds.has(encodeId(e.to)) && !inSubtree(e.to, tableKey), + ); + if (!loadBearing) remove.add(index); + }); + + return remove.size > 0 + ? actions.filter((_, index) => !remove.has(index)) + : [...actions]; +} + +/** The single privilege PostgreSQL grants to PUBLIC on a freshly-created object + * of each kind (Table 5.2, "Summary of Access Privileges"). Kinds absent here + * (table, view, sequence, schema, …) get NO PUBLIC default, so any PUBLIC grant + * on them is intentional and must be kept. Version-stable — unlike the owner's + * full default set (PG17 added MAINTAIN), so we never encode that. */ +const PUBLIC_DEFAULT_PRIVILEGE: Partial> = { + type: "USAGE", + domain: "USAGE", + language: "USAGE", + function: "EXECUTE", + procedure: "EXECUTE", + aggregate: "EXECUTE", +}; + +/** Order-insensitive equality of two privilege lists (both arrive sorted from + * extraction, but compare as sets to stay robust to ordering). */ +function samePrivilegeSet(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const set = new Set(a); + return b.every((priv) => set.has(priv)); +} + +/** + * Whether an `ALTER DEFAULT PRIVILEGES` customizes the create-time default ACL + * for this objtype, so a co-created object's ACL can no longer be assumed to be + * the plain built-in default. + * + * The default-ACL elision drops an object's REVOKE/GRANT group only when the + * group is a no-op against the create-time ACL. That holds for the built-in + * default, but NOT when an ADP is in play: an ADP can reduce the default (e.g. + * revoke the built-in PUBLIC `EXECUTE`, or revoke `UPDATE` from the owner), and + * — critically — a plan that creates both the ADP and the object does NOT + * guarantee the ADP runs first, so the object may be created with the built-in + * default while the desired ACL is the reduced one (or vice versa). In every + * such case the explicit REVOKE/GRANT group is load-bearing, so we keep it + * (review P2). The redundant leading REVOKE is still trimmed by + * elideCoCreateRevokeBeforeGrant. + * + * ADP is keyed by the CREATING role (`defaclrole`); when `capability` is known + * we filter to the applier's role, else (corpus/raw) consider any role's ADP + * (conservative — at worst we keep a redundant REVOKE/GRANT). + */ +function adpCustomizesObjtype( + desired: FactBase, + target: StableId, + capability: ApplierCapability | undefined, +): boolean { + const objtype = ruleFlag(target.kind, "defaclObjtype"); + if (objtype === undefined) return false; // kind has no default-ACL mechanism + const targetSchema = + target.kind === "schema" + ? null + : ((target as { schema?: string }).schema ?? null); + return desired.facts().some((fact) => { + if (fact.id.kind !== "defaultPrivilege") return false; + const d = fact.id as Extract; + if (d.objtype !== objtype) return false; + if (d.schema !== null && d.schema !== targetSchema) return false; + if (capability !== undefined && d.role !== capability.role) return false; + return true; + }); +} + +/** + * Compaction (§3.6), default-ACL elision: a freshly `CREATE`d object already + * carries PostgreSQL's built-in default privileges, so the `acl` rule's + * REVOKE-ALL+GRANT pair that merely re-materializes those defaults is a no-op on + * a co-created target. `grantActions` emits them unconditionally (pg_dump's + * model), which bloats every create. This prettifies that away — mirroring the + * old engine's `filterPublicBuiltInDefaults` + owner-privilege filtering, but as + * a late cosmetic pass so the diff/extract semantics are untouched. + * + * An `acl` create group is elidable iff its target object is itself created in + * THIS plan AND the grant reproduces the grantee's EFFECTIVE create-time default + * (the built-in default, OR what `ALTER DEFAULT PRIVILEGES` made it — see + * effectiveDefaultPrivileges): + * - grantee is the target's owner — compared to `_ownerDefault` (the version- + * correct built-in owner set captured at extract); + * - grantee is PUBLIC — compared to the kind's built-in PUBLIC default. + * In both cases an active ADP that reduced the default (e.g. revoked the PUBLIC + * EXECUTE) makes the group load-bearing, so it is kept. Anything else (non- + * default grant, third-party grantee, grant option, an ACL on a pre-existing + * object) is kept verbatim. + * + * Safe + cosmetic via LOCAL checks: it only drops an `acl` group whose effect PG + * guarantees on create, and the acl fact id is consumed by nothing outside its + * own REVOKE/GRANT actions, so removing the whole group strands no consumer and + * the proven end-state is unchanged. NEVER suppresses a REVOKE the desired state + * needs (a revoked default is simply absent from the fact base — there is no acl + * create action to elide, and this pass never adds or removes anything else). + */ +export function elideDefaultAclCreates( + actions: readonly Action[], + desired: FactBase, + capability?: ApplierCapability, +): Action[] { + // ids of the objects actually created in this plan (acl satellites excluded). + const createdObjects = new Set(); + for (const action of actions) { + if (action.verb !== "create") continue; + for (const id of action.produces) { + if (id.kind !== "acl") createdObjects.add(encodeId(id)); + } + } + + const elidable = new Set(); + for (const action of actions) { + if (action.verb !== "create") continue; + const aclId = action.produces.find((id) => id.kind === "acl"); + if (aclId === undefined || aclId.kind !== "acl") continue; + if (!createdObjects.has(encodeId(aclId.target))) continue; + const fact = desired.get(aclId); + if (fact === undefined) continue; + const payload = fact.payload as { + privileges?: string[]; + grantable?: string[]; + // non-semantic metadata (`_` prefix): the owner's create-time default + // privilege set, captured at extract. Excluded from the hash/diff (hash.ts) + // so it never causes cross-version/snapshot drift; read here only to decide + // elision. + _ownerDefault?: string[]; + }; + if ((payload.grantable ?? []).length > 0) continue; // grant option is never default + const privileges = payload.privileges ?? []; + + // The group is a no-op (elidable) IFF the co-created object already grants + // this grantee EXACTLY the desired privileges at CREATE time, i.e. the + // desired ACL equals the BUILT-IN default. An ALTER DEFAULT PRIVILEGES + // customizing this objtype breaks that assumption (the effective default + // differs, and ADP-vs-CREATE order is not guaranteed in a from-empty plan), + // so the explicit REVOKE/GRANT is load-bearing — keep it (review P2). + if (adpCustomizesObjtype(desired, aclId.target, capability)) continue; + + if (aclId.grantee === "PUBLIC") { + const def = PUBLIC_DEFAULT_PRIVILEGE[aclId.target.kind]; + if (def !== undefined && privileges.length === 1 && privileges[0] === def) + elidable.add(encodeId(aclId)); + continue; + } + // owner grant: PostgreSQL grants the owner the full built-in default on a + // fresh create. The set is version-dependent (PG17 added MAINTAIN), so we + // compare against `_ownerDefault` — the owner's create-time set captured from + // acldefault() at extract (non-semantic `_` metadata). A strict subset means + // the owner revoked a default; eliding would leave the full default in place. + const ownerEdge = desired + .outgoingEdges(aclId.target) + .find((e) => e.kind === "owner"); + if ( + ownerEdge !== undefined && + ownerEdge.to.kind === "role" && + ownerEdge.to.name === aclId.grantee && + payload._ownerDefault !== undefined && + samePrivilegeSet(privileges, payload._ownerDefault) + ) + elidable.add(encodeId(aclId)); + } + + if (elidable.size === 0) return [...actions]; + // every action of an elidable group either produces the acl id (the REVOKE) or + // consumes it (the GRANTs); nothing else touches the id, so this drops exactly + // the group. + return actions.filter( + (action) => + !action.produces.some((id) => elidable.has(encodeId(id))) && + !action.consumes.some((id) => elidable.has(encodeId(id))), + ); +} + +/** + * Compaction (§3.6), co-create ownership fold: a freshly `CREATE`d object is + * emitted applier-owned, followed by an `ALTER … OWNER TO ` (move 6 — + * create no longer sets the owner). Two cosmetic cleanups on that pair: + * + * 1. SCHEMA (always-on, syntactic): `CREATE SCHEMA s` + `ALTER SCHEMA s OWNER + * TO r` collapse into `CREATE SCHEMA s AUTHORIZATION r`. AUTHORIZATION is the + * canonical single-statement form even for a foreign owner and carries the + * IDENTICAL applier-capability requirement as the two-statement form, so the + * fold never changes whether apply succeeds — it runs regardless of + * capability. + * 2. EVERY OTHER ownable kind (only when applier-known): drop the owner ALTER + * when the desired owner IS the applier (`capability.role`). On a + * creates-as-applier object that is a genuine no-op (already applier-owned on + * create). A foreign owner keeps its ALTER. + * + * Detected STRUCTURALLY (no SQL parsing — guardrail): the owner ALTER has + * `verb === "alter"`, produces/destroys/releases nothing, consumes exactly the + * created object id + one role id, the kind has an `ownerAlterPrefix` rule, and + * the desired graph carries an `owner` edge object → role. `canSetOwner` already + * fail-fasts at emit time, so every surviving owner ALTER here is one the applier + * could run; Rule 2's "keep when owner ≠ applier" only fires for the + * capability-undefined and superuser-applier cases. + * + * The fold also re-checks `canSetOwner` locally (when capability is known): both + * the schema AUTHORIZATION form and the two-statement form carry the same + * capability requirement, so an applier that cannot set the owner can run + * NEITHER. Folding such a pair would be harmless against a converging plan (it + * fails identically either way), but the local check keeps the pass + * self-contained — correct even if a future caller runs it without the emit-time + * fail-fast — instead of silently depending on that upstream guard. + */ +export function foldCoCreateOwnership( + actions: readonly Action[], + desired: FactBase, + capability?: ApplierCapability, +): Action[] { + // ids created in THIS plan (acl satellites excluded), with their create action. + const createActionOf = new Map(); + for (const action of actions) { + if (action.verb !== "create") continue; + for (const id of action.produces) + if (id.kind !== "acl") createActionOf.set(encodeId(id), action); + } + if (createActionOf.size === 0) return [...actions]; + + const drop = new Set(); + actions.forEach((action, index) => { + if (action.verb !== "alter") return; + if (action.produces.length > 0 || action.destroys.length > 0) return; + if (action.releases.length > 0) return; // owner CHANGE, not a fresh-create set + if (action.newSegmentBefore || action.transactionality !== "transactional") + return; + // structural owner-ALTER shape: exactly one object consume + one role consume + const roleConsumes = action.consumes.filter((id) => id.kind === "role"); + const objConsumes = action.consumes.filter((id) => id.kind !== "role"); + if (roleConsumes.length !== 1 || objConsumes.length !== 1) return; + const objId = objConsumes[0] as StableId; + const owner = roleConsumes[0] as { kind: "role"; name: string }; + const createAction = createActionOf.get(encodeId(objId)); + if (createAction === undefined) return; // not co-created → real owner change + if (ruleFlag(objId.kind, "ownerAlterPrefix") === undefined) return; + const hasOwnerEdge = desired + .outgoingEdges(objId) + .some( + (e) => + e.kind === "owner" && + e.to.kind === "role" && + e.to.name === owner.name, + ); + if (!hasOwnerEdge) return; + // local appliability check (#2): never collapse an owner ALTER the known + // applier could not execute. Capability-undefined keeps the unrestricted + // (superuser/CI) behavior — fold regardless. + if (capability !== undefined && !canSetOwner(capability, owner.name)) + return; + + if (objId.kind === "schema") { + // Rule 1 — syntactic fold into CREATE SCHEMA … AUTHORIZATION. Compare the + // create against the canonical bare render; only fold the exact shape. + if ( + createAction.newSegmentBefore || + createAction.transactionality !== "transactional" + ) + return; + const schemaName = (objId as { kind: "schema"; name: string }).name; + if (createAction.sql !== schemaCreateSql(schemaName)) return; + createAction.sql = schemaCreateSql(schemaName, owner.name); + if (!createAction.consumes.some((c) => encodeId(c) === encodeId(owner))) + createAction.consumes.push(owner); + drop.add(index); + return; + } + + // Rule 2 — no-op elision only when the applier IS the owner. + if (capability !== undefined && capability.role === owner.name) + drop.add(index); + }); + + return drop.size > 0 + ? actions.filter((_, index) => !drop.has(index)) + : [...actions]; +} + +/** + * Compaction (§3.6), co-create REVOKE elision: `grantActions` emits ACL via + * pg_dump's REVOKE-first model (`REVOKE ALL … FROM g` then `GRANT … TO g`). On a + * freshly co-created object whose grantee has no conflicting create-time default + * privilege, the leading `REVOKE ALL` is cosmetic — the object starts with no + * third-party grants, so the GRANT alone converges. This drops that REVOKE while + * keeping every GRANT. + * + * Distinct from `elideDefaultAclCreates` (which drops WHOLE owner/PUBLIC default + * groups): this runs AFTER it and only trims the REVOKE off the REMAINING + * third-party groups, keeping the load-bearing GRANT. + * + * Guarded — keep the REVOKE when it is load-bearing: + * - target not co-created, or the group has no GRANT → untouched; + * - REVOKE-only group (empty privileges) → untouched (a revoked default); + * - explicit acl carries a grant option → untouched (REVOKE also clears those); + * - a potentially-active `defaultPrivilege` in desired would grant this grantee + * a privilege or grant option NOT in the explicit acl → untouched (strict- + * superset guard). With known capability "potentially active" means + * `default.role === capability.role` (creates-as-applier); without capability, + * any matching default (objtype + schema scope + grantee) is treated active. + */ +export function elideCoCreateRevokeBeforeGrant( + actions: readonly Action[], + desired: FactBase, + capability?: ApplierCapability, +): Action[] { + const createdObjects = new Set(); + for (const action of actions) { + if (action.verb !== "create") continue; + for (const id of action.produces) + if (id.kind !== "acl") createdObjects.add(encodeId(id)); + } + if (createdObjects.size === 0) return [...actions]; + + // index default-privilege facts once (small set) for the superset guard. + const defaults = desired + .facts() + .filter((f) => f.id.kind === "defaultPrivilege"); + + const defaultGrantsOutside = ( + target: StableId, + grantee: string, + explicit: Set, + ): boolean => { + // which pg_default_acl objtype this kind maps to is declared per-kind in the + // rule table (`defaclObjtype`, shared with the emitter's hygiene pass); + // absent → no default ACLs, so no default can ever fire on it. + const objtype = ruleFlag(target.kind, "defaclObjtype"); + if (objtype === undefined) return false; // kind has no default mechanism + const targetSchema = + target.kind === "schema" + ? null + : ((target as { schema?: string }).schema ?? null); + for (const fact of defaults) { + const d = fact.id as Extract; + if (d.objtype !== objtype || d.grantee !== grantee) continue; + if (d.schema !== null && d.schema !== targetSchema) continue; + if (capability !== undefined && d.role !== capability.role) continue; + const payload = fact.payload as { + privileges?: string[]; + grantable?: string[]; + }; + if ((payload.grantable ?? []).length > 0) return true; // grant option + for (const priv of payload.privileges ?? []) + if (!explicit.has(priv)) return true; // extra privilege + } + return false; + }; + + // index, once, the acl ids that a GRANT consumes. The REVOKE leader PRODUCES + // the acl id (and consumes only its target), while every GRANT CONSUMES it + // (emitCreate spec index > 0), so any acl id appearing in a `consumes` belongs + // to a GRANT. Keeps the "group still has a GRANT" test O(1) per acl group. + const aclIdsWithGrant = new Set(); + for (const action of actions) + for (const id of action.consumes) + if (id.kind === "acl") aclIdsWithGrant.add(encodeId(id)); + + const dropRevoke = new Set(); + const strippedAclKeys = new Set(); + actions.forEach((action, index) => { + if (action.verb !== "create") return; + const aclId = action.produces.find((id) => id.kind === "acl"); + if (aclId === undefined || aclId.kind !== "acl") return; // not a REVOKE leader + if (!createdObjects.has(encodeId(aclId.target))) return; // not co-created + const fact = desired.get(aclId); + if (fact === undefined) return; + const payload = fact.payload as { + privileges?: string[]; + grantable?: string[]; + }; + const privileges = payload.privileges ?? []; + if (privileges.length === 0) return; // REVOKE-only group + if ((payload.grantable ?? []).length > 0) return; // explicit grant option + const aclKey = encodeId(aclId); + if (!aclIdsWithGrant.has(aclKey)) return; // no GRANT → REVOKE is the whole effect + // the OWNER starts with the full create-time default, so its leading + // REVOKE is load-bearing whenever the owner's grant is a strict subset — + // and that is the ONLY owner case reaching here, because a full-default + // owner group was already dropped wholesale by elideDefaultAclCreates. + // Stripping it would leave PostgreSQL's full default in place (review P2). + const ownerEdge = desired + .outgoingEdges(aclId.target) + .find((e) => e.kind === "owner"); + if ( + ownerEdge !== undefined && + ownerEdge.to.kind === "role" && + ownerEdge.to.name === aclId.grantee + ) + return; + if (defaultGrantsOutside(aclId.target, aclId.grantee, new Set(privileges))) + return; // REVOKE is load-bearing + dropRevoke.add(index); + strippedAclKeys.add(aclKey); + }); + + if (dropRevoke.size === 0) return [...actions]; + return actions + .filter((_, index) => !dropRevoke.has(index)) + .map((action) => { + // strip the now-unproduced acl id from kept GRANT consumes (cosmetic — the + // graph is not re-consulted post-compaction, but keep the artifact clean). + if (!action.consumes.some((c) => strippedAclKeys.has(encodeId(c)))) + return action; + return { + ...action, + consumes: action.consumes.filter( + (c) => !strippedAclKeys.has(encodeId(c)), + ), + }; + }); +} + +/** Aggregate the per-action safety metadata (§3.7): destructive / rewrite / + * non-transactional counts and a histogram of documented lock classes. */ +export function computeSafetyReport(actions: readonly Action[]): SafetyReport { + const safetyReport: SafetyReport = { + destructiveActions: actions.filter((a) => a.dataLoss === "destructive") + .length, + rewriteRiskActions: actions.filter((a) => a.rewriteRisk).length, + nonTransactionalActions: actions.filter( + (a) => a.transactionality === "nonTransactional", + ).length, + lockClasses: {}, + }; + for (const action of actions) { + safetyReport.lockClasses[action.lockClass] = + (safetyReport.lockClasses[action.lockClass] ?? 0) + 1; + } + return safetyReport; +} diff --git a/packages/pg-delta/src/plan/locks.test.ts b/packages/pg-delta/src/plan/locks.test.ts new file mode 100644 index 000000000..a30bb4864 --- /dev/null +++ b/packages/pg-delta/src/plan/locks.test.ts @@ -0,0 +1,45 @@ +/** + * Targeted per-form assertions for the vetted lock-class table (stage 5 + * deliverable 7): each entry mirrors PostgreSQL's documented lock level. + */ +import { describe, expect, test } from "bun:test"; +import { lockClassFor } from "./locks.ts"; + +describe("vetted lock-class table", () => { + test("ALTER TABLE forms take ACCESS EXCLUSIVE", () => { + expect(lockClassFor("table", "alter")).toBe("accessExclusive"); + expect(lockClassFor("column", "create")).toBe("accessExclusive"); + expect(lockClassFor("column", "alter")).toBe("accessExclusive"); + expect(lockClassFor("default", "create")).toBe("accessExclusive"); + }); + + test("CREATE INDEX takes SHARE; DROP INDEX takes ACCESS EXCLUSIVE", () => { + expect(lockClassFor("index", "create")).toBe("share"); + expect(lockClassFor("index", "drop")).toBe("accessExclusive"); + }); + + test("CREATE TRIGGER takes SHARE ROW EXCLUSIVE", () => { + expect(lockClassFor("trigger", "create")).toBe("shareRowExclusive"); + }); + + test("ALTER PUBLICATION SET takes SHARE UPDATE EXCLUSIVE on listed tables", () => { + expect(lockClassFor("publication", "alter")).toBe("shareUpdateExclusive"); + }); + + test("creating new relations locks nothing existing", () => { + expect(lockClassFor("table", "create")).toBe("none"); + expect(lockClassFor("view", "create")).toBe("none"); + expect(lockClassFor("sequence", "create")).toBe("none"); + }); + + test("cluster-level and catalog-only kinds report none", () => { + expect(lockClassFor("role", "create")).toBe("none"); + expect(lockClassFor("schema", "drop")).toBe("none"); + expect(lockClassFor("comment", "alter")).toBe("none"); + expect(lockClassFor("acl", "create")).toBe("none"); + }); + + test("unknown kinds report the conservative worst case", () => { + expect(lockClassFor("mystery", "alter")).toBe("accessExclusive"); + }); +}); diff --git a/packages/pg-delta/src/plan/locks.ts b/packages/pg-delta/src/plan/locks.ts new file mode 100644 index 000000000..cc83ab88a --- /dev/null +++ b/packages/pg-delta/src/plan/locks.ts @@ -0,0 +1,123 @@ +/** + * The vetted lock-class table (target-architecture §3.7, stage 5 + * deliverable 7): per-DDL-form lock levels from PostgreSQL's documentation. + * Lock classes are REPORTED, not certified — no runtime introspection + * (stage 6 pitfall). Classes describe the strongest lock the statement + * takes on EXISTING user relations; creating a brand-new object locks + * nothing a user query can collide with, so it reports "none". + * + * Sources: PostgreSQL docs "Explicit Locking" (table-level lock modes) and + * the ALTER TABLE / CREATE INDEX / CREATE TRIGGER reference pages. + */ + +export type LockClass = + /** no lock on any existing user relation (new objects, cluster/catalog-only DDL) */ + | "none" + /** SHARE — blocks writes, allows reads (CREATE INDEX) */ + | "share" + /** SHARE ROW EXCLUSIVE — blocks writes + other DDL (CREATE TRIGGER, ADD FOREIGN KEY) */ + | "shareRowExclusive" + /** SHARE UPDATE EXCLUSIVE — blocks DDL, allows reads+writes (CONCURRENTLY forms, VALIDATE CONSTRAINT) */ + | "shareUpdateExclusive" + /** ACCESS EXCLUSIVE — blocks everything (most ALTER TABLE forms, DROP) */ + | "accessExclusive"; + +/** + * Default lock class per (kind, verb). Rules override per-spec where a + * specific DDL form is weaker/stronger than its kind's default (e.g. + * FK constraints, CONCURRENTLY index builds). + */ +const KIND_VERB_LOCKS: Record< + string, + Partial> +> = { + // relation-touching kinds + table: { create: "none", alter: "accessExclusive", drop: "accessExclusive" }, + column: { + create: "accessExclusive", // ALTER TABLE ADD COLUMN + alter: "accessExclusive", + drop: "accessExclusive", + }, + default: { + create: "accessExclusive", // ALTER TABLE … SET DEFAULT (brief catalog-only, still AE) + alter: "accessExclusive", + drop: "accessExclusive", + }, + constraint: { + create: "accessExclusive", // rules override: FK = shareRowExclusive, VALIDATE = SUE + alter: "accessExclusive", + drop: "accessExclusive", + }, + index: { + create: "share", // CREATE INDEX; CONCURRENTLY overrides to shareUpdateExclusive + alter: "accessExclusive", + drop: "accessExclusive", + }, + trigger: { + create: "shareRowExclusive", + alter: "shareRowExclusive", // ENABLE/DISABLE TRIGGER + drop: "accessExclusive", + }, + policy: { + create: "accessExclusive", + alter: "accessExclusive", + drop: "accessExclusive", + }, + rule: { + create: "accessExclusive", + alter: "accessExclusive", + drop: "accessExclusive", + }, + view: { create: "none", alter: "accessExclusive", drop: "accessExclusive" }, + materializedView: { + create: "none", + alter: "accessExclusive", + drop: "accessExclusive", + }, + foreignTable: { + create: "none", + alter: "accessExclusive", + drop: "accessExclusive", + }, + sequence: { + create: "none", + alter: "accessExclusive", + drop: "accessExclusive", + }, + // ALTER PUBLICATION … SET takes ShareUpdateExclusive on the listed tables + publication: { create: "none", alter: "shareUpdateExclusive", drop: "none" }, +}; + +/** Kinds whose DDL never locks an existing user relation. */ +const NO_RELATION_LOCK_KINDS = new Set([ + "schema", + "role", + "membership", + "defaultPrivilege", + "extension", + "function", + "procedure", + "aggregate", + "domain", + "type", + "collation", + "eventTrigger", + "subscription", + "fdw", + "server", + "userMapping", + "comment", + "acl", +]); + +export function lockClassFor( + kind: string, + verb: "create" | "alter" | "drop", +): LockClass { + const perKind = KIND_VERB_LOCKS[kind]; + if (perKind?.[verb] !== undefined) return perKind[verb]; + if (NO_RELATION_LOCK_KINDS.has(kind)) return "none"; + // unknown kind: report the conservative worst case rather than a + // soothing default + return "accessExclusive"; +} diff --git a/packages/pg-delta/src/plan/phases/action-emitter.ts b/packages/pg-delta/src/plan/phases/action-emitter.ts new file mode 100644 index 000000000..246def786 --- /dev/null +++ b/packages/pg-delta/src/plan/phases/action-emitter.ts @@ -0,0 +1,640 @@ +/** + * Planner phase 3 — ActionEmitter (target-architecture §3.4–3.5). + * + * Turns the projected change set + replacement expansion into the atomic action + * list, with its OWN producer/destroyer/fold bookkeeping (local to this phase — + * the cohesive emission algorithm the planner once inlined). Emits, in order: + * rename actions, creates (parents first), default-privilege hygiene, drops, + * replaces (drop + recreate), in-place alters, role-rename changed-pair + * mutations, and owner-edge ALTERs. Enforces the create-produces-its-fact + * invariant at the phase boundary (review architecture rec #2). + */ +import type { Delta } from "../../core/diff.ts"; +import type { Fact, FactBase } from "../../core/fact.ts"; +import { canonicalize } from "../../core/hash.ts"; +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import { + canSetOwner, + type ApplierCapability, +} from "../../policy/capability.ts"; +import { factMatches, type SerializeRule } from "../../policy/policy.ts"; +import { lockClassFor } from "../locks.ts"; +import type { Action } from "../plan.ts"; +import { grantTarget, qid } from "../render.ts"; +import { subtreeIds } from "../renames.ts"; +import { cascadesToChildren, ruleFlag } from "../rule-flags.ts"; +import { ownerEdgeKey } from "../role-rename-carry.ts"; +import { type ActionSpec, type PlanParams, type RulesForId } from "../rules.ts"; +import type { ChangedRoleFact } from "./change-set.ts"; + +export interface ActionEmitterInput { + /** resolved source / desired views + the projected plan target */ + source: FactBase; + desired: FactBase; + projectedDesired: FactBase; + /** add/remove worklists + grouped set-deltas (rename cancellation applied) */ + removed: ReadonlyMap; + added: ReadonlyMap; + setsByFact: ReadonlyMap[]>; + /** from ReplacementExpansion */ + replaceIds: ReadonlySet; + dropRootOf: ReadonlyMap; + /** from ChangeSet */ + acceptedRenames: ReadonlyArray<{ from: Fact; to: Fact }>; + roleRenameMap: ReadonlyMap; + carriedOwnerLinks: ReadonlySet; + changedRoleFacts: readonly ChangedRoleFact[]; + deltas: readonly Delta[]; + /** serialize params + policy serialize rules */ + params: PlanParams; + serializeRules: readonly SerializeRule[]; + capability: ApplierCapability | undefined; + /** id-keyed rule resolver (schema kinds via the static RULES table, + * `extensionIntent` via the profile's intent rules) */ + rulesForId: RulesForId; +} + +export interface ActionEmitterOutput { + actions: Action[]; + producerOf: Map; + destroyerOf: Map; + foldHints: Array<{ foldInto: StableId; clause: string } | undefined>; + acceptsFolds: boolean[]; + renameActionIndices: Set; +} + +/** + * Emit the atomic action list from the change set + replacement expansion. + * Behavior-preserving extraction of `plan()`'s emission block. + */ +export function emitActions(input: ActionEmitterInput): ActionEmitterOutput { + const { + source, + desired, + projectedDesired, + removed, + added, + setsByFact, + replaceIds, + dropRootOf, + acceptedRenames, + roleRenameMap, + carriedOwnerLinks, + changedRoleFacts, + deltas, + params, + serializeRules, + capability, + rulesForId, + } = input; + + const actions: Action[] = []; + const producerOf = new Map(); + const destroyerOf = new Map(); + // transient per-action compaction metadata (never enters the artifact) + const foldHints: Array<{ foldInto: StableId; clause: string } | undefined> = + []; + const acceptsFolds: boolean[] = []; + + const pushAction = ( + verb: Action["verb"], + spec: ActionSpec, + opts: { + produces?: StableId[]; + consumes?: StableId[]; + destroys?: StableId[]; + }, + ): number => { + const index = actions.length; + const produces = [...(opts.produces ?? []), ...(spec.alsoProduces ?? [])]; + const destroys = [...(opts.destroys ?? []), ...(spec.alsoDestroys ?? [])]; + const consumes = [...(opts.consumes ?? []), ...(spec.consumes ?? [])]; + const subjectKind = (produces[0] ?? destroys[0] ?? consumes[0])?.kind; + actions.push({ + sql: spec.sql, + verb, + produces, + consumes, + destroys, + releases: spec.releases ?? [], + transactionality: spec.transactionality ?? "transactional", + lockClass: + spec.lockClass ?? + (subjectKind === undefined ? "none" : lockClassFor(subjectKind, verb)), + newSegmentBefore: false, + dataLoss: spec.dataLoss ?? "none", + rewriteRisk: spec.rewriteRisk ?? false, + }); + foldHints[index] = spec.compaction; + acceptsFolds[index] = spec.acceptsColumnFolds ?? false; + for (const id of produces) { + const key = encodeId(id); + if (!producerOf.has(key)) producerOf.set(key, index); + } + for (const id of destroys) destroyerOf.set(encodeId(id), index); + return index; + }; + + const paramsCache = new Map(); + const paramsFor = (fact: Fact): PlanParams => { + if (serializeRules.length === 0) return params; + const key = encodeId(fact.id); + const cached = paramsCache.get(key); + if (cached !== undefined) return cached; + let merged = params; + for (const rule of serializeRules) { + if (factMatches(rule.match, fact, desired)) { + merged = { ...rule.params, ...params }; + break; + } + } + paramsCache.set(key, merged); + return merged; + }; + + const emitCreate = (fact: Fact, base: FactBase): void => { + const specs = rulesForId(fact.id).create( + fact, + base, + paramsFor(fact), + source, + ); + specs.forEach((spec, i) => { + pushAction("create", spec, { + produces: i === 0 ? [fact.id] : [], + consumes: [ + ...(i === 0 ? [] : [fact.id]), + ...(fact.parent !== undefined ? [fact.parent] : []), + ], + }); + }); + }; + + // renames: one action renames the whole subtree — produces every new id, + // destroys every old id; dependents order against those sets. Tracked so + // buildActionGraph can treat them as identity-only: a rename does NOT + // establish or tear down the owner edge (PostgreSQL preserves the owner across + // RENAME), so owner edges on the renamed subtree must not drive graph ordering + // through the rename (review P1 #2: rename/rename cycle). + const renameActionIndices = new Set(); + for (const { from, to } of acceptedRenames) { + const rename = rulesForId(from.id).rename; + if (rename === undefined) { + throw new Error( + `rename: kind '${from.id.kind}' matched as candidate but has no rename rule`, + ); + } + renameActionIndices.add( + pushAction("alter", rename(from, to.id), { + produces: subtreeIds(desired, to.id), + destroys: subtreeIds(source, from.id), + consumes: to.parent !== undefined ? [to.parent] : [], + }), + ); + } + + // replaces: drop old + create new (+ recreate unchanged descendants). + // Emitted BEFORE the added-creates loop so a replaced parent's CREATE registers + // its inlined delta-set children (publication members, etc.) in `producerOf` + // first — the added loop's producerOf check then suppresses the redundant + // standalone create of a child the replacement already materialized (a member + // ADDed by CREATE … FOR TABLE must not also emit ALTER PUBLICATION ADD TABLE). + // Emission order does not affect apply order (the action graph re-sorts). + const recreatedByReplace = new Set(); + for (const key of replaceIds) { + const oldFact = source.getByEncoded(key) as Fact; + // the replacement is rendered from the PROJECTED plan target, so a filtered + // attribute change or child fact is not baked into the recreated SQL (P1 #1) + const newFact = projectedDesired.getByEncoded(key) as Fact; + // The old subtree dies with the replace. A child folds into its parent's + // DROP only when that parent's DROP CASCADES to it (DROP TABLE → its + // columns); a child across a NON-cascading boundary (a foreign table or user + // mapping under a server, whose DROP SERVER is RESTRICT) needs its OWN drop + // action, which the graph's child-teardown rule orders before the parent's + // drop. Without this the bare DROP SERVER fails on its surviving dependents. + const emitReplaceDrop = (rootFact: Fact): void => { + const destroys: StableId[] = [rootFact.id]; + const walk = (fact: Fact): void => { + for (const child of source.childrenOf(fact.id)) { + if (cascadesToChildren(fact.id.kind)) { + destroys.push(child.id); + walk(child); + } else { + emitReplaceDrop(child); + } + } + }; + walk(rootFact); + // A non-root (boundary) child drop must NOT consume its parent: the parent + // is re-created in this same plan, so consuming it would order the child + // drop AFTER the parent's re-create. Its ordering before the parent drop + // comes from the child-teardown rule (source parent → parentDestroyer). + const isRoot = encodeId(rootFact.id) === key; + pushAction("drop", rulesForId(rootFact.id).drop(rootFact, source), { + consumes: + isRoot && rootFact.parent !== undefined ? [rootFact.parent] : [], + destroys, + }); + }; + emitReplaceDrop(oldFact); + emitCreate(newFact, projectedDesired); + // recreate surviving descendants from the PROJECTED plan target (satellites, + // sub-facts). Descendants with their own attribute deltas are covered: the + // create renders the projected payload, so their alters are skipped; a + // descendant whose add was policy-filtered is absent and so not recreated. + const recreate = (id: StableId): void => { + for (const child of projectedDesired.childrenOf(id)) { + const childKey = encodeId(child.id); + if (added.has(childKey)) continue; // already created via add delta + // already materialized by an ancestor's create via `alsoProduces` + // (delta-set inlining — e.g. a validated CHECK inlined into CREATE + // DOMAIN, a partitioned table's columns): don't recreate it as a + // standalone action (which would duplicate it and fail apply), but still + // descend for any non-inlined descendants. Mirrors the added-create loop. + if (producerOf.has(childKey)) { + recreate(child.id); + continue; + } + recreatedByReplace.add(childKey); + emitCreate(child, projectedDesired); + recreate(child.id); + } + }; + recreate(newFact.id); + } + + // creates — parents first, so a parent's delta-set inlining (e.g. a + // partitioned table's columns rendered inside its CREATE, registered via + // alsoProduces) is visible before its children are considered + const depthOf = (fact: Fact): number => { + let depth = 0; + let parent = fact.parent; + while (parent !== undefined) { + depth++; + parent = desired.get(parent)?.parent; + } + return depth; + }; + for (const fact of [...added.values()].sort( + (a, b) => depthOf(a) - depthOf(b), + )) { + if (producerOf.has(encodeId(fact.id))) continue; + // EMISSION sees the PROJECTED plan target, not full `desired`: a child fact + // whose own add delta was policy-filtered (a column's DEFAULT, a partitioned + // table's column, a composite type's attribute, a publication's relation) is + // absent here, so create rules cannot inline it via `alsoProduces` (review + // P1 #1). buildActionGraph still reads un-projected `desired` for the + // missing-requirement invariant — the two views are deliberately distinct + // (docs/roadmap/tier-3-engine-refactors.md §1). + emitCreate(fact, projectedDesired); + } + + // create-produces-its-fact invariant (review architecture rec #2): every + // worklist `add` must be materialized by a producing action — either its own + // create or a parent's `alsoProduces` inlining. A create rule that returns no + // producing action would otherwise surface only later as a missing-requirement + // (or, like the role-config bug, silently lose payload). This is the + // STRUCTURAL floor of "a create materializes the desired fact"; full per-kind + // payload materialization (e.g. role GUC config) is each create rule's own + // contract, pinned by its rule test. + for (const fact of added.values()) { + if (!producerOf.has(encodeId(fact.id))) { + throw new Error( + `emit invariant: added fact ${encodeId(fact.id)} (kind '${fact.id.kind}') ` + + `is not materialized by any create action — its create rule must produce it`, + ); + } + } + + // default-privilege hygiene: objects created under active default ACLs receive + // implicit grants; revoke them when the desired state has no corresponding acl + // fact (pg_dump-style clean slate). EMISSION reads the PROJECTED plan target, + // not full `desired` (review P1 #3): a policy can filter the default-privilege + // add AND its grantee role, and the hygiene REVOKE must not surface a + // filtered-away role (which would then fail the planner's own + // missing-requirement check). Mirrors the create/alter seam. + // + // Hygiene covers every fact this plan CREATES on the target: added facts AND + // replaced facts (drop + recreate) with their replace-recreated descendants — + // a recreate fires active default ACLs exactly like a fresh create. The ADP + // itself may be UNCHANGED (present on both sides, no delta) yet still inject + // a grant the desired object never had, because on the source the object + // predated the ADP (regression: the Supabase baseline's replaced + // extensions.grant_pg_net_access() acquired a stale `postgres` grant from the + // image's pre-existing default privileges). + const hygieneTargets: Fact[] = [...added.values()]; + for (const key of [...replaceIds, ...recreatedByReplace]) { + const fact = projectedDesired.getByEncoded(key); + if (fact) hygieneTargets.push(fact); + } + for (const fact of hygieneTargets) { + // which pg_default_acl objtype this kind maps to is declared per-kind in the + // rule table (`defaclObjtype`); absent → no default ACLs + const objtype = ruleFlag(fact.id.kind, "defaclObjtype"); + if (objtype === undefined) continue; + // a created object whose fact is absent from the projected target (its add + // was effectively reverted) has no hygiene to do + if (!projectedDesired.has(fact.id)) continue; + // owner is now an edge, not a payload field (move 2) + const ownerEdge = projectedDesired + .outgoingEdges(fact.id) + .find((e) => e.kind === "owner"); + const owner = + ownerEdge?.to.kind === "role" + ? (ownerEdge.to as { kind: "role"; name: string }).name + : undefined; + if (typeof owner !== "string") continue; + const schema = (fact.id as { schema?: string }).schema ?? null; + for (const dp of projectedDesired.facts()) { + if (dp.id.kind !== "defaultPrivilege") continue; + const dpid = dp.id as { + role: string; + schema: string | null; + objtype: string; + grantee: string; + }; + if (dpid.role !== owner || dpid.objtype !== objtype) continue; + if (dpid.schema != null && dpid.schema !== schema) continue; + if (dpid.grantee === owner) continue; // the owner's implicit entry IS the default + const aclId: StableId = { + kind: "acl", + target: fact.id, + grantee: dpid.grantee, + }; + // an explicit acl in the PROJECTED target recreates the grant with a + // REVOKE-first, so hygiene would be redundant (and a filtered acl is + // correctly absent here → hygiene still fires) + if (projectedDesired.has(aclId)) continue; + pushAction( + "alter", + { + sql: `REVOKE ALL ON ${grantTarget(fact.id)} FROM ${dpid.grantee === "PUBLIC" ? "PUBLIC" : qid(dpid.grantee)}`, + consumes: + dpid.grantee === "PUBLIC" + ? [] + : [{ kind: "role", name: dpid.grantee } as StableId], + }, + { consumes: [fact.id] }, + ); + } + } + + // drops (suppressed children fold into their root's destroys) + const destroysByRoot = new Map(); + for (const [key, fact] of removed) { + const root = dropRootOf.get(key) as string; + const list = destroysByRoot.get(root) ?? []; + list.push(fact.id); + destroysByRoot.set(root, list); + } + for (const [key, fact] of removed) { + if (dropRootOf.get(key) !== key) continue; // suppressed + if (replaceIds.has(key)) continue; // replace handles its own drop + // pass the resolved SOURCE view so a drop rule can read the fact's context + // (e.g. DROP EXTENSION derives data-loss from its members' edges). + const spec = rulesForId(fact.id).drop(fact, source); + const destroyList = destroysByRoot.get(key) ?? [fact.id]; + pushAction("drop", spec, { + consumes: fact.parent !== undefined ? [fact.parent] : [], + // the root fact leads: it is the action's subject (tie-break, locks) + destroys: [fact.id, ...destroyList.filter((id) => encodeId(id) !== key)], + }); + } + + // in-place alters (skipped for facts a replace already recreated) + for (const [key, sets] of setsByFact) { + if (replaceIds.has(key) || recreatedByReplace.has(key)) continue; + // alters also render against the PROJECTED plan target: an alter that inlines + // a child reference (ALTER COLUMN … TYPE … re-applying the desired DEFAULT, + // REPLICA IDENTITY USING a desired index) must not surface a filtered-out + // child (review P1 #1). `source` stays as the from-state for the rule. + const fact = projectedDesired.get(sets[0]!.id) as Fact; + const rules = rulesForId(fact.id); + for (const s of sets) { + const attrRule = rules.attributes[s.attr]; + if (attrRule === undefined || attrRule === "replace") continue; + const specs = attrRule.alter( + fact, + s.from, + s.to, + projectedDesired, + source, + ); + for (const spec of Array.isArray(specs) ? specs : [specs]) { + pushAction("alter", spec, { consumes: [fact.id] }); + } + } + } + + // role-rename changed-pair mutations (review P2, fourth follow-up): the role + // rename carries the fact's IDENTITY by OID, so emit only the PAYLOAD change + // against the post-rename id — never old-name teardown + new-name create. The + // renamed roles the new id references (`orderingConsumes`) order every emitted + // action AFTER the `ALTER ROLE … RENAME` that produces them. We must NOT + // consume the carried fact id itself: it is neither in `source` nor produced + // by any action, so buildActionGraph would flag it missing. + for (const { toFact, fromPayload, orderingConsumes } of changedRoleFacts) { + const rules = rulesForId(toFact.id); + const alterSpecs: ActionSpec[] = []; + let needsReplace = false; + const attrs = new Set([ + ...Object.keys(fromPayload), + ...Object.keys(toFact.payload), + ]); + for (const attr of attrs) { + const from = fromPayload[attr]; + const to = toFact.payload[attr]; + const canon = (v: typeof from): string => + v === undefined ? " absent" : canonicalize(v); + if (canon(from) === canon(to)) continue; + const attrRule = rules.attributes[attr]; + if (attrRule === undefined || attrRule === "replace") { + // replace-shaped attr (acl/defaultPrivilege): the whole fact is replaced + needsReplace = true; + continue; + } + const specs = attrRule.alter(toFact, from, to, projectedDesired, source); + alterSpecs.push(...(Array.isArray(specs) ? specs : [specs])); + } + if (needsReplace) { + // drop+create against the carried (post-rename) id. The drop rule reads + // only fact.id (no `source` lookup), so it works although `to` is absent + // from source; "destroy before re-produce" orders the drop before create. + pushAction("drop", rules.drop(toFact), { + destroys: [toFact.id], + consumes: orderingConsumes, + }); + const createSpecs = rules.create( + toFact, + projectedDesired, + paramsFor(toFact), + source, + ); + createSpecs.forEach((spec, i) => { + pushAction("create", spec, { + produces: i === 0 ? [toFact.id] : [], + consumes: [...(i === 0 ? [] : [toFact.id]), ...orderingConsumes], + }); + }); + } else { + for (const spec of alterSpecs) { + pushAction("alter", spec, { consumes: orderingConsumes }); + } + } + } + + // owner-edge changes: emit ALTER … OWNER TO from link/unlink deltas (move 2: + // owner is now an edge, not a payload attribute) + { + // collect old owner roles per fact so the link action can release them + const oldOwnerByFact = new Map(); + for (const delta of deltas) { + if (delta.verb !== "unlink" || delta.edge.kind !== "owner") continue; + oldOwnerByFact.set(encodeId(delta.edge.from), delta.edge.to); + } + // `roleRenameMap` (source role name → dest) is reused: a table owned by + // `old` and renamed alongside keeps the SAME owner OID, surfacing in + // `desired` as `new` — so the owner is CARRIED by the two renames, not + // changed. Accepted renames carry ownership: `ALTER … RENAME` never changes + // the owner, so the renamed subtree's owner edge resurfaces as a fresh link + // in the desired base even when nothing changed. Map each renamed-to id to + // the owner its rename-from counterpart held in source — projected THROUGH + // any accepted role rename, so a table+owner-role pair both renamed reads as + // an unchanged owner (the renames carry it; no `ALTER … OWNER TO`, and no + // rename/rename cycle — review P1 #2). A genuinely changed owner still + // emits. Subtree ids zip by index — the rename matched on a structural + // rollup. + const renamedOwner = new Map(); + // and the OLD owner's StableId, so a genuinely-changed owner's link action + // can `releases` it (the source-side unlink is keyed by the OLD id, which + // the destination link never looks up — review P1 #1: drop old role too + // early). + const renamedOwnerId = new Map(); + for (const { from, to } of acceptedRenames) { + const srcIds = subtreeIds(source, from.id); + const dstIds = subtreeIds(desired, to.id); + for (let i = 0; i < dstIds.length; i++) { + const srcId = srcIds[i]; + const dstId = dstIds[i]; + if (srcId === undefined || dstId === undefined) continue; + const ownerEdge = source + .outgoingEdges(srcId) + .find((e) => e.kind === "owner"); + if (ownerEdge?.to.kind !== "role") { + renamedOwner.set(encodeId(dstId), null); + continue; + } + const srcOwnerName = (ownerEdge.to as { name: string }).name; + renamedOwner.set( + encodeId(dstId), + roleRenameMap.get(srcOwnerName) ?? srcOwnerName, + ); + renamedOwnerId.set(encodeId(dstId), ownerEdge.to); + } + } + // objKeys whose owner a link delta already (re-)established below, so the + // replaced-fact pass does not emit a second ALTER … OWNER TO for them. + const ownerEmitted = new Set(); + for (const delta of deltas) { + if (delta.verb !== "link" || delta.edge.kind !== "owner") continue; + const objId = delta.edge.from; + const objKey = encodeId(objId); + // Created objects need this too: create no longer sets the owner (move 2), + // so a fresh object owned by a non-applier role needs an explicit + // ALTER … OWNER TO, ordered after its create (consumes: [objId]) and after + // the role. An owner role projected out of the view has no edge here (it + // was pruned), so the object is left applier-owned — skipAuthorization + // elimination falls out for free. + const fact = desired.get(objId); + if (!fact) continue; + const ownerAlterPrefix = ruleFlag(fact.id.kind, "ownerAlterPrefix"); + if (!ownerAlterPrefix) continue; + const prefix = ownerAlterPrefix(fact); + const newRoleId = delta.edge.to; + if (newRoleId.kind !== "role") continue; + const roleName = (newRoleId as { kind: "role"; name: string }).name; + // ownership carried unchanged by an accepted OBJECT rename (the object id + // changed; renamedOwner maps it through any role rename) — no action + if (renamedOwner.get(objKey) === roleName) continue; + // ownership carried by an accepted ROLE rename on a STABLE object: the + // owner edge relinks r1→r2 on the same id, but PostgreSQL carries it by + // OID. Skip BEFORE the capability check — there is no owner action to + // authorize (third follow-up review P1: role-only rename cycle / false + // capability failure). The general role-rename carry seam decided this. + if (carriedOwnerLinks.has(ownerEdgeKey(objId, newRoleId))) continue; + // Owner residue (move 6): `ALTER … OWNER TO R` requires the applier to be + // a superuser or a member of R. If a capability is supplied and the + // applier cannot, fail fast at plan time with an actionable message — + // surfaced before any statement runs, and avoiding a non-converging + // "leave it applier-owned" (the owner is acldefault-relative). Unset only + // for owner CHANGES/creates (this is an owner link delta), not pre-existing + // unchanged ownership. + if (capability !== undefined && !canSetOwner(capability, roleName)) { + throw new Error( + `capability: cannot set owner of ${encodeId(objId)} to role "${roleName}" — applier "${capability.role}" is not a superuser or a member of that role; grant membership or apply as a member/superuser`, + ); + } + // for an accepted rename the source-side owner unlink is keyed by the OLD + // id, so `oldOwnerByFact` (keyed by the link's `from`, i.e. the NEW id) has + // no entry — fall back to the owner the renamed subtree carried in source + // (review P1 #1), so the release edge orders this before the old role drop. + const oldRoleId = + oldOwnerByFact.get(objKey) ?? renamedOwnerId.get(objKey); + pushAction( + "alter", + { + sql: `${prefix} OWNER TO ${qid(roleName)}`, + consumes: [newRoleId], + ...(oldRoleId !== undefined ? { releases: [oldRoleId] } : {}), + }, + { consumes: [objId] }, + ); + ownerEmitted.add(objKey); + } + + // Replaced facts (drop + recreate) revert to the applying role's ownership; + // their owner edge is UNCHANGED source->target so it produced no owner link + // delta above. Re-establish it from the PROJECTED target for every replaced + // fact (and any descendant a replace recreated) a link delta did not already + // own — mirroring how the replace loop recreates child ACL facts. Without + // this, a function/type/table whose body/definition changed is silently + // re-owned to whoever runs the migration (regression: Supabase auth.uid() et + // al., owned by supabase_auth_admin, reverted to the applier after replace). + for (const key of [...replaceIds, ...recreatedByReplace]) { + if (ownerEmitted.has(key)) continue; + const fact = projectedDesired.getByEncoded(key); + if (!fact) continue; + const ownerAlterPrefix = ruleFlag(fact.id.kind, "ownerAlterPrefix"); + if (!ownerAlterPrefix) continue; + const ownerEdge = projectedDesired + .outgoingEdges(fact.id) + .find((e) => e.kind === "owner"); + if (ownerEdge?.to.kind !== "role") continue; + const roleName = (ownerEdge.to as { kind: "role"; name: string }).name; + if (capability !== undefined && !canSetOwner(capability, roleName)) { + throw new Error( + `capability: cannot set owner of ${key} to role "${roleName}" — ` + + `applier "${capability.role}" is not a superuser or a member of that role; ` + + `grant membership or apply as a member/superuser`, + ); + } + pushAction( + "alter", + { + sql: `${ownerAlterPrefix(fact)} OWNER TO ${qid(roleName)}`, + consumes: [ownerEdge.to], + }, + { consumes: [fact.id] }, + ); + } + } + + return { + actions, + producerOf, + destroyerOf, + foldHints, + acceptsFolds, + renameActionIndices, + }; +} diff --git a/packages/pg-delta/src/plan/phases/action-graph.ts b/packages/pg-delta/src/plan/phases/action-graph.ts new file mode 100644 index 000000000..f9129327a --- /dev/null +++ b/packages/pg-delta/src/plan/phases/action-graph.ts @@ -0,0 +1,199 @@ +/** + * Planner phase 4 — ActionGraph (target-architecture §3.5–3.6). + * + * Turns the emitted action list into a deterministically ordered, compacted + * final list plus the aggregated safety report. Pure over its inputs (the + * emitted actions + producer/destroyer indexes + the two fact bases); the graph + * construction, topo sort, segment-boundary marking, and compaction building + * blocks live in ../internal.ts. Extracted so the ordering/compaction stage is a + * named phase boundary rather than a tail of `plan()`. + */ +import type { FactBase } from "../../core/fact.ts"; +import type { Action, SafetyReport } from "../plan.ts"; +import { topoSort } from "../graph.ts"; +import type { StableId } from "../../core/stable-id.ts"; +import type { ApplierCapability } from "../../policy/capability.ts"; +import type { RulesForId } from "../rules.ts"; +import { + actionTieKey, + buildActionGraph, + compactColumnFolds, + computeSafetyReport, + elideCascadeSubsumedPolicyDrops, + elideCoCreateRevokeBeforeGrant, + elideDefaultAclCreates, + elideRedundantDrops, + foldCoCreateOwnership, +} from "../internal.ts"; + +export interface FinalizeInput { + actions: Action[]; + producerOf: ReadonlyMap; + destroyerOf: ReadonlyMap; + /** resolved source / desired views (NOT the projected target): graph build + * order reads desired edges, teardown reads source edges. */ + source: FactBase; + desired: FactBase; + renameActionIndices: ReadonlySet; + /** per-action compaction metadata captured during emission (never persisted). */ + foldHints: ReadonlyArray<{ foldInto: StableId; clause: string } | undefined>; + acceptsFolds: readonly boolean[]; + /** policy-declared roles assumed to exist at apply time (e.g. Supabase + * anon/authenticated) — exempt from the missing-requirement guard just like + * the `pg_` prefix and PUBLIC. Empty under the raw/no-policy path. */ + assumedRoleNames: ReadonlySet; + /** policy-declared schemas assumed to exist at apply time (e.g. Supabase's + * `extensions`) — exempt from the missing-requirement guard like the assumed + * roles. Empty under the raw/no-policy path. */ + assumedSchemaNames: ReadonlySet; + /** applier capability (move 6) — needed by the co-create compaction passes: + * the owner-ALTER no-op elision and the REVOKE-before-GRANT superset guard key + * off `capability.role`. Undefined under the unrestricted (superuser/CI/raw) + * path, where those capability-gated elisions stay conservative. */ + capability: ApplierCapability | undefined; + /** §3.6 compaction; cosmetic-by-contract (proof unchanged). Default true. */ + compact: boolean; + /** Export-only constraint folding: apply the constraint rules' inline-fold + * hints (CONSTRAINT name into the table's CREATE parens), excluding + * the given encoded constraint ids (cycle-participating FKs). Undefined + * (the default, and every non-export path) leaves those hints inert. */ + foldConstraints: { exclude?: ReadonlySet } | undefined; + /** id-keyed rule resolver (schema kinds + `extensionIntent`), used by the + * tie-break so intent actions sort on their declared late weight. */ + rulesForId: RulesForId; +} + +export interface FinalizeOutput { + actions: Action[]; + safetyReport: SafetyReport; +} + +/** + * Order, segment-mark, and compact the emitted actions; compute the safety + * report. Behavior-preserving extraction of `plan()`'s graph/order/compaction + * tail. + */ +export function finalizeActions(input: FinalizeInput): FinalizeOutput { + const { + actions, + producerOf, + destroyerOf, + source, + desired, + renameActionIndices, + foldHints, + acceptsFolds, + assumedRoleNames, + assumedSchemaNames, + capability, + compact, + foldConstraints, + rulesForId, + } = input; + + // ── graph edges + deterministic order ───────────────────────────────── + const edges = buildActionGraph( + actions, + producerOf, + destroyerOf, + source, + desired, + renameActionIndices, + assumedRoleNames, + assumedSchemaNames, + ); + + // Order a table's ADD COLUMN creates by declared column position + // (pg_attribute.attnum, carried as the non-semantic `_position` field) instead + // of column NAME, so a from-empty CREATE renders columns in declared order — + // and the compaction pass, which folds them into the CREATE parens in this + // order, inherits it. Only column CREATES are affected; drops/alters and every + // other kind keep their encoded-id tie-break. + const columnSubjectKey = ( + subject: StableId, + action: Action, + ): string | undefined => { + if (action.verb !== "create" || subject.kind !== "column") return undefined; + const pos = desired.get(subject)?.payload["_position"]; + if (typeof pos !== "number") return undefined; + const c = subject as { schema: string; table: string; name: string }; + // Group each table's columns together (schema+table prefix), then order + // within by zero-padded attnum. Every column create uses this same shape, so + // the total order stays deterministic. + return `column\x00${c.schema}\x00${c.table}\x00${String(pos).padStart(6, "0")}`; + }; + const order = topoSort( + actions.length, + edges, + (i) => actionTieKey(actions, i, rulesForId, columnSubjectKey), + (i) => (actions[i] as Action).sql, + ); + + // ── commitBoundaryAfter segment boundary (§3.8) ─────────────────────── + // Mark the FIRST graph successor of each commitBoundaryAfter action with + // newSegmentBefore. apply.ts already closes the segment unconditionally after + // a commitBoundaryAfter action (review #6), so this flag's load-bearing role + // now is COMPACTION PROTECTION — compaction refuses to fold a clause across a + // newSegmentBefore boundary. This loop is the sole producer of the flag. + const positionOf = Array.from({ length: actions.length }, () => 0); + order.forEach((actionIndex, position) => { + positionOf[actionIndex] = position; + }); + const orderedActions = order.map((i) => actions[i] as Action); + for (let u = 0; u < actions.length; u++) { + if ((actions[u] as Action).transactionality !== "commitBoundaryAfter") + continue; + let firstConsumerPos = Number.POSITIVE_INFINITY; + for (const [a, b] of edges) { + if (a !== u) continue; + const pos = positionOf[b] as number; + if (pos < firstConsumerPos) firstConsumerPos = pos; + } + if (Number.isFinite(firstConsumerPos)) { + (orderedActions[firstConsumerPos] as Action).newSegmentBefore = true; + } + } + + // ── compaction (§3.6) ───────────────────────────────────────────────── + // fold ADD COLUMN clauses into their bare CREATE TABLE (no edge may cross the + // merge), drop a replace's redundant drop when the create reproduces the + // identical statement, elide REVOKE/GRANT pairs that only re-materialize a + // freshly-created object's built-in default ACL, trim the cosmetic leading + // REVOKE off remaining third-party co-create grants, then fold a co-created + // object's owner ALTER into its CREATE (CREATE SCHEMA … AUTHORIZATION, or drop + // an applier-redundant ALTER). Purely cosmetic — the proof is unchanged. + const finalActions = compact + ? foldCoCreateOwnership( + elideCoCreateRevokeBeforeGrant( + elideDefaultAclCreates( + elideCascadeSubsumedPolicyDrops( + elideRedundantDrops( + compactColumnFolds( + orderedActions, + order, + edges, + foldHints, + acceptsFolds, + positionOf, + foldConstraints, + ), + source, + ), + source, + ), + desired, + capability, + ), + desired, + capability, + ), + desired, + capability, + ) + : orderedActions; + + return { + actions: finalActions, + safetyReport: computeSafetyReport(finalActions), + }; +} diff --git a/packages/pg-delta/src/plan/phases/change-set.ts b/packages/pg-delta/src/plan/phases/change-set.ts new file mode 100644 index 000000000..f3e288f04 --- /dev/null +++ b/packages/pg-delta/src/plan/phases/change-set.ts @@ -0,0 +1,228 @@ +/** + * Planner phase 1 — ChangeSet (target-architecture §3.4, §3.9, §4.1). + * + * Resolves the managed VIEW (baseline subtraction + policy/extension-member + * projection) on both sides, diffs, applies the policy delta filter, and groups + * the kept deltas into added/removed/set worklists. Then it cancels what an + * accepted rename or a role rename carries — so replacement expansion and action + * emission never see a delta the rename already accounts for. Pure over its + * inputs; the resolved views + worklists + rename bookkeeping it returns are the + * single input to the rest of the planner. + */ +import { diff, type Delta } from "../../core/diff.ts"; +import type { Fact, FactBase } from "../../core/fact.ts"; +import type { Payload } from "../../core/hash.ts"; +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import { filterDeltas, validatePolicy } from "../../policy/policy.ts"; +import { reconstructManagedView } from "../../policy/reconstruct.ts"; +import type { PlanOptions } from "../plan.ts"; +import type { RulesForId } from "../rules.ts"; +import { projectTarget } from "../project.ts"; +import { + matchRenameCandidates, + subtreeIds, + type RenameCandidate, + type RenameMode, +} from "../renames.ts"; +import { + buildRoleRenameMap, + computeRoleRenameCarry, + roleNamesIn, +} from "../role-rename-carry.ts"; + +/** A role-name-bearing fact whose identity a role rename carries but whose + * payload also changed: emit the payload change against the post-rename id, + * ordered after the rename (orderingConsumes). */ +export interface ChangedRoleFact { + toFact: Fact; + fromPayload: Payload; + orderingConsumes: StableId[]; +} + +export interface ChangeSet { + /** resolved (managed-view) source / desired — what everything downstream uses */ + source: FactBase; + desired: FactBase; + /** desired with every FILTERED delta reverted to source — the honest plan + * target (fingerprint + proof target) */ + projectedDesired: FactBase; + deltas: Delta[]; + filteredDeltas: Delta[]; + /** add/remove worklists (rename + role-rename cancellation already applied) + * and set-deltas grouped by encoded fact id */ + removed: Map; + added: Map; + setsByFact: Map[]>; + renameCandidates: RenameCandidate[]; + acceptedRenames: Array<{ from: Fact; to: Fact }>; + /** source-role-name → dest-role-name, from accepted role renames */ + roleRenameMap: Map; + /** owner LINK edge keys a role rename carries (skip in the owner loop) */ + carriedOwnerLinks: Set; + changedRoleFacts: ChangedRoleFact[]; +} + +/** + * Build the change set: resolve views, diff, filter, group, and apply rename / + * role-rename cancellation. Behavior-preserving extraction of `plan()`'s head. + */ +export function buildChangeSet( + rawSource: FactBase, + rawDesired: FactBase, + options: PlanOptions | undefined, + rulesForId: RulesForId, +): ChangeSet { + if (options?.policy) validatePolicy(options.policy); + // a declared baseline must NEVER be silently ignored (review finding 3): if + // the policy names a baseline, the caller must resolve it (resolveBaseline) + // and pass it as options.baseline. Refuse otherwise — at every entry point. + if ( + options?.policy?.baseline !== undefined && + options.baseline === undefined + ) { + throw new Error( + `plan: policy "${options.policy.id}" declares baseline "${options.policy.baseline}" ` + + `but no resolved baseline was provided. Resolve it with ` + + `resolveBaseline(policy, { pgMajor }) and pass it as options.baseline, so ` + + `platform facts are actually subtracted — a declared baseline is never silently ignored.`, + ); + } + // the managed VIEW the engine diffs (docs/architecture/managed-view-architecture.md): + // the platform baseline is subtracted, then the policy's scope (non-`verb`) + // rules are projected out and extension members are marked reference-only, at + // the FACT level on BOTH sides, so the proof stays honest by construction. + // `verb` rules remain for the delta-level filter below. With no policy/baseline + // and no member edges this is the identity projection, so the corpus is unchanged. + // + // Managed view under scope: `reconstructManagedView` seals resolveView THEN + // projectManagementScope (owner edges must survive for policy exclusion before + // database-scope role prune). Plan / apply / prove / export share that helper + // so `plan == prove == run`. `scope` defaults to "cluster" (identity). + const source = reconstructManagedView(rawSource, { + policy: options?.policy, + capability: options?.capability, + baseline: options?.baseline, + scope: options?.scope, + defaultOwner: options?.defaultOwner, + }); + const desired = reconstructManagedView(rawDesired, { + policy: options?.policy, + capability: options?.capability, + baseline: options?.baseline, + scope: options?.scope, + defaultOwner: options?.defaultOwner, + }); + + const allDeltas = diff(source, desired); + const { kept: deltas, filtered: filteredDeltas } = options?.policy + ? filterDeltas(allDeltas, options.policy, source, desired) + : { kept: allDeltas, filtered: [] }; + // the honest plan target: `desired` with every FILTERED delta reverted to its + // source value, since the plan only applies KEPT deltas (review #2). The + // fingerprint and the proof both target THIS, not full `desired`. + const projectedDesired = projectTarget(desired, filteredDeltas); + + const removed = new Map(); + const added = new Map(); + const setsByFact = new Map[]>(); + for (const delta of deltas) { + if (delta.verb === "remove") + removed.set(encodeId(delta.fact.id), delta.fact); + if (delta.verb === "add") added.set(encodeId(delta.fact.id), delta.fact); + if (delta.verb === "set") { + const key = encodeId(delta.id); + const list = setsByFact.get(key) ?? []; + list.push(delta); + setsByFact.set(key, list); + } + } + + // ── rename detection (§4.1, stage 9) ────────────────────────────────── + // accepted renames cancel their remove/add subtrees BEFORE replace, rebuild, + // and suppression see them; the rename action is emitted later. + const renameMode: RenameMode = options?.renames ?? "off"; + const renameCandidates: RenameCandidate[] = []; + const acceptedRenames: Array<{ from: Fact; to: Fact }> = []; + if (renameMode !== "off") { + const candidates = matchRenameCandidates( + removed, + added, + source, + desired, + rulesForId, + ); + renameCandidates.push(...candidates); + const confirmed = new Set( + (options?.acceptRenames ?? []).map( + (r) => `${encodeId(r.from)}>${encodeId(r.to)}`, + ), + ); + for (const candidate of candidates) { + if (candidate.status !== "unambiguous") continue; + const key = `${encodeId(candidate.from)}>${encodeId(candidate.to)}`; + if (renameMode === "prompt" && !confirmed.has(key)) continue; + const fromFact = removed.get(encodeId(candidate.from)) as Fact; + const toFact = added.get(encodeId(candidate.to)) as Fact; + // structural equality covers the whole subtree: cancel every descendant's + // remove/add — the rename carries them implicitly + for (const id of subtreeIds(source, candidate.from)) + removed.delete(encodeId(id)); + for (const id of subtreeIds(desired, candidate.to)) + added.delete(encodeId(id)); + acceptedRenames.push({ from: fromFact, to: toFact }); + } + } + + // ── role-rename carry (role-rename-carry.ts) ────────────────────────── + // PostgreSQL carries every role-name-bearing fact through `ALTER ROLE … + // RENAME` by OID. The diff still surfaces those as remove/add (or owner + // unlink/link) pairs differing only by the renamed name; this Module decides, + // in ONE place, which the rename carries so emission re-issues no DDL for + // them. carriedFactKeys (acl/membership/userMapping/defaultPrivilege) are + // cancelled from the worklists here; carriedOwnerLinks are skipped in the + // owner-edge loop later (where the role-only-rename owner cycle lived). + const roleRenameMap = buildRoleRenameMap(acceptedRenames); + const { carriedFactKeys, carriedOwnerLinks, changedFacts } = + computeRoleRenameCarry(deltas, roleRenameMap); + for (const key of carriedFactKeys) { + removed.delete(key); + added.delete(key); + } + // A changed pair carries the IDENTITY (old name → new name by OID) but the + // payload also changed. Cancel the old-name teardown AND the new-name create, + // and capture the facts so emission can mutate the post-rename id instead + // (review P2, fourth follow-up). The renamed roles the new id references order + // that mutation AFTER the role rename. + const targetRoleNames = new Set(roleRenameMap.values()); + const changedRoleFacts: ChangedRoleFact[] = []; + for (const { from, to } of changedFacts) { + const fromFact = removed.get(encodeId(from)); + const toFact = added.get(encodeId(to)); + removed.delete(encodeId(from)); + added.delete(encodeId(to)); + if (fromFact === undefined || toFact === undefined) continue; + changedRoleFacts.push({ + toFact, + fromPayload: fromFact.payload, + orderingConsumes: [...roleNamesIn(to)] + .filter((name) => targetRoleNames.has(name)) + .map((name) => ({ kind: "role", name }) as StableId), + }); + } + + return { + source, + desired, + projectedDesired, + deltas, + filteredDeltas, + removed, + added, + setsByFact, + renameCandidates, + acceptedRenames, + roleRenameMap, + carriedOwnerLinks, + changedRoleFacts, + }; +} diff --git a/packages/pg-delta/src/plan/phases/replacement-expansion.ts b/packages/pg-delta/src/plan/phases/replacement-expansion.ts new file mode 100644 index 000000000..226c94b00 --- /dev/null +++ b/packages/pg-delta/src/plan/phases/replacement-expansion.ts @@ -0,0 +1,180 @@ +/** + * Planner phase 2 — ReplacementExpansion (target-architecture §3.4–3.5). + * + * Given the grouped change set, decides which facts are REPLACED (drop + + * recreate) versus altered in place, expands the forced dependent rebuild, and + * computes drop-root suppression/redirect. Pure over its inputs; produces the + * `replaceIds` set and the `dropRootOf` map the emitter consumes. Extracted from + * `plan()` so the replace/rebuild/suppression invariants live behind one named + * boundary instead of inline. + */ +import type { Delta } from "../../core/diff.ts"; +import type { Fact, FactBase } from "../../core/fact.ts"; +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import { cascadesToChildren, isRebuildable } from "../rule-flags.ts"; +import type { RulesForId } from "../rules.ts"; + +export interface ReplacementExpansionInput { + /** removed facts keyed by encoded id (rename/role-rename cancellation applied) */ + removed: ReadonlyMap; + /** set-deltas grouped by encoded fact id */ + setsByFact: ReadonlyMap[]>; + /** resolved source / desired views */ + source: FactBase; + desired: FactBase; + /** id-keyed rule resolver (schema kinds + `extensionIntent`) */ + rulesForId: RulesForId; +} + +export interface ReplacementExpansion { + /** encoded ids the planner replaces (drop old + recreate from desired) */ + replaceIds: Set; + /** encoded id → encoded id of the drop action that subsumes it (suppression) */ + dropRootOf: Map; +} + +/** + * Classify set-deltas (in-place alter vs replace), expand the forced dependent + * rebuild, then compute drop-root suppression + redirect. Behavior-preserving + * extraction of `plan()`'s replacement/suppression block. + */ +export function expandReplacements( + input: ReplacementExpansionInput, +): ReplacementExpansion { + const { removed, setsByFact, source, desired, rulesForId } = input; + + // ── classify set-deltas: in-place alter vs replace ──────────────────── + const replaceIds = new Set(); + // alters that invalidate dependents (e.g. an enum value-set replacement, or an + // ALTER COLUMN TYPE that views/policies reference) seed the forced-rebuild + // pass without replacing the fact itself. The value is the set of dependent + // kinds to rebuild (null = all rebuildable kinds). + const rebuildSeeds = new Map | null>(); + for (const [key, sets] of setsByFact) { + const fact = desired.get(sets[0]!.id) as Fact; + const rules = rulesForId(fact.id); + for (const s of sets) { + const attrRule = rules.attributes[s.attr]; + if (attrRule === undefined) { + throw new Error( + `rule table: kind '${fact.id.kind}' has no rule for attribute '${s.attr}' (${key}) — extend the rule vocabulary (guardrail 3)`, + ); + } + if (attrRule === "replace") { + replaceIds.add(key); + continue; + } + // a transition with no in-place ALTER grammar routes the whole fact to + // replace (drop + recreate) — its `alter` is never rendered. + if (attrRule.replaceWhen?.(s.from, s.to, fact)) { + replaceIds.add(key); + continue; + } + const rebuild = attrRule.rebuildsDependents?.(s.from, s.to); + if (rebuild === true) rebuildSeeds.set(key, null); + else if (Array.isArray(rebuild)) rebuildSeeds.set(key, new Set(rebuild)); + } + } + + // ── forced dependent rebuild (the clean expand-replace, §3.4) ───────── + // A surviving dependent of something this plan destroys must be dropped and + // recreated from the desired state — recursively. Which kinds are rebuildable + // is declared per-kind in the rule table (`rebuildable`). + { + // `fullDestroy` ids rebuild EVERY rebuildable dependent; `rebuildSeeds` (an + // in-place alter that invalidates only some dependent kinds) rebuild only + // their declared kinds. Once a dependent is rebuilt it joins `fullDestroy`, + // so its own subtree rebuilds completely. + const fullDestroy = new Set([...removed.keys(), ...replaceIds]); + const targets = new Set([...fullDestroy, ...rebuildSeeds.keys()]); + // Reverse-dependency reachability from the initial targets, instead of + // rescanning every source edge each fixpoint round (O(reachable) vs + // O(edges × rounds)). Same checks/precedence as the fixpoint: a dependent of + // a destroyed/replaced fact (or a kind-restricted seed) that is rebuildable + // and survives in `desired` is replaced, and itself becomes a full-destroy + // target so its own subtree rebuilds. + const worklist = [...targets]; + while (worklist.length > 0) { + const toKey = worklist.pop() as string; + for (const edge of source.incomingEdgesByEncoded(toKey)) { + const fromKey = encodeId(edge.from); + if (targets.has(fromKey)) continue; + const dependent = source.get(edge.from); + if (!dependent || !desired.has(edge.from)) continue; + if (!isRebuildable(dependent.id.kind)) continue; + // reached only via a kind-restricted seed: honor the allowed kinds + if (!fullDestroy.has(toKey)) { + const allowed = rebuildSeeds.get(toKey); + if (allowed && !allowed.has(dependent.id.kind)) continue; + } + replaceIds.add(fromKey); + fullDestroy.add(fromKey); + targets.add(fromKey); + worklist.push(fromKey); + } + } + // descendants of replaced facts are handled by the ancestor's subtree + // recreate — keep only the topmost replaced facts. Deleting the entry under + // iteration is safe for a JS Set. + for (const key of replaceIds) { + const fact = source.getByEncoded(key); + let ancestor = fact?.parent; + while (ancestor !== undefined) { + if (replaceIds.has(encodeId(ancestor))) { + replaceIds.delete(key); + break; + } + ancestor = source.get(ancestor)?.parent; + } + } + } + + // ── suppression: child removals that cascade with an ancestor's drop ── + // dropRootOf(id) = nearest removed ancestor whose drop action will exist. FK + // constraint drops are NEVER suppressed: an explicit DROP CONSTRAINT before + // the table drops makes mutual-FK teardown cycles unconstructible + // (decomposition over repair, §3.5). + const isRemovedId = (id: StableId): boolean => { + const key = encodeId(id); + return removed.has(key) || replaceIds.has(key); + }; + const dropRootOf = new Map(); + const findDropRoot = (fact: Fact): string => { + const key = encodeId(fact.id); + const cached = dropRootOf.get(key); + if (cached) return cached; + let root = key; + const rules = rulesForId(fact.id); + const suppressible = rules.suppressible?.(fact) ?? true; + const parent = fact.parent; + if (parent !== undefined && suppressible) { + const parentRemoved = isRemovedId(parent); + // a metadata satellite folds into ANY removed parent; otherwise the parent + // kind must be one whose DROP cascades to children + const cascades = + rules.metadata === true || cascadesToChildren(parent.kind); + if (parentRemoved && cascades) { + root = findDropRoot( + removed.get(encodeId(parent)) ?? (source.get(parent) as Fact), + ); + } + } + dropRootOf.set(key, root); + return root; + }; + for (const fact of removed.values()) findDropRoot(fact); + + // a fact whose drop folds into a NON-parent ancestor (an OWNED BY sequence + // into its owning column/table) — declared per-kind via dropRootRedirect. + for (const fact of removed.values()) { + const redirect = rulesForId(fact.id).dropRootRedirect?.(fact, isRemovedId); + if (redirect === undefined) continue; + const redirectKey = encodeId(redirect); + dropRootOf.set( + encodeId(fact.id), + dropRootOf.get(redirectKey) ?? redirectKey, + ); + } + + return { replaceIds, dropRootOf }; +} diff --git a/packages/pg-delta/src/plan/plan.ts b/packages/pg-delta/src/plan/plan.ts new file mode 100644 index 000000000..a6f51dd08 --- /dev/null +++ b/packages/pg-delta/src/plan/plan.ts @@ -0,0 +1,561 @@ +/** + * The planner (target-architecture §3.4–3.6): deltas × rule table → atomic + * actions → one mixed dependency graph → one deterministic sort. + */ +import { INTENT_UNKEYED, USER_MAPPING_UNREADABLE } from "../core/diagnostic.ts"; +import { subjectOf, type Delta } from "../core/diff.ts"; +import type { FactBase } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { flattenPolicy, type Policy } from "../policy/policy.ts"; +import type { ApplierCapability } from "../policy/capability.ts"; +import type { ManagementScope } from "../policy/view.ts"; +import { emitActions } from "./phases/action-emitter.ts"; +import { finalizeActions } from "./phases/action-graph.ts"; +import { buildChangeSet } from "./phases/change-set.ts"; +import { expandReplacements } from "./phases/replacement-expansion.ts"; +import type { LockClass } from "./locks.ts"; +import type { RenameCandidate, RenameMode } from "./renames.ts"; +import { + buildRuleResolver, + type IntentRuleIndex, + KNOWN_PARAMS, + type PlanParams, +} from "./rules.ts"; + +/** Engine version stamped into plan artifacts; apply refuses artifacts + * from an engine it does not understand (stage 6 deliverable 1). */ +export const ENGINE_VERSION = "0.2.0"; + +// The plan-artifact (JSON serialize/parse) helpers live in ./artifact.ts but are +// part of this module's public surface: docs/getting-started.md imports them from +// `@supabase/pg-delta/plan` (the subpath that maps here), so the documented path +// must be real. Cycle-safe: artifact.ts's only import from this module is +// ENGINE_VERSION, which it reads inside a function body, never at module-eval time. +export { parsePlan, serializePlan } from "./artifact.ts"; + +export interface Action { + sql: string; + verb: "create" | "alter" | "drop"; + produces: StableId[]; + consumes: StableId[]; + destroys: StableId[]; + /** ids this action stops referencing — must run before their destroyer */ + releases: StableId[]; + /** three-valued transactionality (§3.8) */ + transactionality: + | "transactional" + | "nonTransactional" + | "commitBoundaryAfter"; + /** documented lock level of this DDL form — reported, never certified */ + lockClass: LockClass; + /** forces a COMMIT before this action. Set on the first consumer of a + * commitBoundaryAfter action; consumed BOTH by apply (a segment boundary — + * now belt-and-suspenders, since apply also closes the segment + * unconditionally after a commitBoundaryAfter action, review #6) AND by + * compaction, which must not fold a clause across this boundary + * (internal.ts). The latter is its load-bearing role today. */ + newSegmentBefore: boolean; + dataLoss: "none" | "destructive"; + rewriteRisk: boolean; +} + +/** Aggregated per-action safety metadata (§3.7). Lock classes and + * rewrite/data-loss counts; the proof loop turns dataLoss into a + * verified claim, lock classes stay reported. */ +export interface SafetyReport { + destructiveActions: number; + rewriteRiskActions: number; + nonTransactionalActions: number; + lockClasses: Partial>; +} + +export interface Plan { + formatVersion: 1; + engineVersion: string; + source: { fingerprint: string }; + target: { fingerprint: string }; + /** whether the source/desired fact bases were extracted with secret redaction + * on (the extract default). Stamped by the CLI so `apply`/`prove` re-extract + * the target with the SAME redaction mode for the fingerprint gate: a plan + * fingerprinted over unredacted secrets (`--unsafe-show-secrets`) would + * otherwise mismatch a default-redacted re-extract and fail the gate. Absent + * on direct library plans (corpus), which apply treats as the default (on). */ + redactSecrets?: boolean; + /** session settings the executor applies per transaction segment — + * explicit plan metadata, not loose SQL in the action list */ + preamble: { name: string; value: string }[]; + deltas: Delta[]; + /** deltas the policy filtered out — reported, never silently absent + * (§3.9): drift the user chose not to manage is still drift they can + * ask about */ + filteredDeltas: Delta[]; + /** the policy that shaped this plan, inlined for reproducibility */ + policy?: Policy; + /** the applier capability the plan was produced with (move 6 / follow-up 2), + * inlined so a later prove/apply recovers the SAME view. `memberOf` is an + * array → the artifact round-trips losslessly. */ + capability?: ApplierCapability; + /** the integration profile that produced this plan, stamped whenever the plan + * was produced through a resolved profile (always, via the CLI). `apply`/ + * `prove` default to this profile when `--profile` is omitted and reject a + * contradicting `--profile`, so the plan == prove == apply invariant is + * enforced by the artifact, not just by a comment. Absent only when `plan()` + * is called directly with no profile (the raw, no-integration library path — + * e.g. the corpus); such a plan is treated as `raw`. */ + profile?: { id: string }; + /** the DIGEST of the baseline subtracted from both sides, stamped whenever the + * plan was produced with a baseline (via a resolved profile). `apply`/`prove` + * reconcile the baseline they resolve against this digest and fail loud on a + * mismatch, so a swapped or edited baseline can't silently diff a different + * view. Absent when no baseline was in effect. */ + baseline?: { digest: string }; + /** the management scope the plan's managed view was projected to (§scope), + * stamped whenever it is not the default cluster scope. `apply`/`prove` + * reconstruct the fingerprint/proof view with `projectManagementScope(..., + * scope)` applied AFTER `resolveView`, so plan == prove == run holds under a + * database-scoped profile. Absent (⇒ "cluster") on direct library plans. */ + scope?: ManagementScope; + /** the resolved DEFAULT OWNER the database-scope projection kept implicit (its + * `owner` edges pruned → no `ALTER … OWNER TO`), stamped so `apply`/`prove` + * reconstruct the identical managed-view-under-scope. Absent ⇒ verbose (every + * retained owner edge serializes) and on cluster/direct-library plans. */ + defaultOwner?: string; + /** every rename candidate found, applied or not — "prompt" mode renders + * these as questions; near-misses explain why they degraded (§4.1) */ + renameCandidates: RenameCandidate[]; + /** the renames this plan actually applied (as { from, to } stable-id pairs), + * stamped only when non-empty so corpus / direct-library plan artifacts stay + * byte-identical. The proof loop reads this to keep a renamed table under + * data-preservation coverage: an accepted rename destroys the OLD subtree, so + * without this stamp the old relKey looks "recreated" and the renamed table + * is silently skipped (F7). */ + acceptedRenames?: Array<{ from: StableId; to: StableId }>; + actions: Action[]; + safetyReport: SafetyReport; +} + +export interface PlanOptions { + /** named serialize parameters consumed by rule templates; unknown + * names are a plan-time error (stage 8 wires policies here) */ + params?: PlanParams; + /** policy (§3.9): filters which deltas this plan manages and supplies + * serialize parameters. If the policy DECLARES a baseline, the resolved + * baseline FactBase must be passed as `baseline` below — plan() refuses an + * unresolved declared baseline rather than silently ignoring it. */ + policy?: Policy; + /** resolved platform baseline (§3.9): facts present-and-identical here are + * subtracted from both sides before diffing, so platform-managed objects are + * invisible. Resolve a policy's declared baseline NAME into this FactBase + * with `resolveBaseline(policy, { pgMajor })`. plan() stays pure — it + * subtracts a provided FactBase, never reads a file. */ + baseline?: FactBase; + /** rename detection (§4.1, stage 9). "auto" applies unambiguous + * candidates; "prompt" reports candidates and applies only those in + * acceptRenames; "off" (default) preserves drop+create. */ + renames?: RenameMode; + /** in "prompt" mode: the candidates the caller confirmed */ + acceptRenames?: Array<{ from: StableId; to: StableId }>; + /** compaction (§3.6): fold column clauses into their CREATE TABLE when + * no graph edge crosses the merge. Cosmetic by contract — proof results + * never change (asserted by the compaction suite). Default: true. */ + compact?: boolean; + /** Export-only constraint folding: also fold VALIDATED table constraints + * into their table's CREATE parens (`CONSTRAINT name `), like + * hand-written SQL. ONLY safe when the plan's SQL is consumed by the + * file loader (bounded retry / reorder) rather than the apply executor — + * a folded FK may reference a table a LATER file creates. Set by + * `schema export`; leave unset everywhere else. `exclude` lists encoded + * constraint ids that must stay as ALTERs (cycle-participating FKs, which + * the export routes to `.fk.sql`). */ + foldConstraints?: { exclude?: ReadonlySet }; + /** applier capability (move 6): operations the applier cannot execute (e.g. + * FDW ACLs for a non-superuser) are projected out of the view. Supplied by + * the resolved profile (`resolveProfile(pool, profile, { restrictToApplier: + * true })`), or probe directly with `probeApplierCapability` from + * `@supabase/pg-delta/integrations`. Default unrestricted. */ + capability?: ApplierCapability; + /** the integration profile id to stamp on the plan artifact (set by the + * resolved profile's `planOptions`), so `apply`/`prove` can reconstruct the + * same managed view without the operator re-specifying `--profile`. */ + profile?: { id: string }; + /** the resolved baseline's DIGEST, stamped onto the plan artifact's + * `baseline` field (set by the resolved profile's `planOptions`). Metadata + * only — the facts to subtract are `baseline` above; this is what apply/prove + * reconcile against. Absent when no baseline is in effect. */ + baselineMeta?: { digest: string }; + /** schemas/roles assumed present-but-unmanaged at apply time, supplementing + * any derived from `policy`. The DB-to-DB path supplies a `policy` and the + * sets are read from it; callers that already hold a RESOLVED managed view + * (e.g. declarative export, which re-plans the view from a pristine baseline) + * pass the assumed sets directly so the action-graph requirement guard does + * not treat a kept `CREATE EXTENSION … SCHEMA ` / `GRANT … TO ` as + * a stranded reference — without re-running policy filtering/serialize rules + * over an already-resolved view. */ + assumedSchemas?: string[]; + assumedRoles?: string[]; + /** the redaction mode used to extract the source/desired fact bases, stamped + * onto the artifact so `apply`/`prove` reconstruct the fingerprint identically + * (see `Plan.redactSecrets`). Omit on direct library plans. */ + redactSecrets?: boolean; + /** management scope of the managed view (§scope): "database" (the declarative + * default) removes cluster-global role/membership facts and their owner edges + * from BOTH diff sides AFTER `resolveView`, so a policy owner-exclusion rule + * still sees the owner edges it matches on. "cluster" (the default here when + * omitted) is the identity projection — direct library callers / the corpus + * are unaffected. Set by the CLI's `schema apply`. */ + scope?: ManagementScope; + /** the DEFAULT OWNER for the database-scope projection: `owner` edges to this + * role are pruned (kept implicit → no `ALTER … OWNER TO`); every other + * surviving object's owner edge is retained as an assumed reference and + * serializes. Undefined ⇒ verbose (keep every retained owner edge). Ignored + * at cluster scope (roles are managed). Stamped onto the artifact so + * `apply`/`prove` reconstruct the identical view. Set by `schema export` / + * `schema apply` from the resolved default-owner chain. */ + defaultOwner?: string; + /** intent-rule index for stateful-extension intent facts (`extensionIntent` + * kind — pg_cron jobs, …). Supplied by the resolved profile + * (`resolveProfile` builds it from the profile's handlers' `intentKinds`); + * direct library callers with no intent facts omit it. NOT serialized onto + * the artifact (it holds functions); `apply`/`prove` reconstruct it from the + * same profile. */ + intentRules?: IntentRuleIndex; +} + +export function plan( + rawSource: FactBase, + rawDesired: FactBase, + options?: PlanOptions, +): Plan { + // ── phase 1: change set (managed-view resolution, diff, filter, group, + // rename + role-rename cancellation) → ./phases/change-set.ts. `source` / + // `desired` below are the RESOLVED managed views. ──────────────────── + // A desired-side intent object the engine cannot key (an unnamed pg_cron job) + // can never converge — refuse rather than silently drop it. The handler emits + // this as a warning during capture; here, on the DESIRED side, it is fatal. + // (A SOURCE-side unkeyed intent is just unmanaged drift — left untouched.) + const unkeyed = rawDesired.diagnostics.filter( + (d) => d.code === INTENT_UNKEYED, + ); + if (unkeyed.length > 0) { + throw new Error( + `plan: the desired state declares intent the engine cannot key — name it so it can be managed:\n` + + unkeyed.map((d) => ` - ${d.message}`).join("\n"), + ); + } + + // one id-keyed rule resolver for the whole plan: schema kinds via the static + // RULES table, `extensionIntent` via the profile-supplied intent rules. Built + // once and threaded through every phase so all of them dispatch identically. + const rulesForId = buildRuleResolver(options?.intentRules); + + const { + source, + desired, + projectedDesired, + deltas, + filteredDeltas, + removed, + added, + setsByFact, + renameCandidates, + acceptedRenames, + roleRenameMap, + carriedOwnerLinks, + changedRoleFacts, + } = buildChangeSet(rawSource, rawDesired, options, rulesForId); + + // A user-mapping row whose options were unreadable via pg_user_mappings on + // either side (extraction-time warning, USER_MAPPING_UNREADABLE — see + // src/extract/foreign.ts) has an UNKNOWN true state on that side. A warning + // alone doesn't protect anything here: if only one side's extraction could + // see the mapping, the other side's missing fact reads as an intentional + // add/remove and would otherwise plan a wrong CREATE/DROP USER MAPPING + // (Codex P1 on PR #338). Escalate to fatal exactly when a delta that would + // actually produce an action touches one of these subjects — checked + // against `deltas` (the KEPT, post-policy-filter list that actually drives + // actions; `filteredDeltas` is the policy-EXCLUDED complement — see + // buildChangeSet's `{ kept: deltas, filtered: filteredDeltas }`), so a + // policy that excludes user mappings entirely keeps planning legal. Hidden + // on BOTH sides means no delta ever touches the subject (nothing to diff), + // so planning proceeds. + // + // The same blind spot applies one level up (Codex P2s, PR #338): a DROP of + // the mapping's containing server, or of its (non-PUBLIC) mapped role, + // implicitly destroys the hidden mapping too — CASCADE-style — without any + // delta ever naming the mapping directly. So a `remove` delta on either is + // ALSO gated. Remove-verb ONLY: ALTERs / owner changes on the server or + // role don't destroy the mapping, so gating them would be pure + // over-blocking with no correctness benefit (zero-over-block property). A + // source-side unreadable diagnostic already PROVES the mapping exists, so a + // source-side DROP SERVER/ROLE is guaranteed to fail at apply regardless + // (FK-style: Postgres won't let you drop a server/role a mapping still + // references) — refusing in plan() just surfaces that earlier and louder. + // + // KNOWN LIMITATION (deliberately not handled here, tracked as a follow-up): + // a role RENAME combined with a one-side-hidden mapping. Rename-carry logic + // cancels the resulting remove/add pair into a single rename action before + // this gate runs on raw deltas from the ORIGINAL role name, so a renamed + // role is invisible to the `unreadableRoles` name-set built below in that + // case. In the realistic direction (the mapping is hidden on the SOURCE + // side), this still fails safely — apply cannot rename a role a hidden + // mapping references, for the same FK-style reason as a DROP. The only + // truly gap is a hidden mapping combined with a rename that requires an + // atypical desired-side privilege inversion, which is not addressed by a + // rename-aware translation here. + // + // KNOWN LIMITATION #2 (Codex P1, PR #338 comment 3603601149 — documented, + // NOT gated): a DESIRED-side unreadable mapping whose containing server (or + // FDW/role) doesn't exist on the SOURCE side at all. The resulting `add` + // deltas for the container (CREATE FDW / CREATE SERVER / CREATE ROLE) are + // NOT gated, so plan() proceeds and simply omits the un-creatable CREATE + // USER MAPPING (the mapping fact itself was never added to the desired + // FactBase — it was skipped as unreadable). This is deliberate: the gate + // family above protects PHYSICAL safety (destroying/failing-to-apply + // something that already exists); this case is a DESIRED-STATE FIDELITY + // problem instead — the delta belongs to the server/role, but the + // manageability question belongs to the mapping, so blocking the + // container's `add` here would be a policy-projection layering violation, + // not a safety fix. It's silent-but-visible: the extraction-time + // USER_MAPPING_UNREADABLE diagnostic already prints (and is a candidate for + // the #340 reporting channel to surface more prominently); nothing here + // fabricates or corrupts state. The SOURCE-side analogue is vacuous — a + // source-side unreadable diagnostic, by construction, means the container + // exists on source (extraction reached it to emit the diagnostic), so an + // `add` for it can never occur from that side. + const unreadableMappingSubjects = new Set(); + const unreadableServers = new Set(); + const unreadableRoles = new Set(); + for (const d of [...rawSource.diagnostics, ...rawDesired.diagnostics]) { + if (d.code !== USER_MAPPING_UNREADABLE || d.subject === undefined) { + continue; + } + const subject = d.subject as { + kind: "userMapping"; + server: string; + role: string; + }; + unreadableMappingSubjects.add(encodeId(subject)); + unreadableServers.add(subject.server); + if (subject.role !== "PUBLIC") unreadableRoles.add(subject.role); + } + if (unreadableMappingSubjects.size > 0) { + const touched = new Map(); + for (const delta of deltas) { + const id = subjectOf(delta); + const key = encodeId(id); + if (unreadableMappingSubjects.has(key)) { + touched.set(key, { id, relation: "mapping" }); + continue; + } + if (delta.verb === "remove") { + if (id.kind === "server" && unreadableServers.has(id.name)) { + touched.set(key, { + id, + relation: "server of an unreadable mapping", + }); + } else if (id.kind === "role" && unreadableRoles.has(id.name)) { + touched.set(key, { id, relation: "role of an unreadable mapping" }); + } + continue; + } + // A "replace"-class attribute (server.type / server.fdw — see + // rules/foreign.ts) never in-place ALTERs: expandReplacements (below, + // after this gate) turns a `set` delta on one into DROP + CREATE, + // which destroys the hidden mapping exactly like an explicit DROP + // SERVER would (Codex P2, PR #338 comment 3602512706). Gate it the + // same way, using the SAME rule table the expander itself consults + // (`rulesForId`, built above) so the two can never drift. Non-replace + // attributes (version, options, owner) genuinely in-place ALTER and + // leave the mapping alone — must stay ungated (zero-over-block). + if ( + delta.verb === "set" && + id.kind === "server" && + unreadableServers.has(id.name) && + rulesForId(id).attributes[delta.attr] === "replace" + ) { + touched.set(key, { + id, + relation: "server of an unreadable mapping (replaced)", + }); + } + } + if (touched.size > 0) { + const lines = [...touched.values()] + .map(({ id, relation }) => { + if (id.kind === "userMapping") { + const m = id as { server: string; role: string }; + return `${m.server}/${m.role} (mapping)`; + } + const named = id as { name: string }; + return `${named.name} (${relation})`; + }) + .sort(); + throw new Error( + `plan: the state of these user mappings is unknown on one side (options unreadable via pg_user_mappings) — refusing to plan changes touching them, their containing server, or their mapped role; extract with a role that can read pg_user_mapping:\n` + + lines.map((n) => ` - ${n}`).join("\n"), + ); + } + } + + // serialize params are emission-time setup, independent of the change set. + const params: PlanParams = options?.params ?? {}; + for (const name of Object.keys(params)) { + if (!KNOWN_PARAMS.has(name)) { + throw new Error( + `plan: unknown serialize parameter '${name}' — the rule table declares ${[...KNOWN_PARAMS].join(", ")}`, + ); + } + } + // policy serialize rules apply PER FACT (first matching rule's params, §3.9) — + // explicit options.params override rule-supplied values + const serializeRules = options?.policy + ? flattenPolicy(options.policy).serialize + : []; + + // roles the policy assumes exist at apply time but does not manage (e.g. + // Supabase's anon/authenticated). Threaded into the action-graph guard so a + // kept `GRANT … TO ` whose role object is filtered out of the view is + // not mistaken for a stranded requirement (§ managed-view-architecture). + const assumedRoleNames = new Set([ + ...(options?.policy ? flattenPolicy(options.policy).assumedRoles : []), + ...(options?.assumedRoles ?? []), + ]); + + // Database-scope projection RETAINS `owner` edges to scope-projected roles as + // dangling assumed references (view.ts), so a kept `ALTER … OWNER TO ` + // would otherwise strand the action-graph requirement guard (the role object + // was projected out but exists at apply time). Auto-add every such target role + // name (from BOTH resolved sides) to the assumed set — the exact analogue of a + // GRANT to an assumed role — so apply works without the caller re-threading it. + for (const fb of [source, desired]) { + for (const e of fb.edges) { + if (e.kind === "owner" && e.to.kind === "role" && !fb.has(e.to)) { + assumedRoleNames.add((e.to as { kind: "role"; name: string }).name); + } + } + } + + // schemas the policy assumes exist at apply time but does not manage (e.g. + // Supabase's `extensions`). Threaded into the action-graph guard so a kept + // `CREATE EXTENSION … SCHEMA ` whose schema object is filtered out of + // the view is not mistaken for a stranded requirement (§ managed-view-architecture). + const assumedSchemaNames = new Set([ + ...(options?.policy ? flattenPolicy(options.policy).assumedSchemas : []), + ...(options?.assumedSchemas ?? []), + ]); + + // ── phase 2: replacement expansion + drop-root suppression ──────────── + // Classify set-deltas (alter vs replace), expand the forced dependent + // rebuild, and compute drop-root suppression/redirect (./phases/ + // replacement-expansion.ts). Produces the replaceIds set + dropRootOf map. + const { replaceIds, dropRootOf } = expandReplacements({ + removed, + setsByFact, + source, + desired, + rulesForId, + }); + + // ── phase 3: emit actions (./phases/action-emitter.ts) ──────────────── + // Rename actions, creates (parents first), default-privilege hygiene, + // drops, replaces, in-place alters, role-rename changed-pair mutations, and + // owner-edge ALTERs — with the emitter's own producer/destroyer/fold + // bookkeeping. Enforces the create-produces-its-fact invariant. + const { + actions, + producerOf, + destroyerOf, + foldHints, + acceptsFolds, + renameActionIndices, + } = emitActions({ + source, + desired, + projectedDesired, + removed, + added, + setsByFact, + replaceIds, + dropRootOf, + acceptedRenames, + roleRenameMap, + carriedOwnerLinks, + changedRoleFacts, + deltas, + params, + serializeRules, + capability: options?.capability, + rulesForId, + }); + + // ── phase 4: order, segment-mark, compact, and report ───────────────── + // Graph build + requirement checks, tie-break, segment-boundary marking, and + // the two cosmetic compaction passes are the ActionGraph phase + // (./phases/action-graph.ts → ./internal.ts building blocks). Reads only the + // emitted actions + producer/destroyer indexes + the two RESOLVED fact bases. + const { actions: finalActions, safetyReport } = finalizeActions({ + actions, + producerOf, + destroyerOf, + source, + desired, + renameActionIndices, + foldHints, + acceptsFolds, + assumedRoleNames, + assumedSchemaNames, + capability: options?.capability, + compact: options?.compact !== false, + foldConstraints: options?.foldConstraints, + rulesForId, + }); + + return { + formatVersion: 1, + engineVersion: ENGINE_VERSION, + source: { fingerprint: source.rootHash }, + target: { fingerprint: projectedDesired.rootHash }, + preamble: [ + // Pin the applier's deparse/resolution path to `pg_catalog` so the + // rendered (fully qualified) DDL resolves identically regardless of the + // applier role's default search_path. Extraction canonicalizes to the + // same path, so every emitted statement target is already qualified. + { name: "search_path", value: "pg_catalog" }, + { name: "check_function_bodies", value: "off" }, + ], + deltas, + filteredDeltas, + ...(options?.policy ? { policy: options.policy } : {}), + ...(options?.capability ? { capability: options.capability } : {}), + ...(options?.profile ? { profile: options.profile } : {}), + ...(options?.baselineMeta ? { baseline: options.baselineMeta } : {}), + // stamp scope only when it is not the default cluster projection, so corpus + // / direct-library plan artifacts stay byte-identical. + ...(options?.scope !== undefined && options.scope !== "cluster" + ? { scope: options.scope } + : {}), + // stamp the resolved default owner so apply/prove reconstruct the identical + // database-scope view. Only meaningful (and only stamped) at database scope. + ...(options?.defaultOwner !== undefined && + options?.scope !== undefined && + options.scope !== "cluster" + ? { defaultOwner: options.defaultOwner } + : {}), + ...(options?.redactSecrets !== undefined + ? { redactSecrets: options.redactSecrets } + : {}), + renameCandidates, + // stamp accepted renames only when there are any, so corpus / direct-library + // plan artifacts stay byte-identical (F7). + ...(acceptedRenames.length > 0 + ? { + acceptedRenames: acceptedRenames.map((r) => ({ + from: r.from.id, + to: r.to.id, + })), + } + : {}), + actions: finalActions, + safetyReport, + }; +} diff --git a/packages/pg-delta/src/plan/policy-clause-removal.test.ts b/packages/pg-delta/src/plan/policy-clause-removal.test.ts new file mode 100644 index 000000000..bf1397492 --- /dev/null +++ b/packages/pg-delta/src/plan/policy-clause-removal.test.ts @@ -0,0 +1,56 @@ +/** + * Removing a policy's USING / WITH CHECK clause (Codex review): PostgreSQL has + * no `ALTER POLICY … DROP USING`, so a clause-removal transition must rebuild + * the (rebuildable) policy. Before the fix the in-place alter called `str(to)` + * on the null and threw at plan time. Pure rule/diff level — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const tableFact: Fact = { + id: { kind: "table", schema: "app", name: "t" }, + parent: { kind: "schema", name: "app" }, + payload: { owner: "test", persistence: "p" }, +}; +const policyId: StableId = { + kind: "policy", + schema: "app", + table: "t", + name: "p", +}; +const policyFact = (usingExpr: string | null): Fact => ({ + id: policyId, + parent: { kind: "table", schema: "app", name: "t" }, + payload: { + cmd: "*", + permissive: true, + roles: ["PUBLIC"], + usingExpr, + checkExpr: null, + }, +}); +const base = (extra: Fact[]) => + buildFactBase([schemaFact, tableFact, ...extra], []); + +describe("policy clause removal", () => { + test("removing USING rebuilds the policy instead of throwing", () => { + const sql = plan( + base([policyFact("(true)")]), + base([policyFact(null)]), + ).actions.map((a) => a.sql); + expect(sql.some((s) => s.startsWith(`DROP POLICY "p" ON "app"."t"`))).toBe( + true, + ); + expect( + sql.some((s) => s.startsWith(`CREATE POLICY "p" ON "app"."t"`)), + ).toBe(true); + // the rebuilt policy carries no USING clause + expect(sql.some((s) => s.includes("USING"))).toBe(false); + }); +}); diff --git a/packages/pg-delta/src/plan/project.test.ts b/packages/pg-delta/src/plan/project.test.ts new file mode 100644 index 000000000..c251be2a7 --- /dev/null +++ b/packages/pg-delta/src/plan/project.test.ts @@ -0,0 +1,163 @@ +/** + * Unit tests for projected plan target (src/plan/project.ts). + * No Docker / database required. + * + * Hardening Item 1 / review #2: the plan only applies KEPT deltas, so the state + * it reaches is `desired` with every FILTERED delta reverted to its source + * value. The target fingerprint and the proof must target THIS, not full + * `desired` — otherwise a policy-hidden delta makes the plan intentionally not + * converge while the metadata claims the unprojected target. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { Payload } from "../core/hash.ts"; +import type { Delta } from "../core/diff.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; +import type { Policy } from "../policy/policy.ts"; +import { projectTarget } from "./project.ts"; + +const schemaPublic: StableId = { kind: "schema", name: "public" }; +const users: StableId = { kind: "table", schema: "public", name: "users" }; +const legacy: StableId = { kind: "table", schema: "public", name: "legacy" }; +const usersEmail: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "email", +}; + +function makeFact( + id: StableId, + payload: Payload = {}, + parent?: StableId, +): Fact { + return parent ? { id, parent, payload } : { id, payload }; +} + +describe("projectTarget — revert filtered deltas to source", () => { + test("empty filtered list returns desired unchanged", () => { + const desired = buildFactBase([makeFact(schemaPublic)], []); + expect(projectTarget(desired, []).rootHash).toBe(desired.rootHash); + }); + + test("filtered remove restores the source fact in the target", () => { + // desired dropped `legacy`; the drop was filtered → target keeps it + const desired = buildFactBase( + [makeFact(schemaPublic), makeFact(users, {}, schemaPublic)], + [], + ); + const filtered: Delta[] = [ + { + verb: "remove", + fact: makeFact(legacy, { persistence: "p" }, schemaPublic), + }, + ]; + const projected = projectTarget(desired, filtered); + expect(projected.has(legacy)).toBe(true); + }); + + test("filtered add removes the fact from the target", () => { + const desired = buildFactBase( + [makeFact(schemaPublic), makeFact(legacy, {}, schemaPublic)], + [], + ); + const filtered: Delta[] = [ + { verb: "add", fact: makeFact(legacy, {}, schemaPublic) }, + ]; + const projected = projectTarget(desired, filtered); + expect(projected.has(legacy)).toBe(false); + }); + + test("filtered set reverts the attribute to its source value", () => { + const desired = buildFactBase( + [ + makeFact(schemaPublic), + makeFact(users, {}, schemaPublic), + makeFact(usersEmail, { type: "text" }, users), + ], + [], + ); + const sourceColumn = buildFactBase( + [ + makeFact(schemaPublic), + makeFact(users, {}, schemaPublic), + makeFact(usersEmail, { type: "varchar" }, users), + ], + [], + ); + const filtered: Delta[] = [ + { + verb: "set", + id: usersEmail, + attr: "type", + from: "varchar", + to: "text", + }, + ]; + const projected = projectTarget(desired, filtered); + // the reverted column hashes identically to the source column + expect(projected.hashOf(usersEmail)).toBe(sourceColumn.hashOf(usersEmail)); + }); + + test("filtered add that would orphan a kept child drops the child too", () => { + // desired adds legacy + a column on it; filtering the table add must not + // leave the column parentless (buildFactBase would otherwise throw) + const legacyCol: StableId = { + kind: "column", + schema: "public", + table: "legacy", + name: "id", + }; + const desired = buildFactBase( + [ + makeFact(schemaPublic), + makeFact(legacy, {}, schemaPublic), + makeFact(legacyCol, { type: "integer" }, legacy), + ], + [], + ); + const filtered: Delta[] = [ + { verb: "add", fact: makeFact(legacy, {}, schemaPublic) }, + ]; + const projected = projectTarget(desired, filtered); + expect(projected.has(legacy)).toBe(false); + expect(projected.has(legacyCol)).toBe(false); + }); +}); + +describe("plan target reflects the managed view (review #2)", () => { + test("a scope-excluded object is outside the view — never dropped, no action", () => { + // source has `legacy`; desired drops it; the policy scopes `legacy` OUT of + // the managed universe. In the managed-view model (move 3) `legacy` is + // projected from BOTH sides at the fact level, so it is not a "suppressed + // drop" delta — it simply isn't part of the diff. The user-facing guarantee + // (legacy is never dropped) holds, and the proof applies the same view. + const source = buildFactBase( + [ + makeFact(schemaPublic), + makeFact(users, {}, schemaPublic), + makeFact(legacy, { persistence: "p" }, schemaPublic), + ], + [], + ); + const desired = buildFactBase( + [makeFact(schemaPublic), makeFact(users, {}, schemaPublic)], + [], + ); + const policy: Policy = { + id: "scope-out-legacy", + filter: [ + { + match: { all: [{ kind: "table" }, { name: "legacy" }] }, + action: "exclude", + }, + ], + }; + const p = plan(source, desired, { policy }); + expect(p.actions.length).toBe(0); // legacy is outside the view → nothing to do + // the plan is a no-op in the view: its target equals its (projected) source, + // so applying it drops nothing — legacy survives. + expect(p.target.fingerprint).toBe(p.source.fingerprint); + }); +}); diff --git a/packages/pg-delta/src/plan/project.ts b/packages/pg-delta/src/plan/project.ts new file mode 100644 index 000000000..bc753a576 --- /dev/null +++ b/packages/pg-delta/src/plan/project.ts @@ -0,0 +1,103 @@ +/** + * Projected plan target (docs/archive/hardening-plan.md Item 1; review + * #2). + * + * `filterDeltas` (policy) removes deltas the plan will NOT apply, but the plan + * still has a real target: the state reached by applying only the KEPT deltas. + * That state is `desired` with every FILTERED delta reverted to its source + * value. Fingerprinting and proving against THIS — not full `desired` — removes + * the ambiguity "is the plan proving against desired, or desired-through-policy?" + * + * No `source` argument is needed: each delta carries its source-side data + * (a `remove` carries the source fact, a `set` carries `from`, an `unlink` + * carries the source edge), so the revert is fully determined by `desired` + + * the filtered deltas. + */ +import type { Delta } from "../core/diff.ts"; +import { + buildFactBase, + type DependencyEdge, + type Fact, + type FactBase, + retainOwnerRoleDangling, +} from "../core/fact.ts"; +import type { Payload } from "../core/hash.ts"; +import { encodeId } from "../core/stable-id.ts"; + +const edgeKey = (e: DependencyEdge): string => + `${encodeId(e.from)}|${e.kind}|${encodeId(e.to)}`; + +export function projectTarget( + desired: FactBase, + filteredDeltas: Delta[], +): FactBase { + if (filteredDeltas.length === 0) return desired; + + const facts = new Map( + desired.facts().map((f) => [encodeId(f.id), f]), + ); + const edges = new Map( + desired.edges.map((e) => [edgeKey(e), e]), + ); + + for (const d of filteredDeltas) { + switch (d.verb) { + case "add": // not added → absent from the target + facts.delete(encodeId(d.fact.id)); + break; + case "remove": // not dropped → the source fact stays in the target + facts.set(encodeId(d.fact.id), d.fact); + break; + case "set": { + // attribute not changed → revert to the source value (`from`) + const key = encodeId(d.id); + const cur = facts.get(key); + if (cur === undefined) break; + const payload: Payload = { ...cur.payload, [d.attr]: d.from }; + facts.set(key, { ...cur, payload }); + break; + } + case "link": // edge not added → absent from the target + edges.delete(edgeKey(d.edge)); + break; + case "unlink": // edge not removed → the source edge stays in the target + edges.set(edgeKey(d.edge), d.edge); + break; + } + } + + // Integrity: a filtered subtree must not orphan a surviving child. Drop facts + // whose parent is now missing (transitively), then prune edges whose + // endpoints are gone (mirrors subtractBaseline / excludeManaged cleanup). + let changed = true; + while (changed) { + changed = false; + for (const [key, fact] of facts) { + if (fact.parent !== undefined && !facts.has(encodeId(fact.parent))) { + facts.delete(key); + changed = true; + } + } + } + for (const [key, edge] of edges) { + const fromPresent = facts.has(encodeId(edge.from)); + const toPresent = facts.has(encodeId(edge.to)); + if (fromPresent && toPresent) continue; + // Ownership carve-out: a retained dangling owner→role edge (database scope) + // keeps its role endpoint absent by design — preserve it so the projected + // target (fingerprint + proof) still reflects the serialized OWNER TO, rather + // than pruning it and drifting from the applied result. + if (fromPresent && retainOwnerRoleDangling(edge)) continue; + edges.delete(key); + } + + return buildFactBase( + [...facts.values()], + [...edges.values()], + desired.source, + // referenceOnly is intentionally NOT carried forward here (unchanged from the + // original 3-arg call); the 4th arg is supplied only to reach `allowDangling`. + new Set(), + { allowDangling: retainOwnerRoleDangling }, + ); +} diff --git a/packages/pg-delta/src/plan/range-type-options.test.ts b/packages/pg-delta/src/plan/range-type-options.test.ts new file mode 100644 index 000000000..970a04370 --- /dev/null +++ b/packages/pg-delta/src/plan/range-type-options.test.ts @@ -0,0 +1,72 @@ +/** + * CREATE TYPE … AS RANGE must reproduce SUBTYPE_OPCLASS and + * MULTIRANGE_TYPE_NAME, not just SUBTYPE/COLLATION/SUBTYPE_DIFF (PR #299 + * review, supabase/pg-toolbelt). Before this, a range type that pinned a + * non-default subtype operator class or a custom multirange type name was + * recreated without those options. Pure rule/diff level — no DB. Range types + * are drop+create, so each option must render in the CREATE. + * + * CANONICAL is intentionally deferred: its function takes the range type as an + * argument, so it needs a shell-type-first ordering, and a canonical function + * can only be written in C — unreachable from pure user-schema SQL DDL. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const rangeId: StableId = { kind: "type", schema: "app", name: "r" }; +const rangeFact = (extra: Record): Fact => ({ + id: rangeId, + parent: { kind: "schema", name: "app" }, + payload: { + variant: "range", + subtype: "integer", + collation: null, + subtypeDiff: null, + subtypeOpclass: null, + multirangeTypeName: null, + ...extra, + }, +}); +const base = (extra: Fact[]) => buildFactBase([schemaFact, ...extra], []); + +describe("range type option rendering", () => { + const create = (extra: Record): string => + plan(base([]), base([rangeFact(extra)])) + .actions.map((a) => a.sql) + .join("\n"); + + test("renders SUBTYPE_OPCLASS", () => { + expect(create({ subtypeOpclass: "pg_catalog.int4_ops" })).toContain( + "SUBTYPE_OPCLASS = pg_catalog.int4_ops", + ); + }); + + test("renders MULTIRANGE_TYPE_NAME", () => { + expect(create({ multirangeTypeName: `"app"."r_mr"` })).toContain( + `MULTIRANGE_TYPE_NAME = "app"."r_mr"`, + ); + }); + + test("a default opclass / auto multirange name render nothing extra", () => { + const sql = create({}); + expect(sql).not.toContain("SUBTYPE_OPCLASS"); + expect(sql).not.toContain("MULTIRANGE_TYPE_NAME"); + }); + + test("an opclass-only change recreates the range type", () => { + const sql = plan( + base([rangeFact({ subtypeOpclass: null })]), + base([rangeFact({ subtypeOpclass: "pg_catalog.int4_ops" })]), + ).actions.map((a) => a.sql); + expect(sql.some((s) => s.startsWith("DROP TYPE"))).toBe(true); + expect( + sql.some((s) => s.includes("SUBTYPE_OPCLASS = pg_catalog.int4_ops")), + ).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/plan/redundant-drop-elision.test.ts b/packages/pg-delta/src/plan/redundant-drop-elision.test.ts new file mode 100644 index 000000000..887eb280f --- /dev/null +++ b/packages/pg-delta/src/plan/redundant-drop-elision.test.ts @@ -0,0 +1,80 @@ +/** + * Unit tests for compaction's redundant-drop elision (fourth follow-up review + * discussion). No Docker / database required. + * + * `acl` replace is rendered as drop + create, but `grantActions` (the create) + * already leads with its own `REVOKE ALL … FROM grantee` reset — so the drop's + * `REVOKE ALL … FROM grantee` is a byte-identical, redundant statement. This is + * a GENERAL property of every ACL privilege change (no rename needed), so the + * elision lives in the cosmetic compaction pass (correctness first, prettify + * later) rather than being special-cased per kind in the planner. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { Payload } from "../core/hash.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaApp: StableId = { kind: "schema", name: "app" }; +const tableT: StableId = { kind: "table", schema: "app", name: "t" }; +const roleR: StableId = { kind: "role", name: "r" }; +const aclR: StableId = { kind: "acl", target: tableT, grantee: "r" }; + +const f = (id: StableId, payload: Payload = {}, parent?: StableId): Fact => + parent ? { id, parent, payload } : { id, payload }; +const tablePayload = (): Payload => ({ + persistence: "p", + rowSecurity: false, + forceRowSecurity: false, + replicaIdentity: "d", + replicaIdentityIndex: null, + partitionKey: null, + partitionBound: null, + parentTable: null, +}); + +describe("compaction elides the redundant ACL-replace REVOKE", () => { + const acl = (privs: string[]): Fact => + f(aclR, { privileges: privs, grantable: [] }, tableT); + const source = buildFactBase( + [ + f(schemaApp), + f(roleR), + f(tableT, tablePayload(), schemaApp), + acl(["SELECT"]), + ], + [], + ); + const desired = buildFactBase( + [ + f(schemaApp), + f(roleR), + f(tableT, tablePayload(), schemaApp), + acl(["SELECT", "INSERT"]), + ], + [], + ); + + test("an ACL privilege change emits exactly one REVOKE (no rename)", () => { + const p = plan(source, desired); + const revokes = p.actions.filter((a) => + a.sql.includes('REVOKE ALL ON TABLE "app"."t" FROM "r"'), + ); + expect(revokes).toHaveLength(1); + // the GRANT of the new privileges still follows + expect( + p.actions.some( + (a) => a.sql.includes("GRANT") && a.sql.includes('TO "r"'), + ), + ).toBe(true); + }); + + test("with compaction off, the redundant REVOKE is preserved (correctness-first)", () => { + const p = plan(source, desired, { compact: false }); + const revokes = p.actions.filter((a) => + a.sql.includes('REVOKE ALL ON TABLE "app"."t" FROM "r"'), + ); + // uncompacted plan keeps the drop's REVOKE + the create's reset REVOKE + expect(revokes).toHaveLength(2); + }); +}); diff --git a/packages/pg-delta/src/plan/reloptions.test.ts b/packages/pg-delta/src/plan/reloptions.test.ts new file mode 100644 index 000000000..aade39d7b --- /dev/null +++ b/packages/pg-delta/src/plan/reloptions.test.ts @@ -0,0 +1,68 @@ +/** + * View / table reloptions are facts now (supabase/cli#5476, Codex review): an + * options-only change (security_invoker, fillfactor, …) plans an + * `ALTER … SET/RESET` instead of being invisible. Before this, reloptions were + * not extracted, so source and desired hashed identically and nothing planned — + * each test below produced ZERO actions. Pure rule/diff level — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const viewId: StableId = { kind: "view", schema: "app", name: "v" }; +const viewFact = (reloptions: string[] | null): Fact => ({ + id: viewId, + parent: { kind: "schema", name: "app" }, + payload: { def: " SELECT 1;", reloptions }, +}); +const tableId: StableId = { kind: "table", schema: "app", name: "t" }; +const tableFact = (reloptions: string[] | null): Fact => ({ + id: tableId, + parent: { kind: "schema", name: "app" }, + payload: { owner: "test", persistence: "p", reloptions }, +}); +const base = (extra: Fact[]) => buildFactBase([schemaFact, ...extra], []); + +describe("view reloptions", () => { + test("create carries the WITH (...) clause", () => { + const sql = plan( + base([]), + base([viewFact(["security_invoker=true"])]), + ).actions.map((a) => a.sql); + expect(sql).toContain( + `CREATE VIEW "app"."v" WITH (security_invoker=true) AS SELECT 1;`, + ); + }); + + test("an options-only swap plans SET + RESET (was invisible before)", () => { + const sql = plan( + base([viewFact(["security_barrier=true"])]), + base([viewFact(["security_invoker=true"])]), + ).actions.map((a) => a.sql); + expect(sql).toContain(`ALTER VIEW "app"."v" SET (security_invoker=true)`); + expect(sql).toContain(`ALTER VIEW "app"."v" RESET (security_barrier)`); + }); + + test("dropping every option plans a RESET", () => { + const sql = plan( + base([viewFact(["security_invoker=true"])]), + base([viewFact(null)]), + ).actions.map((a) => a.sql); + expect(sql).toContain(`ALTER VIEW "app"."v" RESET (security_invoker)`); + }); +}); + +describe("table reloptions", () => { + test("a storage-option change plans an ALTER TABLE SET", () => { + const sql = plan( + base([tableFact(["fillfactor=70"])]), + base([tableFact(["fillfactor=90"])]), + ).actions.map((a) => a.sql); + expect(sql).toContain(`ALTER TABLE "app"."t" SET (fillfactor=90)`); + }); +}); diff --git a/packages/pg-delta/src/plan/rename-ownership.test.ts b/packages/pg-delta/src/plan/rename-ownership.test.ts new file mode 100644 index 000000000..e4cd54c01 --- /dev/null +++ b/packages/pg-delta/src/plan/rename-ownership.test.ts @@ -0,0 +1,457 @@ +/** + * Unit regressions for accepted-rename ownership modeling (second follow-up + * review 2026-06-15, P1 #1 + #2). No Docker / database required. + * + * `ALTER … RENAME` changes IDENTITY, not OWNER: PostgreSQL preserves the owner + * OID across a rename, and any genuine owner change is a separate owner action. + * Two consequences the planner must honor: + * + * 1. owner CHANGE under a rename — the owner-link action must `releases` the + * OLD owner so the old role's drop sorts AFTER the reassignment, not before + * (else `DROP OWNED BY old; DROP ROLE old` drops the still-old-owned table). + * 2. owner CARRIED through a role rename — when a table and its owner role are + * BOTH renamed, the owner is already correct after the two renames, so NO + * `ALTER … OWNER TO` is emitted and the rename actions must not form a cycle. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const rolePayload = (login = false) => ({ + superuser: false, + inherit: true, + createRole: false, + createDb: false, + login, + replication: false, + bypassRls: false, + config: [], +}); + +const tablePayload = () => ({ + persistence: "p", + rowSecurity: false, + forceRowSecurity: false, + replicaIdentity: "d", + replicaIdentityIndex: null, + partitionKey: null, + partitionBound: null, + parentTable: null, +}); + +const role1: StableId = { kind: "role", name: "r1" }; +const role2: StableId = { kind: "role", name: "r2" }; +const schema: StableId = { kind: "schema", name: "app" }; +const oldTable: StableId = { kind: "table", schema: "app", name: "old_t" }; +const newTable: StableId = { kind: "table", schema: "app", name: "new_t" }; + +describe("accepted rename + owner change (review P1 #1)", () => { + test("ALTER … OWNER TO releases the old owner and sorts before its DROP", () => { + // source: r1 owns app.old_t + const source = buildFactBase( + [ + { id: role1, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: oldTable, parent: schema, payload: tablePayload() }, + ], + [{ from: oldTable, to: role1, kind: "owner" }], + ); + // desired: app.new_t (accepted rename of old_t) owned by a NEW role r2; r1 gone + const desired = buildFactBase( + [ + { id: role2, payload: rolePayload(true) }, + { id: schema, payload: {} }, + { id: newTable, parent: schema, payload: tablePayload() }, + ], + [{ from: newTable, to: role2, kind: "owner" }], + ); + + const p = plan(source, desired, { renames: "auto", compact: false }); + + const ownerActionIdx = p.actions.findIndex((a) => + a.sql.includes('OWNER TO "r2"'), + ); + expect(ownerActionIdx).toBeGreaterThanOrEqual(0); + const ownerAction = p.actions[ownerActionIdx]!; + // the owner alter must release the old role so the drop is ordered after it + expect(ownerAction.releases.map(encodeId)).toContain(encodeId(role1)); + + const dropRoleIdx = p.actions.findIndex( + (a) => a.verb === "drop" && a.sql.includes('DROP ROLE "r1"'), + ); + expect(dropRoleIdx).toBeGreaterThanOrEqual(0); + // ALTER … OWNER TO r2 must come BEFORE DROP ROLE r1 in the final order + expect(ownerActionIdx).toBeLessThan(dropRoleIdx); + }); +}); + +describe("accepted table rename + accepted owner-role rename (review P1 #2)", () => { + test("owner carried through both renames → no cycle, no spurious OWNER TO", () => { + // r1 and r2 are structurally identical → the role rename is accepted too + const source = buildFactBase( + [ + { id: role1, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: oldTable, parent: schema, payload: tablePayload() }, + ], + [{ from: oldTable, to: role1, kind: "owner" }], + ); + const desired = buildFactBase( + [ + { id: role2, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: newTable, parent: schema, payload: tablePayload() }, + ], + [{ from: newTable, to: role2, kind: "owner" }], + ); + + let p!: ReturnType; + expect(() => { + p = plan(source, desired, { renames: "auto", compact: false }); + }).not.toThrow(); + + // both renames are emitted + expect(p.actions.filter((a) => a.sql.includes("RENAME TO"))).toHaveLength( + 2, + ); + // ownership is carried by the renames — no ALTER … OWNER TO is needed + expect(p.actions.filter((a) => a.sql.includes("OWNER TO"))).toHaveLength(0); + }); +}); + +const stableTable: StableId = { kind: "table", schema: "app", name: "t" }; + +describe("role-only rename carries ownership on a stable object (review P1)", () => { + // source: r1 owns app.t; desired: structurally identical r2 owns the SAME + // app.t. Only the role is renamed; the table id does not change. PostgreSQL + // carries the owner by OID across ALTER ROLE … RENAME, so no ALTER … OWNER TO + // is needed and the role rename must not deadlock an owner action. + const source = buildFactBase( + [ + { id: role1, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: stableTable, parent: schema, payload: tablePayload() }, + ], + [{ from: stableTable, to: role1, kind: "owner" }], + ); + const desired = buildFactBase( + [ + { id: role2, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: stableTable, parent: schema, payload: tablePayload() }, + ], + [{ from: stableTable, to: role2, kind: "owner" }], + ); + + test("no cycle, ALTER ROLE rename emitted, no spurious OWNER TO", () => { + let p!: ReturnType; + expect(() => { + p = plan(source, desired, { renames: "auto", compact: false }); + }).not.toThrow(); + expect( + p.actions.some((a) => a.sql.includes('ALTER ROLE "r1" RENAME TO "r2"')), + ).toBe(true); + expect(p.actions.filter((a) => a.sql.includes("OWNER TO"))).toHaveLength(0); + }); + + test("a restrictive capability does not falsely fail (no owner action to authorize)", () => { + // applier cannot set owner r2; but no ALTER … OWNER TO is required, so plan + // must not throw the capability error. + expect(() => + plan(source, desired, { + renames: "auto", + compact: false, + capability: { role: "applier", isSuperuser: false, memberOf: [] }, + }), + ).not.toThrow(); + }); +}); + +describe("role rename carries role-name-bearing facts (review P2)", () => { + const dpPayload = { privileges: ["SELECT"], grantable: [] }; + + test("identical default privileges are carried, not churned (no ALTER DEFAULT PRIVILEGES)", () => { + const dp1: StableId = { + kind: "defaultPrivilege", + role: "r1", + schema: "app", + objtype: "r", + grantee: "PUBLIC", + }; + const dp2: StableId = { + kind: "defaultPrivilege", + role: "r2", + schema: "app", + objtype: "r", + grantee: "PUBLIC", + }; + const source = buildFactBase( + [ + { id: role1, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: dp1, payload: dpPayload }, + ], + [], + ); + const desired = buildFactBase( + [ + { id: role2, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: dp2, payload: dpPayload }, + ], + [], + ); + + const p = plan(source, desired, { renames: "auto", compact: false }); + expect( + p.actions.some((a) => a.sql.includes('ALTER ROLE "r1" RENAME TO "r2"')), + ).toBe(true); + // the default privilege is carried by the role rename's OID — no DDL + expect( + p.actions.filter((a) => a.sql.includes("DEFAULT PRIVILEGES")), + ).toHaveLength(0); + expect( + p.actions.filter( + (a) => a.sql.includes("GRANT") || a.sql.includes("REVOKE"), + ), + ).toHaveLength(0); + }); + + test("identical membership is carried, not churned (no GRANT/REVOKE … membership)", () => { + // r1 is a member of grp in source; after r1 → r2 the membership is carried + const grp: StableId = { kind: "role", name: "grp" }; + const m1: StableId = { kind: "membership", role: "grp", member: "r1" }; + const m2: StableId = { kind: "membership", role: "grp", member: "r2" }; + const source = buildFactBase( + [ + { id: grp, payload: rolePayload(false) }, + { id: role1, payload: rolePayload(false) }, + { id: m1, payload: { admin: false } }, + ], + [], + ); + const desired = buildFactBase( + [ + { id: grp, payload: rolePayload(false) }, + { id: role2, payload: rolePayload(false) }, + { id: m2, payload: { admin: false } }, + ], + [], + ); + + const p = plan(source, desired, { renames: "auto", compact: false }); + expect( + p.actions.some((a) => a.sql.includes('ALTER ROLE "r1" RENAME TO "r2"')), + ).toBe(true); + // the membership is carried — no GRANT/REVOKE role membership churn + expect( + p.actions.filter( + (a) => a.sql.includes("GRANT") || a.sql.includes("REVOKE"), + ), + ).toHaveLength(0); + }); +}); + +describe("role rename carries role-name-bearing facts with CHANGED payloads (review P2, fourth)", () => { + // PostgreSQL carries the role-referencing fact's IDENTITY through the rename + // by OID; only the payload mutation needs DDL, applied to the post-rename id. + // The planner must not tear down the old-name fact and recreate the new-name + // one (extra DDL, REVOKE … CASCADE, transient privilege churn). + const grp: StableId = { kind: "role", name: "grp" }; + + test("membership.admin false→true: GRANT WITH ADMIN OPTION on r2, no REVOKE … CASCADE", () => { + const source = buildFactBase( + [ + { id: grp, payload: rolePayload(false) }, + { id: role1, payload: rolePayload(false) }, + { + id: { kind: "membership", role: "grp", member: "r1" }, + payload: { admin: false }, + }, + ], + [], + ); + const desired = buildFactBase( + [ + { id: grp, payload: rolePayload(false) }, + { id: role2, payload: rolePayload(false) }, + { + id: { kind: "membership", role: "grp", member: "r2" }, + payload: { admin: true }, + }, + ], + [], + ); + const p = plan(source, desired, { renames: "auto", compact: false }); + const sql = p.actions.map((a) => a.sql); + expect(sql.some((s) => s.includes('ALTER ROLE "r1" RENAME TO "r2"'))).toBe( + true, + ); + expect(sql.some((s) => s === 'GRANT "grp" TO "r2" WITH ADMIN OPTION')).toBe( + true, + ); + // the old-name teardown (with CASCADE) must be gone + expect(sql.some((s) => s.includes("CASCADE"))).toBe(false); + expect(sql.some((s) => s.includes('FROM "r1"'))).toBe(false); + }); + + test("membership.admin true→false: REVOKE ADMIN OPTION on r2, no drop/recreate", () => { + const source = buildFactBase( + [ + { id: grp, payload: rolePayload(false) }, + { id: role1, payload: rolePayload(false) }, + { + id: { kind: "membership", role: "grp", member: "r1" }, + payload: { admin: true }, + }, + ], + [], + ); + const desired = buildFactBase( + [ + { id: grp, payload: rolePayload(false) }, + { id: role2, payload: rolePayload(false) }, + { + id: { kind: "membership", role: "grp", member: "r2" }, + payload: { admin: false }, + }, + ], + [], + ); + const p = plan(source, desired, { renames: "auto", compact: false }); + const sql = p.actions.map((a) => a.sql); + expect(sql.some((s) => s.includes('ALTER ROLE "r1" RENAME TO "r2"'))).toBe( + true, + ); + expect( + sql.some((s) => s === 'REVOKE ADMIN OPTION FOR "grp" FROM "r2"'), + ).toBe(true); + expect(sql.some((s) => s.includes("CASCADE"))).toBe(false); + }); + + test("userMapping.options change: ALTER USER MAPPING on r2, no DROP/CREATE USER MAPPING", () => { + const srv: StableId = { kind: "server", name: "srv" }; + const um1: StableId = { kind: "userMapping", server: "srv", role: "r1" }; + const um2: StableId = { kind: "userMapping", server: "srv", role: "r2" }; + const source = buildFactBase( + [ + { id: role1, payload: rolePayload(false) }, + { id: srv, payload: { fdw: "postgres_fdw", options: [] } }, + { id: um1, parent: srv, payload: { options: ["a=b"] } }, + ], + [], + ); + const desired = buildFactBase( + [ + { id: role2, payload: rolePayload(false) }, + { id: srv, payload: { fdw: "postgres_fdw", options: [] } }, + { id: um2, parent: srv, payload: { options: ["a=c"] } }, + ], + [], + ); + const p = plan(source, desired, { renames: "auto", compact: false }); + const sql = p.actions.map((a) => a.sql); + expect(sql.some((s) => s.includes('ALTER ROLE "r1" RENAME TO "r2"'))).toBe( + true, + ); + expect( + sql.some((s) => s.includes("ALTER USER MAPPING") && s.includes('"r2"')), + ).toBe(true); + expect(sql.some((s) => s.includes("DROP USER MAPPING"))).toBe(false); + expect(sql.some((s) => s.includes("CREATE USER MAPPING"))).toBe(false); + }); + + test("acl privilege change: no pre-rename REVOKE FROM r1; replacement targets r2", () => { + const table: StableId = { kind: "table", schema: "app", name: "t" }; + const acl1: StableId = { kind: "acl", target: table, grantee: "r1" }; + const acl2: StableId = { kind: "acl", target: table, grantee: "r2" }; + const source = buildFactBase( + [ + { id: role1, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: table, parent: schema, payload: tablePayload() }, + { + id: acl1, + parent: table, + payload: { privileges: ["SELECT"], grantable: [] }, + }, + ], + [], + ); + const desired = buildFactBase( + [ + { id: role2, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: table, parent: schema, payload: tablePayload() }, + { + id: acl2, + parent: table, + payload: { privileges: ["SELECT", "INSERT"], grantable: [] }, + }, + ], + [], + ); + const p = plan(source, desired, { renames: "auto", compact: false }); + const sql = p.actions.map((a) => a.sql); + expect(sql.some((s) => s.includes('ALTER ROLE "r1" RENAME TO "r2"'))).toBe( + true, + ); + // no pre-rename teardown against the old name + expect(sql.some((s) => s.includes('FROM "r1"'))).toBe(false); + // the new privileges are granted to the post-rename name + expect(sql.some((s) => s.includes("GRANT") && s.includes('TO "r2"'))).toBe( + true, + ); + }); + + test("defaultPrivilege privilege removal: REVOKE+GRANT against r2 only, no FOR ROLE r1", () => { + const dp1: StableId = { + kind: "defaultPrivilege", + role: "r1", + schema: "app", + objtype: "r", + grantee: "PUBLIC", + }; + const dp2: StableId = { + kind: "defaultPrivilege", + role: "r2", + schema: "app", + objtype: "r", + grantee: "PUBLIC", + }; + const source = buildFactBase( + [ + { id: role1, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: dp1, payload: { privileges: ["SELECT"], grantable: [] } }, + ], + [], + ); + const desired = buildFactBase( + [ + { id: role2, payload: rolePayload(false) }, + { id: schema, payload: {} }, + { id: dp2, payload: { privileges: ["INSERT"], grantable: [] } }, + ], + [], + ); + const p = plan(source, desired, { renames: "auto", compact: false }); + const sql = p.actions.map((a) => a.sql); + expect(sql.some((s) => s.includes('ALTER ROLE "r1" RENAME TO "r2"'))).toBe( + true, + ); + // no default-privilege DDL against the old role name + expect(sql.some((s) => s.includes('FOR ROLE "r1"'))).toBe(false); + // the privilege change is applied against the post-rename role: a REVOKE ALL + // (so the dropped SELECT is removed) and a GRANT INSERT, both FOR ROLE r2 + expect( + sql.some((s) => s.includes('FOR ROLE "r2"') && s.includes("REVOKE ALL")), + ).toBe(true); + expect( + sql.some( + (s) => s.includes('FOR ROLE "r2"') && s.includes("GRANT INSERT"), + ), + ).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/plan/renames.ts b/packages/pg-delta/src/plan/renames.ts new file mode 100644 index 000000000..977508ba6 --- /dev/null +++ b/packages/pg-delta/src/plan/renames.ts @@ -0,0 +1,152 @@ +/** + * Rename detection (target-architecture §4.1, stage 9): over the diff's + * remove/add pairs, find candidates whose STRUCTURAL rollup (the + * identity-free fold from stage 1) matches — same content, different name. + * A rename rewrites the whole subtree's IDs without emitting subtree + * actions, and preserves data by construction. + * + * Never guess: ambiguity (n removed × m added with equal rollups) and + * swaps/chains (the target name already exists in the source) are + * surfaced for the policy gate to resolve, not auto-applied. + * + * Known limit (§4.1, documented in the verdict): payloads referencing + * other objects BY NAME (an index def naming its table, a FK naming the + * renamed table) break transitive rollup equality — those candidates + * degrade to drop+create and are reported as near-misses. + */ +import type { Fact, FactBase } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { defaultRulesForId, type RulesForId } from "./rules.ts"; + +export type RenameMode = "auto" | "prompt" | "off"; + +export interface RenameCandidate { + kind: string; + from: StableId; + to: StableId; + /** + * - unambiguous: 1×1 structural match — auto-appliable + * - ambiguous: several equal-rollup facts on one side — never guessed + * - nearMiss: own payload matches but the subtree differs (usually a + * name-bearing child payload) — degrades to drop+create, reported why + * + * Swaps/chains cannot appear here BY CONSTRUCTION: an `add` delta means + * the target id does not exist in the source, so a rename whose target + * name is occupied surfaces as a set-delta on the occupied fact instead + * — handled as an alter/replace, never a guessed rename (the stage-9 + * swap scenario asserts this). + */ + status: "unambiguous" | "ambiguous" | "nearMiss"; + reason?: string; +} + +function groupKey(fact: Fact, rollup: string): string { + const parent = fact.parent === undefined ? "" : encodeId(fact.parent); + return `${fact.id.kind}|${parent}|${rollup}`; +} + +export function matchRenameCandidates( + removed: ReadonlyMap, + added: ReadonlyMap, + source: FactBase, + desired: FactBase, + // id-keyed resolver so an `extensionIntent` add/remove present during rename + // matching resolves its (rename-less) intent rule instead of throwing through + // the schema-only `rulesFor`. Defaults to the no-intent resolver for direct + // callers/tests that never see intent facts. + rulesForId: RulesForId = defaultRulesForId, +): RenameCandidate[] { + const candidates: RenameCandidate[] = []; + + const removedGroups = new Map(); + for (const fact of removed.values()) { + if (rulesForId(fact.id).rename === undefined) continue; + // children of a removed/renamed container are handled by their root + if (fact.parent !== undefined && removed.has(encodeId(fact.parent))) + continue; + const key = groupKey(fact, source.structuralRollupOf(fact.id)); + const list = removedGroups.get(key) ?? []; + list.push(fact); + removedGroups.set(key, list); + } + const addedGroups = new Map(); + for (const fact of added.values()) { + if (rulesForId(fact.id).rename === undefined) continue; + if (fact.parent !== undefined && added.has(encodeId(fact.parent))) continue; + const key = groupKey(fact, desired.structuralRollupOf(fact.id)); + const list = addedGroups.get(key) ?? []; + list.push(fact); + addedGroups.set(key, list); + } + + const matchedRemoved = new Set(); + for (const [key, removedFacts] of removedGroups) { + const addedFacts = addedGroups.get(key); + if (addedFacts === undefined) continue; + if (removedFacts.length === 1 && addedFacts.length === 1) { + const from = (removedFacts[0] as Fact).id; + const to = (addedFacts[0] as Fact).id; + matchedRemoved.add(encodeId(from)); + candidates.push({ kind: from.kind, from, to, status: "unambiguous" }); + } else { + for (const removedFact of removedFacts) { + matchedRemoved.add(encodeId(removedFact.id)); + for (const addedFact of addedFacts) { + candidates.push({ + kind: removedFact.id.kind, + from: removedFact.id, + to: addedFact.id, + status: "ambiguous", + reason: `${removedFacts.length} removed × ${addedFacts.length} added with identical content — cannot pick`, + }); + } + } + } + } + + // near-misses: own payload identical, subtree rollup not — say why a + // would-be rename degrades (§4.1) + for (const fact of removed.values()) { + if (matchedRemoved.has(encodeId(fact.id))) continue; + if (rulesForId(fact.id).rename === undefined) continue; + if (fact.parent !== undefined && removed.has(encodeId(fact.parent))) + continue; + for (const addedFact of added.values()) { + if (addedFact.id.kind !== fact.id.kind) continue; + const sameParent = + (fact.parent === undefined && addedFact.parent === undefined) || + (fact.parent !== undefined && + addedFact.parent !== undefined && + encodeId(fact.parent) === encodeId(addedFact.parent)); + if (!sameParent) continue; + if (source.hashOf(fact.id) !== desired.hashOf(addedFact.id)) continue; + candidates.push({ + kind: fact.id.kind, + from: fact.id, + to: addedFact.id, + status: "nearMiss", + reason: + "own payload matches but the subtree differs — likely a name-bearing child payload (index/constraint defs embed names, §4.1); staying drop+create", + }); + } + } + + return candidates.sort((a, b) => { + const ka = `${encodeId(a.from)}>${encodeId(a.to)}`; + const kb = `${encodeId(b.from)}>${encodeId(b.to)}`; + return ka < kb ? -1 : 1; + }); +} + +/** A fact id plus every descendant id, root first. */ +export function subtreeIds(fb: FactBase, root: StableId): StableId[] { + const ids: StableId[] = [root]; + const walk = (id: StableId): void => { + for (const child of fb.childrenOf(id)) { + ids.push(child.id); + walk(child.id); + } + }; + walk(root); + return ids; +} diff --git a/packages/pg-delta/src/plan/render-sql.test.ts b/packages/pg-delta/src/plan/render-sql.test.ts new file mode 100644 index 000000000..dd758b0bb --- /dev/null +++ b/packages/pg-delta/src/plan/render-sql.test.ts @@ -0,0 +1,70 @@ +/** + * renderPlanSql turns a plan (its preamble + ordered actions) into a single + * replayable .sql script — the same statement order apply() executes, with the + * preamble emitted as leading SET statements. This is what the Supabase + * baseline-fixture pipeline persists and what `applySupabaseBaseInit` replays. + */ +import { describe, expect, test } from "bun:test"; +import { renderPlanSql } from "./render-sql.ts"; + +describe("renderPlanSql", () => { + test("emits preamble SETs then each action, semicolon-terminated", () => { + const sql = renderPlanSql({ + preamble: [{ name: "check_function_bodies", value: "off" }], + actions: [ + { sql: "CREATE SCHEMA auth AUTHORIZATION supabase_admin" }, + { sql: 'GRANT USAGE ON SCHEMA auth TO "anon"' }, + ], + }); + expect(sql).toMatchInlineSnapshot(` + "SET check_function_bodies = off; + + CREATE SCHEMA auth AUTHORIZATION supabase_admin; + + GRANT USAGE ON SCHEMA auth TO "anon"; + + RESET check_function_bodies; + " + `); + }); + + test("does not double a trailing semicolon already present on an action", () => { + const sql = renderPlanSql({ + preamble: [], + actions: [{ sql: "CREATE SCHEMA foo;" }], + }); + expect(sql).toBe("CREATE SCHEMA foo;\n"); + }); + + test("trims trailing whitespace/newlines before terminating", () => { + const sql = renderPlanSql({ + preamble: [], + actions: [{ sql: "CREATE SCHEMA foo\n" }], + }); + expect(sql).toBe("CREATE SCHEMA foo;\n"); + }); + + test("returns an empty string for an empty plan (no preamble, no actions)", () => { + expect(renderPlanSql({ preamble: [], actions: [] })).toBe(""); + }); + + test("filters search_path out of the rendered preamble (redundant for fully qualified DDL; would misresolve a replayer's unqualified statements)", () => { + const sql = renderPlanSql({ + preamble: [ + { name: "search_path", value: "pg_catalog" }, + { name: "check_function_bodies", value: "off" }, + ], + actions: [{ sql: "CREATE SCHEMA app" }], + }); + // search_path is neither SET nor RESET — only check_function_bodies is. + expect(sql).not.toContain("search_path"); + expect(sql).toMatchInlineSnapshot(` + "SET check_function_bodies = off; + + CREATE SCHEMA app; + + RESET check_function_bodies; + " + `); + }); +}); diff --git a/packages/pg-delta/src/plan/render-sql.ts b/packages/pg-delta/src/plan/render-sql.ts new file mode 100644 index 000000000..8af87f8e9 --- /dev/null +++ b/packages/pg-delta/src/plan/render-sql.ts @@ -0,0 +1,56 @@ +/** + * Render a plan as a single replayable .sql script. + * + * The output mirrors what `apply()` (src/apply/apply.ts) executes: the plan's + * `preamble` session settings emitted as leading `SET`s (so forward-referencing + * function bodies elaborate — `check_function_bodies = off` is always present), + * then every action's SQL in `plan.actions` order (the dependency-sorted replay + * order), each terminated with a single semicolon. + * + * Unlike the executor, this produces a flat script with no transaction + * framing — callers replay it as one multi-statement batch on a single + * connection (`applySupabaseBaseInit`). That is correct for an all-transactional + * baseline (schemas / tables / functions / grants / roles); a plan containing a + * `nonTransactional` or `commitBoundaryAfter` action must not be rendered this + * way (the batch would fail inside the implicit transaction block). The Supabase + * baseline is all-transactional, and the pipeline's zero-diff replay gate would + * surface any regression. + */ +import type { Plan } from "./plan.ts"; + +export type RenderablePlan = Pick & { + actions: ReadonlyArray>; +}; + +export function renderPlanSql(plan: RenderablePlan): string { + const parts: string[] = []; + // Emit the preamble session settings EXCEPT search_path. The rendered DDL is + // already fully schema-qualified (extraction canonicalizes to pg_catalog + // before deparse), so pinning search_path is redundant here — and a + // third-party replayer that shares this batch's session with its own + // UNqualified statements (e.g. dbmate's `INSERT INTO schema_migrations ...`) + // would misresolve them under a pinned pg_catalog path. apply() keeps the pin + // on its own dedicated connection. check_function_bodies is retained. + const settings = plan.preamble.filter((s) => s.name !== "search_path"); + for (const setting of settings) { + parts.push(`SET ${setting.name} = ${setting.value};`); + } + for (const action of plan.actions) { + const sql = action.sql.trimEnd(); + parts.push(sql.endsWith(";") ? sql : `${sql};`); + } + // This script has no transaction framing, so its session-level SETs would + // persist on the connection after the batch — and callers replay it on a + // POOLED connection they hand back for reuse (schema-plan.ts seeds a shadow + // then releases the client; applySupabaseBaseInit replays on one connection). + // Reset the settings we actually emitted so a later borrower of that + // connection does not inherit them (no search_path RESET when no search_path + // SET was emitted). Targeted RESETs (not RESET ALL) leave any other session + // state the caller set around this batch untouched. + if (parts.length > 0) { + for (const setting of settings) { + parts.push(`RESET ${setting.name};`); + } + } + return parts.length === 0 ? "" : `${parts.join("\n\n")}\n`; +} diff --git a/packages/pg-delta/src/plan/render.ts b/packages/pg-delta/src/plan/render.ts new file mode 100644 index 000000000..000a0cf45 --- /dev/null +++ b/packages/pg-delta/src/plan/render.ts @@ -0,0 +1,165 @@ +/** SQL rendering primitives shared by the rule table. */ +import type { StableId } from "../core/stable-id.ts"; + +export function qid(name: string): string { + return `"${name.replaceAll('"', '""')}"`; +} + +export function lit(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function rel(schema: string, name: string): string { + return `${qid(schema)}.${qid(name)}`; +} + +export function routineSig(id: { + schema: string; + name: string; + args: string[]; +}): string { + return `${rel(id.schema, id.name)}(${id.args.join(", ")})`; +} + +/** SQL identity phrase for COMMENT ON / GRANT targets, per target kind. + * `opts.domainConstraint` selects the `ON DOMAIN …` form for a constraint that + * belongs to a domain (the id shape is identical to a table constraint). */ +export function commentTarget( + id: StableId, + opts?: { + domainConstraint?: boolean; + aggregateSignature?: string | undefined; + }, +): string { + switch (id.kind) { + case "schema": + return `SCHEMA ${qid(id.name)}`; + case "table": + return `TABLE ${rel(id.schema, id.name)}`; + case "view": + return `VIEW ${rel(id.schema, id.name)}`; + case "materializedView": + return `MATERIALIZED VIEW ${rel(id.schema, id.name)}`; + case "sequence": + return `SEQUENCE ${rel(id.schema, id.name)}`; + case "index": + return `INDEX ${rel(id.schema, id.name)}`; + case "column": + return `COLUMN ${rel(id.schema, id.table)}.${qid(id.name)}`; + case "constraint": + return opts?.domainConstraint + ? `CONSTRAINT ${qid(id.name)} ON DOMAIN ${rel(id.schema, id.table)}` + : `CONSTRAINT ${qid(id.name)} ON ${rel(id.schema, id.table)}`; + case "trigger": + return `TRIGGER ${qid(id.name)} ON ${rel(id.schema, id.table)}`; + case "policy": + return `POLICY ${qid(id.name)} ON ${rel(id.schema, id.table)}`; + case "function": + return `FUNCTION ${routineSig(id)}`; + case "procedure": + return `PROCEDURE ${routineSig(id)}`; + case "aggregate": + // A zero-argument aggregate's signature is `(*)`, not `()` — PostgreSQL + // requires COMMENT ON / SECURITY LABEL ON AGGREGATE name(*). An ordered-set + // / hypothetical-set aggregate must be addressed as + // `direct ORDER BY ordered`; the caller resolves that via the aggregate + // fact's `aggSig` and passes it as `opts.aggregateSignature` (undefined + // for ordinary aggregates, whose flat arg list is the correct signature). + return `AGGREGATE ${rel(id.schema, id.name)}(${ + opts?.aggregateSignature ?? + (id.args.length > 0 ? id.args.join(", ") : "*") + })`; + case "extension": + return `EXTENSION ${qid(id.name)}`; + case "role": + return `ROLE ${qid(id.name)}`; + case "domain": + return `DOMAIN ${rel(id.schema, id.name)}`; + case "type": + return `TYPE ${rel(id.schema, id.name)}`; + case "collation": + return `COLLATION ${rel(id.schema, id.name)}`; + case "foreignTable": + return `FOREIGN TABLE ${rel(id.schema, id.name)}`; + case "rule": + return `RULE ${qid(id.name)} ON ${rel(id.schema, id.table)}`; + case "eventTrigger": + return `EVENT TRIGGER ${qid(id.name)}`; + case "publication": + return `PUBLICATION ${qid(id.name)}`; + case "subscription": + return `SUBSCRIPTION ${qid(id.name)}`; + case "fdw": + return `FOREIGN DATA WRAPPER ${qid(id.name)}`; + case "server": + return `SERVER ${qid(id.name)}`; + default: + throw new Error(`commentTarget: unsupported kind ${id.kind}`); + } +} + +/** GRANT/REVOKE object phrase per target kind. */ +export function grantTarget(id: StableId): string { + switch (id.kind) { + case "table": + case "view": + case "materializedView": + return `TABLE ${rel(id.schema, id.name)}`; + case "sequence": + return `SEQUENCE ${rel(id.schema, id.name)}`; + case "schema": + return `SCHEMA ${qid(id.name)}`; + case "procedure": + return `PROCEDURE ${routineSig(id)}`; + case "function": + // aggregates are granted via the FUNCTION form (there is no + // GRANT ... ON AGGREGATE in PostgreSQL's privilege grammar). + case "aggregate": + return `FUNCTION ${routineSig(id)}`; + case "domain": + case "type": + return `TYPE ${rel(id.schema, id.name)}`; + case "foreignTable": + return `TABLE ${rel(id.schema, id.name)}`; + case "fdw": + return `FOREIGN DATA WRAPPER ${qid(id.name)}`; + case "server": + return `FOREIGN SERVER ${qid(id.name)}`; + default: + throw new Error(`grantTarget: unsupported kind ${id.kind}`); + } +} + +/** "k=v" option strings (as stored in pg_*options) → OPTIONS clause pieces. */ +export function splitOption(opt: string): [key: string, value: string] { + const i = opt.indexOf("="); + return i === -1 ? [opt, ""] : [opt.slice(0, i), opt.slice(i + 1)]; +} + +export function optionsClause(options: string[]): string { + if (options.length === 0) return ""; + const parts = options.map((opt) => { + const [key, value] = splitOption(opt); + return `${qid(key)} ${lit(value)}`; + }); + return ` OPTIONS (${parts.join(", ")})`; +} + +/** ALTER … OPTIONS (ADD/SET/DROP …) clause from old vs new option lists. */ +export function alterOptionsClause( + oldOptions: string[], + newOptions: string[], +): string { + const oldMap = new Map(oldOptions.map(splitOption)); + const newMap = new Map(newOptions.map(splitOption)); + const parts: string[] = []; + for (const [key, value] of newMap) { + if (!oldMap.has(key)) parts.push(`ADD ${qid(key)} ${lit(value)}`); + else if (oldMap.get(key) !== value) + parts.push(`SET ${qid(key)} ${lit(value)}`); + } + for (const key of oldMap.keys()) { + if (!newMap.has(key)) parts.push(`DROP ${qid(key)}`); + } + return `OPTIONS (${parts.join(", ")})`; +} diff --git a/packages/pg-delta/src/plan/role-config.test.ts b/packages/pg-delta/src/plan/role-config.test.ts new file mode 100644 index 000000000..39d48079c --- /dev/null +++ b/packages/pg-delta/src/plan/role-config.test.ts @@ -0,0 +1,45 @@ +/** + * Creating a role must materialize its GUC config (REVIEW_HANDOFF.md P1). + * Role config was handled only as a set-delta on an already-existing role, so + * an empty->configured-role plan emitted CREATE ROLE but dropped the + * `ALTER ROLE ... SET ...` follow-ups — the created role silently lost its + * config. A create rule must materialize every payload attribute that is not + * carried by a child fact or edge. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import { plan } from "./plan.ts"; + +const roleFact = (config: string[]): Fact => ({ + id: { kind: "role", name: "app_user" }, + payload: { login: true, inherit: true, config }, +}); + +describe("role create materializes GUC config", () => { + test("empty -> configured role emits ALTER ROLE ... SET", () => { + const actions = plan( + buildFactBase([], []), + buildFactBase([roleFact(["statement_timeout=5s"])], []), + ).actions; + const sql = actions.map((a) => a.sql); + expect(sql.some((s) => s.startsWith(`CREATE ROLE "app_user"`))).toBe(true); + expect(sql).toContain( + `ALTER ROLE "app_user" SET "statement_timeout" TO '5s'`, + ); + }); + + test("multiple config entries each emit a SET", () => { + const actions = plan( + buildFactBase([], []), + buildFactBase( + [roleFact(["statement_timeout=5s", "lock_timeout=1s"])], + [], + ), + ).actions; + const sql = actions.map((a) => a.sql); + expect(sql).toContain( + `ALTER ROLE "app_user" SET "statement_timeout" TO '5s'`, + ); + expect(sql).toContain(`ALTER ROLE "app_user" SET "lock_timeout" TO '1s'`); + }); +}); diff --git a/packages/pg-delta/src/plan/role-rename-carry.test.ts b/packages/pg-delta/src/plan/role-rename-carry.test.ts new file mode 100644 index 000000000..9959da2a3 --- /dev/null +++ b/packages/pg-delta/src/plan/role-rename-carry.test.ts @@ -0,0 +1,515 @@ +/** + * Unit tests for the role-rename carry Module (third follow-up review P2). + * Pure functions — no Docker / database required. + */ +import { describe, expect, test } from "bun:test"; +import type { Delta } from "../core/diff.ts"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import { + ALL_FACT_KINDS, + encodeId, + type FactKind, + type StableId, +} from "../core/stable-id.ts"; +import { + buildRoleRenameMap, + computeRoleRenameCarry, + ownerEdgeKey, + relabelRoleNames, + ROLE_NAME_BEARING_KINDS, + roleNamesIn, +} from "./role-rename-carry.ts"; +import { plan } from "./plan.ts"; + +const rename = new Map([["r1", "r2"]]); + +const rolePayload = () => ({ + superuser: false, + inherit: true, + createRole: false, + createDb: false, + login: false, + replication: false, + bypassRls: false, + config: ["statement_timeout=42424ms"], +}); + +const schema: StableId = { kind: "schema", name: "app" }; +const table: StableId = { kind: "table", schema: "app", name: "docs" }; +const policy: StableId = { + kind: "policy", + schema: "app", + table: "docs", + name: "docs_read", +}; + +const policyFact = (role: string): Fact => ({ + id: policy, + parent: table, + payload: { + cmd: "r", + permissive: true, + roles: [role], + usingExpr: "true", + checkExpr: null, + }, +}); + +const rolePolicyBase = (role: string, includeRole = true) => + buildFactBase( + [ + ...(includeRole + ? [ + { + id: { kind: "role", name: role } as StableId, + payload: rolePayload(), + }, + ] + : []), + { id: schema, payload: {} }, + { id: table, parent: schema, payload: { persistence: "p" } }, + policyFact(role), + ], + [], + ); + +describe("accepted role rename + policy role payload (B1)", () => { + test("orders the rename before ALTER POLICY without a dependency cycle", () => { + let thePlan!: ReturnType; + expect(() => { + thePlan = plan(rolePolicyBase("role_a"), rolePolicyBase("role_b"), { + renames: "auto", + compact: false, + }); + }).not.toThrow(); + + expect(thePlan.actions.map((action) => action.sql)).toMatchInlineSnapshot(` + [ + "ALTER ROLE "role_a" RENAME TO "role_b"", + "ALTER POLICY "docs_read" ON "app"."docs" TO "role_b"", + ] + `); + }); + + test("still releases a genuinely dropped role before DROP ROLE", () => { + const source = rolePolicyBase("role_a"); + const desired = rolePolicyBase("PUBLIC", false); + const thePlan = plan(source, desired, { + renames: "auto", + compact: false, + }); + + const alterPolicy = thePlan.actions.findIndex((action) => + action.sql.startsWith('ALTER POLICY "docs_read"'), + ); + const dropRole = thePlan.actions.findIndex((action) => + action.sql.includes('DROP ROLE "role_a"'), + ); + expect(alterPolicy).toBeGreaterThanOrEqual(0); + expect(dropRole).toBeGreaterThanOrEqual(0); + expect(alterPolicy).toBeLessThan(dropRole); + }); +}); + +describe("relabelRoleNames", () => { + test("remaps a bare role id", () => { + expect(relabelRoleNames({ kind: "role", name: "r1" }, rename)).toEqual({ + kind: "role", + name: "r2", + }); + }); + + test("remaps acl grantee, leaves the object target", () => { + const id: StableId = { + kind: "acl", + target: { kind: "table", schema: "app", name: "t" }, + grantee: "r1", + }; + expect(relabelRoleNames(id, rename)).toEqual({ + kind: "acl", + target: { kind: "table", schema: "app", name: "t" }, + grantee: "r2", + }); + }); + + test("preserves the column field of a COLUMN-level acl", () => { + // regression: a COLUMN-level grant's `column` field must survive relabeling + // (only the grantee/role changes). Dropping it makes the relabeled key miss + // its desired counterpart, so a pure role rename spuriously REVOKE/GRANTs + // the column grant instead of letting PostgreSQL carry it by OID. + const id: StableId = { + kind: "acl", + target: { kind: "table", schema: "app", name: "t" }, + grantee: "r1", + column: "col1", + }; + expect(relabelRoleNames(id, rename)).toEqual({ + kind: "acl", + target: { kind: "table", schema: "app", name: "t" }, + grantee: "r2", + column: "col1", + }); + }); + + test("remaps both ends of a membership", () => { + const id: StableId = { kind: "membership", role: "r1", member: "r1" }; + expect(relabelRoleNames(id, rename)).toEqual({ + kind: "membership", + role: "r2", + member: "r2", + }); + }); + + test("remaps defaultPrivilege role + grantee, keeps schema/objtype", () => { + const id: StableId = { + kind: "defaultPrivilege", + role: "r1", + schema: "app", + objtype: "r", + grantee: "r1", + }; + expect(relabelRoleNames(id, rename)).toEqual({ + kind: "defaultPrivilege", + role: "r2", + schema: "app", + objtype: "r", + grantee: "r2", + }); + }); + + test("remaps userMapping role, keeps server", () => { + const id: StableId = { kind: "userMapping", server: "srv", role: "r1" }; + expect(relabelRoleNames(id, rename)).toEqual({ + kind: "userMapping", + server: "srv", + role: "r2", + }); + }); + + test("recurses into a comment ON a role", () => { + const id: StableId = { + kind: "comment", + target: { kind: "role", name: "r1" }, + }; + expect(relabelRoleNames(id, rename)).toEqual({ + kind: "comment", + target: { kind: "role", name: "r2" }, + }); + }); + + test("leaves an id that references no renamed role unchanged", () => { + const id: StableId = { kind: "table", schema: "app", name: "t" }; + expect(encodeId(relabelRoleNames(id, rename))).toBe(encodeId(id)); + const dpOther: StableId = { + kind: "defaultPrivilege", + role: "other", + schema: "app", + objtype: "r", + grantee: "PUBLIC", + }; + expect(encodeId(relabelRoleNames(dpOther, rename))).toBe(encodeId(dpOther)); + }); +}); + +describe("buildRoleRenameMap", () => { + test("collects role↔role renames only", () => { + const map = buildRoleRenameMap([ + { + from: { id: { kind: "role", name: "r1" }, payload: {} }, + to: { id: { kind: "role", name: "r2" }, payload: {} }, + }, + { + from: { + id: { kind: "table", schema: "app", name: "old" }, + payload: {}, + }, + to: { id: { kind: "table", schema: "app", name: "new" }, payload: {} }, + }, + ]); + expect([...map]).toEqual([["r1", "r2"]]); + }); +}); + +describe("computeRoleRenameCarry", () => { + const dp = (role: string): StableId => ({ + kind: "defaultPrivilege", + role, + schema: "app", + objtype: "r", + grantee: "PUBLIC", + }); + const table: StableId = { kind: "table", schema: "app", name: "t" }; + + test("carries an identical default-privilege remove/add pair", () => { + const deltas: Delta[] = [ + { + verb: "remove", + fact: { + id: dp("r1"), + payload: { privileges: ["SELECT"], grantable: [] }, + }, + }, + { + verb: "add", + fact: { + id: dp("r2"), + payload: { privileges: ["SELECT"], grantable: [] }, + }, + }, + ]; + const { carriedFactKeys } = computeRoleRenameCarry(deltas, rename); + expect(carriedFactKeys.has(encodeId(dp("r1")))).toBe(true); + expect(carriedFactKeys.has(encodeId(dp("r2")))).toBe(true); + }); + + test("carries an identical COLUMN-level acl remove/add pair", () => { + // regression: the carry must recognise a column-qualified grant across a + // pure role rename (identity differs only by grantee), preserving `column`. + const colAcl = (grantee: string): StableId => ({ + kind: "acl", + target: table, + grantee, + column: "col1", + }); + const deltas: Delta[] = [ + { + verb: "remove", + fact: { + id: colAcl("r1"), + payload: { privileges: ["SELECT"], grantable: [] }, + }, + }, + { + verb: "add", + fact: { + id: colAcl("r2"), + payload: { privileges: ["SELECT"], grantable: [] }, + }, + }, + ]; + const { carriedFactKeys } = computeRoleRenameCarry(deltas, rename); + expect(carriedFactKeys.has(encodeId(colAcl("r1")))).toBe(true); + expect(carriedFactKeys.has(encodeId(colAcl("r2")))).toBe(true); + }); + + test("does NOT carry a pair whose payload also changed", () => { + const deltas: Delta[] = [ + { + verb: "remove", + fact: { + id: dp("r1"), + payload: { privileges: ["SELECT"], grantable: [] }, + }, + }, + { + verb: "add", + fact: { + id: dp("r2"), + payload: { privileges: ["INSERT"], grantable: [] }, + }, + }, + ]; + const { carriedFactKeys } = computeRoleRenameCarry(deltas, rename); + expect(carriedFactKeys.size).toBe(0); + }); + + test("carries an owner unlink/link pair on a stable object", () => { + const deltas: Delta[] = [ + { + verb: "unlink", + edge: { from: table, to: { kind: "role", name: "r1" }, kind: "owner" }, + }, + { + verb: "link", + edge: { from: table, to: { kind: "role", name: "r2" }, kind: "owner" }, + }, + ]; + const { carriedOwnerLinks } = computeRoleRenameCarry(deltas, rename); + expect( + carriedOwnerLinks.has(ownerEdgeKey(table, { kind: "role", name: "r2" })), + ).toBe(true); + }); + + test("does NOT carry an owner change to a non-renamed role", () => { + const deltas: Delta[] = [ + { + verb: "unlink", + edge: { from: table, to: { kind: "role", name: "r1" }, kind: "owner" }, + }, + { + verb: "link", + edge: { from: table, to: { kind: "role", name: "r3" }, kind: "owner" }, + }, + ]; + const { carriedOwnerLinks } = computeRoleRenameCarry(deltas, rename); + expect(carriedOwnerLinks.size).toBe(0); + }); + + test("empty rename map carries nothing", () => { + const deltas: Delta[] = [ + { verb: "remove", fact: { id: dp("r1"), payload: {} } }, + { verb: "add", fact: { id: dp("r2"), payload: {} } }, + ]; + const { carriedFactKeys, carriedOwnerLinks } = computeRoleRenameCarry( + deltas, + new Map(), + ); + expect(carriedFactKeys.size).toBe(0); + expect(carriedOwnerLinks.size).toBe(0); + }); + + test("a changed payload becomes a changedFacts pair, not a carried key", () => { + const deltas: Delta[] = [ + { + verb: "remove", + fact: { + id: dp("r1"), + payload: { privileges: ["SELECT"], grantable: [] }, + }, + }, + { + verb: "add", + fact: { + id: dp("r2"), + payload: { privileges: ["INSERT"], grantable: [] }, + }, + }, + ]; + const { carriedFactKeys, changedFacts } = computeRoleRenameCarry( + deltas, + rename, + ); + expect(carriedFactKeys.size).toBe(0); + expect(changedFacts).toHaveLength(1); + expect(encodeId(changedFacts[0]!.from)).toBe(encodeId(dp("r1"))); + expect(encodeId(changedFacts[0]!.to)).toBe(encodeId(dp("r2"))); + }); + + test("a remove with no relabeled counterpart is neither carried nor changed", () => { + const deltas: Delta[] = [ + { verb: "remove", fact: { id: dp("r1"), payload: {} } }, + ]; + const { carriedFactKeys, changedFacts } = computeRoleRenameCarry( + deltas, + rename, + ); + expect(carriedFactKeys.size).toBe(0); + expect(changedFacts).toHaveLength(0); + }); +}); + +describe("roleNamesIn", () => { + test("collects role names across kinds (recursing into targets)", () => { + expect( + [...roleNamesIn({ kind: "membership", role: "g", member: "m" })].sort(), + ).toEqual(["g", "m"]); + expect( + [ + ...roleNamesIn({ + kind: "defaultPrivilege", + role: "owner", + schema: "app", + objtype: "r", + grantee: "PUBLIC", + }), + ].sort(), + ).toEqual(["PUBLIC", "owner"]); + expect([ + ...roleNamesIn({ + kind: "comment", + target: { kind: "role", name: "r1" }, + }), + ]).toEqual(["r1"]); + // an object id embeds no role name + expect([ + ...roleNamesIn({ kind: "table", schema: "app", name: "t" }), + ]).toEqual([]); + }); +}); + +describe("role-name-bearing kind registry (review P3 guard)", () => { + // every StableId kind must be classified as role-name-bearing or not; a NEW + // kind added to ALL_FACT_KINDS lands here and fails until it is triaged. + const NON_ROLE_BEARING: ReadonlySet = new Set([ + "schema", + "extension", + "language", + "eventTrigger", + "publication", + "subscription", + "fdw", + "server", + "table", + "view", + "materializedView", + "foreignTable", + "sequence", + "index", + "collation", + "domain", + "type", + "column", + "constraint", + "trigger", + "rule", + "policy", + "default", + "function", + "procedure", + "aggregate", + "typeAttribute", + "publicationRel", + "publicationSchema", + // extension intent ids carry ext/intentKind/key only; any role reference + // (e.g. a cron job's `username`) lives in the payload, not the id, so no + // role name is relabeled — same honest blind spot as function bodies. + "extensionIntent", + ]); + + test("ROLE_NAME_BEARING_KINDS and NON_ROLE_BEARING partition ALL_FACT_KINDS", () => { + for (const kind of ALL_FACT_KINDS) { + const bearing = ROLE_NAME_BEARING_KINDS.has(kind); + const nonBearing = NON_ROLE_BEARING.has(kind); + // exactly one of the two sets must claim the kind + expect(bearing !== nonBearing).toBe(true); + } + expect(ROLE_NAME_BEARING_KINDS.size + NON_ROLE_BEARING.size).toBe( + ALL_FACT_KINDS.length, + ); + }); + + test("relabelRoleNames actually transforms every role-name-bearing kind", () => { + // each bearing kind, given an id that references the renamed role, must come + // back CHANGED — i.e. it is handled in the switch, not the default branch + const samples: Record = { + role: { kind: "role", name: "r1" }, + membership: { kind: "membership", role: "r1", member: "x" }, + userMapping: { kind: "userMapping", server: "s", role: "r1" }, + defaultPrivilege: { + kind: "defaultPrivilege", + role: "r1", + schema: null, + objtype: "r", + grantee: "PUBLIC", + }, + acl: { + kind: "acl", + target: { kind: "table", schema: "a", name: "t" }, + grantee: "r1", + }, + comment: { kind: "comment", target: { kind: "role", name: "r1" } }, + securityLabel: { + kind: "securityLabel", + target: { kind: "role", name: "r1" }, + provider: "p", + }, + }; + for (const kind of ROLE_NAME_BEARING_KINDS) { + const sample = samples[kind]; + expect(sample, `missing sample for ${kind}`).toBeDefined(); + expect(encodeId(relabelRoleNames(sample!, rename))).not.toBe( + encodeId(sample!), + ); + } + }); +}); diff --git a/packages/pg-delta/src/plan/role-rename-carry.ts b/packages/pg-delta/src/plan/role-rename-carry.ts new file mode 100644 index 000000000..2ef3a13da --- /dev/null +++ b/packages/pg-delta/src/plan/role-rename-carry.ts @@ -0,0 +1,225 @@ +/** + * Role-rename carry (third follow-up review, P2 — the Depth Module). + * + * PostgreSQL stores role references by OID, so `ALTER ROLE r1 RENAME TO r2` + * carries every role-name-bearing fact automatically — the planner must not + * re-issue DDL for them. Several stable ids embed role NAMES rather than OIDs: + * + * - `owner` edges (edge.to is a role) + * - `acl` (grantee; target may itself be a role for comments) + * - `membership` (role + member) + * - `userMapping` (role) + * - `defaultPrivilege` (role + grantee) + * + * A diff taken across an accepted role rename surfaces each of these as a + * remove/add (or owner unlink/link) pair differing only by the renamed name. + * Left alone they CHURN: a REVOKE for the old name, the rename, a GRANT for the + * new name. The final state still converges, but the DDL is unnecessary, may + * demand privileges a pure rename would not, and — for owner edges — produces a + * dependency cycle (the owner action consumes the produced role and releases + * the destroyed one). See the third follow-up review P1/P2. + * + * This is the single seam that answers "does an accepted role rename carry this + * delta?", so the planner cancels/skips carried deltas in ONE place instead of + * spreading role-name knowledge across emission branches. + */ +import type { Delta } from "../core/diff.ts"; +import type { Fact } from "../core/fact.ts"; +import { canonicalize } from "../core/hash.ts"; +import { encodeId, type FactKind, type StableId } from "../core/stable-id.ts"; + +/** + * Every `StableId` kind that embeds a role NAME (and is therefore relabeled by + * `relabelRoleNames`). `comment` / `securityLabel` qualify only via a role + * `target`; `acl` via its `grantee` (and a role target). A guard test + * (role-rename-carry.test.ts) partitions the full `ALL_FACT_KINDS` inventory + * against this set, so a NEW role-name-bearing kind cannot slip through + * `relabelRoleNames`' default branch silently (review P3). + */ +export const ROLE_NAME_BEARING_KINDS: ReadonlySet = new Set([ + "role", + "membership", + "userMapping", + "defaultPrivilege", + "acl", + "comment", + "securityLabel", +]); + +/** Build the source-role-name → dest-role-name map from accepted renames. + * Only role↔role renames contribute; object renames are carried elsewhere. */ +export function buildRoleRenameMap( + acceptedRenames: ReadonlyArray<{ from: Fact; to: Fact }>, +): Map { + const map = new Map(); + for (const { from, to } of acceptedRenames) { + if (from.id.kind === "role" && to.id.kind === "role") { + map.set( + (from.id as { name: string }).name, + (to.id as { name: string }).name, + ); + } + } + return map; +} + +/** Remap every role NAME embedded in a stable id through an accepted role + * rename (source name → dest name); recurses into `target` for comment / acl / + * securityLabel (a comment or security label may be ON a role). Ids that embed + * no renamed role come back referentially unchanged in content. */ +export function relabelRoleNames( + id: StableId, + rename: ReadonlyMap, +): StableId { + const remap = (name: string): string => rename.get(name) ?? name; + // Each case SPREADS the original id and overrides ONLY the role-bearing + // field(s) (and recurses into `target`). Reconstructing an id field-by-field + // silently drops any field not re-listed — that regressed the `column` field + // of a COLUMN-level acl, making a pure role rename spuriously REVOKE/GRANT + // the column grant. Spreading keeps future id-field additions carried. + switch (id.kind) { + case "role": + return { ...id, name: remap(id.name) }; + case "membership": + return { ...id, role: remap(id.role), member: remap(id.member) }; + case "userMapping": + return { ...id, role: remap(id.role) }; + case "defaultPrivilege": + return { ...id, role: remap(id.role), grantee: remap(id.grantee) }; + case "acl": + return { + ...id, + target: relabelRoleNames(id.target, rename), + grantee: remap(id.grantee), + }; + case "comment": + return { ...id, target: relabelRoleNames(id.target, rename) }; + case "securityLabel": + return { ...id, target: relabelRoleNames(id.target, rename) }; + default: + // object kinds (table, schema, function, …) embed no role name in their id + return id; + } +} + +/** Every role NAME a stable id embeds (recursing into comment/acl/securityLabel + * targets). Used to order a post-rename mutation AFTER the role rename by + * consuming the renamed role(s) the id references. */ +export function roleNamesIn(id: StableId): Set { + const names = new Set(); + const walk = (x: StableId): void => { + switch (x.kind) { + case "role": + names.add(x.name); + break; + case "membership": + names.add(x.role); + names.add(x.member); + break; + case "userMapping": + names.add(x.role); + break; + case "defaultPrivilege": + names.add(x.role); + names.add(x.grantee); + break; + case "acl": + names.add(x.grantee); + walk(x.target); + break; + case "comment": + case "securityLabel": + walk(x.target); + break; + default: + break; + } + }; + walk(id); + return names; +} + +/** Encoded key for an `owner` edge — the planner's owner-emission loop skips + * links whose key is carried. */ +export function ownerEdgeKey(from: StableId, to: StableId): string { + return `${encodeId(from)}|owner|${encodeId(to)}`; +} + +export interface RoleRenameCarry { + /** encoded ids of remove+add FACT deltas the rename carries UNCHANGED — cancel + * these from the planner's `removed`/`added` worklists so no drop/create emits */ + carriedFactKeys: Set; + /** owner LINK edge keys the rename carries — the owner-emission loop skips + * these (the role rename already relabels the owner by OID) */ + carriedOwnerLinks: Set; + /** role-name-bearing facts whose IDENTITY the rename carries but whose PAYLOAD + * also changed: `from` (old-name remove) → `to` (new-name add). The planner + * cancels both and emits a post-rename MUTATION on `to` (alter, or drop+create + * for replace-shaped attrs) instead of old-name teardown + new-name create + * (review P2, fourth follow-up). */ + changedFacts: Array<{ from: StableId; to: StableId }>; +} + +/** + * Determine which deltas an accepted set of role renames carries. A delta is + * carried iff relabeling its role-name references yields the EXACT counterpart + * on the other side: + * + * - a `remove` fact whose relabeled id matches an `add` fact with an + * identical payload, or + * - an owner `unlink` whose relabeled target matches an owner `link` on the + * SAME object. + * + * A pair whose payload ALSO changed is returned as a `changedFacts` entry: the + * rename carries the identity, and the planner mutates the post-rename id (it + * does not tear down the old name and recreate the new one). + */ +export function computeRoleRenameCarry( + deltas: readonly Delta[], + rename: ReadonlyMap, +): RoleRenameCarry { + const carriedFactKeys = new Set(); + const carriedOwnerLinks = new Set(); + const changedFacts: Array<{ from: StableId; to: StableId }> = []; + if (rename.size === 0) + return { carriedFactKeys, carriedOwnerLinks, changedFacts }; + + const addByKey = new Map(); + const ownerLinkKeys = new Set(); + for (const d of deltas) { + if (d.verb === "add") addByKey.set(encodeId(d.fact.id), d.fact); + else if (d.verb === "link" && d.edge.kind === "owner") + ownerLinkKeys.add(ownerEdgeKey(d.edge.from, d.edge.to)); + } + + for (const d of deltas) { + if (d.verb === "remove") { + const sourceKey = encodeId(d.fact.id); + const relabeled = relabelRoleNames(d.fact.id, rename); + const relabeledKey = encodeId(relabeled); + if (relabeledKey === sourceKey) continue; // references no renamed role + const add = addByKey.get(relabeledKey); + if (add === undefined) continue; // no counterpart → genuine drop, not carried + if (canonicalize(add.payload) === canonicalize(d.fact.payload)) { + // identical payload → the rename carries it wholesale; no DDL + carriedFactKeys.add(sourceKey); + carriedFactKeys.add(relabeledKey); + } else { + // identity carried, payload changed → mutate the post-rename id + changedFacts.push({ from: d.fact.id, to: add.id }); + } + } else if (d.verb === "unlink" && d.edge.kind === "owner") { + const to = d.edge.to; + if (to.kind !== "role") continue; + const newName = rename.get((to as { name: string }).name); + if (newName === undefined) continue; + const linkKey = ownerEdgeKey(d.edge.from, { + kind: "role", + name: newName, + }); + if (ownerLinkKeys.has(linkKey)) carriedOwnerLinks.add(linkKey); + } + } + + return { carriedFactKeys, carriedOwnerLinks, changedFacts }; +} diff --git a/packages/pg-delta/src/plan/routine-metadata.test.ts b/packages/pg-delta/src/plan/routine-metadata.test.ts new file mode 100644 index 000000000..f68b52f7a --- /dev/null +++ b/packages/pg-delta/src/plan/routine-metadata.test.ts @@ -0,0 +1,107 @@ +/** + * Routine metadata/ACL rendering must respect function vs procedure + * (REVIEW_HANDOFF.md P0). PostgreSQL rejects the FUNCTION form for a real + * procedure: `COMMENT ON FUNCTION public.p()` and `GRANT ... ON FUNCTION + * public.p()` both error with "public.p() is not a function". The comment / + * ACL / security-label renderers address a routine purely by its stable id, + * so the id must carry the function-vs-procedure distinction. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const roleFact: Fact = { + id: { kind: "role", name: "r" }, + payload: {}, +}; + +const procId: StableId = { + kind: "procedure", + schema: "app", + name: "p", + args: [], +}; +const procFact: Fact = { + id: procId, + parent: { kind: "schema", name: "app" }, + payload: { + def: `CREATE PROCEDURE "app"."p"() LANGUAGE sql AS $$ SELECT 1 $$`, + }, +}; +const procComment: Fact = { + id: { kind: "comment", target: procId }, + parent: procId, + payload: { text: "does things" }, +}; +const procAcl: Fact = { + id: { kind: "acl", target: procId, grantee: "r" }, + parent: procId, + payload: { privileges: ["EXECUTE"], grantable: [] }, +}; + +// the schema + grantee role pre-exist on both sides; only the routine and its +// satellites are added, so the plan is the metadata DDL under test. +const base = (extra: Fact[]) => + buildFactBase([schemaFact, roleFact, ...extra], []); + +describe("routine metadata/ACL rendering (function vs procedure)", () => { + test("COMMENT on a procedure uses PROCEDURE, not FUNCTION", () => { + const actions = plan(base([]), base([procFact, procComment])).actions; + const sql = actions.map((a) => a.sql); + expect(sql).toContain(`COMMENT ON PROCEDURE "app"."p"() IS 'does things'`); + expect(sql.some((s) => s.includes(`COMMENT ON FUNCTION "app"."p"()`))).toBe( + false, + ); + }); + + test("GRANT EXECUTE on a procedure uses PROCEDURE, not FUNCTION", () => { + // compact: false to keep the decomposed REVOKE+GRANT pair — the co-create + // REVOKE elision (elideCoCreateRevokeBeforeGrant) would otherwise drop the + // leading REVOKE, and this test asserts the keyword on BOTH statements. + const actions = plan(base([]), base([procFact, procAcl]), { + compact: false, + }).actions; + const sql = actions.map((a) => a.sql); + expect(sql).toContain(`GRANT EXECUTE ON PROCEDURE "app"."p"() TO "r"`); + expect(sql).toContain(`REVOKE ALL ON PROCEDURE "app"."p"() FROM "r"`); + expect(sql.some((s) => s.includes(`ON FUNCTION "app"."p"()`))).toBe(false); + }); + + // regression guard: a real function must still render the FUNCTION keyword. + test("COMMENT/GRANT on a function still use FUNCTION", () => { + const fnId: StableId = { + kind: "function", + schema: "app", + name: "f", + args: [], + }; + const fnFact: Fact = { + id: fnId, + parent: { kind: "schema", name: "app" }, + payload: { + def: `CREATE FUNCTION "app"."f"() RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$`, + }, + }; + const fnComment: Fact = { + id: { kind: "comment", target: fnId }, + parent: fnId, + payload: { text: "computes" }, + }; + const fnAcl: Fact = { + id: { kind: "acl", target: fnId, grantee: "r" }, + parent: fnId, + payload: { privileges: ["EXECUTE"], grantable: [] }, + }; + const sql = plan(base([]), base([fnFact, fnComment, fnAcl])).actions.map( + (a) => a.sql, + ); + expect(sql).toContain(`COMMENT ON FUNCTION "app"."f"() IS 'computes'`); + expect(sql).toContain(`GRANT EXECUTE ON FUNCTION "app"."f"() TO "r"`); + expect(sql.some((s) => s.includes(`ON PROCEDURE "app"."f"()`))).toBe(false); + }); +}); diff --git a/packages/pg-delta/src/plan/rule-flags.ts b/packages/pg-delta/src/plan/rule-flags.ts new file mode 100644 index 000000000..f366ea55b --- /dev/null +++ b/packages/pg-delta/src/plan/rule-flags.ts @@ -0,0 +1,33 @@ +/** + * Per-kind graph/suppression flags read from the rule table (guardrail 3): the + * planner body and its phases hold NO kind-name lists — they ask the rule table. + * `rulesFor` throws for unknown kinds, so `ruleFlag` guards it and the boolean + * accessors default to false. + * + * This stays keyed on the kind STRING (not an id resolver): every flag it serves + * (`cascadesToChildren`, `rebuildable`, and via callers `defaclObjtype`, + * `ownerAlterPrefix`) must be `undefined`/false for the `extensionIntent` kind — + * intent facts have no children, are not rebuildable, have no default ACLs, and + * are not ownable. `rulesFor("extensionIntent")` throws → the catch returns + * undefined, which is exactly the correct answer, so no intent-aware resolver is + * needed here. + */ +import type { KindRules } from "./rules.ts"; +import { rulesFor } from "./rules.ts"; + +export function ruleFlag( + kind: string, + flag: K, +): KindRules[K] | undefined { + try { + return rulesFor(kind)[flag]; + } catch { + return undefined; + } +} + +export const cascadesToChildren = (kind: string): boolean => + ruleFlag(kind, "cascadesToChildren") === true; + +export const isRebuildable = (kind: string): boolean => + ruleFlag(kind, "rebuildable") === true; diff --git a/packages/pg-delta/src/plan/rules.ts b/packages/pg-delta/src/plan/rules.ts new file mode 100644 index 000000000..f58bb1e91 --- /dev/null +++ b/packages/pg-delta/src/plan/rules.ts @@ -0,0 +1,325 @@ +/** + * The rule table (target-architecture §3.4): the ONLY per-kind logic in the + * system. Structured data — functions confined to template slots + * (guardrail 3). Each rule maps facts/attribute-changes to SQL plus the + * dependency metadata the graph needs. + */ +import type { DependencyEdge, Fact } from "../core/fact.ts"; +import type { PayloadValue } from "../core/hash.ts"; +import type { StableId } from "../core/stable-id.ts"; +import type { LockClass } from "./locks.ts"; +import { constraintRules } from "./rules/constraints.ts"; +import { foreignRules } from "./rules/foreign.ts"; +import { indexRules } from "./rules/indexes.ts"; +import { metadataRules } from "./rules/metadata.ts"; +import { policyRules } from "./rules/policies.ts"; +import { publicationRules } from "./rules/publications.ts"; +import { roleRules } from "./rules/roles.ts"; +import { routineRules } from "./rules/routines.ts"; +import { schemaRules } from "./rules/schemas.ts"; +import { sequenceRules } from "./rules/sequences.ts"; +import { tableRules } from "./rules/tables.ts"; +import { triggerRules } from "./rules/triggers.ts"; +import { typeRules } from "./rules/types.ts"; +import { viewRules } from "./rules/views.ts"; + +export interface ActionSpec { + sql: string; + /** extra consumed ids beyond the fact's parent (which is implicit) */ + consumes?: StableId[]; + /** additional fact ids this statement produces (delta-set inlining) */ + alsoProduces?: StableId[]; + /** ids this statement implicitly destroys even though no drop action + * exists for them (e.g. DROP IDENTITY removes the backing sequence) */ + alsoDestroys?: StableId[]; + /** ids this statement stops referencing (e.g. the OLD owner of an + * ALTER … OWNER TO) — the action must run before their destroyer */ + releases?: StableId[]; + dataLoss?: "none" | "destructive"; + rewriteRisk?: boolean; + /** lock-class override for this specific DDL form (defaults come from + * the vetted (kind, verb) table in locks.ts) */ + lockClass?: LockClass; + /** three-valued transactionality (§3.8). Default: "transactional". + * - nonTransactional: cannot run inside a transaction block at all + * (CREATE INDEX CONCURRENTLY, DROP SUBSCRIPTION with a slot) + * - commitBoundaryAfter: runs in a transaction but its effect is not + * usable before commit (ALTER TYPE … ADD VALUE) — the executor forces + * a segment boundary before any consumer of what it touched */ + transactionality?: + | "transactional" + | "nonTransactional" + | "commitBoundaryAfter"; + /** compaction (§3.6): this statement is a clause that may fold into the + * CREATE of `foldInto` when no graph edge crosses the merge */ + compaction?: { foldInto: StableId; clause: string }; + /** this CREATE accepts column-clause folds (bare CREATE TABLE only) */ + acceptsColumnFolds?: boolean; +} + +/** Named serialize parameters the rule table consumes. Policies (stage 8) + * set them; referencing an unknown name is a plan-time error, not a + * silent no-op. */ +export const KNOWN_PARAMS: ReadonlySet = new Set(["concurrentIndexes"]); +export type PlanParams = Record; + +export type AttributeRule = + | { + alter: ( + fact: Fact, + from: PayloadValue, + to: PayloadValue, + view: FactView, + sourceView: FactView, + ) => ActionSpec | ActionSpec[]; + /** When this transition force-rebuilds surviving dependents (drop + + * recreate around the alter): + * - `true` → every rebuildable dependent (enum value-set migration: + * views/defaults/routines must be out of the way) + * - `string[]` → only dependents of these kinds (ALTER COLUMN TYPE is + * blocked by views/rules/policies but NOT indexes/constraints, + * which PostgreSQL rebuilds itself — force-dropping a PK with + * dependent FKs would cascade harmfully) + * - `false`/absent → none */ + rebuildsDependents?: ( + from: PayloadValue, + to: PayloadValue, + ) => boolean | readonly string[]; + /** When a specific transition has no in-place ALTER grammar, route the + * WHOLE fact to replace (drop + recreate) instead of emitting `alter` — + * e.g. unsetting a foreign server VERSION, or SET SCHEMA on a + * non-relocatable extension. Evaluated during replacement classification; + * the fact is then dropped + recreated (its `alter` never runs). */ + replaceWhen?: ( + from: PayloadValue, + to: PayloadValue, + fact: Fact, + ) => boolean; + } + | "replace"; + +/** Read-only view over the desired state, for rules that inline children. */ +export interface FactView { + childrenOf(id: StableId): Fact[]; + facts(): Fact[]; + get(id: StableId): Fact | undefined; + /** Outgoing dependency edges of `id` (with kind), so a rule can order its + * action after the objects the fact references — e.g. a routine's def-alter + * consuming its `depends` targets for BEGIN ATOMIC body validation. */ + outgoingEdges(id: StableId): readonly DependencyEdge[]; + /** Edges pointing AT `id` (with kind), so a rule can inspect what depends on + * it — e.g. the DROP EXTENSION rule reading `memberOfExtension` edges from its + * reference-only members to decide data-loss from the member closure. */ + incomingEdges(id: StableId): readonly DependencyEdge[]; + readonly edges: readonly { from: StableId; to: StableId }[]; + /** Whether `id` is present for REFERENCE ONLY (kept in the view so dependents + * resolve, but never itself created/dropped/altered — e.g. an assumed-schema + * platform object). Lets a rule tell "present on the target" from "produced + * by this plan". */ + isReferenceOnly(id: StableId): boolean; +} + +export interface KindRules { + /** `sourceView` is the resolved SOURCE (target) view, so a rule can decide by + * plan-time presence — e.g. CREATE EXTENSION omits `SCHEMA s` when schema `s` + * is neither on the target nor produced by this plan (the extension creates + * it). Optional so existing single-arg rules stay type-compatible. */ + create( + fact: Fact, + view: FactView, + params?: PlanParams, + sourceView?: FactView, + ): ActionSpec[]; + /** `view` is the resolved SOURCE (target) view the dropped fact lives in, so a + * rule can derive drop metadata from the view — e.g. DROP EXTENSION reading + * its reference-only members' `memberOfExtension` edges to flag data-loss. + * Optional so existing single-arg drop rules stay type-compatible. */ + drop(fact: Fact, view?: FactView): ActionSpec; + /** rename support (stage 9): render the in-place rename from the old + * fact to the new id. Kinds without this member never become rename + * candidates (their changes stay drop+create). */ + rename?: (fact: Fact, to: StableId) => ActionSpec; + attributes: Record; + /** kind weight for deterministic tie-breaking (pg_dump-inspired) */ + weight: number; + /** Returns the `ALTER ` prefix WITHOUT ` OWNER TO …`, + * used by the planner to emit owner actions from owner-edge link deltas. + * Absent for kinds that are not ownable (have no ALTER … OWNER TO). */ + ownerAlterPrefix?: (fact: Fact) => string; + + // ── graph/suppression policy (guardrail 3: per-kind knowledge lives + // HERE, never in the planner body) ────────────────────────────────── + /** this fact vanishes with its parent regardless of the parent's kind + * (comment, acl) — a metadata satellite */ + metadata?: boolean; + /** a DROP of this kind cascades to its children in PostgreSQL, so child + * drops fold into it (table, view, type, …). schema/role do NOT cascade. */ + cascadesToChildren?: boolean; + /** a surviving dependent of a destroyed fact of this kind is force- + * rebuilt (drop + recreate from the desired state) */ + rebuildable?: boolean; + /** whether a fact of this kind MAY be folded into a parent's cascading + * drop. Default true. FK constraints return false: an explicit + * DROP CONSTRAINT first makes mutual-FK teardown cycles unconstructible. */ + suppressible?: (fact: Fact) => boolean; + /** redirect this fact's drop to fold into a NON-parent ancestor's drop + * when that ancestor is being removed (an OWNED BY sequence folds into + * its owning column/table, which is not its catalog parent). */ + dropRootRedirect?: ( + fact: Fact, + isRemoved: (id: StableId) => boolean, + ) => StableId | undefined; + /** pg_default_acl objtype char for the default-privilege hygiene pass + * (table/view/matview/foreignTable → 'r', sequence → 'S', + * procedure/aggregate → 'f'); absent for kinds with no default ACLs */ + defaclObjtype?: string; +} + +/** + * The rule registry: the single planner-facing interface (guardrail 3). + * Per-kind logic lives in the per-family modules under `./rules/`; this object + * is their composition. Family files import the types above type-only, so the + * runtime import graph (rules → family → helpers → render) carries no cycle. + */ +export const RULES: Record = { + ...roleRules, + ...schemaRules, + ...tableRules, + ...sequenceRules, + ...constraintRules, + ...indexRules, + ...routineRules, + ...typeRules, + ...viewRules, + ...triggerRules, + ...policyRules, + ...publicationRules, + ...foreignRules, + ...metadataRules, +}; + +export function rulesFor(kind: string): KindRules { + const rules = RULES[kind]; + if (!rules) { + throw new Error( + `rule table: no rules for kind '${kind}' — extend the rule vocabulary (guardrail 3)`, + ); + } + return rules; +} + +// ── extension intent (docs/architecture/extension-intent.md §4) ─────────────── +// A stateful extension's intent (a pg_cron job, a future pgmq queue, …) is an +// ordinary fact of the single generic `extensionIntent` kind. Its rules live in +// the integration layer (a handler's `intentKinds`), resolved per-plan through +// the profile — NEVER mutated into the global RULES table (that would be shared +// mutable state across the corpus's many plans). rules.ts stays the single +// lookup seam; the intent index is an ARGUMENT to that seam (guardrail 3). + +/** The intent counterpart of `KindRules`: a narrow, data-shaped replay rule for + * one `(ext, intentKind)`. ActionSpec-shaped so replays reuse the SAME action + * metadata grammar (consumes/dataLoss/transactionality) as schema rules — no + * second action vocabulary (docs §4.1 deviation, resolved toward the code). */ +export interface IntentKindRule { + /** replay that (re)creates the intent, e.g. `select cron.schedule(...)`. The + * `view` is the desired-state view (unused by simple replays like cron). */ + create(fact: Fact, view: FactView): ActionSpec[]; + /** replay that removes the intent, e.g. `select cron.unschedule(...)`. */ + drop(fact: Fact): ActionSpec; + /** the payload attributes this intent recognises. EVERY listed attribute is + * treated as `"replace"`: extension intent has no in-place ALTER, so any + * change replays as drop+create by key (docs §3.2). A changed attribute NOT + * listed here trips the "extend the rule vocabulary" guard in + * replacement-expansion — payload evolution fails loudly, never silently. */ + readonly payloadAttrs: readonly string[]; + /** tie-break weight; defaults to {@link INTENT_DEFAULT_WEIGHT} — later than + * every schema kind, so replay orders after all schema DDL (docs §4.5). */ + weight?: number; +} + +/** Later than every schema kind's weight (the max schema weight is far below + * this), so intent replays sort after all schema DDL on ties (docs §4.5). */ +export const INTENT_DEFAULT_WEIGHT = 90; + +/** An intent-rule index keyed by `${ext}\x00${intentKind}`, each value a + * `KindRules` adapter the generic planner dispatches exactly like a schema + * kind. */ +export type IntentRuleIndex = ReadonlyMap; + +/** A rule resolver keyed by the whole id: schema kinds resolve by `id.kind`, + * `extensionIntent` by `(id.ext, id.intentKind)`. */ +export type RulesForId = (id: StableId) => KindRules; + +function intentKey(ext: string, intentKind: string): string { + return `${ext}\x00${intentKind}`; +} + +/** Wrap an `IntentKindRule` as a `KindRules` so the generic planner dispatches + * it like any schema kind. Extension-intent facts have no children, satellites, + * renames, owners, or default ACLs, so every OPTIONAL `KindRules` member is + * omitted — the planner's undefined-guards then skip rename candidacy, + * default-privilege hygiene, owner emission, and folding. `lockClass: "none"` + * is injected as the per-spec default (a `select .()` replay takes no + * lock on an existing user relation); a rule may still override per spec. */ +function intentRuleToKindRules(rule: IntentKindRule): KindRules { + const attributes: Record = {}; + for (const attr of rule.payloadAttrs) attributes[attr] = "replace"; + const withLock = (spec: ActionSpec): ActionSpec => ({ + lockClass: "none", + ...spec, + }); + return { + create: (fact, view) => rule.create(fact, view).map(withLock), + drop: (fact) => withLock(rule.drop(fact)), + attributes, + weight: rule.weight ?? INTENT_DEFAULT_WEIGHT, + }; +} + +/** Build the intent-rule index from a profile's handlers. Handlers without + * `intentKinds` (filter-only Phase-A handlers like pg_partman) contribute + * nothing. */ +export function buildIntentRuleIndex( + handlers: ReadonlyArray<{ + extension: string; + intentKinds?: Record; + }>, +): IntentRuleIndex { + const index = new Map(); + for (const handler of handlers) { + if (handler.intentKinds === undefined) continue; + for (const [intentKind, rule] of Object.entries(handler.intentKinds)) { + index.set( + intentKey(handler.extension, intentKind), + intentRuleToKindRules(rule), + ); + } + } + return index; +} + +/** Build the per-plan rule resolver. Schema kinds resolve through the static + * {@link RULES} table (via {@link rulesFor}); `extensionIntent` ids resolve + * through the profile-supplied `intentRules` by `(ext, intentKind)`. An intent + * id with no registered rule throws — a plan cannot silently drop declared + * intent it has no rule for. */ +export function buildRuleResolver(intentRules?: IntentRuleIndex): RulesForId { + return (id: StableId): KindRules => { + if (id.kind === "extensionIntent") { + const rules = intentRules?.get(intentKey(id.ext, id.intentKind)); + if (rules === undefined) { + throw new Error( + `rule table: no intent rule registered for extension '${id.ext}' ` + + `intent kind '${id.intentKind}' — register the handler's intentKinds ` + + `via the integration profile`, + ); + } + return rules; + } + return rulesFor(id.kind); + }; +} + +/** The default resolver with no intent rules — for direct callers/tests that + * never see intent facts (the corpus). An intent id throws through it. */ +export const defaultRulesForId: RulesForId = buildRuleResolver(); diff --git a/packages/pg-delta/src/plan/rules/aggregate-metadata-signature.test.ts b/packages/pg-delta/src/plan/rules/aggregate-metadata-signature.test.ts new file mode 100644 index 000000000..4f65595b7 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/aggregate-metadata-signature.test.ts @@ -0,0 +1,93 @@ +/** + * COMMENT ON / SECURITY LABEL ON an ordered-set (or hypothetical-set) aggregate + * must address it with the `direct ORDER BY ordered` signature, exactly like the + * CREATE / DROP / ALTER AGGREGATE rules do via `aggSig`. Before this fix the + * metadata target rendered the flat comma list of every argument type, which + * PostgreSQL rejects at apply for an ordered-set aggregate ("function ... does + * not exist"). Pure rule/diff level — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../../core/fact.ts"; +import type { StableId } from "../../core/stable-id.ts"; +import { plan } from "../plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; + +// ordered-set aggregate: proargtypes = [direct..., ordered...]; numDirectArgs +// splits them, aggKind 'o' selects the ORDER BY rendering. +const orderedSetAggId: StableId = { + kind: "aggregate", + schema: "app", + name: "my_pd", + args: ["double precision", "double precision"], +}; +const orderedSetAggFact: Fact = { + id: orderedSetAggId, + parent: { kind: "schema", name: "app" }, + payload: { + aggKind: "o", + numDirectArgs: 1, + sfunc: "pg_catalog.ordered_set_transition", + stype: "internal", + finalfunc: "pg_catalog.percentile_disc_final", + finalfuncExtra: true, + finalfuncModify: "r", + mfinalfuncModify: "r", + sspace: 0, + msspace: 0, + initcond: null, + minitcond: null, + combinefunc: null, + serialfunc: null, + deserialfunc: null, + msfunc: null, + minvfunc: null, + mstype: null, + mfinalfunc: null, + mfinalfuncExtra: false, + sortop: null, + parallel: "u", + }, +}; + +const commentFact: Fact = { + id: { kind: "comment", target: orderedSetAggId }, + parent: orderedSetAggId, + payload: { text: "percentile disc" }, +}; +const securityLabelFact: Fact = { + id: { kind: "securityLabel", target: orderedSetAggId, provider: "dummy" }, + parent: orderedSetAggId, + payload: { label: "secret" }, +}; + +const base = (extra: Fact[]) => buildFactBase([schemaFact, ...extra], []); + +describe("ordered-set aggregate metadata signature", () => { + test("COMMENT ON addresses the aggregate with ORDER BY", () => { + const sql = plan( + base([orderedSetAggFact]), + base([orderedSetAggFact, commentFact]), + ) + .actions.map((a) => a.sql) + .join("\n"); + expect(sql).toContain( + `COMMENT ON AGGREGATE "app"."my_pd"(double precision ORDER BY double precision) IS 'percentile disc'`, + ); + }); + + test("SECURITY LABEL addresses the aggregate with ORDER BY", () => { + const sql = plan( + base([orderedSetAggFact]), + base([orderedSetAggFact, securityLabelFact]), + ) + .actions.map((a) => a.sql) + .join("\n"); + expect(sql).toContain( + `ON AGGREGATE "app"."my_pd"(double precision ORDER BY double precision) IS 'secret'`, + ); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/constraint-validated.test.ts b/packages/pg-delta/src/plan/rules/constraint-validated.test.ts new file mode 100644 index 000000000..46eba16f2 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/constraint-validated.test.ts @@ -0,0 +1,69 @@ +/** + * Constraint `validated` true → false (VALIDATED → NOT VALID). + * + * On a real Postgres round-trip, `pg_get_constraintdef()` bakes the + * `NOT VALID` suffix into the `def` text itself for CHECK/FK constraints, so + * `def` (unconditionally "replace") always changes in lockstep with + * `validated` and the fact is replaced wholesale — this specific transition + * is unreachable via the corpus. This unit test isolates the `validated` + * attribute rule directly (same `def` text on both sides, only the boolean + * flips) to exercise the code path a hand-built FactBase — or a future + * constraint kind/PG version whose def doesn't encode NOT VALID — could + * still reach. Pure — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../../core/fact.ts"; +import type { StableId } from "../../core/stable-id.ts"; +import { constraintRules } from "./constraints.ts"; + +const schemaFact: Fact = { id: { kind: "schema", name: "app" }, payload: {} }; +const tableFact: Fact = { + id: { kind: "table", schema: "app", name: "items" }, + parent: { kind: "schema", name: "app" }, + payload: { owner: "test", persistence: "p" }, +}; +const conId: StableId = { + kind: "constraint", + schema: "app", + table: "items", + name: "items_order_id_fkey", +}; +const def = `FOREIGN KEY (order_id) REFERENCES app.orders(id)`; + +const conFact = (validated: boolean): Fact => ({ + id: conId, + parent: { kind: "table", schema: "app", name: "items" }, + payload: { def, type: "f", validated }, +}); + +describe("constraint validated: true -> false", () => { + test("false -> true still renders VALIDATE CONSTRAINT", () => { + const validatedRule = constraintRules.constraint!.attributes["validated"]; + if (typeof validatedRule !== "object") + throw new Error("validated must be an alter rule"); + const fact = conFact(true); + const view = buildFactBase([schemaFact, tableFact, fact], []); + const spec = validatedRule.alter(fact, false, true, view, view); + const sql = Array.isArray(spec) ? spec.map((s) => s.sql) : [spec.sql]; + expect(sql).toEqual([ + `ALTER TABLE "app"."items" VALIDATE CONSTRAINT "items_order_id_fkey"`, + ]); + }); + + test("true -> false does not throw — replaces via DROP + ADD ... NOT VALID", () => { + const validatedRule = constraintRules.constraint!.attributes["validated"]; + if (typeof validatedRule !== "object") + throw new Error("validated must be an alter rule"); + const fact = conFact(false); + const view = buildFactBase([schemaFact, tableFact, fact], []); + expect(() => + validatedRule.alter(fact, true, false, view, view), + ).not.toThrow(); + const spec = validatedRule.alter(fact, true, false, view, view); + const sql = Array.isArray(spec) ? spec.map((s) => s.sql) : [spec.sql]; + expect(sql).toEqual([ + `ALTER TABLE "app"."items" DROP CONSTRAINT "items_order_id_fkey"`, + `ALTER TABLE "app"."items" ADD CONSTRAINT "items_order_id_fkey" FOREIGN KEY (order_id) REFERENCES app.orders(id) NOT VALID`, + ]); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/constraints.ts b/packages/pg-delta/src/plan/rules/constraints.ts new file mode 100644 index 000000000..7c4f57f85 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/constraints.ts @@ -0,0 +1,94 @@ +/** Rule definitions for table / domain constraints. */ +import { qid } from "../render.ts"; +import type { KindRules } from "../rules.ts"; +import { constraintTarget, p, str } from "./helpers.ts"; + +export const constraintRules: Record = { + constraint: { + weight: 10, + cascadesToChildren: true, + rebuildable: true, + suppressible: (fact) => fact.payload["type"] !== "f", + rename: (fact, to) => { + const id = fact.id as { name: string }; + return { + sql: `${constraintTarget(fact)} RENAME CONSTRAINT ${qid(id.name)} TO ${qid((to as { name: string }).name)}`, + }; + }, + create: (fact) => { + const id = fact.id as { schema: string; table: string; name: string }; + const target = constraintTarget(fact); + let sql = `${target} ADD CONSTRAINT ${qid(id.name)} ${str(p(fact, "def"))}`; + if (!p(fact, "validated") && !str(p(fact, "def")).includes("NOT VALID")) { + sql += " NOT VALID"; + } + // Inline-fold hint (compaction §3.6, constraint folding): a VALIDATED + // TABLE constraint can render inline inside its table's CREATE parens as + // `CONSTRAINT name ` (pg_get_constraintdef text, verbatim). Data + // only — the fold pass applies it solely under + // `PlanOptions.foldConstraints` (set by `schema export`, whose files are + // loaded by the retry/reorder loader, not the apply executor). NOT VALID + // constraints never hint: an inline constraint always validates. + const foldHint = + p(fact, "validated") === true && fact.parent?.kind === "table" + ? { + compaction: { + foldInto: fact.parent, + clause: `CONSTRAINT ${qid(id.name)} ${str(p(fact, "def"))}`, + }, + } + : {}; + // ADD FOREIGN KEY takes SHARE ROW EXCLUSIVE (both tables), weaker + // than the ACCESS EXCLUSIVE default for other constraint forms + return [ + { + sql, + ...(p(fact, "type") === "f" + ? { lockClass: "shareRowExclusive" as const } + : {}), + ...foldHint, + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; table: string; name: string }; + return { + sql: `${constraintTarget(fact)} DROP CONSTRAINT ${qid(id.name)}`, + }; + }, + attributes: { + def: "replace", + type: "replace", + validated: { + alter: (fact, _from, to) => { + const id = fact.id as { schema: string; table: string; name: string }; + const target = constraintTarget(fact); + if (!to) { + // VALIDATED → NOT VALID: PostgreSQL has no ALTER form to + // de-validate a constraint in place — only ADD CONSTRAINT … + // NOT VALID accepts it. Replace the constraint itself (drop + + // re-add), mirroring create()'s NOT VALID suffixing (defensive + // guard against a def that already carries the text). + const defText = str(p(fact, "def")); + const addSql = !defText.includes("NOT VALID") + ? `${defText} NOT VALID` + : defText; + return [ + { sql: `${target} DROP CONSTRAINT ${qid(id.name)}` }, + { + sql: `${target} ADD CONSTRAINT ${qid(id.name)} ${addSql}`, + ...(p(fact, "type") === "f" + ? { lockClass: "shareRowExclusive" as const } + : {}), + }, + ]; + } + return { + sql: `${target} VALIDATE CONSTRAINT ${qid(id.name)}`, + lockClass: "shareUpdateExclusive", + }; + }, + }, + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/default-privilege.test.ts b/packages/pg-delta/src/plan/rules/default-privilege.test.ts new file mode 100644 index 000000000..a4ebb3182 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/default-privilege.test.ts @@ -0,0 +1,94 @@ +/** + * Default-privilege rendering, incl. the "revoked built-in default" marker + * (empty `privileges` + `_revokedDefault`) that lets an + * `ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC` round-trip: + * CREATE emits the REVOKE, DROP restores the default with a GRANT. + */ +import { describe, expect, test } from "bun:test"; +import type { Fact } from "../../core/fact.ts"; +import { + defaultPrivilegeCreateActions, + defaultPrivilegeDropActions, +} from "./helpers.ts"; + +const marker: Fact = { + id: { + kind: "defaultPrivilege", + role: "owner", + schema: null, + objtype: "f", + grantee: "PUBLIC", + }, + payload: { privileges: [], grantable: [], _revokedDefault: ["EXECUTE"] }, +}; + +const grant: Fact = { + id: { + kind: "defaultPrivilege", + role: "owner", + schema: "app", + objtype: "r", + grantee: "reader", + }, + payload: { privileges: ["SELECT"], grantable: [] }, +}; + +/** Large objects are never schema-scoped (PG rejects `IN SCHEMA` with `ON + * LARGE OBJECTS`), so extraction never produces a non-null schema here. */ +const largeObjectGrant: Fact = { + id: { + kind: "defaultPrivilege", + role: "owner", + schema: null, + objtype: "L", + grantee: "reader", + }, + payload: { privileges: ["SELECT"], grantable: [] }, +}; + +const unknownObjtype: Fact = { + id: { + kind: "defaultPrivilege", + role: "owner", + schema: null, + objtype: "z", + grantee: "reader", + }, + payload: { privileges: ["SELECT"], grantable: [] }, +}; + +describe("default-privilege rendering", () => { + test("revoked-default marker: CREATE revokes, DROP restores via GRANT", () => { + expect(defaultPrivilegeCreateActions(marker).map((a) => a.sql)).toEqual([ + `ALTER DEFAULT PRIVILEGES FOR ROLE "owner" REVOKE ALL ON FUNCTIONS FROM PUBLIC`, + ]); + expect(defaultPrivilegeDropActions(marker).sql).toBe( + `ALTER DEFAULT PRIVILEGES FOR ROLE "owner" GRANT EXECUTE ON FUNCTIONS TO PUBLIC`, + ); + }); + + test("positive grant: CREATE grants, DROP revokes", () => { + expect(defaultPrivilegeCreateActions(grant).map((a) => a.sql)).toEqual([ + `ALTER DEFAULT PRIVILEGES FOR ROLE "owner" IN SCHEMA "app" GRANT SELECT ON TABLES TO "reader"`, + ]); + expect(defaultPrivilegeDropActions(grant).sql).toBe( + `ALTER DEFAULT PRIVILEGES FOR ROLE "owner" IN SCHEMA "app" REVOKE ALL ON TABLES FROM "reader"`, + ); + }); + + test("large-object default privilege: renders ON LARGE OBJECTS, not TABLES", () => { + expect( + defaultPrivilegeCreateActions(largeObjectGrant).map((a) => a.sql), + ).toEqual([ + `ALTER DEFAULT PRIVILEGES FOR ROLE "owner" GRANT SELECT ON LARGE OBJECTS TO "reader"`, + ]); + expect(defaultPrivilegeDropActions(largeObjectGrant).sql).toBe( + `ALTER DEFAULT PRIVILEGES FOR ROLE "owner" REVOKE ALL ON LARGE OBJECTS FROM "reader"`, + ); + }); + + test("unknown objtype fails loud instead of silently rendering TABLES", () => { + expect(() => defaultPrivilegeCreateActions(unknownObjtype)).toThrow(); + expect(() => defaultPrivilegeDropActions(unknownObjtype)).toThrow(); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/extension-create.test.ts b/packages/pg-delta/src/plan/rules/extension-create.test.ts new file mode 100644 index 000000000..c12373a32 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/extension-create.test.ts @@ -0,0 +1,113 @@ +/** + * CREATE EXTENSION emits `SCHEMA ` iff schema `s` will EXIST when the + * statement runs — present on the target (source view, incl. reference-only + * platform schemas like Supabase's `extensions`) OR created by this plan (a + * managed, non-reference-only desired schema). Otherwise the extension creates + * its own schema from its control file (pgmq), so the clause would fail against + * a not-yet-existing schema — emit the bare form. Built-in schemas (pg_catalog, + * pg_cron's target) are never extracted, so they also fall to bare. + * + * This is a PLAN-TIME property. `relocatable` / `_schemaIsMember` cannot express + * it: the `deptype='e'` schema→extension edge never exists (Postgres does not + * record extension ownership of schemas), so `_schemaIsMember` was always false + * and the rule always emitted the clause — breaking pgmq and pg_cron (da8ce04). + */ +import { describe, expect, test } from "bun:test"; +import type { Fact } from "../../core/fact.ts"; +import type { Payload } from "../../core/hash.ts"; +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import type { FactView } from "../rules.ts"; +import { schemaRules } from "./schemas.ts"; + +/** Minimal FactView: `present` ids resolve via get(); `refOnly` ids report + * reference-only. Everything else the extension create rule ignores. */ +function view(present: StableId[], refOnly: StableId[] = []): FactView { + const keys = new Set(present.map(encodeId)); + const ro = new Set(refOnly.map(encodeId)); + return { + get: (id) => + keys.has(encodeId(id)) ? ({ id, payload: {} } as Fact) : undefined, + isReferenceOnly: (id) => ro.has(encodeId(id)), + childrenOf: () => [], + facts: () => [], + outgoingEdges: () => [], + incomingEdges: () => [], + edges: [], + }; +} + +const EMPTY = view([]); +const sch = (name: string): StableId => ({ kind: "schema", name }); + +function createSql( + payload: Payload, + sourceView: FactView, + desiredView: FactView = EMPTY, +): string { + const fact: Fact = { id: { kind: "extension", name: "x" }, payload }; + return schemaRules.extension!.create( + fact, + desiredView, + undefined, + sourceView, + )[0]!.sql; +} + +describe("CREATE EXTENSION schema clause (presence-based)", () => { + test("non-relocatable ext into a schema present on the target → SCHEMA (pg_net)", () => { + // `extensions` is reference-only in both views (Supabase platform schema) + // but physically present on the target → the clause is valid + needed. + expect( + createSql( + { schema: "extensions", relocatable: false }, + view([sch("extensions")], [sch("extensions")]), + view([sch("extensions")], [sch("extensions")]), + ), + ).toBe(`CREATE EXTENSION "x" SCHEMA "extensions"`); + }); + + test("relocatable ext into a present schema → SCHEMA (citext, unchanged)", () => { + expect( + createSql( + { schema: "extensions", relocatable: true }, + view([sch("extensions")], [sch("extensions")]), + ), + ).toBe(`CREATE EXTENSION "x" SCHEMA "extensions"`); + }); + + test("ext whose schema is created by THIS plan (managed desired) → SCHEMA", () => { + // schema present in desired and NOT reference-only → produced by the plan; + // the `consumes` edge orders CREATE SCHEMA first. Absent from the target. + expect( + createSql( + { schema: "app", relocatable: false }, + EMPTY, + view([sch("app")], []), + ), + ).toBe(`CREATE EXTENSION "x" SCHEMA "app"`); + }); + + test("ext that creates its OWN schema (absent target, reference-only desired) → bare (pgmq)", () => { + // RED before the fix: the old gate emitted `SCHEMA "pgmq"`, which fails at + // apply because pgmq does not exist until CREATE EXTENSION creates it. + expect( + createSql( + { schema: "pgmq", relocatable: false, _schemaIsMember: false }, + EMPTY, + view([sch("pgmq")], [sch("pgmq")]), + ), + ).toBe(`CREATE EXTENSION "x"`); + }); + + test("ext into a built-in schema (never extracted) → bare (pg_cron / pg_catalog)", () => { + // RED before the fix: emitted `SCHEMA "pg_catalog"`, which trips the + // requirement guard (pg_catalog is not a fact and not assumed). + expect( + createSql( + { schema: "pg_catalog", relocatable: false, _schemaIsMember: false }, + EMPTY, + EMPTY, + ), + ).toBe(`CREATE EXTENSION "x"`); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/extension-drop.test.ts b/packages/pg-delta/src/plan/rules/extension-drop.test.ts new file mode 100644 index 000000000..852eb3cbe --- /dev/null +++ b/packages/pg-delta/src/plan/rules/extension-drop.test.ts @@ -0,0 +1,83 @@ +/** + * DROP EXTENSION cascades to its member objects (pg_depend deptype 'e'). Members + * are kept reference-only in the resolved view (never projected out), so the drop + * rule derives data-loss from the member closure: destructive iff the extension + * owns a data-bearing persisted relation (table / materialized view). An + * extension whose members are only functions/types/etc. drops without data loss. + * + * This is the safety-flag counterpart to the presence-based CREATE clause + * (extension-create.test.ts): both are PLAN-TIME properties derived from the + * resolved view, not from an extract-time payload field. + */ +import { describe, expect, test } from "bun:test"; +import type { DependencyEdge, Fact } from "../../core/fact.ts"; +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import type { FactView } from "../rules.ts"; +import { schemaRules } from "./schemas.ts"; + +const extId: StableId = { kind: "extension", name: "x" }; +const extFact: Fact = { id: extId, payload: {} }; + +/** Minimal FactView exposing incomingEdges for the drop rule's member-closure + * walk. `members` are the ids that carry a `memberOfExtension` edge to ext x. */ +function viewWithMembers(members: StableId[]): FactView { + const incoming: DependencyEdge[] = members.map((from) => ({ + from, + to: extId, + kind: "memberOfExtension" as const, + })); + return { + get: () => undefined, + isReferenceOnly: () => false, + childrenOf: () => [], + facts: () => [], + outgoingEdges: () => [], + incomingEdges: (id) => (encodeId(id) === encodeId(extId) ? incoming : []), + edges: [], + }; +} + +const tableMember: StableId = { kind: "table", schema: "app", name: "queue" }; +const matviewMember: StableId = { + kind: "materializedView", + schema: "app", + name: "mv", +}; +const fnMember: StableId = { + kind: "function", + schema: "app", + name: "f", + args: [], +}; + +describe("DROP EXTENSION data-loss (member closure)", () => { + test("extension owning a table member → destructive", () => { + const spec = schemaRules.extension!.drop( + extFact, + viewWithMembers([tableMember, fnMember]), + ); + expect(spec.sql).toBe(`DROP EXTENSION "x"`); + expect(spec.dataLoss).toBe("destructive"); + }); + + test("extension owning a materialized-view member → destructive", () => { + const spec = schemaRules.extension!.drop( + extFact, + viewWithMembers([matviewMember]), + ); + expect(spec.dataLoss).toBe("destructive"); + }); + + test("functions-only extension → non-destructive", () => { + const spec = schemaRules.extension!.drop( + extFact, + viewWithMembers([fnMember]), + ); + expect(spec.dataLoss ?? "none").toBe("none"); + }); + + test("no view supplied (member closure unknown) → non-destructive", () => { + const spec = schemaRules.extension!.drop(extFact); + expect(spec.dataLoss ?? "none").toBe("none"); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/foreign.ts b/packages/pg-delta/src/plan/rules/foreign.ts new file mode 100644 index 000000000..ddcd75627 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/foreign.ts @@ -0,0 +1,164 @@ +/** Rule definitions for foreign-data objects: FDWs, servers, user mappings, + * and foreign tables. */ +import type { StableId } from "../../core/stable-id.ts"; +import { alterOptionsClause, lit, optionsClause, qid, rel } from "../render.ts"; +import type { KindRules } from "../rules.ts"; +import { p, renameRule, str } from "./helpers.ts"; + +export const foreignRules: Record = { + fdw: { + weight: 2, + create: (fact) => { + const name = qid((fact.id as { name: string }).name); + let sql = `CREATE FOREIGN DATA WRAPPER ${name}`; + const handler = p(fact, "handler"); + if (handler != null) sql += ` HANDLER ${str(handler)}`; + const validator = p(fact, "validator"); + if (validator != null) sql += ` VALIDATOR ${str(validator)}`; + sql += optionsClause((p(fact, "options") as string[]) ?? []); + return [{ sql }]; + }, + drop: (fact) => ({ + sql: `DROP FOREIGN DATA WRAPPER ${qid((fact.id as { name: string }).name)}`, + }), + ownerAlterPrefix: (fact) => + `ALTER FOREIGN DATA WRAPPER ${qid((fact.id as { name: string }).name)}`, + attributes: { + options: { + alter: (fact, from, to) => ({ + sql: `ALTER FOREIGN DATA WRAPPER ${qid((fact.id as { name: string }).name)} ${alterOptionsClause( + (from as string[] | null) ?? [], + (to as string[] | null) ?? [], + )}`, + }), + }, + handler: { + alter: (fact, _from, to) => ({ + sql: `ALTER FOREIGN DATA WRAPPER ${qid((fact.id as { name: string }).name)} ${to == null ? "NO HANDLER" : `HANDLER ${str(to)}`}`, + }), + }, + validator: { + alter: (fact, _from, to) => ({ + sql: `ALTER FOREIGN DATA WRAPPER ${qid((fact.id as { name: string }).name)} ${to == null ? "NO VALIDATOR" : `VALIDATOR ${str(to)}`}`, + }), + }, + }, + }, + + server: { + weight: 3, + create: (fact) => { + const name = qid((fact.id as { name: string }).name); + let sql = `CREATE SERVER ${name}`; + const type = p(fact, "type"); + if (type != null) sql += ` TYPE ${lit(str(type))}`; + const version = p(fact, "version"); + if (version != null) sql += ` VERSION ${lit(str(version))}`; + sql += ` FOREIGN DATA WRAPPER ${qid(str(p(fact, "fdw")))}`; + sql += optionsClause((p(fact, "options") as string[]) ?? []); + return [{ sql }]; + }, + drop: (fact) => ({ + sql: `DROP SERVER ${qid((fact.id as { name: string }).name)}`, + }), + ownerAlterPrefix: (fact) => + `ALTER SERVER ${qid((fact.id as { name: string }).name)}`, + attributes: { + version: { + alter: (fact, _from, to) => ({ + sql: `ALTER SERVER ${qid((fact.id as { name: string }).name)} VERSION ${lit(str(to))}`, + }), + // PostgreSQL has no ALTER SERVER grammar to UNSET a version, so removing + // it (to == null) forces a drop + recreate of the server. + replaceWhen: (_from, to) => to == null, + }, + options: { + alter: (fact, from, to) => ({ + sql: `ALTER SERVER ${qid((fact.id as { name: string }).name)} ${alterOptionsClause( + (from as string[] | null) ?? [], + (to as string[] | null) ?? [], + )}`, + }), + }, + type: "replace", + fdw: "replace", + }, + }, + + userMapping: { + weight: 4, + create: (fact) => { + const id = fact.id as { server: string; role: string }; + const roleName = id.role === "PUBLIC" ? "PUBLIC" : qid(id.role); + return [ + { + sql: `CREATE USER MAPPING FOR ${roleName} SERVER ${qid(id.server)}${optionsClause((p(fact, "options") as string[]) ?? [])}`, + ...(id.role === "PUBLIC" + ? {} + : { consumes: [{ kind: "role", name: id.role } as StableId] }), + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { server: string; role: string }; + const roleName = id.role === "PUBLIC" ? "PUBLIC" : qid(id.role); + return { + sql: `DROP USER MAPPING FOR ${roleName} SERVER ${qid(id.server)}`, + }; + }, + attributes: { + options: { + alter: (fact, from, to) => { + const id = fact.id as { server: string; role: string }; + const roleName = id.role === "PUBLIC" ? "PUBLIC" : qid(id.role); + return { + sql: `ALTER USER MAPPING FOR ${roleName} SERVER ${qid(id.server)} ${alterOptionsClause( + (from as string[] | null) ?? [], + (to as string[] | null) ?? [], + )}`, + }; + }, + }, + }, + }, + + foreignTable: { + weight: 5, + cascadesToChildren: true, + defaclObjtype: "r", + rename: renameRule((fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER FOREIGN TABLE ${rel(id.schema, id.name)}`; + }), + create: (fact) => { + const id = fact.id as { schema: string; name: string }; + return [ + { + sql: `CREATE FOREIGN TABLE ${rel(id.schema, id.name)} () SERVER ${qid(str(p(fact, "server")))}${optionsClause((p(fact, "options") as string[]) ?? [])}`, + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { sql: `DROP FOREIGN TABLE ${rel(id.schema, id.name)}` }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER FOREIGN TABLE ${rel(id.schema, id.name)}`; + }, + attributes: { + options: { + alter: (fact, from, to) => { + const id = fact.id as { schema: string; name: string }; + return { + sql: `ALTER FOREIGN TABLE ${rel(id.schema, id.name)} ${alterOptionsClause( + (from as string[] | null) ?? [], + (to as string[] | null) ?? [], + )}`, + }; + }, + }, + server: "replace", + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/helpers.ts b/packages/pg-delta/src/plan/rules/helpers.ts new file mode 100644 index 000000000..0bf6e6b11 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/helpers.ts @@ -0,0 +1,830 @@ +/** + * Shared helpers for the rule table (target-architecture §3.4). These are the + * rendering / metadata utilities the per-kind rule families compose. Types are + * imported type-only from `../rules.ts` so this module never forms a runtime + * import cycle with the registry that re-exports it. + */ +import type { Fact } from "../../core/fact.ts"; +import type { PayloadValue } from "../../core/hash.ts"; +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import { grantTarget, qid, rel, splitOption } from "../render.ts"; +import type { ActionSpec, FactView } from "../rules.ts"; + +const TEXT_ENCODER = new TextEncoder(); + +/** UTF-8 byte length of a string. Runtime-agnostic (no `Buffer`), so it works + * identically in Bun / Node / Deno — used where PostgreSQL's byte-based + * identifier limit (NAMEDATALEN) matters, not the JS UTF-16 code-unit length. */ +export function byteLength(s: string): number { + return TEXT_ENCODER.encode(s).length; +} + +/** Clip `s` to at most `maxBytes` UTF-8 bytes WITHOUT splitting a code point, so + * the result is an identifier PostgreSQL will store verbatim (never itself + * truncate). Iterates by code point (`for…of`), never by UTF-16 unit. */ +export function clipToByteLength(s: string, maxBytes: number): string { + if (maxBytes <= 0) return ""; + if (byteLength(s) <= maxBytes) return s; + let out = ""; + let used = 0; + for (const ch of s) { + const chBytes = byteLength(ch); + if (used + chBytes > maxBytes) break; + out += ch; + used += chBytes; + } + return out; +} + +/** Most renames are ` RENAME TO `. */ +export function renameRule( + alterPrefix: (fact: Fact) => string, +): (fact: Fact, to: StableId) => ActionSpec { + return (fact, to) => ({ + sql: `${alterPrefix(fact)} RENAME TO ${qid((to as { name: string }).name)}`, + }); +} + +export const str = (v: PayloadValue): string => { + if (v === null || v === undefined || typeof v === "object") { + throw new Error( + `rule rendering: expected a scalar, got ${JSON.stringify(v)}`, + ); + } + return String(v); +}; + +export function p(fact: Fact, key: string): PayloadValue { + return fact.payload[key]; +} + +/** The `depends` edge targets of `id` in `view` — the objects the fact's + * definition references. A routine's def-alter (CREATE OR REPLACE) consumes + * these so it is ordered AFTER their creates: a BEGIN ATOMIC body is parsed and + * dependency-checked at replace time. plpgsql / quoted-string bodies are not + * (check_function_bodies=off in the preamble) and record no such edges, so this + * is exactly the set that needs ordering. Consuming an id nothing in-plan + * produces is harmless — no ordering edge is added. */ +export function dependencyConsumes(view: FactView, id: StableId): StableId[] { + return view + .outgoingEdges(id) + .filter((e) => e.kind === "depends") + .map((e) => e.to); +} + +/** ` WITH (k=v, …)` clause from a fact's `reloptions` payload (the canonical + * sorted `key=value` array captured from pg_class.reloptions), or "" when the + * relation carries no storage/view options. */ +export function reloptionsWithClause(fact: Fact): string { + const opts = p(fact, "reloptions") as string[] | null; + return opts != null && opts.length > 0 ? ` WITH (${opts.join(", ")})` : ""; +} + +/** ` SET (…)` / ` RESET (…)` specs for a reloptions transition. + * Emits SET for keys whose value was added or changed and RESET for keys that + * disappeared — the in-place form for both views (security_invoker, …) and + * tables (fillfactor, autovacuum_*, …) so an options-only change is no longer + * invisible (it used to hash identically and plan nothing). */ +export function reloptionsAlterSpecs( + alterPrefix: string, + from: PayloadValue, + to: PayloadValue, +): ActionSpec[] { + const fromMap = new Map((from as string[] | null)?.map(splitOption) ?? []); + const toMap = new Map((to as string[] | null)?.map(splitOption) ?? []); + const setParts: string[] = []; + for (const [key, value] of toMap) { + if (fromMap.get(key) !== value) setParts.push(`${key}=${value}`); + } + const resetKeys: string[] = []; + for (const key of fromMap.keys()) { + if (!toMap.has(key)) resetKeys.push(key); + } + const specs: ActionSpec[] = []; + if (setParts.length > 0) { + specs.push({ sql: `${alterPrefix} SET (${setParts.join(", ")})` }); + } + if (resetKeys.length > 0) { + specs.push({ sql: `${alterPrefix} RESET (${resetKeys.join(", ")})` }); + } + return specs; +} + +/** true when `partial` appears in `full` in order (possibly with gaps) */ +export function isSubsequence(partial: string[], full: string[]): boolean { + let i = 0; + for (const value of full) { + if (i < partial.length && value === partial[i]) i++; + } + return i === partial.length; +} + +/** Role attribute keyword map (CREATE ROLE / ALTER ROLE flags). */ +export const ROLE_FLAGS: Record = { + superuser: ["SUPERUSER", "NOSUPERUSER"], + inherit: ["INHERIT", "NOINHERIT"], + createRole: ["CREATEROLE", "NOCREATEROLE"], + createDb: ["CREATEDB", "NOCREATEDB"], + login: ["LOGIN", "NOLOGIN"], + replication: ["REPLICATION", "NOREPLICATION"], + bypassRls: ["BYPASSRLS", "NOBYPASSRLS"], +}; + +export function roleFlagSql(payload: Fact["payload"]): string { + return Object.entries(ROLE_FLAGS) + .map(([key, [on, off]]) => (payload[key] ? on : off)) + .join(" "); +} + +/** A backing identity sequence's parameters (mirrors the sequence payload, + * minus dataType — that is fixed by the column type). */ +export interface IdentityOptions { + increment: string; + start: string; + minValue: string; + maxValue: string; + cache: string; + cycle: boolean; +} + +/** Identity payload: + * { generation: 'a'|'d', sequence: {schema,name}, options: IdentityOptions } | null. + * The backing sequence rides along so identity transitions can declare the + * physical sequence they implicitly create/destroy; its options ride along so + * a non-default START/INCREMENT/MIN/MAX/CACHE/CYCLE is reproduced. */ +interface IdentityPayload { + generation: string; + sequence: { schema: string; name: string } | null; + options?: IdentityOptions | null; +} + +export function identityGeneration(value: PayloadValue): string | null { + if (value == null) return null; + return (value as unknown as IdentityPayload).generation; +} + +export function identitySequenceId(value: PayloadValue): StableId | null { + if (value == null) return null; + const sequence = (value as unknown as IdentityPayload).sequence; + if (sequence == null) return null; + return { kind: "sequence", schema: sequence.schema, name: sequence.name }; +} + +export function identityOptions(value: PayloadValue): IdentityOptions | null { + if (value == null) return null; + return (value as unknown as IdentityPayload).options ?? null; +} + +/** Type-derived max for the implicit identity sequence; null for a type we + * don't recognise (then any captured options are treated as non-default and + * rendered explicitly, which is always safe). */ +const IDENTITY_TYPE_MAX: Record = { + smallint: "32767", + integer: "2147483647", + bigint: "9223372036854775807", +}; + +/** true when the identity sequence carries exactly the parameters PostgreSQL + * picks for a bare `GENERATED … AS IDENTITY` of the given column type, so the + * clause can stay bare (no churn for ordinary identity columns). */ +export function isDefaultIdentityOptions( + options: IdentityOptions, + columnType: string, +): boolean { + return ( + options.increment === "1" && + options.start === "1" && + options.minValue === "1" && + options.cache === "1" && + options.cycle === false && + options.maxValue === IDENTITY_TYPE_MAX[columnType] + ); +} + +/** The name PostgreSQL auto-derives for an identity column's implicit backing + * sequence: `
__seq` (in the table's own schema). */ +function defaultIdentitySequenceName(table: string, column: string): string { + return `${table}_${column}_seq`; +} + +/** ` SEQUENCE NAME "".""` when the backing sequence's name (or + * schema) differs from PostgreSQL's `
__seq` default — the + * sequence was renamed or created via `SEQUENCE NAME`. Returns "" for the + * ordinary default-named case so exports stay minimal. Truncation/collision + * edge cases produce a name that never equals the naive default, so they + * always emit the clause — verbose but always valid, never a wrong-name + * round-trip. */ +export function identitySequenceNameClause( + value: PayloadValue, + ref: { schema: string; table: string; column: string }, +): string { + if (value == null) return ""; + const sequence = (value as unknown as IdentityPayload).sequence; + if (sequence == null) return ""; + const isDefault = + sequence.schema === ref.schema && + sequence.name === defaultIdentitySequenceName(ref.table, ref.column); + return isDefault + ? "" + : `SEQUENCE NAME ${rel(sequence.schema, sequence.name)}`; +} + +/** ` (SEQUENCE NAME … INCREMENT BY … MINVALUE … …)` for an identity sequence + * with a non-default name and/or parameters, or "" when the name is the + * `
__seq` default and the parameters are the type defaults. + * `SEQUENCE NAME` is a valid identity option and PostgreSQL accepts it first + * in the list (pg_dump renders it the same way). */ +export function identityOptionsClause( + options: IdentityOptions | null, + columnType: string, + sequenceNameClause = "", +): string { + const parts: string[] = []; + if (sequenceNameClause) parts.push(sequenceNameClause); + if (options != null && !isDefaultIdentityOptions(options, columnType)) { + parts.push( + `INCREMENT BY ${options.increment}`, + `MINVALUE ${options.minValue}`, + `MAXVALUE ${options.maxValue}`, + `START WITH ${options.start}`, + `CACHE ${options.cache}`, + options.cycle ? "CYCLE" : "NO CYCLE", + ); + } + return parts.length === 0 ? "" : ` (${parts.join(" ")})`; +} + +/** true when the OLD `[oldMin, oldMax]` value range and the NEW `[newMin, newMax]` + * range are provably DISJOINT (`oldMax < newMin || oldMin > newMax`). + * + * A sequence's / identity's live counter (`last_value`) is UNMODELED runtime + * state — the diff never sees it. When the two ranges are disjoint the counter + * is GUARANTEED to fall outside the new range, so the only in-place path that + * converges is `RESTART` (realign to the new START, always inside the new range + * — matching what a fresh `… START WITH …` would produce). When the ranges + * OVERLAP we must NOT `RESTART`: the live counter is very likely still valid + * (e.g. counter at 500, MIN 1→0 + START 1→2 leaves 500 usable), and silently + * resetting it to START replays already-issued values → duplicate keys. If an + * overlapping change happens to leave the counter outside the new range, + * PostgreSQL rejects the ALTER loudly and the operator decides — we never + * silently reset. Bounds are bigint-valued, so compare with BigInt (they reach + * 9223372036854775807, past Number's safe-integer range). */ +function rangesDisjoint( + oldMin: string, + oldMax: string, + newMin: string, + newMax: string, +): boolean { + return BigInt(oldMax) < BigInt(newMin) || BigInt(oldMin) > BigInt(newMax); +} + +/** in-place identity-sequence parameter transition (no rebuild), emitted as ONE + * `ALTER COLUMN … SET SET …` statement. + * + * A single ALTER COLUMN with CHAINED `SET` clauses validates the FINAL state: + * splitting the change into one statement per option (in diff-field order) ran + * `SET MAXVALUE 50` while MIN was still 100 when moving both bounds down — + * Postgres rejects the transient `min > max`. Chaining defers the range check to + * the end of the statement (verified on PG15/17). + * + * `RESTART` is appended only when the old and new ranges are provably DISJOINT + * (see {@link rangesDisjoint}). An overlapping change — even one that moves a + * bound AND the START — leaves the live counter alone, because it is probably + * still valid and resetting it risks duplicate keys. */ +export function identityOptionAlterSpecs( + target: string, + from: IdentityOptions | null, + to: IdentityOptions | null, +): ActionSpec[] { + if (to == null) return []; + const clauses: string[] = []; + if (from == null || from.increment !== to.increment) + clauses.push(`SET INCREMENT BY ${to.increment}`); + if (from == null || from.minValue !== to.minValue) + clauses.push(`SET MINVALUE ${to.minValue}`); + if (from == null || from.maxValue !== to.maxValue) + clauses.push(`SET MAXVALUE ${to.maxValue}`); + if (from == null || from.start !== to.start) + clauses.push(`SET START WITH ${to.start}`); + if (from == null || from.cache !== to.cache) + clauses.push(`SET CACHE ${to.cache}`); + if (from == null || from.cycle !== to.cycle) + clauses.push(`SET ${to.cycle ? "CYCLE" : "NO CYCLE"}`); + if (clauses.length === 0) return []; + if ( + from != null && + rangesDisjoint(from.minValue, from.maxValue, to.minValue, to.maxValue) + ) + clauses.push("RESTART"); + return [{ sql: `${target} ${clauses.join(" ")}` }]; +} + +/** The CREATE-SEQUENCE-style value options a standalone sequence carries, in a + * fixed render order. `ownedBy` is deliberately excluded: it carries its own + * dependency metadata (consumes/releases) and is emitted as a separate + * `OWNED BY` statement. */ +const SEQUENCE_VALUE_OPTIONS = [ + "dataType", + "increment", + "minValue", + "maxValue", + "start", + "cache", + "cycle", +] as const; + +function sequenceOptionClause(fact: Fact, option: string): string { + switch (option) { + case "dataType": + return `AS ${str(p(fact, "dataType"))}`; + case "increment": + return `INCREMENT BY ${str(p(fact, "increment"))}`; + case "minValue": + return `MINVALUE ${str(p(fact, "minValue"))}`; + case "maxValue": + return `MAXVALUE ${str(p(fact, "maxValue"))}`; + case "start": + return `START WITH ${str(p(fact, "start"))}`; + case "cache": + return `CACHE ${str(p(fact, "cache"))}`; + case "cycle": + return p(fact, "cycle") ? "CYCLE" : "NO CYCLE"; + default: + throw new Error(`sequence rule: unknown value option '${option}'`); + } +} + +/** Combined `ALTER SEQUENCE … …` for a standalone sequence's + * value-option transition, emitted ONCE — from whichever changed option sorts + * first — because the emitter calls each changed attribute's `alter` + * independently and we want a SINGLE statement covering all of them. + * + * One statement validates the FINAL state: per-field `ALTER SEQUENCE` statements + * in diff-field (lexicographic) order ran `MAXVALUE 50` while MIN was still 100 + * when moving both bounds down, and Postgres rejects the transient `min > max`. + * + * `RESTART` is appended only when the old and new ranges are provably DISJOINT — + * see {@link rangesDisjoint} and {@link identityOptionAlterSpecs} for the + * identity seam's identical reasoning: the sequence's live counter (unmanaged + * runtime state, not part of the diff) is left in place for an overlapping + * change, because it is probably still valid and resetting it risks duplicate + * keys; only a disjoint shift guarantees the counter is invalid. */ +export function sequenceOptionAlter( + currentAttr: string, + fact: Fact, + sourceView: FactView, +): ActionSpec[] { + const source = sourceView.get(fact.id); + const changed = SEQUENCE_VALUE_OPTIONS.filter( + (key) => source === undefined || source.payload[key] !== fact.payload[key], + ); + if (changed.length === 0) return []; + const [lead] = [...changed].sort(); + if (currentAttr !== lead) return []; + const id = fact.id as { schema: string; name: string }; + const clauses = changed.map((option) => sequenceOptionClause(fact, option)); + if ( + source !== undefined && + rangesDisjoint( + str(source.payload["minValue"]), + str(source.payload["maxValue"]), + str(p(fact, "minValue")), + str(p(fact, "maxValue")), + ) + ) + clauses.push("RESTART"); + return [ + { sql: `ALTER SEQUENCE ${rel(id.schema, id.name)} ${clauses.join(" ")}` }, + ]; +} + +export function columnRef(fact: Fact): { + table: string; + schema: string; + column: string; +} { + const id = fact.id as { schema: string; table: string; name: string }; + return { schema: id.schema, table: id.table, column: id.name }; +} + +export function columnClause(fact: Fact): string { + const { column } = columnRef(fact); + const type = str(p(fact, "type")); + let sql = `${qid(column)} ${type}`; + const collation = p(fact, "collation"); + if (collation != null) sql += ` COLLATE ${str(collation)}`; + const generated = p(fact, "generatedExpr"); + if (generated != null) + sql += ` GENERATED ALWAYS AS (${str(generated)}) STORED`; + const identity = p(fact, "identity"); + const generation = identityGeneration(identity); + if (generation === "a" || generation === "d") { + sql += ` GENERATED ${generation === "a" ? "ALWAYS" : "BY DEFAULT"} AS IDENTITY`; + sql += identityOptionsClause( + identityOptions(identity), + type, + identitySequenceNameClause(identity, columnRef(fact)), + ); + } + if (p(fact, "notNull")) sql += ` NOT NULL`; + return sql; +} + +const POLICY_CMD: Record = { + r: "SELECT", + a: "INSERT", + w: "UPDATE", + d: "DELETE", + "*": "ALL", +}; + +export function policySql(fact: Fact): string { + const id = fact.id as { schema: string; table: string; name: string }; + const roles = (p(fact, "roles") as string[]).map((r) => + r === "PUBLIC" ? "PUBLIC" : qid(r), + ); + let sql = `CREATE POLICY ${qid(id.name)} ON ${rel(id.schema, id.table)}`; + if (!p(fact, "permissive")) sql += ` AS RESTRICTIVE`; + sql += ` FOR ${POLICY_CMD[str(p(fact, "cmd"))] ?? "ALL"}`; + sql += ` TO ${roles.join(", ")}`; + const using = p(fact, "usingExpr"); + if (using != null) sql += ` USING (${str(using)})`; + const check = p(fact, "checkExpr"); + if (check != null) sql += ` WITH CHECK (${str(check)})`; + return sql; +} + +/** + * OWNED BY is rendered as a follow-up statement (pg_dump's model): an auto + * edge sequence→column would cycle with the column default that references + * the sequence. + */ +export function sequenceOwnedBySpecs( + fact: Fact, + opts: { + allowNone?: boolean; + /** the previous owner (an ownedBy payload value) when this is an in-place + * reassignment — released so the ALTER runs before a same-plan DROP of the + * old owning column/table, which would otherwise cascade the sequence away + * before it is re-owned. */ + releaseOld?: { schema: string; table: string; column: string } | null; + } = {}, +): ActionSpec[] { + const id = fact.id as { schema: string; name: string }; + const old = opts.releaseOld; + const releases: StableId[] = + old != null + ? [ + { + kind: "column", + schema: old.schema, + table: old.table, + name: old.column, + }, + ] + : []; + const ownedBy = p(fact, "ownedBy") as { + schema: string; + table: string; + column: string; + } | null; + if (ownedBy == null) { + return opts.allowNone + ? [ + { + sql: `ALTER SEQUENCE ${rel(id.schema, id.name)} OWNED BY NONE`, + ...(releases.length > 0 ? { releases } : {}), + }, + ] + : []; + } + return [ + { + sql: `ALTER SEQUENCE ${rel(id.schema, id.name)} OWNED BY ${rel(ownedBy.schema, ownedBy.table)}.${qid(ownedBy.column)}`, + consumes: [ + { + kind: "column", + schema: ownedBy.schema, + table: ownedBy.table, + name: ownedBy.column, + }, + ], + ...(releases.length > 0 ? { releases } : {}), + }, + ]; +} + +/** Constraints attach to tables OR domains; the parent kind decides. */ +export function constraintTarget(fact: Fact): string { + const id = fact.id as { schema: string; table: string }; + const keyword = + fact.parent?.kind === "domain" + ? "DOMAIN" + : fact.parent?.kind === "foreignTable" + ? "FOREIGN TABLE" + : "TABLE"; + return `ALTER ${keyword} ${rel(id.schema, id.table)}`; +} + +/** A composite type attribute's `name type [COLLATE …]` clause. */ +export function typeAttributeClause(fact: Fact): string { + const name = (fact.id as { name: string }).name; + const collation = p(fact, "collation"); + return `${qid(name)} ${str(p(fact, "type"))}${collation != null ? ` COLLATE ${str(collation)}` : ""}`; +} + +/** Table columns that USE a given composite type (via the column→type + * dependency edge). ALTER TYPE … ATTRIBUTE … CASCADE rewrites those + * tables; referencing the columns lets the proof's rewrite attribution + * map the type-scoped action to the tables it actually touches. */ +export function compositeUserColumns( + view: FactView, + typeId: StableId, +): StableId[] { + const key = encodeId(typeId); + return view.edges + .filter((e) => e.from.kind === "column" && encodeId(e.to) === key) + .map((e) => e.from) + .filter((id) => view.get(id) !== undefined); +} + +/** O/D/R/A enabled-state chars → ALTER … ENABLE/DISABLE phrases. */ +export function enabledPhrase(state: string): string { + switch (state) { + case "D": + return "DISABLE"; + case "R": + return "ENABLE REPLICA"; + case "A": + return "ENABLE ALWAYS"; + default: + return "ENABLE"; + } +} + +/** + * REPLICA IDENTITY rendered from the desired payload (both attributes render + * the identical full clause, so order between them never matters). USING + * INDEX consumes whichever fact owns that index name (a real index fact, or + * the constraint backing it). + */ +export function replicaIdentitySpec(fact: Fact, view: FactView): ActionSpec { + const id = fact.id as { schema: string; name: string }; + const mode = str(p(fact, "replicaIdentity") ?? "d"); + const relName = rel(id.schema, id.name); + if (mode === "n") { + return { sql: `ALTER TABLE ${relName} REPLICA IDENTITY NOTHING` }; + } + if (mode === "f") { + return { sql: `ALTER TABLE ${relName} REPLICA IDENTITY FULL` }; + } + if (mode === "i") { + const indexName = str(p(fact, "replicaIdentityIndex")); + const consumes: StableId[] = []; + const ownedConstraint = view + .childrenOf(fact.id) + .find((c) => c.id.kind === "constraint" && c.id.name === indexName); + if (ownedConstraint) consumes.push(ownedConstraint.id); + else consumes.push({ kind: "index", schema: id.schema, name: indexName }); + return { + sql: `ALTER TABLE ${relName} REPLICA IDENTITY USING INDEX ${qid(indexName)}`, + consumes, + }; + } + return { sql: `ALTER TABLE ${relName} REPLICA IDENTITY DEFAULT` }; +} + +export function grantActions(fact: Fact, verb: "grant"): ActionSpec[] { + const id = fact.id as { + kind: "acl"; + target: StableId; + grantee: string; + column?: string; + }; + const grantee = id.grantee === "PUBLIC" ? "PUBLIC" : qid(id.grantee); + const privileges = p(fact, "privileges") as string[]; + const grantable = new Set((p(fact, "grantable") as string[]) ?? []); + const plain = privileges.filter((priv) => !grantable.has(priv)); + const withOption = privileges.filter((priv) => grantable.has(priv)); + const consumes: StableId[] = + id.grantee === "PUBLIC" ? [] : [{ kind: "role", name: id.grantee }]; + // Column-level grant: each privilege is qualified by the column + // (`SELECT (col)`) and REVOKE ALL takes the column list too. Object-level + // grants render the bare privilege list. + const col = id.column; + const q = (privs: string[]): string => + col === undefined + ? privs.join(", ") + : privs.map((priv) => `${priv} (${qid(col)})`).join(", "); + const revokeAll = + col === undefined ? "REVOKE ALL" : `REVOKE ALL (${qid(col)})`; + const specs: ActionSpec[] = [ + // pg_dump's model: reset to a clean slate first — implicit default- + // privilege grants on freshly created objects would otherwise linger + { + sql: `${revokeAll} ON ${grantTarget(id.target)} FROM ${grantee}`, + consumes, + }, + ]; + if (plain.length > 0) { + specs.push({ + sql: `GRANT ${q(plain)} ON ${grantTarget(id.target)} TO ${grantee}`, + consumes, + }); + } + if (withOption.length > 0) { + specs.push({ + sql: `GRANT ${q(withOption)} ON ${grantTarget(id.target)} TO ${grantee} WITH GRANT OPTION`, + consumes, + }); + } + void verb; + return specs; +} + +/** Aggregate signature: direct args [ORDER BY ordered args]; '*' when none. */ +export function aggSig(fact: Fact): string { + const args = (fact.id as { args: string[] }).args; + const aggKind = str(p(fact, "aggKind") ?? "n"); + if (aggKind === "o" || aggKind === "h") { + const direct = Number(p(fact, "numDirectArgs") ?? 0); + return `${args.slice(0, direct).join(", ")} ORDER BY ${args.slice(direct).join(", ")}`; + } + return args.length > 0 ? args.join(", ") : "*"; +} + +const DEFACL_OBJTYPE: Record = { + r: "TABLES", + S: "SEQUENCES", + f: "FUNCTIONS", + T: "TYPES", + n: "SCHEMAS", + L: "LARGE OBJECTS", +}; + +/** Render a `pg_default_acl.defaclobjtype` code, or throw loud — an unmapped + * code silently rendered as `TABLES` (the old `?? "TABLES"` fallback) would + * emit the WRONG DDL for a future/unhandled objtype instead of surfacing the + * gap. */ +function defaclObjType(objtype: string): string { + const rendered = DEFACL_OBJTYPE[objtype]; + if (rendered === undefined) { + throw new Error( + `defaultPrivilege: unmapped pg_default_acl.defaclobjtype "${objtype}" — add it to DEFACL_OBJTYPE (helpers.ts)`, + ); + } + return rendered; +} + +export function defaultPrivPrefix(id: { + role: string; + schema: string | null; +}): string { + let sql = `ALTER DEFAULT PRIVILEGES FOR ROLE ${qid(id.role)}`; + if (id.schema != null) sql += ` IN SCHEMA ${qid(id.schema)}`; + return sql; +} + +export function defaultPrivConsumes(id: { + role: string; + schema: string | null; + grantee: string; +}): StableId[] { + const consumes: StableId[] = [{ kind: "role", name: id.role }]; + if (id.grantee !== "PUBLIC") + consumes.push({ kind: "role", name: id.grantee }); + if (id.schema != null) consumes.push({ kind: "schema", name: id.schema }); + return consumes; +} + +/** + * A `defaultPrivilege` fact with EMPTY `privileges` is a synthesized marker for + * a REVOKED built-in default (e.g. `ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON + * FUNCTIONS FROM PUBLIC`): the grantee's built-in default was taken away. Its + * `_revokedDefault` carries the privileges that were removed so the DROP can + * restore them. A non-empty fact is an ordinary positive grant. + */ +function isRevokedDefaultMarker(fact: Fact): boolean { + return ((p(fact, "privileges") as string[]) ?? []).length === 0; +} + +/** CREATE a default-privilege fact: GRANT the privileges, or — for a revoked + * default marker — REVOKE the built-in default the marker records is gone. */ +export function defaultPrivilegeCreateActions(fact: Fact): ActionSpec[] { + const id = fact.id as { + role: string; + schema: string | null; + objtype: string; + grantee: string; + }; + const grantee = id.grantee === "PUBLIC" ? "PUBLIC" : qid(id.grantee); + const objtype = defaclObjType(id.objtype); + const consumes = defaultPrivConsumes(id); + if (isRevokedDefaultMarker(fact)) { + return [ + { + sql: `${defaultPrivPrefix(id)} REVOKE ALL ON ${objtype} FROM ${grantee}`, + consumes, + }, + ]; + } + const privileges = (p(fact, "privileges") as string[]) ?? []; + const grantable = new Set((p(fact, "grantable") as string[]) ?? []); + const plain = privileges.filter((priv) => !grantable.has(priv)); + const withOption = privileges.filter((priv) => grantable.has(priv)); + const specs: ActionSpec[] = []; + if (plain.length > 0) { + specs.push({ + sql: `${defaultPrivPrefix(id)} GRANT ${plain.join(", ")} ON ${objtype} TO ${grantee}`, + consumes, + }); + } + if (withOption.length > 0) { + specs.push({ + sql: `${defaultPrivPrefix(id)} GRANT ${withOption.join(", ")} ON ${objtype} TO ${grantee} WITH GRANT OPTION`, + consumes, + }); + } + return specs; +} + +/** DROP a default-privilege fact: REVOKE the positive grant, or — for a revoked + * default marker — GRANT the built-in default back (restoring it). */ +export function defaultPrivilegeDropActions(fact: Fact): ActionSpec { + const id = fact.id as { + role: string; + schema: string | null; + objtype: string; + grantee: string; + }; + const grantee = id.grantee === "PUBLIC" ? "PUBLIC" : qid(id.grantee); + const objtype = defaclObjType(id.objtype); + const consumes = defaultPrivConsumes(id); + if (isRevokedDefaultMarker(fact)) { + const restored = (p(fact, "_revokedDefault") as string[]) ?? []; + return { + sql: `${defaultPrivPrefix(id)} GRANT ${restored.join(", ")} ON ${objtype} TO ${grantee}`, + consumes, + }; + } + return { + sql: `${defaultPrivPrefix(id)} REVOKE ALL ON ${objtype} FROM ${grantee}`, + consumes, + }; +} + +/** The `rel [(cols)] [WHERE (…)]` member for a publicationRel fact, without the + * leading `TABLE` keyword (so it can be grouped under a single `TABLE`). */ +function publicationRelMember(fact: Fact): string { + const id = fact.id as { schema: string; table: string }; + let member = rel(id.schema, id.table); + const cols = p(fact, "columns") as string[] | null; + if (cols != null && cols.length > 0) { + member += ` (${cols.map((c) => qid(c)).join(", ")})`; + } + const where = p(fact, "where"); + if (where != null) member += ` WHERE (${str(where)})`; + return member; +} + +/** The `TABLE rel [(cols)] [WHERE (…)]` clause for a publicationRel fact, used + * by the standalone `ALTER PUBLICATION … ADD TABLE` path. */ +export function publicationRelClause(fact: Fact): string { + return `TABLE ${publicationRelMember(fact)}`; +} + +/** Inlined FOR-clause object list for a fresh publication, gathered from its + * publicationRel / publicationSchema children, with the ids consumed and + * the child facts produced (delta-set inlining). */ +export function publicationObjects( + fact: Fact, + view: FactView, +): { clauses: string[]; consumes: StableId[]; produced: StableId[] } { + // Group all table relations under a single `TABLE` keyword and all schemas + // under a single `TABLES IN SCHEMA`. Repeating the keyword per item + // (`FOR TABLE a, TABLE b`) is only valid grammar on PG15+; PG14 requires the + // collapsed `FOR TABLE a, b` form, and the collapsed form is valid on every + // version (PG14 never has schema members). + const tableMembers: string[] = []; + const schemaMembers: string[] = []; + const consumes: StableId[] = []; + const produced: StableId[] = []; + for (const child of view.childrenOf(fact.id)) { + if (child.id.kind === "publicationRel") { + const cid = child.id as { schema: string; table: string }; + tableMembers.push(publicationRelMember(child)); + consumes.push({ kind: "table", schema: cid.schema, name: cid.table }); + produced.push(child.id); + } else if (child.id.kind === "publicationSchema") { + const cid = child.id as { schema: string }; + schemaMembers.push(qid(cid.schema)); + consumes.push({ kind: "schema", name: cid.schema }); + produced.push(child.id); + } + } + const clauses: string[] = []; + if (tableMembers.length > 0) clauses.push(`TABLE ${tableMembers.join(", ")}`); + if (schemaMembers.length > 0) + clauses.push(`TABLES IN SCHEMA ${schemaMembers.join(", ")}`); + return { clauses, consumes, produced }; +} diff --git a/packages/pg-delta/src/plan/rules/indexes.ts b/packages/pg-delta/src/plan/rules/indexes.ts new file mode 100644 index 000000000..ae62f6847 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/indexes.ts @@ -0,0 +1,55 @@ +/** Rule definitions for standalone indexes. */ +import { rel } from "../render.ts"; +import type { KindRules } from "../rules.ts"; +import { p, renameRule, str } from "./helpers.ts"; + +export const indexRules: Record = { + index: { + weight: 14, + cascadesToChildren: true, + rebuildable: true, + rename: renameRule((fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER INDEX ${rel(id.schema, id.name)}`; + }), + create: (fact, view, params) => { + const def = str(p(fact, "def")); + // PostgreSQL REJECTS `CREATE INDEX CONCURRENTLY` on a partitioned table's + // parent index (relkind='p') — the parent index is metadata-only and is + // materialized by building each partition's own index, so there is + // nothing to build concurrently at the parent. Detect it via the parent + // TABLE fact's `partitionKey` (the `PARTITION BY` clause, null for + // ordinary tables) and keep the create plain/transactional; each + // partition's index attachment still builds normally. (Building each + // partition's index concurrently then attaching is out of scope.) + const parentTable = + fact.parent?.kind === "table" ? view.get(fact.parent) : undefined; + const parentIsPartitioned = + parentTable != null && p(parentTable, "partitionKey") != null; + if (params?.["concurrentIndexes"] === true && !parentIsPartitioned) { + // pg_get_indexdef never includes CONCURRENTLY (an execution choice, + // not state); splice it into the canonical def + return [ + { + sql: def.replace( + /^CREATE (UNIQUE )?INDEX /, + "CREATE $1INDEX CONCURRENTLY ", + ), + lockClass: "shareUpdateExclusive", + transactionality: "nonTransactional", + }, + ]; + } + return [{ sql: def }]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { sql: `DROP INDEX ${rel(id.schema, id.name)}` }; + }, + // `valid` (pg_index.indisvalid) participates in the diff: an invalid index + // (failed CREATE INDEX CONCURRENTLY) differs from the desired valid one even + // when their `def` is identical, and the only repair is drop + recreate — + // hence "replace", same strategy as `def`. See extract/relations.ts. + attributes: { def: "replace", valid: "replace" }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/metadata.ts b/packages/pg-delta/src/plan/rules/metadata.ts new file mode 100644 index 000000000..bb7839b4a --- /dev/null +++ b/packages/pg-delta/src/plan/rules/metadata.ts @@ -0,0 +1,177 @@ +/** Rule definitions for metadata satellites: comments, security labels, and + * ACL grants. */ +import type { Fact } from "../../core/fact.ts"; +import type { StableId } from "../../core/stable-id.ts"; +import { commentTarget, grantTarget, lit, qid } from "../render.ts"; +import type { FactView, KindRules } from "../rules.ts"; +import { aggSig, grantActions, p, str } from "./helpers.ts"; + +/** The COMMENT / SECURITY LABEL signature for an aggregate `target` — the + * `direct ORDER BY ordered` form for an ordered-set / hypothetical-set + * aggregate, reusing the aggregate DDL's `aggSig`. The aggregate metadata + * (aggKind / numDirectArgs) lives on the aggregate FACT, not the target id, so + * resolve the fact from whichever view holds it (desired for create/alter, + * source for drop). Returns undefined for a non-aggregate target or an + * unresolvable one, so `commentTarget` falls back to the plain arg list — + * which is already correct for an ordinary aggregate. */ +function aggregateTargetSignature( + target: StableId, + ...views: (FactView | undefined)[] +): string | undefined { + if (target.kind !== "aggregate") return undefined; + for (const view of views) { + const fact = view?.get(target); + if (fact !== undefined) return aggSig(fact); + } + return undefined; +} + +export const metadataRules: Record = { + comment: { + weight: 20, + metadata: true, + create: (fact, view, _params, sourceView) => { + const target = (fact.id as { target: StableId }).target; + const opts = { + domainConstraint: p(fact, "onDomain") === true, + aggregateSignature: aggregateTargetSignature(target, view, sourceView), + }; + return [ + { + sql: `COMMENT ON ${commentTarget(target, opts)} IS ${lit(str(p(fact, "text")))}`, + }, + ]; + }, + drop: (fact, view) => { + const target = (fact.id as { target: StableId }).target; + const opts = { + domainConstraint: p(fact, "onDomain") === true, + aggregateSignature: aggregateTargetSignature(target, view), + }; + return { sql: `COMMENT ON ${commentTarget(target, opts)} IS NULL` }; + }, + attributes: { + text: { + alter: (fact, _from, to, view, sourceView) => { + const target = (fact.id as { target: StableId }).target; + const opts = { + domainConstraint: p(fact, "onDomain") === true, + aggregateSignature: aggregateTargetSignature( + target, + view, + sourceView, + ), + }; + return { + sql: `COMMENT ON ${commentTarget(target, opts)} IS ${lit(str(to))}`, + }; + }, + }, + }, + }, + + // a global satellite rule (like comment): SECURITY LABEL shares COMMENT's + // ON-target grammar, so it reuses commentTarget. The provider lives in + // the fact id; the label text is the payload. + securityLabel: { + weight: 20, + metadata: true, + create: (fact, view, _params, sourceView) => { + const id = fact.id as { target: StableId; provider: string }; + const opts = { + aggregateSignature: aggregateTargetSignature( + id.target, + view, + sourceView, + ), + }; + return [ + { + sql: `SECURITY LABEL FOR ${lit(id.provider)} ON ${commentTarget(id.target, opts)} IS ${lit(str(p(fact, "label")))}`, + }, + ]; + }, + drop: (fact, view) => { + const id = fact.id as { target: StableId; provider: string }; + const opts = { + aggregateSignature: aggregateTargetSignature(id.target, view), + }; + return { + sql: `SECURITY LABEL FOR ${lit(id.provider)} ON ${commentTarget(id.target, opts)} IS NULL`, + }; + }, + attributes: { + label: { + alter: (fact, _from, to, view, sourceView) => { + const id = fact.id as { target: StableId; provider: string }; + const opts = { + aggregateSignature: aggregateTargetSignature( + id.target, + view, + sourceView, + ), + }; + return { + sql: `SECURITY LABEL FOR ${lit(id.provider)} ON ${commentTarget(id.target, opts)} IS ${lit(str(to))}`, + }; + }, + }, + }, + }, + + acl: { + weight: 21, + metadata: true, + create: (fact) => grantActions(fact, "grant"), + drop: (fact) => { + const id = fact.id as { + kind: "acl"; + target: StableId; + grantee: string; + column?: string; + }; + const grantee = id.grantee === "PUBLIC" ? "PUBLIC" : qid(id.grantee); + const consumes: StableId[] = + id.grantee === "PUBLIC" ? [] : [{ kind: "role", name: id.grantee }]; + // Column-level grants revoke with the column list; object-level ones don't. + const revokeAll = + id.column === undefined + ? "REVOKE ALL" + : `REVOKE ALL (${qid(id.column)})`; + const init = p(fact, "_initPrivs") as + | { privileges: string[]; grantable: string[] } + | undefined; + // Ordinary ACL (or an extension member with NO install grant for this + // grantee) → a bare REVOKE ALL, byte-identical to grantActions' leading + // REVOKE so the replace-path drop elision still fires. + if (init === undefined) { + return { + sql: `${revokeAll} ON ${grantTarget(id.target)} FROM ${grantee}`, + consumes, + }; + } + // Extension-member customization removed → revert to the AS-INSTALLED grant + // (`_initPrivs` from pg_init_privs) rather than stripping the extension's + // own grant. Emitted as ONE atomic multi-statement action, NOT two graph + // nodes: the reset REVOKE and the restore GRANT(s) target the same + // object+grantee and must never be reordered — two nodes carry no ordering + // edge, so the trailing REVOKE could re-drop the restored grant. Safe + // because ACL DDL is transactional (the string runs in one implicit + // transaction); do NOT copy this shape for a nonTransactional action. + const restore: Fact = { + id: fact.id, + payload: { privileges: init.privileges, grantable: init.grantable }, + }; + return { + sql: grantActions(restore, "grant") + .map((s) => s.sql) + .join(";\n"), + consumes, + }; + }, + attributes: { + privileges: "replace", + grantable: "replace", + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/policies.ts b/packages/pg-delta/src/plan/rules/policies.ts new file mode 100644 index 000000000..4e1ee5e44 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/policies.ts @@ -0,0 +1,78 @@ +/** Rule definitions for row-level security policies. */ +import type { StableId } from "../../core/stable-id.ts"; +import { qid, rel } from "../render.ts"; +import type { KindRules } from "../rules.ts"; +import { p, policySql } from "./helpers.ts"; + +export const policyRules: Record = { + policy: { + weight: 16, + cascadesToChildren: true, + rebuildable: true, + // Never fold a policy's drop into its table's DROP (mirrors the FK-constraint + // exception). A policy's USING / WITH CHECK can reference another object (a + // view, function, …) dropped separately; PostgreSQL refuses to drop that + // object while the policy still references it, and the table-cascade would + // only remove the policy AFTER the table — which itself must drop after the + // referenced object, forming an unbreakable teardown cycle. An explicit DROP + // POLICY ordered before the referenced drop makes teardown constructible. A + // redundant explicit drop (policy with no external reference) is trimmed by + // the cosmetic elideCascadeSubsumedPolicyDrops compaction pass. + suppressible: () => false, + rename: (fact, to) => { + const id = fact.id as { schema: string; table: string; name: string }; + return { + sql: `ALTER POLICY ${qid(id.name)} ON ${rel(id.schema, id.table)} RENAME TO ${qid((to as { name: string }).name)}`, + }; + }, + create: (fact) => { + const roles = (p(fact, "roles") as string[]) + .filter((r) => r !== "PUBLIC") + .map((r): StableId => ({ kind: "role", name: r })); + return [{ sql: policySql(fact), consumes: roles }]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; table: string; name: string }; + return { + sql: `DROP POLICY ${qid(id.name)} ON ${rel(id.schema, id.table)}`, + }; + }, + attributes: { + // USING / WITH CHECK predicates can be ADDED or REMOVED, not merely + // edited, and PostgreSQL offers no `ALTER POLICY … DROP USING`; an + // in-place ALTER would crash on the null (clause-removed) transition. + // Rebuild the (rebuildable) policy instead — exactly how `cmd` and + // `permissive` below already work, since they have no in-place ALTER. + usingExpr: "replace", + checkExpr: "replace", + roles: { + alter: (fact, from, to) => { + const id = fact.id as { schema: string; table: string; name: string }; + const fromRoles = from as string[]; + const toRoles = to as string[]; + const roles = toRoles.map((r) => + r === "PUBLIC" ? "PUBLIC" : qid(r), + ); + const roleId = (r: string): StableId => ({ kind: "role", name: r }); + // Consume roles newly listed (ordered after a same-plan CREATE ROLE) + // and release roles removed (ordered before a same-plan DROP ROLE, + // which PostgreSQL refuses while the policy still references it). + // PUBLIC is not an object, so it never forms an edge. + const consumes = toRoles + .filter((r) => r !== "PUBLIC" && !fromRoles.includes(r)) + .map(roleId); + const releases = fromRoles + .filter((r) => r !== "PUBLIC" && !toRoles.includes(r)) + .map(roleId); + return { + sql: `ALTER POLICY ${qid(id.name)} ON ${rel(id.schema, id.table)} TO ${roles.join(", ")}`, + ...(consumes.length > 0 ? { consumes } : {}), + ...(releases.length > 0 ? { releases } : {}), + }; + }, + }, + cmd: "replace", + permissive: "replace", + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/publications.test.ts b/packages/pg-delta/src/plan/rules/publications.test.ts new file mode 100644 index 000000000..e1a4ebdef --- /dev/null +++ b/packages/pg-delta/src/plan/rules/publications.test.ts @@ -0,0 +1,106 @@ +/** + * A subscription reconstructed from a REDACTED extraction has a placeholder + * conninfo (real host/credentials are unrecoverable) but keeps `enabled=true`. + * Emitting the `ALTER SUBSCRIPTION … ENABLE` follow-up starts a replication + * worker against a bogus host that fails asynchronously forever, while catalog + * convergence still passes. The redacted CREATE must therefore stay DISABLED + * and surface a note telling the operator to set a real CONNECTION and enable + * it manually. Unredacted enabled creates are unchanged. Pure rule/plan level — + * no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../../core/fact.ts"; +import type { StableId } from "../../core/stable-id.ts"; +import { SUBSCRIPTION_CONNINFO_PLACEHOLDER } from "../../extract/sensitive-options.ts"; +import { plan } from "../plan.ts"; + +const subId: StableId = { kind: "subscription", name: "s" }; + +const subFact = (overrides: Record = {}): Fact => ({ + id: subId, + payload: { + _serverMajor: 18, + enabled: true, + conninfo: "dbname=app host=real.example.com user=repl password=hunter2", + slotName: null, + publications: ["p"], + binary: false, + streaming: "off", + synchronousCommit: "off", + disableOnError: false, + twoPhase: false, + runAsOwner: false, + origin: "any", + ...overrides, + }, +}); + +const createActions = (fact: Fact): string[] => + plan(buildFactBase([], []), buildFactBase([fact], [])).actions.map( + (a) => a.sql, + ); + +const twoPhaseAlter = (from: Fact, to: Fact): string[] => + plan(buildFactBase([from], []), buildFactBase([to], [])).actions.map( + (a) => a.sql, + ); + +describe("subscription create redaction gate", () => { + test("an unredacted enabled subscription still emits the ENABLE follow-up", () => { + const sqls = createActions(subFact()); + expect(sqls.some((s) => s.startsWith(`CREATE SUBSCRIPTION "s"`))).toBe( + true, + ); + expect(sqls).toContain(`ALTER SUBSCRIPTION "s" ENABLE`); + }); + + test("a redacted (placeholder-conninfo) enabled subscription stays disabled with a note", () => { + const sqls = createActions( + subFact({ conninfo: SUBSCRIPTION_CONNINFO_PLACEHOLDER }), + ); + const create = sqls.find((s) => s.includes(`CREATE SUBSCRIPTION "s"`)); + expect(create).toBeDefined(); + // no worker is started against the bogus placeholder host + expect(sqls).not.toContain(`ALTER SUBSCRIPTION "s" ENABLE`); + // and the operator is told what to do + expect(create).toMatch(/redacted/i); + expect(create).toMatch(/ENABLE/); + }); +}); + +describe("subscription two_phase change is not a destructive replace", () => { + test("on PG18+ it alters in place (DISABLE → SET), never DROP/CREATE", () => { + const sqls = twoPhaseAlter( + subFact({ _serverMajor: 18, enabled: false, twoPhase: false }), + subFact({ _serverMajor: 18, enabled: false, twoPhase: true }), + ); + expect(sqls.some((s) => s.startsWith("DROP SUBSCRIPTION"))).toBe(false); + expect(sqls.some((s) => s.startsWith("CREATE SUBSCRIPTION"))).toBe(false); + expect(sqls).toContain(`ALTER SUBSCRIPTION "s" DISABLE`); + expect(sqls).toContain(`ALTER SUBSCRIPTION "s" SET (two_phase = true)`); + }); + + test("re-enables afterward when the desired state is enabled", () => { + const sqls = twoPhaseAlter( + subFact({ _serverMajor: 18, enabled: true, twoPhase: false }), + subFact({ _serverMajor: 18, enabled: true, twoPhase: true }), + ); + // DISABLE and ENABLE bracket the SET, in emission order. + expect(sqls).toContain(`ALTER SUBSCRIPTION "s" ENABLE`); + const dis = sqls.indexOf(`ALTER SUBSCRIPTION "s" DISABLE`); + const set = sqls.indexOf(`ALTER SUBSCRIPTION "s" SET (two_phase = true)`); + const en = sqls.indexOf(`ALTER SUBSCRIPTION "s" ENABLE`); + expect(dis).toBeGreaterThanOrEqual(0); + expect(dis).toBeLessThan(set); + expect(set).toBeLessThan(en); + }); + + test("on PG < 18 it fails loudly at plan time instead of dropping the slot", () => { + expect(() => + twoPhaseAlter( + subFact({ _serverMajor: 17, twoPhase: false }), + subFact({ _serverMajor: 17, twoPhase: true }), + ), + ).toThrow(/two_phase requires PostgreSQL 18/); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/publications.ts b/packages/pg-delta/src/plan/rules/publications.ts new file mode 100644 index 000000000..06e42128e --- /dev/null +++ b/packages/pg-delta/src/plan/rules/publications.ts @@ -0,0 +1,282 @@ +/** Rule definitions for publications, their member facts, and subscriptions. */ +import type { Fact } from "../../core/fact.ts"; +import { SUBSCRIPTION_CONNINFO_PLACEHOLDER } from "../../extract/sensitive-options.ts"; +import { lit, qid, rel } from "../render.ts"; +import type { ActionSpec, KindRules } from "../rules.ts"; +import { p, publicationObjects, publicationRelClause, str } from "./helpers.ts"; + +export const publicationRules: Record = { + publication: { + weight: 18, + cascadesToChildren: true, + create: (fact, view) => { + const name = qid((fact.id as { name: string }).name); + const objects = publicationObjects(fact, view); + let sql = `CREATE PUBLICATION ${name}`; + // FOR ALL TABLES has no member facts; otherwise inline the member + // facts (delta-set) so their standalone ADD actions are skipped + if (p(fact, "allTables")) sql += ` FOR ALL TABLES`; + else if (objects.clauses.length > 0) + sql += ` FOR ${objects.clauses.join(", ")}`; + const withParts = [ + `publish = ${lit(((p(fact, "publish") as string[]) ?? []).join(", "))}`, + ]; + if (p(fact, "viaRoot")) + withParts.push(`publish_via_partition_root = true`); + sql += ` WITH (${withParts.join(", ")})`; + return [ + { + sql, + ...(objects.consumes.length > 0 + ? { consumes: objects.consumes } + : {}), + ...(objects.produced.length > 0 + ? { alsoProduces: objects.produced } + : {}), + }, + ]; + }, + drop: (fact) => ({ + sql: `DROP PUBLICATION ${qid((fact.id as { name: string }).name)}`, + }), + ownerAlterPrefix: (fact) => + `ALTER PUBLICATION ${qid((fact.id as { name: string }).name)}`, + attributes: { + publish: { + alter: (fact, _from, to) => ({ + sql: `ALTER PUBLICATION ${qid((fact.id as { name: string }).name)} SET (publish = ${lit(((to as string[] | null) ?? []).join(", "))})`, + }), + }, + viaRoot: { + alter: (fact, _from, to) => ({ + sql: `ALTER PUBLICATION ${qid((fact.id as { name: string }).name)} SET (publish_via_partition_root = ${to ? "true" : "false"})`, + }), + }, + allTables: "replace", + }, + }, + + // a published table is its own fact: ADD/DROP TABLE incrementally. A + // column-list or WHERE change has no in-place form, so those attributes + // replace (DROP TABLE + re-ADD with the new shape). On a fresh + // publication the member is inlined into CREATE PUBLICATION (see above). + publicationRel: { + weight: 18, + create: (fact) => { + const id = fact.id as { + publication: string; + schema: string; + table: string; + }; + return [ + { + sql: `ALTER PUBLICATION ${qid(id.publication)} ADD ${publicationRelClause(fact)}`, + consumes: [{ kind: "table", schema: id.schema, name: id.table }], + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { + publication: string; + schema: string; + table: string; + }; + return { + sql: `ALTER PUBLICATION ${qid(id.publication)} DROP TABLE ${rel(id.schema, id.table)}`, + }; + }, + attributes: { + columns: "replace", + where: "replace", + }, + }, + + // a published schema (FOR TABLES IN SCHEMA, PG15+) as its own fact + publicationSchema: { + weight: 18, + create: (fact) => { + const id = fact.id as { publication: string; schema: string }; + return [ + { + sql: `ALTER PUBLICATION ${qid(id.publication)} ADD TABLES IN SCHEMA ${qid(id.schema)}`, + consumes: [{ kind: "schema", name: id.schema }], + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { publication: string; schema: string }; + return { + sql: `ALTER PUBLICATION ${qid(id.publication)} DROP TABLES IN SCHEMA ${qid(id.schema)}`, + }; + }, + attributes: {}, + }, + + subscription: { + weight: 23, + create: (fact) => { + const name = qid((fact.id as { name: string }).name); + const publications = ((p(fact, "publications") as string[]) ?? []) + .map((pub) => qid(pub)) + .join(", "); + const slot = p(fact, "slotName"); + const conninfo = str(p(fact, "conninfo")); + // A subscription rebuilt from a REDACTED extraction carries a placeholder + // conninfo — its real host/credentials are unrecoverable. Emitting the + // ENABLE follow-up would start a replication worker against that bogus + // host, which fails asynchronously forever while catalog convergence still + // passes. When the desired state is ENABLED, keep it disabled instead and + // tell the operator to set a real connection and enable it by hand. A + // disabled subscription is unaffected (its create never emits ENABLE), and + // unredacted creates are unchanged. + const redacted = conninfo === SUBSCRIPTION_CONNINFO_PLACEHOLDER; + const wantEnabled = Boolean(p(fact, "enabled")); + const suppressEnable = redacted && wantEnabled; + const withParts = [ + "connect = false", + "enabled = false", + `slot_name = ${slot == null ? "NONE" : lit(str(slot))}`, + // every captured option is reproduced at create time: a fresh + // subscription has no prior fact, so the per-attribute ALTER rules + // below never fire for it — only the WITH clause carries the options. + ...subscriptionOptionParts(fact), + ]; + const note = suppressEnable + ? `-- pg-delta: subscription ${name} was exported with REDACTED connection info;\n` + + `-- its CONNECTION below is a placeholder. Set the real connection string and run\n` + + `-- ALTER SUBSCRIPTION ${name} ENABLE; manually before it will replicate.\n` + : ""; + const specs: ActionSpec[] = [ + { + sql: `${note}CREATE SUBSCRIPTION ${name} CONNECTION ${lit(conninfo)} PUBLICATION ${publications} WITH (${withParts.join(", ")})`, + }, + ]; + if (wantEnabled && !suppressEnable) { + specs.push({ sql: `ALTER SUBSCRIPTION ${name} ENABLE` }); + } + return specs; + }, + drop: (fact) => ({ + sql: `DROP SUBSCRIPTION ${qid((fact.id as { name: string }).name)}`, + // with an associated replication slot the drop cannot run inside a + // transaction block; slotless subscriptions drop transactionally + ...(p(fact, "slotName") == null + ? {} + : { transactionality: "nonTransactional" as const }), + }), + ownerAlterPrefix: (fact) => + `ALTER SUBSCRIPTION ${qid((fact.id as { name: string }).name)}`, + attributes: { + enabled: { + alter: (fact, _from, to) => ({ + sql: `ALTER SUBSCRIPTION ${qid((fact.id as { name: string }).name)} ${to ? "ENABLE" : "DISABLE"}`, + }), + }, + publications: { + alter: (fact, _from, to) => ({ + sql: `ALTER SUBSCRIPTION ${qid((fact.id as { name: string }).name)} SET PUBLICATION ${((to as string[] | null) ?? []).map((pub) => qid(pub)).join(", ")} WITH (refresh = false)`, + }), + }, + conninfo: { + alter: (fact, _from, to) => ({ + sql: `ALTER SUBSCRIPTION ${qid((fact.id as { name: string }).name)} CONNECTION ${lit(str(to))}`, + }), + }, + slotName: { + alter: (fact, _from, to) => ({ + sql: `ALTER SUBSCRIPTION ${qid((fact.id as { name: string }).name)} SET (slot_name = ${to == null ? "NONE" : lit(str(to))})`, + }), + }, + // in-place ALTER … SET (opt = …) for the settable replication options + binary: subscriptionBoolSet("binary"), + streaming: subscriptionStringSet("streaming"), + synchronousCommit: subscriptionStringSet("synchronous_commit"), + disableOnError: subscriptionBoolSet("disable_on_error"), + runAsOwner: subscriptionBoolSet("run_as_owner"), + origin: subscriptionStringSet("origin"), + // `two_phase` must NOT be classified "replace": drop+recreate runs + // DROP SUBSCRIPTION, which drops the publisher's replication slot (it + // connects via the catalog conninfo), and the recreate uses + // `connect = false, slot_name = ` — it does not recreate that + // remote slot, so the catalog converges while replication is silently + // broken (and the DROP fails outright when the publisher is unreachable). + // PG18+ added `ALTER SUBSCRIPTION … SET (two_phase)`, allowed only on a + // DISABLED subscription, so route through DISABLE → SET → (re-)ENABLE. On + // PG < 18 there is no in-place form (`two_phase` is rejected as an + // unrecognized parameter) and an automatic recreate is destructive, so + // fail loudly and let the operator recreate the subscription deliberately. + twoPhase: { + alter: (fact, _from, to) => { + const name = subscriptionName(fact); + const major = Number(p(fact, "_serverMajor") ?? 0); + if (major < 18) { + throw new Error( + `subscription ${name}: changing two_phase requires PostgreSQL 18+ ` + + `(ALTER SUBSCRIPTION … SET (two_phase)). On PG${major || " < 18"} ` + + `recreate the subscription manually — an automatic drop/recreate ` + + `would drop the publisher's replication slot and break replication.`, + ); + } + // two_phase is settable only while the subscription is disabled; a + // DISABLE on an already-disabled subscription is a harmless no-op. + // These three specs tie on subject id and stay in emission order. + const specs: ActionSpec[] = [ + { sql: `ALTER SUBSCRIPTION ${name} DISABLE` }, + { + sql: `ALTER SUBSCRIPTION ${name} SET (two_phase = ${to ? "true" : "false"})`, + }, + ]; + if (p(fact, "enabled")) { + specs.push({ sql: `ALTER SUBSCRIPTION ${name} ENABLE` }); + } + return specs; + }, + }, + }, + }, +}; + +/** `CREATE SUBSCRIPTION … WITH (…)` fragments for every non-null option. + * null marks an option the server version does not expose (run_as_owner / + * origin are PG16+), so it is simply omitted. */ +function subscriptionOptionParts(fact: Fact): string[] { + const parts: string[] = []; + const bool = (key: string, opt: string): void => { + const v = p(fact, key); + if (v != null) parts.push(`${opt} = ${v ? "true" : "false"}`); + }; + const text = (key: string, opt: string): void => { + const v = p(fact, key); + if (v != null) parts.push(`${opt} = ${lit(str(v))}`); + }; + bool("binary", "binary"); + text("streaming", "streaming"); + text("synchronousCommit", "synchronous_commit"); + bool("disableOnError", "disable_on_error"); + bool("runAsOwner", "run_as_owner"); + bool("twoPhase", "two_phase"); + text("origin", "origin"); + return parts; +} + +function subscriptionName(fact: Fact): string { + return qid((fact.id as { name: string }).name); +} + +/** boolean ALTER … SET (opt = true|false). */ +function subscriptionBoolSet(opt: string): KindRules["attributes"][string] { + return { + alter: (fact, _from, to) => ({ + sql: `ALTER SUBSCRIPTION ${subscriptionName(fact)} SET (${opt} = ${to ? "true" : "false"})`, + }), + }; +} + +/** quoted-string ALTER … SET (opt = '…'). */ +function subscriptionStringSet(opt: string): KindRules["attributes"][string] { + return { + alter: (fact, _from, to) => ({ + sql: `ALTER SUBSCRIPTION ${subscriptionName(fact)} SET (${opt} = ${lit(str(to))})`, + }), + }; +} diff --git a/packages/pg-delta/src/plan/rules/roles.test.ts b/packages/pg-delta/src/plan/rules/roles.test.ts new file mode 100644 index 000000000..b80e4d9b8 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/roles.test.ts @@ -0,0 +1,34 @@ +/** + * Regression for issue #333 item 1 (P1 destructive): `DROP OWNED BY` in the + * role-drop rule is a sledgehammer — it silently destroys any object the + * role owns OUTSIDE the managed/projected view (never extracted by the + * engine). Managed grants, default ACLs, and owned objects are already + * revoked/reassigned/dropped by their own plan actions before the role drop + * runs, so a plain `DROP ROLE` is sufficient when everything is managed, and + * fails loud (instead of silently destroying data) when it isn't. + */ +import { describe, expect, test } from "bun:test"; +import type { Fact } from "../../core/fact.ts"; +import { roleRules } from "./roles.ts"; + +const roleFact: Fact = { + id: { kind: "role", name: "somerole" }, + payload: { + superuser: false, + inherit: true, + createRole: false, + createDb: false, + login: false, + replication: false, + bypassRls: false, + config: [], + }, +}; + +describe("role drop", () => { + test("emits a plain DROP ROLE, never DROP OWNED BY", () => { + const spec = roleRules.role!.drop!(roleFact); + const sql = Array.isArray(spec) ? spec.map((s) => s.sql) : spec.sql; + expect(sql).toBe(`DROP ROLE "somerole"`); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/roles.ts b/packages/pg-delta/src/plan/rules/roles.ts new file mode 100644 index 000000000..cae63c07f --- /dev/null +++ b/packages/pg-delta/src/plan/rules/roles.ts @@ -0,0 +1,144 @@ +/** Rule definitions for cluster-level role objects: roles, role memberships, + * and default privileges. */ +import type { Fact } from "../../core/fact.ts"; +import type { PayloadValue } from "../../core/hash.ts"; +import { lit, qid, splitOption } from "../render.ts"; +import type { ActionSpec, KindRules } from "../rules.ts"; +import { + defaultPrivilegeCreateActions, + defaultPrivilegeDropActions, + p, + renameRule, + ROLE_FLAGS, + roleFlagSql, +} from "./helpers.ts"; + +/** `ALTER ROLE SET TO ` — the single rendering of a role + * GUC config entry, shared by the create rule (materializing config on a fresh + * role) and the config set-delta alter (changing it later). `role` is already + * quoted by the caller. */ +function roleConfigSetSql(role: string, key: string, value: string): string { + return `ALTER ROLE ${role} SET ${qid(key)} TO ${lit(value)}`; +} + +export const roleRules: Record = { + role: { + weight: 0, + rename: renameRule( + (fact) => `ALTER ROLE ${qid((fact.id as { name: string }).name)}`, + ), + // a create rule must materialize EVERY payload attribute not carried by a + // child fact or edge: CREATE ROLE … WITH , then one + // `ALTER ROLE … SET` per desired config entry (review P1 — creating a + // configured role must not drop its GUC config). Follow-up actions consume + // the role fact so they order after the CREATE. + create: (fact) => { + const role = qid((fact.id as { name: string }).name); + const specs: ActionSpec[] = [ + { sql: `CREATE ROLE ${role} WITH ${roleFlagSql(fact.payload)}` }, + ]; + for (const entry of (p(fact, "config") as string[] | null) ?? []) { + const [key, value] = splitOption(entry); + specs.push({ sql: roleConfigSetSql(role, key, value) }); + } + return specs; + }, + drop: (fact) => { + const name = qid((fact.id as { name: string }).name); + // No `DROP OWNED BY`: every managed grant, default ACL, and owned + // object has already been revoked/reassigned/dropped by its own plan + // action. `DROP OWNED BY` would also sweep up anything the role owns + // OUTSIDE the managed view, silently destroying unmanaged data — a + // plain `DROP ROLE` instead fails loud if unmanaged ownership remains. + return { sql: `DROP ROLE ${name}` }; + }, + attributes: { + ...Object.fromEntries( + Object.entries(ROLE_FLAGS).map(([key, [on, off]]) => [ + key, + { + alter: (fact: Fact, _from: PayloadValue, to: PayloadValue) => ({ + sql: `ALTER ROLE ${qid((fact.id as { name: string }).name)} WITH ${to ? on : off}`, + }), + }, + ]), + ), + config: { + alter: (fact, from, to) => { + const role = qid((fact.id as { name: string }).name); + const oldCfg = new Map( + ((from as string[] | null) ?? []).map(splitOption), + ); + const newCfg = new Map( + ((to as string[] | null) ?? []).map(splitOption), + ); + const specs: ActionSpec[] = []; + for (const [key] of oldCfg) { + if (!newCfg.has(key)) { + specs.push({ sql: `ALTER ROLE ${role} RESET ${qid(key)}` }); + } + } + for (const [key, value] of newCfg) { + if (oldCfg.get(key) !== value) { + specs.push({ sql: roleConfigSetSql(role, key, value) }); + } + } + return specs; + }, + }, + }, + }, + + membership: { + weight: 1, + create: (fact) => { + const id = fact.id as { role: string; member: string }; + return [ + { + sql: `GRANT ${qid(id.role)} TO ${qid(id.member)}${p(fact, "admin") ? " WITH ADMIN OPTION" : ""}`, + consumes: [ + { kind: "role", name: id.role }, + { kind: "role", name: id.member }, + ], + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { role: string; member: string }; + // NO CASCADE: on PG16+, revoking an ADMIN-OPTION membership with CASCADE + // also deletes downstream pg_auth_members rows the member granted onward + // — including ones present on BOTH diff sides that are meant to be kept. + // Extraction is grantor-blind, so nothing plans a corrective re-grant, and + // the kept grant is silently destroyed. Plain REVOKE removes only this + // pair; on PG16+ with a dependent grant it fails LOUDLY ("dependent + // privileges exist") rather than silently — intended for now (convergent + // regrant tracked in #333). + return { + sql: `REVOKE ${qid(id.role)} FROM ${qid(id.member)}`, + consumes: [ + { kind: "role", name: id.role }, + { kind: "role", name: id.member }, + ], + }; + }, + attributes: { + admin: { + alter: (fact, _from, to) => { + const id = fact.id as { role: string; member: string }; + return { + sql: to + ? `GRANT ${qid(id.role)} TO ${qid(id.member)} WITH ADMIN OPTION` + : `REVOKE ADMIN OPTION FOR ${qid(id.role)} FROM ${qid(id.member)}`, + }; + }, + }, + }, + }, + + defaultPrivilege: { + weight: 22, + create: (fact) => defaultPrivilegeCreateActions(fact), + drop: (fact) => defaultPrivilegeDropActions(fact), + attributes: { privileges: "replace", grantable: "replace" }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/routines.test.ts b/packages/pg-delta/src/plan/rules/routines.test.ts new file mode 100644 index 000000000..516f86d09 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/routines.test.ts @@ -0,0 +1,90 @@ +/** + * Routine change classification (§4 def → CREATE OR REPLACE refactor). + * + * A function's stored `def` is `pg_get_functiondef` output — itself a + * `CREATE OR REPLACE FUNCTION …`. A body / volatility / security / strictness / + * SET-clause change (anything that keeps the same stable id and the same return + * type, language, and window-kind) re-runs that statement IN PLACE, preserving + * dependents, owner, and grants (PostgreSQL / pg_dump semantics). Only return + * type, language, and window-kind — which CREATE OR REPLACE refuses or cannot + * express — force a drop + recreate (with the forced dependent rebuild). + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../../core/fact.ts"; +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import { routineRules } from "./routines.ts"; +import { triggerRules } from "./triggers.ts"; + +const fnId: StableId = { kind: "function", schema: "s", name: "f", args: [] }; +const schemaFact: Fact = { id: { kind: "schema", name: "s" }, payload: {} }; + +describe("routine `def` is an in-place CREATE OR REPLACE, not a replace", () => { + test("the `def` attribute is an alter rule (not `replace`)", () => { + const defRule = routineRules.function!.attributes["def"]; + expect(defRule).not.toBe("replace"); + expect(typeof defRule === "object" && "alter" in defRule).toBe(true); + }); + + test("the def-alter renders the stored def verbatim", () => { + const defRule = routineRules.function!.attributes["def"]; + if (typeof defRule !== "object") + throw new Error("def must be an alter rule"); + const def = `CREATE OR REPLACE FUNCTION "s"."f"() RETURNS integer LANGUAGE sql AS $$SELECT 2$$`; + const fact: Fact = { + id: fnId, + parent: { kind: "schema", name: "s" }, + payload: { def }, + }; + const view = buildFactBase([schemaFact, fact], []); + const specs = defRule.alter(fact, "old", "new", view, view); + const spec = Array.isArray(specs) ? specs[0]! : specs; + expect(spec.sql).toBe(def); + }); + + test("the def-alter consumes the function's desired `depends` targets (BEGIN ATOMIC ordering)", () => { + const defRule = routineRules.function!.attributes["def"]; + if (typeof defRule !== "object") + throw new Error("def must be an alter rule"); + const tableId: StableId = { kind: "table", schema: "s", name: "t" }; + const table: Fact = { + id: tableId, + parent: { kind: "schema", name: "s" }, + payload: {}, + }; + const fn: Fact = { + id: fnId, + parent: { kind: "schema", name: "s" }, + payload: { def: `CREATE OR REPLACE FUNCTION "s"."f"() ...` }, + }; + const view = buildFactBase( + [schemaFact, table, fn], + [{ from: fnId, to: tableId, kind: "depends" }], + ); + const specs = defRule.alter(fn, "a", "b", view, view); + const spec = Array.isArray(specs) ? specs[0]! : specs; + expect((spec.consumes ?? []).map(encodeId)).toContain(encodeId(tableId)); + }); + + test("return type, arg signature, language, and window-kind force a replace (drop + recreate)", () => { + // CREATE OR REPLACE refuses each of these (return-type change; parameter + // rename / default removal), or a language/window-kind change is demolished + // for drop-and-recreate safety — so a same-id change to any must demolish. + for (const attr of ["returnType", "argSignature", "language", "isWindow"]) { + expect(routineRules.function!.attributes[attr]).toBe("replace"); + expect(routineRules.procedure!.attributes[attr]).toBe("replace"); + } + }); +}); + +describe("eventTrigger stays rebuildable (backing-function demolition)", () => { + // The `def` refactor makes a function BODY change take the alter path (the ET + // survives untouched), so the corpus scenario no longer exercises the ET + // rebuild. The flag still matters whenever a backing function is genuinely + // demolished (return-type/language/window-kind replace, or REMOVE+ADD): a + // surviving ET must be dropped before and recreated after. Pinned here since + // Alpine cannot express a same-id replace of an `() RETURNS event_trigger` + // plpgsql function to drive it through the corpus. + test("eventTrigger is rebuildable", () => { + expect(triggerRules.eventTrigger!.rebuildable).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/routines.ts b/packages/pg-delta/src/plan/rules/routines.ts new file mode 100644 index 000000000..683a6d634 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/routines.ts @@ -0,0 +1,171 @@ +/** Rule definitions for routines: functions / procedures and aggregates. */ +import type { Fact } from "../../core/fact.ts"; +import { lit, qid, rel, routineSig } from "../render.ts"; +import type { KindRules } from "../rules.ts"; +import { aggSig, dependencyConsumes, p, str } from "./helpers.ts"; + +// aggfinalmodify / aggmfinalmodify → CREATE AGGREGATE keyword. 'r' +// (READ_ONLY) is the default, so it's rendered as undefined (omitted). +const FINALFUNC_MODIFY: Record = { + r: undefined, + s: "SHAREABLE", + w: "READ_WRITE", +}; +// pg_proc.proparallel → PARALLEL keyword. 'u' (UNSAFE) is the default and +// is omitted. +const PARALLEL: Record = { + u: undefined, + s: "SAFE", + r: "RESTRICTED", +}; + +/** FUNCTION / PROCEDURE keyword from the routine's own id kind — never a + * payload field (the kind is part of the address, P0). */ +const routineKeyword = (fact: Fact): "FUNCTION" | "PROCEDURE" => + fact.id.kind === "procedure" ? "PROCEDURE" : "FUNCTION"; + +// Functions and procedures share one rule implementation: identical +// create/drop/rename/owner shapes, differing only by the keyword derived from +// the id kind. Registered under both `function` and `procedure` keys below. +const routineRule: KindRules = { + weight: 8, + cascadesToChildren: true, + rebuildable: true, + defaclObjtype: "f", + rename: (fact, to) => ({ + sql: `ALTER ROUTINE ${routineSig(fact.id as { schema: string; name: string; args: string[] })} RENAME TO ${qid((to as { name: string }).name)}`, + }), + create: (fact) => [ + { + sql: str(p(fact, "def")), + }, + ], + drop: (fact) => { + const id = fact.id as { schema: string; name: string; args: string[] }; + return { sql: `DROP ${routineKeyword(fact)} ${routineSig(id)}` }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string; args: string[] }; + return `ALTER ${routineKeyword(fact)} ${routineSig(id)}`; + }, + attributes: { + // `def` is pg_get_functiondef output — itself a `CREATE OR REPLACE`. A + // body / volatility / security / strictness / cost / SET-clause change + // re-runs it IN PLACE (PostgreSQL / pg_dump semantics: owner, grants, and + // dependents survive). (Arg-name / arg-default changes do NOT alter in place — + // they also flip `argSignature`, which is "replace" below and wins.) The + // alter consumes the routine's `depends` targets so a BEGIN ATOMIC body that + // references a newly-created object is ordered after that object's create. + def: { + alter: (fact, _from, _to, view) => ({ + sql: str(p(fact, "def")), + consumes: dependencyConsumes(view, fact.id), + }), + }, + // CREATE OR REPLACE refuses a return-type change ("cannot change return type + // of existing function"), cannot flip window-ness, and cannot rename a + // parameter or remove a parameter default ("cannot change name of input + // parameter" / "cannot remove parameter defaults"); a language change is + // demolished for the same drop-and-recreate safety. Each forces a replace + // (with the forced dependent rebuild). Arg TYPES are identity → a different + // stable id → natural drop+create, so `argSignature` differs within a stable + // id only by name/mode/default. + returnType: "replace", + argSignature: "replace", + language: "replace", + isWindow: "replace", + }, +}; + +export const routineRules: Record = { + function: routineRule, + procedure: routineRule, + + aggregate: { + weight: 9, + cascadesToChildren: true, + defaclObjtype: "f", + create: (fact) => { + const id = fact.id as { schema: string; name: string; args: string[] }; + const parts = [ + `SFUNC = ${str(p(fact, "sfunc"))}`, + `STYPE = ${str(p(fact, "stype"))}`, + ]; + // emit `KEY = ` for a non-null regproc-style option + const fn = (key: string, clause: string): void => { + const v = p(fact, key); + if (v != null) parts.push(`${clause} = ${str(v)}`); + }; + const sspace = Number(p(fact, "sspace") ?? 0); + if (sspace !== 0) parts.push(`SSPACE = ${sspace}`); + fn("finalfunc", "FINALFUNC"); + if (p(fact, "finalfuncExtra")) parts.push("FINALFUNC_EXTRA"); + const finalfuncModify = + FINALFUNC_MODIFY[str(p(fact, "finalfuncModify") ?? "r")]; + if (finalfuncModify != null) + parts.push(`FINALFUNC_MODIFY = ${finalfuncModify}`); + fn("combinefunc", "COMBINEFUNC"); + fn("serialfunc", "SERIALFUNC"); + fn("deserialfunc", "DESERIALFUNC"); + fn("msfunc", "MSFUNC"); + fn("minvfunc", "MINVFUNC"); + fn("mstype", "MSTYPE"); + const msspace = Number(p(fact, "msspace") ?? 0); + if (msspace !== 0) parts.push(`MSSPACE = ${msspace}`); + fn("mfinalfunc", "MFINALFUNC"); + if (p(fact, "mfinalfuncExtra")) parts.push("MFINALFUNC_EXTRA"); + const mfinalfuncModify = + FINALFUNC_MODIFY[str(p(fact, "mfinalfuncModify") ?? "r")]; + if (mfinalfuncModify != null) + parts.push(`MFINALFUNC_MODIFY = ${mfinalfuncModify}`); + const initcond = p(fact, "initcond"); + if (initcond != null) parts.push(`INITCOND = ${lit(str(initcond))}`); + const minitcond = p(fact, "minitcond"); + if (minitcond != null) parts.push(`MINITCOND = ${lit(str(minitcond))}`); + const sortop = p(fact, "sortop"); + if (sortop != null) parts.push(`SORTOP = ${str(sortop)}`); + const parallel = PARALLEL[str(p(fact, "parallel") ?? "u")]; + if (parallel != null) parts.push(`PARALLEL = ${parallel}`); + if (str(p(fact, "aggKind")) === "h") parts.push("HYPOTHETICAL"); + return [ + { + sql: `CREATE AGGREGATE ${rel(id.schema, id.name)}(${aggSig(fact)}) (${parts.join(", ")})`, + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string; args: string[] }; + return { + sql: `DROP AGGREGATE ${rel(id.schema, id.name)}(${aggSig(fact)})`, + }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string; args: string[] }; + return `ALTER AGGREGATE ${rel(id.schema, id.name)}(${aggSig(fact)})`; + }, + attributes: { + aggKind: "replace", + numDirectArgs: "replace", + sfunc: "replace", + stype: "replace", + finalfunc: "replace", + initcond: "replace", + combinefunc: "replace", + serialfunc: "replace", + deserialfunc: "replace", + msfunc: "replace", + minvfunc: "replace", + mstype: "replace", + msspace: "replace", + mfinalfunc: "replace", + minitcond: "replace", + finalfuncExtra: "replace", + mfinalfuncExtra: "replace", + finalfuncModify: "replace", + mfinalfuncModify: "replace", + sspace: "replace", + sortop: "replace", + parallel: "replace", + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/schemas.ts b/packages/pg-delta/src/plan/rules/schemas.ts new file mode 100644 index 000000000..0992e0f5c --- /dev/null +++ b/packages/pg-delta/src/plan/rules/schemas.ts @@ -0,0 +1,113 @@ +/** Rule definitions for schemas and extensions. */ +import type { StableId } from "../../core/stable-id.ts"; +import { qid } from "../render.ts"; +import type { KindRules } from "../rules.ts"; +import { p, renameRule, str } from "./helpers.ts"; + +/** + * Canonical `CREATE SCHEMA` rendering — the single source of truth shared by the + * schema create rule (bare form) and the co-create ownership fold + * (`internal.ts::foldCoCreateOwnership`), which appends `AUTHORIZATION` so a + * freshly-created schema + its owner ALTER collapse into one statement. Keeping + * both callers on this helper means the fold never reconstructs DDL by string + * surgery — it renders the canonical form and compares. + */ +export function schemaCreateSql( + schemaName: string, + ownerRole?: string, +): string { + const base = `CREATE SCHEMA ${qid(schemaName)}`; + return ownerRole === undefined + ? base + : `${base} AUTHORIZATION ${qid(ownerRole)}`; +} + +export const schemaRules: Record = { + schema: { + weight: 1, + defaclObjtype: "n", // ALTER DEFAULT PRIVILEGES … ON SCHEMAS + rename: renameRule( + (fact) => `ALTER SCHEMA ${qid((fact.id as { name: string }).name)}`, + ), + create: (fact) => [ + { sql: schemaCreateSql((fact.id as { name: string }).name) }, + ], + drop: (fact) => ({ + sql: `DROP SCHEMA ${qid((fact.id as { name: string }).name)}`, + }), + ownerAlterPrefix: (fact) => + `ALTER SCHEMA ${qid((fact.id as { name: string }).name)}`, + attributes: {}, + }, + + extension: { + weight: 2, + // Whether to emit `SCHEMA ` is a PLAN-TIME property, not an extract-time + // one: the clause is valid iff schema `s` EXISTS when CREATE EXTENSION runs. + // Emit it when `s` is present on the target (source view — including the + // reference-only platform schemas the policy keeps, e.g. Supabase's + // "extensions"), OR when `s` is created by this plan (a managed, non- + // reference-only desired schema; the `consumes` edge orders CREATE SCHEMA + // first). Otherwise the extension creates its OWN schema from its control + // file (pgmq), so the clause would fail against a not-yet-existing schema — + // emit the bare form; built-in schemas (pg_cron's pg_catalog) are never + // extracted and fall here too. `relocatable` / `_schemaIsMember` cannot + // express this — the `deptype='e'` schema→extension edge never exists, so + // `_schemaIsMember` was always false and the rule always emitted the clause + // (da8ce04 regression). See docs/architecture/managed-view-architecture.md. + create: (fact, view, _params, sourceView) => { + const schemaName = str(p(fact, "schema")); + const schemaId: StableId = { kind: "schema", name: schemaName }; + const schemaPresent = + sourceView?.get(schemaId) !== undefined || + (view.get(schemaId) !== undefined && !view.isReferenceOnly(schemaId)); + const name = qid((fact.id as { name: string }).name); + return [ + schemaPresent + ? { + sql: `CREATE EXTENSION ${name} SCHEMA ${qid(schemaName)}`, + consumes: [schemaId], + } + : { sql: `CREATE EXTENSION ${name}` }, + ]; + }, + // DROP EXTENSION cascades to its member objects (pg_depend deptype 'e'), but + // those members are projected out of the diff (kept reference-only), so no + // other action raises the destructive flag for them. Derive it here from the + // member closure: destructive iff the extension owns a DATA-BEARING persisted + // relation (table / materialized view) — dropping it destroys user rows. An + // extension whose members are only functions/types/operators/etc. drops + // without data loss, so it stays non-destructive (matches the per-kind + // convention: DROP TABLE is destructive, DROP FUNCTION is not). The proof + // loop turns this into a verified claim; here it is only the reported hazard. + // Reference-only members survive in the resolved view with their + // `memberOfExtension` edges intact, so `incomingEdges` sees them. + drop: (fact, view) => { + const destructive = (view?.incomingEdges(fact.id) ?? []).some( + (e) => + e.kind === "memberOfExtension" && + (e.from.kind === "table" || e.from.kind === "materializedView"), + ); + return { + sql: `DROP EXTENSION ${qid((fact.id as { name: string }).name)}`, + ...(destructive ? { dataLoss: "destructive" as const } : {}), + }; + }, + attributes: { + schema: { + // consume the NEW schema so the relocation is ordered after its CREATE; + // release the OLD schema so it runs before a same-plan DROP SCHEMA of + // the old home (otherwise the drop can be sequenced first and fail). + alter: (fact, from, to) => ({ + sql: `ALTER EXTENSION ${qid((fact.id as { name: string }).name)} SET SCHEMA ${qid(str(to))}`, + consumes: [{ kind: "schema", name: str(to) }], + releases: [{ kind: "schema", name: str(from) }], + }), + // A non-relocatable extension rejects SET SCHEMA, so relocating it must + // be a drop + recreate in the new schema (its create rule emits the + // `SCHEMA ` clause). The relocatable flag is extracted per extension. + replaceWhen: (_from, _to, fact) => p(fact, "relocatable") === false, + }, + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/sequences.test.ts b/packages/pg-delta/src/plan/rules/sequences.test.ts new file mode 100644 index 000000000..0289cd6aa --- /dev/null +++ b/packages/pg-delta/src/plan/rules/sequences.test.ts @@ -0,0 +1,96 @@ +/** + * Sequence option changes must apply ATOMICALLY. Per-field `ALTER SEQUENCE` + * statements were emitted in diff-field (lexicographic) order, so moving both + * bounds down (MIN 100/MAX 200 -> MIN 1/MAX 50) ran `MAXVALUE 50` while MIN was + * still 100 — Postgres rejects the transient range. One combined `ALTER SEQUENCE` + * validates the FINAL state instead, and realigns the counter (RESTART) when the + * range moved so far the old current value would fall outside it. Pure rule/diff + * level — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../../core/fact.ts"; +import type { StableId } from "../../core/stable-id.ts"; +import { plan } from "../plan.ts"; + +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const seqId: StableId = { kind: "sequence", schema: "app", name: "s" }; +const seqFact = (options: Record): Fact => ({ + id: seqId, + parent: { kind: "schema", name: "app" }, + payload: { + dataType: "bigint", + increment: "1", + minValue: "1", + maxValue: "9223372036854775807", + start: "1", + cache: "1", + cycle: false, + ownedBy: null, + ...options, + }, +}); +const base = (extra: Fact[]) => buildFactBase([schemaFact, ...extra], []); + +const seqAlters = (from: Fact, to: Fact): string[] => + plan(base([from]), base([to])) + .actions.map((a) => a.sql) + .filter((s) => s.startsWith("ALTER SEQUENCE")); + +describe("sequence option changes apply atomically", () => { + test("moving both bounds down emits ONE combined ALTER SEQUENCE (final-state valid)", () => { + const alters = seqAlters( + seqFact({ minValue: "100", maxValue: "200", start: "100" }), + seqFact({ minValue: "1", maxValue: "50", start: "1" }), + ); + expect(alters).toHaveLength(1); + expect(alters[0]).toContain("MINVALUE 1"); + expect(alters[0]).toContain("MAXVALUE 50"); + expect(alters[0]).toContain("START WITH 1"); + // the range moved entirely below the old one; realign the counter + expect(alters[0]).toContain("RESTART"); + }); + + test("moving both bounds up emits ONE combined ALTER SEQUENCE", () => { + const alters = seqAlters( + seqFact({ minValue: "1", maxValue: "50", start: "1" }), + seqFact({ minValue: "100", maxValue: "200", start: "100" }), + ); + expect(alters).toHaveLength(1); + expect(alters[0]).toContain("MINVALUE 100"); + expect(alters[0]).toContain("MAXVALUE 200"); + expect(alters[0]).toContain("RESTART"); + }); + + test("a single-option change stays a single minimal statement (no RESTART, no churn)", () => { + const alters = seqAlters(seqFact({}), seqFact({ cache: "20" })); + expect(alters).toEqual([`ALTER SEQUENCE "app"."s" CACHE 20`]); + }); + + test("widening the max bound without moving START does not RESTART", () => { + const alters = seqAlters( + seqFact({ minValue: "1", maxValue: "50", start: "1" }), + seqFact({ minValue: "1", maxValue: "500", start: "1" }), + ); + expect(alters).toHaveLength(1); + expect(alters[0]).toContain("MAXVALUE 500"); + expect(alters[0]).not.toContain("RESTART"); + }); + + test("an OVERLAPPING range change (bound + START both move) must NOT RESTART a live counter", () => { + // MIN 1→0 + START 1→2 with the max bound unchanged: the ranges [1, MAX] and + // [0, MAX] overlap, so a live counter (e.g. 500) stays valid. RESTART here + // would replay already-issued values → duplicate keys. Only a DISJOINT + // range shift may RESTART. + const alters = seqAlters( + seqFact({ minValue: "1", start: "1" }), + seqFact({ minValue: "0", start: "2" }), + ); + expect(alters).toHaveLength(1); + expect(alters[0]).toContain("MINVALUE 0"); + expect(alters[0]).toContain("START WITH 2"); + expect(alters[0]).not.toContain("RESTART"); + }); +}); diff --git a/packages/pg-delta/src/plan/rules/sequences.ts b/packages/pg-delta/src/plan/rules/sequences.ts new file mode 100644 index 000000000..0a0de5cb1 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/sequences.ts @@ -0,0 +1,113 @@ +/** Rule definitions for sequences. */ +import type { StableId } from "../../core/stable-id.ts"; +import { rel } from "../render.ts"; +import type { KindRules } from "../rules.ts"; +import { + p, + renameRule, + sequenceOptionAlter, + sequenceOwnedBySpecs, + str, +} from "./helpers.ts"; + +export const sequenceRules: Record = { + sequence: { + weight: 3, + cascadesToChildren: true, + defaclObjtype: "S", + dropRootRedirect: (fact, isRemoved) => { + const ownedBy = fact.payload["ownedBy"] as { + schema: string; + table: string; + column: string; + } | null; + if (ownedBy == null) return undefined; + const columnId: StableId = { + kind: "column", + schema: ownedBy.schema, + table: ownedBy.table, + name: ownedBy.column, + }; + if (isRemoved(columnId)) return columnId; + const tableId: StableId = { + kind: "table", + schema: ownedBy.schema, + name: ownedBy.table, + }; + if (isRemoved(tableId)) return tableId; + return undefined; + }, + rename: renameRule((fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER SEQUENCE ${rel(id.schema, id.name)}`; + }), + create: (fact) => { + const id = fact.id as { schema: string; name: string }; + return [ + { + sql: + `CREATE SEQUENCE ${rel(id.schema, id.name)} AS ${str(p(fact, "dataType"))}` + + ` INCREMENT BY ${str(p(fact, "increment"))} MINVALUE ${str(p(fact, "minValue"))}` + + ` MAXVALUE ${str(p(fact, "maxValue"))} START WITH ${str(p(fact, "start"))}` + + ` CACHE ${str(p(fact, "cache"))} ${p(fact, "cycle") ? "CYCLE" : "NO CYCLE"}`, + }, + ...sequenceOwnedBySpecs(fact), + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { sql: `DROP SEQUENCE ${rel(id.schema, id.name)}` }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER SEQUENCE ${rel(id.schema, id.name)}`; + }, + attributes: { + // Every value option routes through the SAME combined-alter helper, which + // emits ONE `ALTER SEQUENCE … …` statement from whichever + // changed option sorts first (the emitter iterates changed attributes + // independently). A single statement validates the FINAL state, so moving + // both bounds at once no longer trips Postgres' transient `min > max` + // rejection — see `sequenceOptionAlter`. + dataType: { + alter: (fact, _from, _to, _view, sourceView) => + sequenceOptionAlter("dataType", fact, sourceView), + }, + increment: { + alter: (fact, _from, _to, _view, sourceView) => + sequenceOptionAlter("increment", fact, sourceView), + }, + minValue: { + alter: (fact, _from, _to, _view, sourceView) => + sequenceOptionAlter("minValue", fact, sourceView), + }, + maxValue: { + alter: (fact, _from, _to, _view, sourceView) => + sequenceOptionAlter("maxValue", fact, sourceView), + }, + start: { + alter: (fact, _from, _to, _view, sourceView) => + sequenceOptionAlter("start", fact, sourceView), + }, + cache: { + alter: (fact, _from, _to, _view, sourceView) => + sequenceOptionAlter("cache", fact, sourceView), + }, + cycle: { + alter: (fact, _from, _to, _view, sourceView) => + sequenceOptionAlter("cycle", fact, sourceView), + }, + ownedBy: { + alter: (fact, from) => + sequenceOwnedBySpecs(fact, { + allowNone: true, + releaseOld: from as { + schema: string; + table: string; + column: string; + } | null, + }), + }, + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/tables.ts b/packages/pg-delta/src/plan/rules/tables.ts new file mode 100644 index 000000000..77fd6af80 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/tables.ts @@ -0,0 +1,391 @@ +/** Rule definitions for tables and their column / default sub-objects. */ +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import { qid, rel } from "../render.ts"; +import type { ActionSpec, KindRules } from "../rules.ts"; +import { + columnClause, + columnRef, + dependencyConsumes, + identityGeneration, + identityOptionAlterSpecs, + identityOptions, + identityOptionsClause, + identitySequenceId, + identitySequenceNameClause, + p, + reloptionsAlterSpecs, + renameRule, + replicaIdentitySpec, + str, +} from "./helpers.ts"; + +export const tableRules: Record = { + table: { + weight: 4, + cascadesToChildren: true, + defaclObjtype: "r", + rename: renameRule((fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER TABLE ${rel(id.schema, id.name)}`; + }), + create: (fact, view) => { + const id = fact.id as { schema: string; name: string }; + const relName = rel(id.schema, id.name); + const persistence = str(p(fact, "persistence")); + const unlogged = persistence === "u" ? "UNLOGGED " : ""; + const bound = p(fact, "partitionBound"); + const partKey = p(fact, "partitionKey"); + const parentT = p(fact, "parentTable") as { + schema: string; + name: string; + } | null; + + let createSql: string; + const consumes: StableId[] = []; + const alsoProduces: StableId[] = []; + if (bound != null && parentT != null) { + // a partition: columns are inherited, the bound carries the shape + createSql = `CREATE ${unlogged}TABLE ${relName} PARTITION OF ${rel(parentT.schema, parentT.name)} ${str(bound)}`; + // a partition may itself be partitioned (multi-level partitioning): keep + // its own PARTITION BY so sub-partitions can attach — otherwise the + // middle layer is created as a plain table and its leaves fail to attach. + if (partKey != null) createSql += ` PARTITION BY ${str(partKey)}`; + consumes.push({ + kind: "table", + schema: parentT.schema, + name: parentT.name, + }); + } else { + // partitioned parents must inline their columns: the partition key + // references them, so decomposed ADD COLUMN cannot work (§3.4 + // delta-set inlining). The statement produces the column facts too. + let cols = ""; + if (partKey != null) { + const colFacts = view + .childrenOf(fact.id) + .filter((c) => c.id.kind === "column"); + // Column ORDER is row-layout state: render in declared position + // (`_position` = pg_attribute.attnum, captured at extract time), NOT + // the encoded-id (name) order childrenOf() yields. Fall back to the + // incoming order when `_position` is absent (legacy fact bases) so the + // sort stays stable and total. Mirrors the composite CREATE TYPE path + // (plan/rules/types.ts). The bare foldable path is handled separately: + // its columns arrive as ADD COLUMN folds, ordered by the tie-break in + // plan/phases/action-graph.ts. + if (colFacts.every((c) => p(c, "_position") != null)) { + colFacts.sort( + (a, b) => Number(p(a, "_position")) - Number(p(b, "_position")), + ); + } + cols = colFacts.map(columnClause).join(", "); + for (const c of colFacts) alsoProduces.push(c.id); + } + createSql = `CREATE ${unlogged}TABLE ${relName} (${cols})`; + if (parentT != null) { + createSql += ` INHERITS (${rel(parentT.schema, parentT.name)})`; + consumes.push({ + kind: "table", + schema: parentT.schema, + name: parentT.name, + }); + } + if (partKey != null) createSql += ` PARTITION BY ${str(partKey)}`; + } + + // only the bare shape (no partition machinery, no INHERITS suffix) + // can absorb folded column clauses without SQL surgery ambiguity + const foldable = bound == null && partKey == null && parentT == null; + const specs: ActionSpec[] = [ + { + sql: createSql, + ...(consumes.length > 0 ? { consumes } : {}), + alsoProduces, + ...(foldable ? { acceptsColumnFolds: true } : {}), + }, + ]; + if (p(fact, "rowSecurity")) { + specs.push({ sql: `ALTER TABLE ${relName} ENABLE ROW LEVEL SECURITY` }); + } + if (p(fact, "forceRowSecurity")) { + specs.push({ sql: `ALTER TABLE ${relName} FORCE ROW LEVEL SECURITY` }); + } + const replident = p(fact, "replicaIdentity"); + if (replident != null && replident !== "d") { + specs.push(replicaIdentitySpec(fact, view)); + } + // storage reloptions (fillfactor, autovacuum_*, …) as a SET follow-up, + // keeping them out of the partition/INHERITS/PARTITION BY create grammar + const reloptions = p(fact, "reloptions") as string[] | null; + if (reloptions != null && reloptions.length > 0) { + specs.push({ + sql: `ALTER TABLE ${relName} SET (${reloptions.join(", ")})`, + }); + } + return specs; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { + sql: `DROP TABLE ${rel(id.schema, id.name)}`, + dataLoss: "destructive", + }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER TABLE ${rel(id.schema, id.name)}`; + }, + attributes: { + persistence: { + alter: (fact, _from, to) => { + const id = fact.id as { schema: string; name: string }; + return { + sql: `ALTER TABLE ${rel(id.schema, id.name)} SET ${str(to) === "u" ? "UNLOGGED" : "LOGGED"}`, + rewriteRisk: true, + }; + }, + }, + rowSecurity: { + alter: (fact, _from, to) => { + const id = fact.id as { schema: string; name: string }; + return { + sql: `ALTER TABLE ${rel(id.schema, id.name)} ${to ? "ENABLE" : "DISABLE"} ROW LEVEL SECURITY`, + }; + }, + }, + forceRowSecurity: { + alter: (fact, _from, to) => { + const id = fact.id as { schema: string; name: string }; + return { + sql: `ALTER TABLE ${rel(id.schema, id.name)} ${to ? "FORCE" : "NO FORCE"} ROW LEVEL SECURITY`, + }; + }, + }, + replicaIdentity: { + alter: (fact, _from, _to, view) => replicaIdentitySpec(fact, view), + }, + replicaIdentityIndex: { + alter: (fact, _from, _to, view) => replicaIdentitySpec(fact, view), + }, + reloptions: { + alter: (fact, from, to) => { + const id = fact.id as { schema: string; name: string }; + return reloptionsAlterSpecs( + `ALTER TABLE ${rel(id.schema, id.name)}`, + from, + to, + ); + }, + }, + partitionKey: "replace", + partitionBound: "replace", + parentTable: "replace", + }, + }, + + column: { + weight: 5, + cascadesToChildren: true, + rename: (fact, to) => { + const { schema, table, column } = columnRef(fact); + return { + sql: `ALTER TABLE ${rel(schema, table)} RENAME COLUMN ${qid(column)} TO ${qid((to as { name: string }).name)}`, + }; + }, + create: (fact, view) => { + const { schema, table, column } = columnRef(fact); + // delta-set inlining (§3.4): a column arriving WITH a default must + // carry it inline — ADD COLUMN … NOT NULL fails on populated tables + // otherwise. The statement then produces the default fact too. + const defaultChild = view + .childrenOf(fact.id) + .find((c) => c.id.kind === "default" && c.id.name === column); + let clause = columnClause(fact); + const alsoProduces: StableId[] = []; + if (defaultChild) { + clause += ` DEFAULT ${str(defaultChild.payload["expr"])}`; + alsoProduces.push(defaultChild.id); + } + // ADD COLUMN rewrites the table when it materializes a value for every + // row: a STORED generated column always, or an inline DEFAULT whose + // expression may be volatile (nextval, now, …). We cannot tell a + // constant default from a volatile one without parsing (guardrail 2), + // so any inline default conservatively declares rewriteRisk — over- + // declaring is safe (the proof only fails on UNDER-declared rewrites). + const rewrites = + fact.payload["generatedExpr"] != null || defaultChild !== undefined; + const spec: ActionSpec = { + sql: `ALTER TABLE ${rel(schema, table)} ADD COLUMN ${clause}`, + alsoProduces, + ...(rewrites ? { rewriteRisk: true } : {}), + }; + if (fact.parent !== undefined && fact.parent.kind === "table") { + // Generated columns reference other columns in their expression; folding + // them into an empty CREATE TABLE before those columns are present emits + // invalid SQL (dbdev package_upgrades.from_version_struct roundtrip). + if (fact.payload["generatedExpr"] == null) { + spec.compaction = { foldInto: fact.parent, clause }; + } + } + return [spec]; + }, + drop: (fact) => { + const { schema, table, column } = columnRef(fact); + return { + sql: `ALTER TABLE ${rel(schema, table)} DROP COLUMN ${qid(column)}`, + dataLoss: "destructive", + }; + }, + attributes: { + type: { + // delta-set shape: defaults can't be cast through a type change, so + // the change is sandwiched DROP DEFAULT → TYPE … USING → SET DEFAULT + alter: (fact, _from, to, view, sourceView) => { + const { schema, table, column } = columnRef(fact); + const target = `ALTER TABLE ${rel(schema, table)} ALTER COLUMN ${qid(column)}`; + // Foreign tables have no local storage, so PostgreSQL rejects the + // USING cast clause (" is not a table", 42809) — it would force a + // rewrite. The plain TYPE change is metadata-only and carries no + // rewrite risk. (Regular tables keep the USING cast + rewriteRisk.) + const isForeign = fact.parent?.kind === "foreignTable"; + // The retyped column depends on its NEW type via a column→type + // pg_depend edge; consume it so this TYPE change is ordered AFTER a + // same-plan CREATE of that type. Symmetrically, release the OLD type + // (the source-side edge) so the change runs BEFORE a same-plan DROP of + // it. Built-in types record no such edge (system-scoped endpoints are + // dropped in extract), so a plain widening leaves both sets empty. + const consumes = dependencyConsumes(view, fact.id); + const releases = dependencyConsumes(sourceView, fact.id); + const typeSpec: ActionSpec = isForeign + ? { sql: `${target} TYPE ${str(to)}` } + : { + sql: `${target} TYPE ${str(to)} USING ${qid(column)}::${str(to)}`, + rewriteRisk: true, + }; + if (consumes.length > 0) typeSpec.consumes = consumes; + if (releases.length > 0) typeSpec.releases = releases; + const specs: ActionSpec[] = [ + { sql: `${target} DROP DEFAULT` }, + typeSpec, + ]; + const desiredDefault = view + .childrenOf(fact.id) + .find((c) => c.id.kind === "default"); + if (desiredDefault) { + specs.push({ + sql: `${target} SET DEFAULT ${str(desiredDefault.payload["expr"])}`, + }); + } + return specs; + }, + // PostgreSQL rejects ALTER COLUMN … TYPE while a view, rule, or + // policy references the column (0A000). Those dependents must be + // dropped before the alter and recreated after; indexes and + // constraints are NOT force-rebuilt (PG rebuilds them itself, and + // dropping a PK with dependent FKs would cascade harmfully). + rebuildsDependents: () => [ + "view", + "materializedView", + "rule", + "policy", + ], + }, + notNull: { + alter: (fact, _from, to) => { + const { schema, table, column } = columnRef(fact); + return { + sql: `ALTER TABLE ${rel(schema, table)} ALTER COLUMN ${qid(column)} ${to ? "SET" : "DROP"} NOT NULL`, + }; + }, + }, + identity: { + alter: (fact, from, to) => { + const { schema, table, column } = columnRef(fact); + const target = `ALTER TABLE ${rel(schema, table)} ALTER COLUMN ${qid(column)}`; + const fromSeq = identitySequenceId(from); + const toSeq = identitySequenceId(to); + if (to == null) { + // the backing sequence dies with the identity; declaring it lets + // the graph order a CREATE SEQUENCE of the same name afterwards + return { + sql: `${target} DROP IDENTITY`, + ...(fromSeq == null ? {} : { alsoDestroys: [fromSeq] }), + }; + } + const phrase = + identityGeneration(to) === "a" + ? "GENERATED ALWAYS" + : "GENERATED BY DEFAULT"; + const columnType = str(p(fact, "type")); + if (from == null) { + // ADD IDENTITY materializes the backing sequence; declaring it + // orders this after a DROP SEQUENCE freeing the name. Non-default + // sequence parameters ride along inline. + return { + sql: `${target} ADD ${phrase} AS IDENTITY${identityOptionsClause(identityOptions(to), columnType, identitySequenceNameClause(to, { schema, table, column }))}`, + ...(toSeq == null ? {} : { alsoProduces: [toSeq] }), + }; + } + const specs: ActionSpec[] = []; + if (identityGeneration(from) !== identityGeneration(to)) { + specs.push({ sql: `${target} SET ${phrase}` }); + } + if ( + fromSeq != null && + toSeq != null && + encodeId(fromSeq) !== encodeId(toSeq) + ) { + const fromParts = fromSeq as { schema: string; name: string }; + const toParts = toSeq as { schema: string; name: string }; + specs.push({ + sql: `ALTER SEQUENCE ${rel(fromParts.schema, fromParts.name)} RENAME TO ${qid(toParts.name)}`, + alsoDestroys: [fromSeq], + alsoProduces: [toSeq], + }); + } + // a parameter-only change (START/INCREMENT/CACHE/MIN/MAX/CYCLE) is an + // in-place ALTER COLUMN SET — no sequence rebuild + specs.push( + ...identityOptionAlterSpecs( + target, + identityOptions(from), + identityOptions(to), + ), + ); + return specs; + }, + }, + collation: "replace", + generatedExpr: "replace", + }, + }, + + default: { + weight: 6, + cascadesToChildren: true, + rebuildable: true, + create: (fact) => { + const { schema, table, column } = columnRef(fact); + return [ + { + sql: `ALTER TABLE ${rel(schema, table)} ALTER COLUMN ${qid(column)} SET DEFAULT ${str(p(fact, "expr"))}`, + }, + ]; + }, + drop: (fact) => { + const { schema, table, column } = columnRef(fact); + return { + sql: `ALTER TABLE ${rel(schema, table)} ALTER COLUMN ${qid(column)} DROP DEFAULT`, + }; + }, + attributes: { + expr: { + alter: (fact, _from, to) => { + const { schema, table, column } = columnRef(fact); + return { + sql: `ALTER TABLE ${rel(schema, table)} ALTER COLUMN ${qid(column)} SET DEFAULT ${str(to)}`, + }; + }, + }, + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/triggers.ts b/packages/pg-delta/src/plan/rules/triggers.ts new file mode 100644 index 000000000..9b34ed23b --- /dev/null +++ b/packages/pg-delta/src/plan/rules/triggers.ts @@ -0,0 +1,110 @@ +/** Rule definitions for table triggers and event triggers. */ +import type { StableId } from "../../core/stable-id.ts"; +import { lit, qid, rel } from "../render.ts"; +import type { ActionSpec, KindRules } from "../rules.ts"; +import { enabledPhrase, p, str } from "./helpers.ts"; + +export const triggerRules: Record = { + trigger: { + weight: 15, + cascadesToChildren: true, + rebuildable: true, + rename: (fact, to) => { + const id = fact.id as { schema: string; table: string; name: string }; + return { + sql: `ALTER TRIGGER ${qid(id.name)} ON ${rel(id.schema, id.table)} RENAME TO ${qid((to as { name: string }).name)}`, + }; + }, + create: (fact) => { + const id = fact.id as { schema: string; table: string; name: string }; + const specs: ActionSpec[] = [{ sql: str(p(fact, "def")) }]; + const enabled = p(fact, "enabled"); + if (enabled != null && enabled !== "O") { + specs.push({ + sql: `ALTER TABLE ${rel(id.schema, id.table)} ${enabledPhrase(str(enabled))} TRIGGER ${qid(id.name)}`, + }); + } + return specs; + }, + drop: (fact) => { + const id = fact.id as { schema: string; table: string; name: string }; + return { + sql: `DROP TRIGGER ${qid(id.name)} ON ${rel(id.schema, id.table)}`, + }; + }, + attributes: { + def: "replace", + enabled: { + alter: (fact, _from, to) => { + const id = fact.id as { schema: string; table: string; name: string }; + return { + sql: `ALTER TABLE ${rel(id.schema, id.table)} ${enabledPhrase(str(to))} TRIGGER ${qid(id.name)}`, + }; + }, + }, + }, + }, + + eventTrigger: { + weight: 17, + // An event trigger depends on its backing function (pg_depend 'n' edge + + // the create's `consumes: [fnId]`). A function BODY change now alters in + // place (CREATE OR REPLACE — see routines.ts), leaving the ET untouched; but + // a change CREATE OR REPLACE cannot express (return type / language / window- + // kind) or a REMOVE+ADD of the backing function still DEMOLISHES it. A + // surviving event trigger on a demolished function must therefore be dropped + // before the function and recreated after; without `rebuildable` the closure + // skips it and `DROP FUNCTION` fails with "other objects depend on it" + // (regression: Supabase's grant_pg_net_access et al. back event triggers). + // Pinned by plan/rules/routines.test.ts — Alpine cannot drive an ET-function + // replace through the corpus (its backing fn must stay `() RETURNS + // event_trigger` plpgsql, none of which can change under CREATE OR REPLACE). + rebuildable: true, + create: (fact) => { + const name = qid((fact.id as { name: string }).name); + // an event-trigger function is always a real function (prokind 'f', + // returns event_trigger), never a procedure. + const fnId: StableId = { + kind: "function", + schema: str(p(fact, "functionSchema")), + name: str(p(fact, "functionName")), + args: [], + }; + let sql = `CREATE EVENT TRIGGER ${name} ON ${str(p(fact, "event"))}`; + const tags = (p(fact, "tags") as string[]) ?? []; + if (tags.length > 0) { + sql += ` WHEN TAG IN (${tags.map((t) => lit(t)).join(", ")})`; + } + sql += ` EXECUTE FUNCTION ${rel(str(p(fact, "functionSchema")), str(p(fact, "functionName")))}()`; + const specs: ActionSpec[] = [ + { + sql, + consumes: [fnId], + }, + ]; + const enabled = p(fact, "enabled"); + if (enabled != null && enabled !== "O") { + specs.push({ + sql: `ALTER EVENT TRIGGER ${name} ${enabledPhrase(str(enabled))}`, + }); + } + return specs; + }, + drop: (fact) => ({ + sql: `DROP EVENT TRIGGER ${qid((fact.id as { name: string }).name)}`, + }), + ownerAlterPrefix: (fact) => + `ALTER EVENT TRIGGER ${qid((fact.id as { name: string }).name)}`, + attributes: { + enabled: { + alter: (fact, _from, to) => ({ + sql: `ALTER EVENT TRIGGER ${qid((fact.id as { name: string }).name)} ${enabledPhrase(str(to))}`, + }), + }, + event: "replace", + tags: "replace", + functionSchema: "replace", + functionName: "replace", + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/types.ts b/packages/pg-delta/src/plan/rules/types.ts new file mode 100644 index 000000000..5739297e1 --- /dev/null +++ b/packages/pg-delta/src/plan/rules/types.ts @@ -0,0 +1,543 @@ +/** Rule definitions for domains, user-defined types (enum / composite / range), + * composite-type attributes, and collations. */ +import { encodeId, type StableId } from "../../core/stable-id.ts"; +import { lit, qid, rel } from "../render.ts"; +import type { ActionSpec, KindRules } from "../rules.ts"; +import { + byteLength, + clipToByteLength, + compositeUserColumns, + dependencyConsumes, + isSubsequence, + p, + renameRule, + str, + typeAttributeClause, +} from "./helpers.ts"; + +export const typeRules: Record = { + domain: { + weight: 7, + cascadesToChildren: true, + defaclObjtype: "T", // ALTER DEFAULT PRIVILEGES … ON TYPES covers domains + rename: renameRule((fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER DOMAIN ${rel(id.schema, id.name)}`; + }), + create: (fact, view, _params, sourceView) => { + const id = fact.id as { schema: string; name: string }; + const relName = rel(id.schema, id.name); + // GUARD (baseType/collation replace): both attributes are "replace", so + // any change drops and recreates the domain. A table column is NOT a + // rebuildable kind, so if a SURVIVING user column depends on this domain + // PostgreSQL rejects the DROP at apply ("cannot drop type … other + // objects depend on it"). Fail loud at plan time — mirrors the in-use + // range-type guard above (§ type rule) and the in-use composite ALTER + // ATTRIBUTE guard below. Only a REPLACE (the domain is present in the + // source) can hit this; a fresh create brings its columns with it. + if (sourceView?.get(fact.id) !== undefined) { + const inUse = compositeUserColumns(view, fact.id).filter( + (colId) => sourceView.get(colId) !== undefined, + ); + if (inUse.length > 0) { + const cols = inUse + .map((c) => { + const col = c as { schema: string; table: string; name: string }; + return `${rel(col.schema, col.table)}.${qid(col.name)}`; + }) + .join(", "); + throw new Error( + `domain ${relName}: cannot replace an in-use domain — column(s) ${cols} depend on it, and PostgreSQL forbids dropping a type while a column uses it. Replacing an in-use domain is not supported yet; drop the using column(s), or recreate the domain, first.`, + ); + } + } + let sql = `CREATE DOMAIN ${relName} AS ${str(p(fact, "baseType"))}`; + const collation = p(fact, "collation"); + if (collation != null) sql += ` COLLATE ${str(collation)}`; + const def = p(fact, "default"); + if (def != null) sql += ` DEFAULT ${str(def)}`; + if (p(fact, "notNull")) sql += ` NOT NULL`; + // Inline CHECK constraints into the CREATE (delta-set, like composite + // attributes): a domain already used by a composite type or table column + // cannot be ALTERed to add a constraint ("cannot alter type … because + // column … uses it"), so the constraint must exist at creation time. Their + // standalone ADD is then skipped via alsoProduces. A constraint CHANGE on + // an EXISTING domain still flows through the ALTER DOMAIN constraint path. + // + // EXCEPTION: a NOT VALID constraint (convalidated = false) cannot be + // expressed inline — `pg_get_constraintdef()` returns "… NOT VALID" and + // PostgreSQL only accepts NOT VALID on `ALTER DOMAIN … ADD CONSTRAINT`, + // never on CREATE DOMAIN. Leave it OUT of the inline set so its standalone + // constraint action (which appends NOT VALID correctly) runs instead. + const alsoProduces: StableId[] = []; + for (const child of view.childrenOf(fact.id)) { + if (child.id.kind !== "constraint") continue; + if (!p(child, "validated")) continue; + sql += ` CONSTRAINT ${qid((child.id as { name: string }).name)} ${str(p(child, "def"))}`; + alsoProduces.push(child.id); + } + return [alsoProduces.length > 0 ? { sql, alsoProduces } : { sql }]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { sql: `DROP DOMAIN ${rel(id.schema, id.name)}` }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER DOMAIN ${rel(id.schema, id.name)}`; + }, + attributes: { + default: { + alter: (fact, _from, to) => { + const id = fact.id as { schema: string; name: string }; + return { + sql: + to == null + ? `ALTER DOMAIN ${rel(id.schema, id.name)} DROP DEFAULT` + : `ALTER DOMAIN ${rel(id.schema, id.name)} SET DEFAULT ${str(to)}`, + }; + }, + }, + notNull: { + alter: (fact, _from, to) => { + const id = fact.id as { schema: string; name: string }; + return { + sql: `ALTER DOMAIN ${rel(id.schema, id.name)} ${to ? "SET" : "DROP"} NOT NULL`, + }; + }, + }, + baseType: "replace", + collation: "replace", + }, + }, + + type: { + weight: 7, + cascadesToChildren: true, + defaclObjtype: "T", // ALTER DEFAULT PRIVILEGES … ON TYPES + rename: renameRule((fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER TYPE ${rel(id.schema, id.name)}`; + }), + create: (fact, view, _params, sourceView) => { + const id = fact.id as { schema: string; name: string }; + const relName = rel(id.schema, id.name); + const variant = str(p(fact, "variant")); + let sql: string; + const alsoProduces: StableId[] = []; + if (variant === "enum") { + const values = (p(fact, "values") as string[]).map((v) => lit(v)); + sql = `CREATE TYPE ${relName} AS ENUM (${values.join(", ")})`; + } else if (variant === "composite") { + // attributes are sub-facts (granularity is one): inline them on the + // fresh CREATE (delta-set, like partitioned-table columns) and + // register them as produced so their standalone creates are skipped + // Attribute ORDER is row-layout state: render in declared position + // (`_position`, captured at extract time), NOT the encoded-id (name) + // order childrenOf() yields — else composite columns silently reorder. + // Fall back to the incoming (name) order when `_position` is absent + // (legacy fact bases / snapshots) so the sort stays stable and total. + const attrFacts = view + .childrenOf(fact.id) + .filter((c) => c.id.kind === "typeAttribute"); + const positioned = attrFacts.every((a) => p(a, "_position") != null); + if (positioned) { + attrFacts.sort( + (a, b) => Number(p(a, "_position")) - Number(p(b, "_position")), + ); + } + const attrs = attrFacts.map((a) => typeAttributeClause(a)); + for (const a of attrFacts) alsoProduces.push(a.id); + sql = `CREATE TYPE ${relName} AS (${attrs.join(", ")})`; + } else { + // GUARD (range variant): every range attribute is "replace", so any + // change drops and recreates the type. A table column is NOT a + // rebuildable kind, so if a SURVIVING user column depends on this range + // type PostgreSQL rejects the DROP at apply ("cannot drop type … other + // objects depend on it"). Fail loud at plan time — mirrors the in-use + // composite ALTER ATTRIBUTE guard below. Only a REPLACE (the type is + // present in the source) can hit this; a fresh create brings its + // columns with it. Full in-place range column migration is tracked + // separately. + if (sourceView?.get(fact.id) !== undefined) { + const inUse = compositeUserColumns(view, fact.id).filter( + (colId) => sourceView.get(colId) !== undefined, + ); + if (inUse.length > 0) { + const cols = inUse + .map((c) => { + const col = c as { + schema: string; + table: string; + name: string; + }; + return `${rel(col.schema, col.table)}.${qid(col.name)}`; + }) + .join(", "); + throw new Error( + `range type ${relName}: cannot replace an in-use range type — column(s) ${cols} depend on it, and PostgreSQL forbids dropping a type while a column uses it. Replacing an in-use range type is not supported yet; drop the using column(s), or recreate the type, first.`, + ); + } + } + const parts = [`SUBTYPE = ${str(p(fact, "subtype"))}`]; + const opclass = p(fact, "subtypeOpclass"); + if (opclass != null) parts.push(`SUBTYPE_OPCLASS = ${str(opclass)}`); + const collation = p(fact, "collation"); + if (collation != null) parts.push(`COLLATION = ${str(collation)}`); + const diff = p(fact, "subtypeDiff"); + if (diff != null) parts.push(`SUBTYPE_DIFF = ${str(diff)}`); + const multirange = p(fact, "multirangeTypeName"); + if (multirange != null) + parts.push(`MULTIRANGE_TYPE_NAME = ${str(multirange)}`); + sql = `CREATE TYPE ${relName} AS RANGE (${parts.join(", ")})`; + } + return [ + { + sql, + ...(alsoProduces.length > 0 ? { alsoProduces } : {}), + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { sql: `DROP TYPE ${rel(id.schema, id.name)}` }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER TYPE ${rel(id.schema, id.name)}`; + }, + attributes: { + values: { + alter: (fact, from, to, view, sourceView) => { + const id = fact.id as { schema: string; name: string }; + const relName = rel(id.schema, id.name); + const oldValues = (from as string[] | null) ?? []; + const newValues = (to as string[] | null) ?? []; + if (isSubsequence(oldValues, newValues)) { + // pure growth: each missing value becomes ADD VALUE BEFORE/AFTER + const specs: ActionSpec[] = []; + let oldIdx = 0; + for (let j = 0; j < newValues.length; j++) { + const value = newValues[j] as string; + if (oldIdx < oldValues.length && value === oldValues[oldIdx]) { + oldIdx++; + continue; + } + const anchor = + oldIdx < oldValues.length + ? `BEFORE ${lit(oldValues[oldIdx] as string)}` + : j > 0 + ? `AFTER ${lit(newValues[j - 1] as string)}` + : oldValues.length > 0 + ? `BEFORE ${lit(oldValues[0] as string)}` + : ""; + specs.push({ + sql: `ALTER TYPE ${relName} ADD VALUE ${lit(value)}${anchor ? ` ${anchor}` : ""}`, + // the new value is unusable before COMMIT: the executor + // must place a segment boundary before any consumer (§3.8) + transactionality: "commitBoundaryAfter", + }); + } + return specs; + } + // removal/reorder: rename aside, create the desired value set, walk + // every column of this type through a text cast, drop the old type. + // rebuildsDependents has already forced views/defaults/routines + // that reference the type out of the way. + const enumKey = encodeId(fact.id); + // GUARD (non-column dependents): the rebuild migrates only COLUMN + // dependents. A DOMAIN over the enum, a COMPOSITE type with an + // attribute of the enum, or a RANGE over the enum is NOT a rebuildable + // kind, so a SURVIVING one stays bound to the renamed old type and the + // final DROP TYPE fails at apply ("cannot drop type … other objects + // depend on it"). Fail loud at plan time — mirrors the in-use domain / + // range / composite ALTER ATTRIBUTE guards. Only dependents present on + // BOTH sides (view = desired, sourceView = target DB) can hit this; + // one this plan drops or creates is not a blocker. Full migration of + // non-column dependents is tracked separately. + const seenDependents = new Set(); + const nonColumnDependents = view.edges + .filter( + (e) => + encodeId(e.to) === enumKey && + (e.from.kind === "domain" || e.from.kind === "type") && + encodeId(e.from) !== enumKey && + view.get(e.from) !== undefined && + sourceView.get(e.from) !== undefined, + ) + .map((e) => e.from) + .filter((depId) => { + const key = encodeId(depId); + if (seenDependents.has(key)) return false; + seenDependents.add(key); + return true; + }) + .sort((a, b) => (encodeId(a) < encodeId(b) ? -1 : 1)); + if (nonColumnDependents.length > 0) { + const deps = nonColumnDependents + .map((depId) => { + const d = depId as { + kind: string; + schema: string; + name: string; + }; + const keyword = d.kind === "domain" ? "DOMAIN" : "TYPE"; + return `${keyword} ${rel(d.schema, d.name)}`; + }) + .join(", "); + throw new Error( + `enum ${relName}: cannot remove or reorder values while non-column object(s) depend on it — ${deps}. The rebuild drops the old enum, which those objects still reference, and PostgreSQL forbids dropping a type in use. Migrating a DOMAIN / COMPOSITE / RANGE that uses an enum across a value-set change is not supported yet; drop the dependent object(s), or recreate them, first.`, + ); + } + // A deterministic temp name for the old enum, RENAMEd aside before the + // new value set is created. It must collide with NO occupant of the + // type namespace (pg_type) visible in the fact base — not only managed + // enum/composite/range `type` facts, but DOMAINS and the implicit row + // type every relation (table / view / matview / foreign table / + // sequence) registers in pg_type under its own name. Checking only + // `type` facts let a table (or domain) named `__pgdelta_replaced` + // slip through, so the initial `ALTER TYPE … RENAME TO` failed at apply + // with "type … already exists". + const OCCUPANT_KINDS = [ + "type", + "domain", + "table", + "view", + "materializedView", + "foreignTable", + "sequence", + ] as const; + const taken = (n: string): boolean => + OCCUPANT_KINDS.some( + (kind) => + view.get({ kind, schema: id.schema, name: n } as StableId) !== + undefined || + sourceView.get({ + kind, + schema: id.schema, + name: n, + } as StableId) !== undefined, + ); + // Length-safe: PostgreSQL clips identifiers to NAMEDATALEN-1 (63) + // BYTES, so a long enum name + suffix would be truncated by the server + // and could land back on an occupied name (a 63-byte enum whose temp + // truncates to the ORIGINAL name → RENAME to itself). Clip the base + // ourselves so the whole identifier stays ≤ 63 bytes and is stored + // verbatim; `taken` then guarantees uniqueness. Deterministic: same + // enum name + same fact base → same temp name. + const MAX_IDENT_BYTES = 63; + const REPLACED_SUFFIX = "__pgdelta_replaced"; + const buildTmp = (n: number | null): string => { + const numeric = n === null ? "" : `_${n}`; + const budget = + MAX_IDENT_BYTES - + byteLength(REPLACED_SUFFIX) - + byteLength(numeric); + return `${clipToByteLength(id.name, budget)}${REPLACED_SUFFIX}${numeric}`; + }; + let tmp = buildTmp(null); + for (let n = 2; taken(tmp); n++) { + if (n > 10_000) { + throw new Error( + `enum ${relName}: could not find a free temp name for the value-set rebuild after 10000 attempts — an extraordinary number of \`${REPLACED_SUFFIX}\`-suffixed occupants exist in schema "${id.schema}"`, + ); + } + tmp = buildTmp(n); + } + const specs: ActionSpec[] = [ + { sql: `ALTER TYPE ${relName} RENAME TO ${qid(tmp)}` }, + { + sql: `CREATE TYPE ${relName} AS ENUM (${newValues.map((v) => lit(v)).join(", ")})`, + }, + ]; + const dependentColumns = view.edges + .filter( + (e) => + e.from.kind === "column" && + encodeId(e.to) === enumKey && + view.get(e.from) !== undefined && + // a column that exists only in the DESIRED state is being + // created by this same plan (already with the new type) — + // there is nothing to migrate + sourceView.get(e.from) !== undefined, + ) + .map( + (e) => + e.from as { + kind: "column"; + schema: string; + table: string; + name: string; + }, + ) + .sort((a, b) => + `${a.schema}.${a.table}.${a.name}` < + `${b.schema}.${b.table}.${b.name}` + ? -1 + : 1, + ); + for (const col of dependentColumns) { + // The column's declared type (format_type() output, captured + // verbatim at extract time — structured catalog data, not parsed + // SQL) tells whether it is an ARRAY of this enum: format_type + // renders an array type with a trailing `[]`. A scalar column + // casts through `text`; an array column must cast through + // `text[]` (element-wise) — `col::text` on an array has no + // built-in cast to the scalar enum and either errors + // ("invalid input value for enum ... {a,b}") or, worse, silently + // narrows the column to scalar. + const colFact = view.get(col); + const colType = + colFact !== undefined ? str(p(colFact, "type")) : ""; + const isArray = colType.endsWith("[]"); + const targetType = isArray ? `${relName}[]` : relName; + const usingCast = isArray + ? `${qid(col.name)}::text[]::${relName}[]` + : `${qid(col.name)}::text::${relName}`; + specs.push({ + sql: `ALTER TABLE ${rel(col.schema, col.table)} ALTER COLUMN ${qid(col.name)} TYPE ${targetType} USING ${usingCast}`, + // reference the rewritten column so the proof's rewrite + // attribution maps this action to its table (the action's + // primary subject is the type, not the table it rewrites) + consumes: [col], + dataLoss: "destructive", + rewriteRisk: true, + }); + } + specs.push({ sql: `DROP TYPE ${rel(id.schema, tmp)}` }); + return specs; + }, + rebuildsDependents: (from, to) => + !isSubsequence( + (from as string[] | null) ?? [], + (to as string[] | null) ?? [], + ), + }, + subtype: "replace", + subtypeOpclass: "replace", + subtypeDiff: "replace", + multirangeTypeName: "replace", + collation: "replace", + variant: "replace", + }, + }, + + // composite-type attributes as sub-entity facts (granularity is one). + // On a fresh type they inline into CREATE TYPE (delta-set, see the type + // rule). On an existing type: ADD / DROP / RENAME ATTRIBUTE … CASCADE all + // work even while the type is used in table columns and preserve the + // stored data (verified). ALTER ATTRIBUTE … TYPE is the lone exception — + // PostgreSQL forbids it while a column uses the type (CASCADE only reaches + // typed tables, not columns), so it is supported only for unused + // composites and fails loudly otherwise. + typeAttribute: { + weight: 7, + create: (fact) => { + const id = fact.id as { schema: string; type: string; name: string }; + return [ + { + sql: `ALTER TYPE ${rel(id.schema, id.type)} ADD ATTRIBUTE ${typeAttributeClause(fact)} CASCADE`, + consumes: [{ kind: "type", schema: id.schema, name: id.type }], + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; type: string; name: string }; + // Destructive: DROP ATTRIBUTE … CASCADE nulls the stored value of that + // field across every row of every table whose column is of this + // composite. A collation-only attribute change routes through the + // attribute "replace" strategy (drop + recreate), which renders via THIS + // drop rule too, so this one flag covers that path as well. + return { + sql: `ALTER TYPE ${rel(id.schema, id.type)} DROP ATTRIBUTE ${qid(id.name)} CASCADE`, + dataLoss: "destructive", + }; + }, + rename: (fact, to) => { + const id = fact.id as { schema: string; type: string; name: string }; + return { + sql: `ALTER TYPE ${rel(id.schema, id.type)} RENAME ATTRIBUTE ${qid(id.name)} TO ${qid((to as { name: string }).name)} CASCADE`, + }; + }, + attributes: { + type: { + alter: (fact, _from, to, view, sourceView) => { + const id = fact.id as { schema: string; type: string; name: string }; + const typeId: StableId = { + kind: "type", + schema: id.schema, + name: id.type, + }; + if (compositeUserColumns(view, typeId).length > 0) { + throw new Error( + `composite type ${rel(id.schema, id.type)}: cannot change attribute "${id.name}" type while the type is used by table columns — PostgreSQL forbids ALTER ATTRIBUTE … TYPE on an in-use composite. Drop the using columns, or recreate the type, first.`, + ); + } + // A composite attribute's type dependency is extracted onto the + // enclosing `type` fact, not this `typeAttribute` child (see the + // `comptype` CTE in extract/dependencies.ts). When the composite is + // only being retyped (not dropped/created) its own actions never + // fire, so — mirroring the ALTER COLUMN … TYPE fix in tables.ts — + // release the OLD referenced types (source side) so this alter runs + // BEFORE their same-plan DROP, and consume the NEW ones (target side) + // so it runs AFTER their same-plan CREATE. The edges are keyed to the + // parent type fact; over-scoping to a sibling attribute's still-used + // type is harmless (nothing drops it → no ordering edge is added). + const consumes = dependencyConsumes(view, typeId); + const releases = dependencyConsumes(sourceView, typeId); + const spec: ActionSpec = { + sql: `ALTER TYPE ${rel(id.schema, id.type)} ALTER ATTRIBUTE ${qid(id.name)} TYPE ${str(to)} CASCADE`, + }; + if (consumes.length > 0) spec.consumes = consumes; + if (releases.length > 0) spec.releases = releases; + return spec; + }, + }, + // a collation-only change has no in-place form; replace the attribute + collation: "replace", + }, + }, + + collation: { + weight: 7, + create: (fact) => { + const id = fact.id as { schema: string; name: string }; + const provider = str(p(fact, "provider")); + const parts: string[] = []; + if (provider === "i") { + parts.push(`PROVIDER = icu`, `LOCALE = ${lit(str(p(fact, "locale")))}`); + if (!p(fact, "deterministic")) parts.push(`DETERMINISTIC = false`); + } else if (provider === "b") { + parts.push( + `PROVIDER = builtin`, + `LOCALE = ${lit(str(p(fact, "locale")))}`, + ); + } else { + parts.push( + `LC_COLLATE = ${lit(str(p(fact, "lcCollate")))}`, + `LC_CTYPE = ${lit(str(p(fact, "lcCtype")))}`, + ); + } + return [ + { + sql: `CREATE COLLATION ${rel(id.schema, id.name)} (${parts.join(", ")})`, + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { sql: `DROP COLLATION ${rel(id.schema, id.name)}` }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER COLLATION ${rel(id.schema, id.name)}`; + }, + attributes: { + provider: "replace", + deterministic: "replace", + locale: "replace", + lcCollate: "replace", + lcCtype: "replace", + }, + }, +}; diff --git a/packages/pg-delta/src/plan/rules/views.ts b/packages/pg-delta/src/plan/rules/views.ts new file mode 100644 index 000000000..b4797d30a --- /dev/null +++ b/packages/pg-delta/src/plan/rules/views.ts @@ -0,0 +1,133 @@ +/** Rule definitions for views, materialized views, and rewrite rules. */ +import { qid, rel } from "../render.ts"; +import type { ActionSpec, KindRules } from "../rules.ts"; +import { + enabledPhrase, + p, + reloptionsAlterSpecs, + reloptionsWithClause, + renameRule, + str, +} from "./helpers.ts"; + +export const viewRules: Record = { + view: { + weight: 12, + cascadesToChildren: true, + rebuildable: true, + defaclObjtype: "r", + rename: renameRule((fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER VIEW ${rel(id.schema, id.name)}`; + }), + create: (fact) => { + const id = fact.id as { schema: string; name: string }; + return [ + { + sql: `CREATE VIEW ${rel(id.schema, id.name)}${reloptionsWithClause(fact)} AS ${str(p(fact, "def"))}`, + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { sql: `DROP VIEW ${rel(id.schema, id.name)}` }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER VIEW ${rel(id.schema, id.name)}`; + }, + attributes: { + def: "replace", + reloptions: { + alter: (fact, from, to) => { + const id = fact.id as { schema: string; name: string }; + return reloptionsAlterSpecs( + `ALTER VIEW ${rel(id.schema, id.name)}`, + from, + to, + ); + }, + }, + }, + }, + + materializedView: { + weight: 13, + cascadesToChildren: true, + rebuildable: true, + defaclObjtype: "r", + rename: renameRule((fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER MATERIALIZED VIEW ${rel(id.schema, id.name)}`; + }), + create: (fact) => { + const id = fact.id as { schema: string; name: string }; + return [ + { + sql: `CREATE MATERIALIZED VIEW ${rel(id.schema, id.name)}${reloptionsWithClause(fact)} AS ${str(p(fact, "def"))}`, + }, + ]; + }, + drop: (fact) => { + const id = fact.id as { schema: string; name: string }; + return { + sql: `DROP MATERIALIZED VIEW ${rel(id.schema, id.name)}`, + dataLoss: "destructive", + }; + }, + ownerAlterPrefix: (fact) => { + const id = fact.id as { schema: string; name: string }; + return `ALTER MATERIALIZED VIEW ${rel(id.schema, id.name)}`; + }, + attributes: { + def: "replace", + reloptions: { + alter: (fact, from, to) => { + const id = fact.id as { schema: string; name: string }; + return reloptionsAlterSpecs( + `ALTER MATERIALIZED VIEW ${rel(id.schema, id.name)}`, + from, + to, + ); + }, + }, + }, + }, + + rule: { + weight: 15, + cascadesToChildren: true, + rebuildable: true, + create: (fact) => { + const id = fact.id as { schema: string; table: string; name: string }; + // A fresh CREATE RULE always lands ENABLED (origin). When the desired rule + // is disabled/replica/always, append the follow-up ALTER so the hashed + // ev_enabled state converges — mirrors the trigger create path. + const specs: ActionSpec[] = [{ sql: str(p(fact, "def")) }]; + const enabled = p(fact, "enabled"); + if (enabled != null && enabled !== "O") { + specs.push({ + sql: `ALTER TABLE ${rel(id.schema, id.table)} ${enabledPhrase(str(enabled))} RULE ${qid(id.name)}`, + }); + } + return specs; + }, + drop: (fact) => { + const id = fact.id as { schema: string; table: string; name: string }; + return { + sql: `DROP RULE ${qid(id.name)} ON ${rel(id.schema, id.table)}`, + }; + }, + attributes: { + def: "replace", + enabled: { + alter: (fact, _from, to) => { + const id = fact.id as { schema: string; table: string; name: string }; + return { + sql: `ALTER TABLE ${rel(id.schema, id.table)} ${enabledPhrase(str(to))} RULE ${qid(id.name)}`, + }; + }, + }, + }, + }, +}; diff --git a/packages/pg-delta/src/plan/security-label.test.ts b/packages/pg-delta/src/plan/security-label.test.ts new file mode 100644 index 000000000..618b5b490 --- /dev/null +++ b/packages/pg-delta/src/plan/security-label.test.ts @@ -0,0 +1,67 @@ +/** + * securityLabel rule (pure, no DB): the global satellite rule renders + * SECURITY LABEL DDL from a fact. End-to-end extraction is exercised in CI + * behind the dummy_seclabel image (see tests/COVERAGE.md); the SQL shape is + * proven here without a provider module. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const tableId: StableId = { kind: "table", schema: "app", name: "users" }; +const schemaFact: Fact = { + id: { kind: "schema", name: "app" }, + payload: { owner: "test" }, +}; +const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "app" }, + payload: { owner: "test", persistence: "p" }, +}; +const labelFact = (label: string): Fact => ({ + id: { kind: "securityLabel", target: tableId, provider: "dummy" }, + parent: tableId, + payload: { label }, +}); + +const base = (extra: Fact[]) => + buildFactBase([schemaFact, tableFact, ...extra], []); + +describe("securityLabel rule", () => { + test("create emits SECURITY LABEL FOR provider ON target IS 'label'", () => { + const actions = plan(base([]), base([labelFact("classified")])).actions; + const sql = actions.map((a) => a.sql); + expect(sql).toContain( + `SECURITY LABEL FOR 'dummy' ON TABLE "app"."users" IS 'classified'`, + ); + }); + + test("drop emits IS NULL", () => { + const actions = plan(base([labelFact("classified")]), base([])).actions; + expect(actions.map((a) => a.sql)).toContain( + `SECURITY LABEL FOR 'dummy' ON TABLE "app"."users" IS NULL`, + ); + }); + + test("label change is an in-place alter (not drop+create)", () => { + const actions = plan( + base([labelFact("classified")]), + base([labelFact("secret")]), + ).actions; + expect(actions).toHaveLength(1); + expect(actions[0]?.sql).toBe( + `SECURITY LABEL FOR 'dummy' ON TABLE "app"."users" IS 'secret'`, + ); + }); + + test("a label vanishes with its target (metadata satellite, no explicit drop)", () => { + // dropping the table cascades; the label is suppressed into the table drop + const actions = plan( + base([labelFact("classified")]), + buildFactBase([], []), + ).actions; + expect(actions.some((a) => a.sql.includes("SECURITY LABEL"))).toBe(false); + expect(actions.some((a) => a.sql.startsWith("DROP TABLE"))).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/plan/subscription-options.test.ts b/packages/pg-delta/src/plan/subscription-options.test.ts new file mode 100644 index 000000000..e459c8605 --- /dev/null +++ b/packages/pg-delta/src/plan/subscription-options.test.ts @@ -0,0 +1,120 @@ +/** + * CREATE / ALTER SUBSCRIPTION must carry the full pg_subscription option set + * (PR #299 review, supabase/pg-toolbelt). The payload only held + * enabled/conninfo/slotName/publications, so binary / streaming / + * synchronous_commit / disable_on_error / run_as_owner / two_phase / origin + * were never captured — a subscription that differed only in those options + * hashed identically and planned nothing. Pure rule/diff level — no DB; the + * corpus needs a live publisher, so the SQL shape is proven here. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const subId: StableId = { kind: "subscription", name: "s" }; +const subFact = (extra: Record): Fact => ({ + id: subId, + payload: { + _serverMajor: 18, + enabled: false, + conninfo: "host=localhost dbname=postgres", + slotName: null, + publications: ["pub"], + binary: false, + streaming: "off", + synchronousCommit: "off", + disableOnError: false, + runAsOwner: false, + twoPhase: false, + origin: "any", + ...extra, + }, +}); +const base = (extra: Fact[]) => buildFactBase(extra, []); + +describe("subscription option rendering", () => { + test("create renders the extended option set in the WITH clause", () => { + const sql = plan( + base([]), + base([ + subFact({ + binary: true, + streaming: "parallel", + synchronousCommit: "local", + disableOnError: true, + runAsOwner: true, + twoPhase: true, + origin: "none", + }), + ]), + ) + .actions.map((a) => a.sql) + .join("\n"); + expect(sql).toContain("binary = true"); + expect(sql).toContain("streaming = 'parallel'"); + expect(sql).toContain("synchronous_commit = 'local'"); + expect(sql).toContain("disable_on_error = true"); + expect(sql).toContain("run_as_owner = true"); + expect(sql).toContain("two_phase = true"); + expect(sql).toContain("origin = 'none'"); + }); + + test("create omits version-gated options that were not captured (null)", () => { + const sql = plan( + base([]), + base([subFact({ runAsOwner: null, origin: null })]), + ) + .actions.map((a) => a.sql) + .join("\n"); + expect(sql).not.toContain("run_as_owner"); + expect(sql).not.toContain("origin"); + }); + + test("a binary-only change is an in-place ALTER SET", () => { + const sql = plan( + base([subFact({ binary: false })]), + base([subFact({ binary: true })]), + ).actions.map((a) => a.sql); + expect(sql).toContain(`ALTER SUBSCRIPTION "s" SET (binary = true)`); + }); + + test("streaming / synchronous_commit / disable_on_error / run_as_owner / origin alter in place", () => { + const sql = plan( + base([subFact({})]), + base([ + subFact({ + streaming: "parallel", + synchronousCommit: "remote_apply", + disableOnError: true, + runAsOwner: true, + origin: "none", + }), + ]), + ).actions.map((a) => a.sql); + expect(sql).toContain( + `ALTER SUBSCRIPTION "s" SET (streaming = 'parallel')`, + ); + expect(sql).toContain( + `ALTER SUBSCRIPTION "s" SET (synchronous_commit = 'remote_apply')`, + ); + expect(sql).toContain( + `ALTER SUBSCRIPTION "s" SET (disable_on_error = true)`, + ); + expect(sql).toContain(`ALTER SUBSCRIPTION "s" SET (run_as_owner = true)`); + expect(sql).toContain(`ALTER SUBSCRIPTION "s" SET (origin = 'none')`); + }); + + test("a two_phase change alters in place on PG18+ (no destructive recreate)", () => { + // Recreating drops the publisher's replication slot; the PG18+ in-place + // ALTER SET (two_phase) preserves it (see rules/publications.ts and the + // subscription-two-phase integration test). + const sql = plan( + base([subFact({ twoPhase: false })]), + base([subFact({ twoPhase: true })]), + ).actions.map((a) => a.sql); + expect(sql.some((s) => s.startsWith("DROP SUBSCRIPTION"))).toBe(false); + expect(sql.some((s) => s.startsWith("CREATE SUBSCRIPTION"))).toBe(false); + expect(sql).toContain(`ALTER SUBSCRIPTION "s" SET (two_phase = true)`); + }); +}); diff --git a/packages/pg-delta/src/plan/user-mapping-unreadable.test.ts b/packages/pg-delta/src/plan/user-mapping-unreadable.test.ts new file mode 100644 index 000000000..fec09faca --- /dev/null +++ b/packages/pg-delta/src/plan/user-mapping-unreadable.test.ts @@ -0,0 +1,275 @@ +/** + * Unit gate for the `USER_MAPPING_UNREADABLE` diagnostic (Codex P1 on PR + * #338, follow-up to the non-superuser `pg_user_mappings` fallback in + * src/extract/foreign.ts). Mirrors the `INTENT_UNKEYED` gate's test shape + * (src/plan/intent-plan.test.ts) but with synthetic FactBases carrying a + * `diagnostics` entry directly, rather than going through a real extraction — + * no Docker needed. + * + * A skipped user-mapping fact means its true state is UNKNOWN on that side. + * If the OTHER side's extraction COULD see the mapping, the missing fact + * would otherwise read as an intentional add/remove and plan a wrong + * CREATE/DROP USER MAPPING — plan() must refuse instead. + */ +import { describe, expect, test } from "bun:test"; +import { USER_MAPPING_UNREADABLE } from "../core/diagnostic.ts"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "./plan.ts"; + +const serverId: StableId = { kind: "server", name: "srv" }; +const mappingId: StableId = { + kind: "userMapping", + server: "srv", + role: "PUBLIC", +}; + +const serverFact: Fact = { + id: serverId, + payload: { fdw: "fdw1", type: null, version: null, options: [] }, +}; +const mappingFact: Fact = { + id: mappingId, + parent: serverId, + payload: { options: [] }, +}; + +describe("plan() — unreadable-user-mapping gate", () => { + test("mapping visible on the desired side only (would-be CREATE) throws", () => { + const source = buildFactBase([serverFact], []); + source.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, + message: "hidden on source", + }); + const desired = buildFactBase([serverFact, mappingFact], []); + + expect(() => plan(source, desired)).toThrow( + /user mappings is unknown on one side/, + ); + expect(() => plan(source, desired)).toThrow(/srv\/PUBLIC/); + }); + + test("mapping visible on the source side only (would-be DROP) throws", () => { + const source = buildFactBase([serverFact, mappingFact], []); + const desired = buildFactBase([serverFact], []); + desired.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, + message: "hidden on desired", + }); + + expect(() => plan(source, desired)).toThrow(/srv\/PUBLIC/); + }); + + test("hidden on both sides — no delta touches it — does not throw", () => { + const source = buildFactBase([serverFact], []); + source.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, + message: "hidden on source", + }); + const desired = buildFactBase([serverFact], []); + desired.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, + message: "hidden on desired", + }); + + expect(() => plan(source, desired)).not.toThrow(); + }); + + test("diagnostic present but the subject is untouched by any delta does not throw", () => { + const source = buildFactBase([serverFact], []); + source.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, + message: "hidden on source", + }); + const desired = buildFactBase([serverFact], []); + + expect(() => plan(source, desired)).not.toThrow(); + }); +}); + +/** + * Round 3 (Codex P2s, comments 3601826179 + 3601826182): a DROP of the + * mapping's containing SERVER, or of its (non-PUBLIC) mapped ROLE, destroys + * the hidden mapping too — CASCADE-style — without any delta ever naming the + * mapping directly. Extends the gate to a `remove` delta on either. ALTERs / + * owner changes must NOT be gated (over-blocking has no correctness benefit), + * and a PUBLIC mapping's pseudo-"role" must never gate a real role's drop. + */ +describe("plan() — unreadable-user-mapping gate extends to server/role drops", () => { + const roleId: StableId = { kind: "role", name: "mapped_role" }; + const roleFact: Fact = { + id: roleId, + payload: { + superuser: false, + inherit: true, + createRole: false, + createDb: false, + login: false, + replication: false, + bypassRls: false, + config: [], + }, + }; + const roleMappingId: StableId = { + kind: "userMapping", + server: "srv2", + role: "mapped_role", + }; + + test("plan would emit DROP SERVER for a server with a hidden child mapping — throws", () => { + const source = buildFactBase([serverFact], []); + source.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, // { server: "srv", role: "PUBLIC" } + message: "hidden on source", + }); + const desired = buildFactBase([], []); + + expect(() => plan(source, desired)).toThrow( + /user mappings is unknown on one side/, + ); + expect(() => plan(source, desired)).toThrow(/srv \(server/); + }); + + test("plan would emit DROP ROLE for a hidden mapping's mapped role — throws", () => { + const source = buildFactBase([roleFact], []); + source.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: roleMappingId, // { server: "srv2", role: "mapped_role" } + message: "hidden on source", + }); + const desired = buildFactBase([], []); + + expect(() => plan(source, desired)).toThrow( + /user mappings is unknown on one side/, + ); + expect(() => plan(source, desired)).toThrow(/mapped_role \(role/); + }); + + test("a genuine in-place ALTER (version, non-replace) on that server does NOT throw", () => { + // `version` has a real `alter` rule (rules/foreign.ts) — an in-place + // `ALTER SERVER … VERSION …`, never a drop+create — so it must NOT be + // gated (zero-over-block: it never touches the hidden mapping). + const serverV1: Fact = { + id: serverId, + payload: { fdw: "fdw1", type: null, version: "1.0", options: [] }, + }; + const serverV2: Fact = { + id: serverId, + payload: { fdw: "fdw1", type: null, version: "2.0", options: [] }, + }; + const source = buildFactBase([serverV1], []); + source.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, // { server: "srv", role: "PUBLIC" } + message: "hidden on source", + }); + const desired = buildFactBase([serverV2], []); + + expect(() => plan(source, desired)).not.toThrow(); + }); + + test("a replace-class server change (type) with a hidden child mapping — throws", () => { + // `server.attributes.type` is `"replace"` (rules/foreign.ts): there is no + // in-place ALTER, so expandReplacements (runs AFTER this gate) turns this + // `set` delta into DROP SERVER + CREATE SERVER — destroying the hidden + // mapping's server exactly like an explicit DROP would. RED (before the + // fix): plan() succeeded and the rendered plan contained "DROP SERVER". + const serverV1: Fact = { + id: serverId, + payload: { fdw: "fdw1", type: "t1", version: null, options: [] }, + }; + const serverV2: Fact = { + id: serverId, + payload: { fdw: "fdw1", type: "t2", version: null, options: [] }, + }; + const source = buildFactBase([serverV1], []); + source.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, // { server: "srv", role: "PUBLIC" } + message: "hidden on source", + }); + const desired = buildFactBase([serverV2], []); + + expect(() => plan(source, desired)).toThrow( + /user mappings is unknown on one side/, + ); + expect(() => plan(source, desired)).toThrow(/srv \(server.*replaced/); + }); + + test("a PUBLIC mapping's diagnostic does not gate an unrelated role's drop", () => { + const unrelatedRoleId: StableId = { kind: "role", name: "unrelated_role" }; + const unrelatedRoleFact: Fact = { + id: unrelatedRoleId, + payload: { + superuser: false, + inherit: true, + createRole: false, + createDb: false, + login: false, + replication: false, + bypassRls: false, + config: [], + }, + }; + const source = buildFactBase([unrelatedRoleFact], []); + source.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, // { server: "srv", role: "PUBLIC" } — excluded + message: "hidden on source", + }); + const desired = buildFactBase([], []); + + expect(() => plan(source, desired)).not.toThrow(); + }); +}); + +/** + * Position-pinning (Codex P1, PR #338 comment 3603601149 — DOCUMENTED, NOT + * gated; see the "KNOWN LIMITATION #2" comment in plan.ts). A desired-side + * unreadable mapping whose containing server doesn't exist on the source at + * all produces an un-gated `add` delta for the server; the un-creatable + * CREATE USER MAPPING is simply never emitted (the mapping fact itself was + * skipped at extraction, never added to the desired FactBase). This is a + * chosen contract, not an oversight: the gate family above protects PHYSICAL + * safety (destruction / guaranteed apply-failure) — this is a DESIRED-STATE + * FIDELITY gap instead (the delta is the server's; the manageability + * question is the mapping's), owned by the diagnostic + the #340 reporting + * channel, not by this gate. This test pins that plan() does NOT throw here. + */ +describe("plan() — desired-side unreadable mapping with no source-side container (fidelity, not safety — #340)", () => { + test("plan() does not throw; it creates the server but omits the un-creatable mapping", () => { + const source = buildFactBase([], []); + const desired = buildFactBase([serverFact], []); + desired.diagnostics.push({ + code: USER_MAPPING_UNREADABLE, + severity: "warning", + subject: mappingId, // { server: "srv", role: "PUBLIC" } + message: "hidden on desired", + }); + + let thePlan: ReturnType | undefined; + expect(() => { + thePlan = plan(source, desired); + }).not.toThrow(); + + const sql = thePlan!.actions.map((a) => a.sql); + expect(sql.some((s) => s.includes("CREATE SERVER"))).toBe(true); + expect(sql.some((s) => s.includes("CREATE USER MAPPING"))).toBe(false); + }); +}); diff --git a/packages/pg-delta/src/policy/baseline-resolve.test.ts b/packages/pg-delta/src/policy/baseline-resolve.test.ts new file mode 100644 index 000000000..8e79a7d78 --- /dev/null +++ b/packages/pg-delta/src/policy/baseline-resolve.test.ts @@ -0,0 +1,108 @@ +/** + * Unit tests for the baseline RESOLUTION seam (review finding 3): a policy that + * declares a `baseline` must never be silently ignored. + * + * - `resolveBaseline` loads the committed snapshot for a policy's baseline, and + * THROWS (fail-loud) when the baseline is declared but no snapshot is + * committed. + * - `plan()` THROWS if handed a baseline-declaring policy without a resolved + * baseline — closing the trap at the core API, regardless of entry point. + * - `resolveView` subtracts a provided baseline FactBase. + * + * No Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import { serializeSnapshot } from "../core/snapshot.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { plan } from "../plan/plan.ts"; +import { resolveBaseline } from "./baseline.ts"; +import { resolveView } from "./policy.ts"; + +const schemaPublic: StableId = { kind: "schema", name: "public" }; +const tableUsers: StableId = { kind: "table", schema: "public", name: "users" }; + +function fact(id: StableId, payload = {}, parent?: StableId): Fact { + return parent ? { id, parent, payload } : { id, payload }; +} + +describe("resolveBaseline — fail-loud", () => { + test("returns undefined when the policy declares no baseline", () => { + expect( + resolveBaseline({ id: "p" }, { pgMajor: 17, dir: tmpdir() }), + ).toBeUndefined(); + }); + + test("THROWS when a baseline is declared but no snapshot is committed", () => { + const dir = mkdtempSync(join(tmpdir(), "baseline-missing-")); + expect(() => + resolveBaseline( + { id: "supabase", baseline: "supabase-baseline" }, + { + pgMajor: 17, + dir, + }, + ), + ).toThrow(/baseline "supabase-baseline"/); + }); + + test("loads the committed snapshot when present (-.json)", () => { + const dir = mkdtempSync(join(tmpdir(), "baseline-present-")); + const baselineFb = buildFactBase([fact(schemaPublic)], []); + writeFileSync( + join(dir, "supabase-baseline-17.json"), + serializeSnapshot(baselineFb, { pgVersion: "17.0" }), + ); + const resolved = resolveBaseline( + { id: "supabase", baseline: "supabase-baseline" }, + { pgMajor: 17, dir }, + ); + expect(resolved?.factBase.has(schemaPublic)).toBe(true); + // the loaded baseline carries the digest (== the snapshot's rootHash) so + // plan/apply/prove can reconcile it across commands. + expect(resolved?.digest).toBe(baselineFb.rootHash); + }); +}); + +describe("plan() — refuses an unresolved declared baseline", () => { + const empty = buildFactBase([], []); + + test("THROWS when the policy declares a baseline and none was resolved", () => { + expect(() => + plan(empty, empty, { + policy: { id: "p", baseline: "supabase-baseline" }, + }), + ).toThrow(/baseline/i); + }); + + test("does NOT throw when a resolved baseline is supplied", () => { + const baseline = buildFactBase([], []); + expect(() => + plan(empty, empty, { + policy: { id: "p", baseline: "supabase-baseline" }, + baseline, + }), + ).not.toThrow(); + }); +}); + +describe("resolveView — subtracts a provided baseline", () => { + test("a fact present-and-identical in the baseline is projected out", () => { + const fb = buildFactBase( + [ + fact(schemaPublic), + fact(tableUsers, { persistence: "p" }, schemaPublic), + ], + [], + ); + // baseline contains schemaPublic identically → it (and only it) subtracts + const baseline = buildFactBase([fact(schemaPublic)], []); + const view = resolveView(fb, undefined, undefined, baseline); + expect(view.has(tableUsers)).toBe(true); + // schemaPublic is the parent of a surviving fact → force-kept by subtraction + expect(view.has(schemaPublic)).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/policy/baseline.test.ts b/packages/pg-delta/src/policy/baseline.test.ts new file mode 100644 index 000000000..87ca1206c --- /dev/null +++ b/packages/pg-delta/src/policy/baseline.test.ts @@ -0,0 +1,469 @@ +/** + * Unit tests for baseline subtraction (src/policy/baseline.ts). + * No Docker / database required. + */ + +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type DependencyEdge, type Fact } from "../core/fact.ts"; +import type { Payload } from "../core/hash.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { plan } from "../plan/plan.ts"; +import { subtractBaseline } from "./baseline.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const schemaPublic: StableId = { kind: "schema", name: "public" }; +const schemaPrivate: StableId = { kind: "schema", name: "private" }; +const tableUsers: StableId = { kind: "table", schema: "public", name: "users" }; +const tableAdmins: StableId = { + kind: "table", + schema: "public", + name: "admins", +}; +const colId: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "id", +}; +const _colEmail: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "email", +}; +const roleOwner: StableId = { kind: "role", name: "owner" }; +const extPostgis: StableId = { kind: "extension", name: "postgis" }; + +function makeFact( + id: StableId, + payload: Payload = {}, + parent?: StableId, +): Fact { + return parent ? { id, parent, payload } : { id, payload }; +} + +// --------------------------------------------------------------------------- +// describe: identity subtraction +// --------------------------------------------------------------------------- + +describe("subtractBaseline — identity subtraction", () => { + test("subtracting identical baseline leaves empty FactBase", () => { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + makeFact(colId, { type: "integer" }, tableUsers), + ]; + const fb = buildFactBase(facts, []); + const baseline = buildFactBase(facts, []); + const result = subtractBaseline(fb, baseline); + expect(result.facts()).toHaveLength(0); + }); + + test("subtracting empty baseline changes nothing", () => { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + ]; + const fb = buildFactBase(facts, []); + const baseline = buildFactBase([], []); + const result = subtractBaseline(fb, baseline); + expect(result.facts()).toHaveLength(2); + }); + + test("fact not present in baseline is kept", () => { + const baselineFacts: Fact[] = [makeFact(schemaPublic)]; + const fullFacts: Fact[] = [ + ...baselineFacts, + makeFact(tableUsers, {}, schemaPublic), + ]; + const fb = buildFactBase(fullFacts, []); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + // tableUsers survives (not in baseline) + // schemaPublic is subtracted (identical in baseline) + // BUT schemaPublic is the parent of tableUsers → force-kept + expect(result.has(tableUsers)).toBe(true); + expect(result.has(schemaPublic)).toBe(true); // kept as ancestor + }); +}); + +// --------------------------------------------------------------------------- +// describe: changed-payload subtraction +// --------------------------------------------------------------------------- + +describe("subtractBaseline — changed payload", () => { + test("fact present in baseline but with changed payload is kept", () => { + const baselineFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + ]; + const currentFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "u" }, schemaPublic), // changed + ]; + const fb = buildFactBase(currentFacts, []); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + // tableUsers changed payload → kept + expect(result.has(tableUsers)).toBe(true); + expect(result.get(tableUsers)?.payload["persistence"]).toBe("u"); + // schemaPublic unchanged but is parent of kept tableUsers → kept + expect(result.has(schemaPublic)).toBe(true); + }); + + test("only the changed subtree is kept; unchanged siblings subtracted", () => { + const baselineFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + makeFact(tableAdmins, { persistence: "p" }, schemaPublic), + ]; + const currentFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "u" }, schemaPublic), // changed + makeFact(tableAdmins, { persistence: "p" }, schemaPublic), // same + ]; + const fb = buildFactBase(currentFacts, []); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + // tableUsers changed → kept + expect(result.has(tableUsers)).toBe(true); + // tableAdmins unchanged → subtracted + expect(result.has(tableAdmins)).toBe(false); + // schemaPublic unchanged but is parent of tableUsers → kept + expect(result.has(schemaPublic)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// describe: edge pruning +// --------------------------------------------------------------------------- + +describe("subtractBaseline — edge pruning", () => { + test("an owner->role edge to a subtracted baseline role is retained as dangling", () => { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "u" }, schemaPublic), // changed vs baseline + makeFact(roleOwner), + ]; + const baselineFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + makeFact(roleOwner), + ]; + const edge: DependencyEdge = { + from: tableUsers, + to: roleOwner, + kind: "owner", + }; + const fb = buildFactBase(facts, [edge]); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + // tableUsers changed → kept; roleOwner unchanged → subtracted. The owner + // edge is an owner->role edge whose OWNED endpoint (tableUsers) survives, so + // it is retained as a dangling assumed reference (retainOwnerRoleDangling) — + // ownership must still serialize as ALTER … OWNER TO the platform role. + expect([...result.edges]).toHaveLength(1); + expect(result.get(tableUsers)).toBeDefined(); + // the role fact itself is NOT resurrected — only the edge is retained. + expect(result.has(roleOwner)).toBe(false); + }); + + test("edges between both surviving facts are kept", () => { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "u" }, schemaPublic), // changed + makeFact(roleOwner, { login: true }), // changed (baseline has false) + ]; + const baselineFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + makeFact(roleOwner, { login: false }), // different payload + ]; + const edge: DependencyEdge = { + from: tableUsers, + to: roleOwner, + kind: "owner", + }; + const fb = buildFactBase(facts, [edge]); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + // both tableUsers and roleOwner have changed payloads → both survive + expect(result.has(tableUsers)).toBe(true); + expect(result.has(roleOwner)).toBe(true); + // edge between two surviving facts is kept + expect([...result.edges]).toHaveLength(1); + }); + + test("edges to subtracted facts are pruned", () => { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "u" }, schemaPublic), // changed + makeFact(extPostgis, { version: "3.0" }), // same as baseline → subtracted + ]; + const baselineFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + makeFact(extPostgis, { version: "3.0" }), // identical + ]; + const edge: DependencyEdge = { + from: tableUsers, + to: extPostgis, + kind: "memberOfExtension", + }; + const fb = buildFactBase(facts, [edge]); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + expect(result.has(tableUsers)).toBe(true); + expect(result.has(extPostgis)).toBe(false); + // edge pruned because extPostgis was subtracted + expect([...result.edges]).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// describe: outgoing-edge signature (payload-equal facts with changed edges) +// --------------------------------------------------------------------------- + +describe("subtractBaseline — outgoing-edge signature", () => { + const roleA: StableId = { kind: "role", name: "a" }; + const roleB: StableId = { kind: "role", name: "b" }; + + test("equal-payload fact whose owner edge changed (A→B) is NOT subtracted", () => { + const shared: Fact[] = [ + makeFact(roleA), + makeFact(roleB), + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + ]; + const baseline = buildFactBase(shared, [ + { from: tableUsers, to: roleA, kind: "owner" }, + ]); + const fb = buildFactBase(shared, [ + { from: tableUsers, to: roleB, kind: "owner" }, + ]); + const result = subtractBaseline(fb, baseline); + // payload P is identical, but the outgoing owner edge changed → T survives + // (before the fix it was subtracted on payload hash alone and the drift was + // invisible). + expect(result.has(tableUsers)).toBe(true); + // and it carries its NEW owner->B edge, retained dangling (roleB is a + // subtracted baseline role). + const owns = [...result.edges].filter((e) => e.kind === "owner"); + expect(owns).toHaveLength(1); + expect(encodeId(owns[0]!.to)).toBe(encodeId(roleB)); + // roleB is NOT resurrected as a fact — only the dangling edge is retained. + expect(result.has(roleB)).toBe(false); + }); + + test("equal payload AND identical owner edge → still fully subtracted", () => { + const facts: Fact[] = [ + makeFact(roleA), + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + ]; + const edges: DependencyEdge[] = [ + { from: tableUsers, to: roleA, kind: "owner" }, + ]; + const fb = buildFactBase(facts, edges); + const baseline = buildFactBase(facts, edges); + const result = subtractBaseline(fb, baseline); + // identical facts AND identical edges → everything subtracted; the owner + // edge is NOT retained because its owned endpoint (tableUsers) is gone too. + expect(result.facts()).toHaveLength(0); + expect([...result.edges]).toHaveLength(0); + }); + + test("a changed `depends` edge to a subtracted endpoint stays pruned", () => { + // Only owner->role edges are retained dangling; a `depends` edge to a + // subtracted fact is pruned as before. + const baseline = buildFactBase( + [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + makeFact(extPostgis, { version: "3.0" }), + ], + [{ from: tableUsers, to: extPostgis, kind: "depends" }], + ); + const fb = buildFactBase( + [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "u" }, schemaPublic), // changed → survives + makeFact(extPostgis, { version: "3.0" }), // identical → subtracted + ], + [{ from: tableUsers, to: extPostgis, kind: "depends" }], + ); + const result = subtractBaseline(fb, baseline); + expect(result.has(tableUsers)).toBe(true); + expect(result.has(extPostgis)).toBe(false); + expect([...result.edges]).toHaveLength(0); + }); + + test("plan() surfaces an ownership delta that payload-only subtraction hid (e2e)", () => { + // Both sides keep T (payload differs from the baseline), differing ONLY in + // the owner edge. Before the fix, Phase 4 pruned the owner->role edge to the + // subtracted role on BOTH sides, so the ownership change vanished; now the + // edges are retained dangling and the delta surfaces as a plan action. + const baseline = buildFactBase( + [ + makeFact(roleA), + makeFact(roleB), + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p0" }, schemaPublic), + ], + [{ from: tableUsers, to: roleA, kind: "owner" }], + ); + const target = buildFactBase( + [ + makeFact(roleA), + makeFact(roleB), + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p1" }, schemaPublic), + ], + [{ from: tableUsers, to: roleA, kind: "owner" }], + ); + const desired = buildFactBase( + [ + makeFact(roleA), + makeFact(roleB), + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p1" }, schemaPublic), + ], + [{ from: tableUsers, to: roleB, kind: "owner" }], + ); + const thePlan = plan(target, desired, { baseline }); + expect(thePlan.actions.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// describe: parent-chain preservation +// --------------------------------------------------------------------------- + +describe("subtractBaseline — parent-chain preservation", () => { + test("deeply nested surviving fact forces all ancestors to survive", () => { + const constraint: StableId = { + kind: "constraint", + schema: "public", + table: "users", + name: "pk", + }; + const baselineFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + // constraint not in baseline + ]; + const currentFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), // unchanged + makeFact(constraint, { type: "p" }, tableUsers), // new + ]; + const fb = buildFactBase(currentFacts, []); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + // constraint is new → survives + expect(result.has(constraint)).toBe(true); + // tableUsers is its parent → force-kept even though it's unchanged vs baseline + expect(result.has(tableUsers)).toBe(true); + // schemaPublic is parent of tableUsers → force-kept + expect(result.has(schemaPublic)).toBe(true); + }); + + test("subtracted parent of no surviving child is removed", () => { + const baselineFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(schemaPrivate), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), // same + ]; + const currentFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(schemaPrivate), // same as baseline → subtracted + makeFact(tableUsers, { persistence: "p" }, schemaPublic), // same → subtracted + ]; + const fb = buildFactBase(currentFacts, []); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + // All facts identical in baseline → all subtracted + expect(result.facts()).toHaveLength(0); + }); + + test("multi-level parent chain: only relevant branch kept", () => { + // schemaPublic → tableUsers → colId (colId changes) + // schemaPrivate → tableAdmins → colAdmin (all same as baseline) + const colAdmin: StableId = { + kind: "column", + schema: "private", + table: "admins", + name: "id", + }; + const tableAdminsPrivate: StableId = { + kind: "table", + schema: "private", + name: "admins", + }; + const baselineFacts: Fact[] = [ + makeFact(schemaPublic), + makeFact(schemaPrivate), + makeFact(tableUsers, { persistence: "p" }, schemaPublic), + makeFact(colId, { type: "integer" }, tableUsers), + makeFact(tableAdminsPrivate, { persistence: "p" }, schemaPrivate), + makeFact(colAdmin, { type: "integer" }, tableAdminsPrivate), + ]; + const currentFacts: Fact[] = [ + ...baselineFacts.slice(0, 3), // schemaPublic, schemaPrivate, tableUsers + makeFact(colId, { type: "bigint" }, tableUsers), // changed! + ...baselineFacts.slice(4), // tableAdminsPrivate, colAdmin — same + ]; + const fb = buildFactBase(currentFacts, []); + const baseline = buildFactBase(baselineFacts, []); + const result = subtractBaseline(fb, baseline); + // colId changed → kept + expect(result.has(colId)).toBe(true); + // tableUsers and schemaPublic are ancestors → force-kept + expect(result.has(tableUsers)).toBe(true); + expect(result.has(schemaPublic)).toBe(true); + // schemaPrivate branch all unchanged → subtracted + expect(result.has(schemaPrivate)).toBe(false); + expect(result.has(tableAdminsPrivate)).toBe(false); + expect(result.has(colAdmin)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// describe: FactBase integrity +// --------------------------------------------------------------------------- + +describe("subtractBaseline — FactBase integrity", () => { + test("result FactBase has no diagnostics for edges between surviving facts", () => { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "u" }, schemaPublic), + makeFact(roleOwner, { login: true }), + ]; + const edge: DependencyEdge = { + from: tableUsers, + to: roleOwner, + kind: "owner", + }; + // baseline has nothing → all facts survive + const fb = buildFactBase(facts, [edge]); + const baseline = buildFactBase([], []); + const result = subtractBaseline(fb, baseline); + expect(result.diagnostics).toHaveLength(0); + expect([...result.edges]).toHaveLength(1); + }); + + test("result FactBase rootHash is deterministic", () => { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(tableUsers, { persistence: "u" }, schemaPublic), + ]; + const fb = buildFactBase(facts, []); + const baseline = buildFactBase([], []); + const r1 = subtractBaseline(fb, baseline); + const r2 = subtractBaseline(fb, baseline); + expect(r1.rootHash).toBe(r2.rootHash); + }); +}); diff --git a/packages/pg-delta/src/policy/baseline.ts b/packages/pg-delta/src/policy/baseline.ts new file mode 100644 index 000000000..cbbcbc25f --- /dev/null +++ b/packages/pg-delta/src/policy/baseline.ts @@ -0,0 +1,227 @@ +/** + * Baseline subtraction: remove facts present-and-identical in the baseline + * from a FactBase (target-architecture §3.9, stage-08-policy). + * + * "Diff against the platform baseline" = set subtraction before planning. + * Facts present in the baseline with the same payload hash are dropped from + * both sides, replacing hand-maintained empty-catalog special cases. + * + * Parent-chain preservation: if a fact survives (its hash differs from the + * baseline or it is new), all its ancestors must also survive so that + * FactBase construction never encounters a missing parent. + * + * Edge pruning: edges whose either endpoint was removed are silently dropped + * (they become dangling, so FactBase would warn about them; we prune them + * here instead). + */ + +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { + buildFactBase, + type DependencyEdge, + type Fact, + type FactBase, + retainOwnerRoleDangling, +} from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { deserializeSnapshot } from "../core/snapshot.ts"; + +/** + * Return a new FactBase containing only facts that are NOT present with an + * identical payload hash in `baseline`. + * + * A fact is "identical in the baseline" when: + * - encodeId(fact.id) exists in baseline, AND + * - baseline.hashOf(fact.id) === fb.hashOf(fact.id) + * + * Parent-chain rule: any ancestor of a surviving fact is also kept, even if + * it would otherwise be subtracted, so that FactBase construction succeeds. + * + * Edge rule: only edges whose both endpoints survive are kept. + */ +export function subtractBaseline(fb: FactBase, baseline: FactBase): FactBase { + const allFacts = fb.facts(); + + // Phase 1: mark each fact as "would subtract" (present-and-identical in + // baseline). "Identical" means BOTH the payload hash AND the outgoing-edge + // signature match — an equal-payload fact whose outgoing edge changed (e.g. + // `owner` A→B, or a `depends`/`memberOfExtension` provenance edge) is a real + // change and must NOT be subtracted, or the drift would be pruned invisibly. + const wouldSubtract = new Set(); + for (const fact of allFacts) { + const encoded = encodeId(fact.id); + if ( + baseline.has(fact.id) && + baseline.hashOf(fact.id) === fb.hashOf(fact.id) && + edgeSignature(baseline, fact.id) === edgeSignature(fb, fact.id) + ) { + wouldSubtract.add(encoded); + } + } + + // Phase 2: walk every fact that survives and ensure its parent chain survives + // Collect surviving encoded ids first (those not in wouldSubtract) + const surviving = new Set(); + for (const fact of allFacts) { + const encoded = encodeId(fact.id); + if (!wouldSubtract.has(encoded)) { + surviving.add(encoded); + } + } + + // For each surviving fact, walk up the parent chain and force-add ancestors + const toForceKeep = new Set(); + for (const fact of allFacts) { + const encoded = encodeId(fact.id); + if (!surviving.has(encoded)) continue; + // Walk parent chain + let current = fact.parent; + while (current !== undefined) { + const parentEncoded = encodeId(current); + if (surviving.has(parentEncoded)) break; // already in surviving + if (toForceKeep.has(parentEncoded)) break; // already force-kept + toForceKeep.add(parentEncoded); + const parentFact = fb.get(current); + current = parentFact?.parent; + } + } + + // Final surviving set = surviving ∪ toForceKeep + const finalSurviving = new Set([...surviving, ...toForceKeep]); + + // Phase 3: collect surviving facts (preserving original order for determinism) + const keptFacts: Fact[] = []; + for (const fact of allFacts) { + if (finalSurviving.has(encodeId(fact.id))) { + keptFacts.push(fact); + } + } + + // Phase 4: collect edges. Keep an edge when both endpoints survive, OR when it + // is an `owner -> role` edge whose OWNED endpoint survives — that role may have + // been subtracted (a platform baseline role), but ownership must still + // serialize as `ALTER … OWNER TO` the assumed role, so the edge is retained as + // a dangling assumed reference (retainOwnerRoleDangling). Role endpoint facts + // are NOT force-kept, and a `depends`/`memberOfExtension`/`managedBy` edge to a + // subtracted endpoint stays pruned as before. + const keptEdges: DependencyEdge[] = []; + for (const edge of fb.edges) { + const fromEncoded = encodeId(edge.from); + const toEncoded = encodeId(edge.to); + const bothSurvive = + finalSurviving.has(fromEncoded) && finalSurviving.has(toEncoded); + if ( + bothSurvive || + (retainOwnerRoleDangling(edge) && finalSurviving.has(fromEncoded)) + ) { + keptEdges.push(edge); + } + } + + // rootHash already folds each fact's outgoing edges (fact.ts #rollup), so a + // retained dangling owner edge is reflected in the digest — no separate digest + // change is needed. `allowDangling: retainOwnerRoleDangling` lets FactBase keep + // the retained owner->role edge without a `dangling_edge` diagnostic. + return buildFactBase(keptFacts, keptEdges, "liveDb", new Set(), { + allowDangling: retainOwnerRoleDangling, + }); +} + +/** + * The outgoing-edge signature of `id` in `fb`: the sorted `${kind}->${to}` list + * over ALL four edge kinds. Two facts with equal payloads still differ when an + * outgoing edge changed, so baseline subtraction compares this alongside the + * payload hash. Mirrors what `FactBase.#rollup` folds (payload + outgoing edges) + * but deliberately does NOT fold children — reusing `rollupOf` would let a + * changed child wrongly resurrect an otherwise-identical parent. + */ +function edgeSignature(fb: FactBase, id: StableId): string { + return fb + .outgoingEdges(id) + .map((e) => `${e.kind}->${encodeId(e.to)}`) + .sort() + .join("|"); +} + +/** + * A baseline loaded from a `pgdelta snapshot` file, carrying the metadata a + * caller needs to keep the managed view consistent across commands: + * - `factBase` — the facts to subtract (fed to `subtractBaseline`); + * - `digest` — the snapshot's verified content hash (`factBase.rootHash`), + * stamped on plan artifacts / export manifests and reconciled + * at apply/prove time so a swapped or edited baseline fails + * loud instead of silently diffing a different view; + * - `redactSecrets` — the redaction mode the snapshot was captured with, so a + * command extracting in a DIFFERENT mode can reject the + * mismatch (redacted vs unredacted payloads hash differently, + * so the baseline would silently stop subtracting); + * - `path` — source path, for diagnostics. + */ +export interface LoadedBaseline { + readonly factBase: FactBase; + readonly digest: string; + readonly redactSecrets?: boolean; + readonly path?: string; +} + +/** + * Load a baseline from a snapshot JSON file at the given path, with the digest + * and redaction metadata needed for cross-command reconciliation. + * + * Uses node:fs (synchronous) to read the file, then deserializes via + * src/core/snapshot.ts. Throws if the file does not exist or the snapshot + * digest is corrupt (deserializeSnapshot re-verifies the digest on load, so a + * successful load IS verification). + */ +export function loadBaselineFile(path: string): LoadedBaseline { + const json = readFileSync(path, "utf-8"); + const snap = deserializeSnapshot(json); + return { + factBase: snap.factBase, + digest: snap.factBase.rootHash, + ...(snap.redactSecrets !== undefined + ? { redactSecrets: snap.redactSecrets } + : {}), + path, + }; +} + +/** Where committed baseline snapshots live (`src/policy/baselines/`). */ +const BASELINE_DIR = fileURLToPath(new URL("./baselines/", import.meta.url)); + +/** + * Resolve a policy's declared baseline NAME to its committed snapshot FactBase + * (review finding 3) — the frontend seam that makes a declared baseline ACTUAL. + * + * Convention: `/-.json`, falling back to + * `/.json`. Returns `undefined` when the policy declares no + * baseline. + * + * Fail-loud: if the policy DOES declare a baseline but no snapshot is committed, + * this THROWS rather than returning undefined — a declared baseline must never + * be silently ignored. (Until the platform baselines are committed — a separate + * v1 validation item — this is the expected behaviour for a policy that sets + * `baseline`.) + */ +export function resolveBaseline( + policy: { id: string; baseline?: string }, + opts: { pgMajor: number; dir?: string }, +): LoadedBaseline | undefined { + if (policy.baseline === undefined) return undefined; + const dir = opts.dir ?? BASELINE_DIR; + const candidates = [ + join(dir, `${policy.baseline}-${opts.pgMajor}.json`), + join(dir, `${policy.baseline}.json`), + ]; + for (const path of candidates) { + if (existsSync(path)) return loadBaselineFile(path); + } + throw new Error( + `policy "${policy.id}" declares baseline "${policy.baseline}" but no baseline ` + + `snapshot is committed (looked for: ${candidates.join(", ")}). ` + + `Generate and commit it, or remove the baseline from the policy — ` + + `a declared baseline must never be silently ignored.`, + ); +} diff --git a/packages/pg-delta/src/policy/baselines/.gitkeep b/packages/pg-delta/src/policy/baselines/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/pg-delta/src/policy/capability.test.ts b/packages/pg-delta/src/policy/capability.test.ts new file mode 100644 index 000000000..f7d5fe5b0 --- /dev/null +++ b/packages/pg-delta/src/policy/capability.test.ts @@ -0,0 +1,103 @@ +/** + * Applier-capability-restricted view (docs/architecture/managed-view-architecture.md move 6). + * + * The managed view is a function of (facts, policy, applier capability). An + * operation the applier cannot execute is projected out — currently FDW ACLs, + * which require superuser to GRANT/REVOKE. This is additive: the Supabase + * Rule 9 (`{ acl, target fdw } → exclude`) still stands; capability derives the + * same exclusion for ANY non-superuser applier. With no capability (or a + * superuser), the view is unrestricted — the corpus path is unchanged. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { resolveView } from "./policy.ts"; +import { plan } from "../plan/plan.ts"; +import { + capabilityExcludedRoots, + type ApplierCapability, +} from "./capability.ts"; + +const f = (id: StableId): Fact => ({ id, payload: {} }); +const fdw: StableId = { kind: "fdw", name: "w" }; +const fdwAcl: StableId = { kind: "acl", target: fdw, grantee: "u" }; +const tbl: StableId = { kind: "table", schema: "public", name: "t" }; +const tblAcl: StableId = { kind: "acl", target: tbl, grantee: "u" }; + +const superuser: ApplierCapability = { + role: "postgres", + isSuperuser: true, + memberOf: [], +}; +const nonSuper: ApplierCapability = { + role: "app", + isSuperuser: false, + memberOf: [], +}; + +describe("ApplierCapability — capability-restricted view (move 6)", () => { + test("non-superuser → FDW ACL projected out of the view; table ACL kept", () => { + const fb = buildFactBase([f(fdw), f(fdwAcl), f(tbl), f(tblAcl)], []); + const view = resolveView(fb, undefined, nonSuper); + expect(view.get(fdwAcl)).toBeUndefined(); // GRANT/REVOKE on FDW needs superuser + expect(view.get(tblAcl)).toBeDefined(); // table ACLs are fine + expect(view.get(fdw)).toBeDefined(); // the FDW object stays — only its ACL is unappliable + }); + + test("superuser → unrestricted view", () => { + const fb = buildFactBase([f(fdw), f(fdwAcl)], []); + expect(resolveView(fb, undefined, superuser).get(fdwAcl)).toBeDefined(); + }); + + test("no capability → unrestricted view (corpus path)", () => { + const fb = buildFactBase([f(fdw), f(fdwAcl)], []); + expect(resolveView(fb, undefined, undefined).get(fdwAcl)).toBeDefined(); + }); + + test("capabilityExcludedRoots: empty for superuser, the FDW ACL for non-superuser", () => { + const fb = buildFactBase([f(fdw), f(fdwAcl), f(tbl), f(tblAcl)], []); + expect(capabilityExcludedRoots(fb, superuser).size).toBe(0); + const roots = capabilityExcludedRoots(fb, nonSuper); + expect(roots.size).toBe(1); + }); +}); + +describe("ApplierCapability — owner residue fail-fast (follow-up 1)", () => { + // owner can't be silently skipped (acldefault is owner-relative → no + // convergence), so an owner action the applier can't run is a plan-time error. + const schemaApp: StableId = { kind: "schema", name: "app" }; + const r1: StableId = { kind: "role", name: "r1" }; + const r2: StableId = { kind: "role", name: "r2" }; + const memberOfR1: ApplierCapability = { + role: "app", + isSuperuser: false, + memberOf: ["r1"], + }; + const ownerEdge = (to: StableId) => + ({ from: schemaApp, to, kind: "owner" }) as const; + const desiredOwnedBy = (role: StableId) => + buildFactBase([f(schemaApp), f(role)], [ownerEdge(role)]); + const source = (role: StableId) => buildFactBase([f(role)], []); + + test("plan throws when a non-superuser must set an owner it is not a member of", () => { + expect(() => + plan(source(r2), desiredOwnedBy(r2), { capability: memberOfR1 }), + ).toThrow(/cannot set owner/); + }); + + test("plan succeeds when the owner is a role the applier is a member of", () => { + expect(() => + plan(source(r1), desiredOwnedBy(r1), { capability: memberOfR1 }), + ).not.toThrow(); + }); + + test("superuser can set any owner (no throw)", () => { + expect(() => + plan(source(r2), desiredOwnedBy(r2), { capability: superuser }), + ).not.toThrow(); + }); + + test("no capability → no owner restriction (corpus path)", () => { + expect(() => plan(source(r2), desiredOwnedBy(r2))).not.toThrow(); + }); +}); diff --git a/packages/pg-delta/src/policy/capability.ts b/packages/pg-delta/src/policy/capability.ts new file mode 100644 index 000000000..eb7766d28 --- /dev/null +++ b/packages/pg-delta/src/policy/capability.ts @@ -0,0 +1,90 @@ +/** + * Applier capability (docs/architecture/managed-view-architecture.md move 6). + * + * The managed view is a function of (facts, policy, applier capability): an + * operation the applier cannot execute is projected out of the view, never + * silently emitted to fail at apply time. Capability is a property of WHO + * applies, not of the objects — so it is not derivable from the catalog; it is + * probed from the applier connection and threaded into plan()/prove() as an + * option. Absent, the view is unrestricted (the default — superuser/CI path). + * + * v1 restriction: FDW ACLs. `GRANT`/`REVOKE ON FOREIGN DATA WRAPPER` requires + * superuser, so a non-superuser applier cannot replay them. This derives, for + * ANY non-superuser, the exclusion the Supabase policy hard-codes as Rule 9 — + * additively (Rule 9 stays until the derivation is proven at parity). + */ +import type { Pool } from "pg"; +import type { FactBase } from "../core/fact.ts"; +import { encodeId } from "../core/stable-id.ts"; + +export interface ApplierCapability { + /** the role the migration is applied as (current_user) */ + role: string; + /** superuser bypasses most permission checks (incl. FDW GRANT/REVOKE) */ + isSuperuser: boolean; + /** roles the applier is a member of (can SET ROLE / own objects as). A plain + * array (not a Set) so the capability persists losslessly in the Plan + * artifact's JSON (follow-up 2 productization). */ + memberOf: readonly string[]; +} + +/** Probe the applier's capability from a live connection. */ +export async function probeApplierCapability( + pool: Pool, +): Promise { + const res = await pool.query(` + SELECT current_user AS role, + (SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user) AS is_superuser, + ARRAY( + SELECT r.rolname::text FROM pg_catalog.pg_roles r + WHERE pg_catalog.pg_has_role(current_user, r.oid, 'MEMBER') + AND r.rolname NOT LIKE 'pg\\_%' + ) AS member_of + `); + const row = res.rows[0] as { + role: string; + is_superuser: boolean; + member_of: string[] | null; + }; + return { + role: String(row.role), + isSuperuser: Boolean(row.is_superuser), + memberOf: row.member_of ?? [], + }; +} + +/** + * Fact-id keys to project out for a given capability — the facts whose + * corresponding action the applier cannot execute. A superuser is unrestricted. + * Currently: FDW ACL facts (GRANT/REVOKE on a FOREIGN DATA WRAPPER is + * superuser-only). + */ +export function capabilityExcludedRoots( + fb: FactBase, + cap: ApplierCapability, +): Set { + const roots = new Set(); + if (cap.isSuperuser) return roots; + for (const fact of fb.facts()) { + if (fact.id.kind === "acl" && fact.id.target.kind === "fdw") { + roots.add(encodeId(fact.id)); + } + } + return roots; +} + +/** + * Whether the applier can run `ALTER OWNER TO roleName` — PostgreSQL + * requires the applier to be a superuser or a member of the target role (the + * owner residue, move 6 / follow-up 1). + * + * Unlike an FDW ACL (a leaf fact that projects out cleanly), an owner cannot be + * silently skipped: leaving an object applier-owned ripples into its + * acldefault-normalized ACL (which is owner-relative), so the state can't + * converge. So an owner action the applier can't run is a FAIL-FAST at plan + * time (the planner throws a clear, actionable error) rather than a silent + * projection — surfaced before any statement is applied. + */ +export function canSetOwner(cap: ApplierCapability, roleName: string): boolean { + return cap.isSuperuser || cap.memberOf.includes(roleName); +} diff --git a/packages/pg-delta/src/policy/extensions/index.ts b/packages/pg-delta/src/policy/extensions/index.ts new file mode 100644 index 000000000..9c183fc46 --- /dev/null +++ b/packages/pg-delta/src/policy/extensions/index.ts @@ -0,0 +1,23 @@ +/** + * Generic stateful-extension handlers (docs/architecture/extension-intent.md §4.1). + * + * A handler reads ONE extension's own catalogs and emits `managedBy` provenance + * edges on the objects that extension created operationally. The handler + * contract lives in the extract layer (`src/extract/handler.ts`) because + * `extract` invokes handlers inside its own snapshot-bound transaction; this + * barrel only re-exports the contract for convenience and collects the concrete + * handlers. + * + * There is no `extractWithHandlers` / `extractManaged` anymore: handlers are + * passed to `extract(pool, { handlers })` (so they run on the core snapshot), + * and `resolveView` is the single projection point that drops `managedBy` facts + * from the diffed view. The Supabase composition of these handlers lives in the + * integration profile (`src/integrations/supabase.ts`). + */ +export type { + CaptureResult, + ExtensionHandler, + HandlerContext, +} from "../../extract/handler.ts"; +export { pgPartmanHandler } from "./pg-partman.ts"; +export { pgCronHandler } from "./pg-cron.ts"; diff --git a/packages/pg-delta/src/policy/extensions/pg-cron.test.ts b/packages/pg-delta/src/policy/extensions/pg-cron.test.ts new file mode 100644 index 000000000..195b25ba1 --- /dev/null +++ b/packages/pg-delta/src/policy/extensions/pg-cron.test.ts @@ -0,0 +1,335 @@ +/** + * Unit tests for the pg_cron handler (docs/architecture/extension-intent.md + * §3.2). No database — a fake `HandlerContext` returns canned rows keyed off + * the SQL the handler issues. Covers: intent-fact shape + depends-edge + * guarding, `supabase_read_only_user` → `postgres` normalization (CLI-1435), + * unnamed-job and duplicate-jobname diagnostics (never reaching the + * FactBase), and the `intentKinds.job` create/drop SQL (including quote + * doubling for jobname/command). + */ +import { describe, expect, test } from "bun:test"; +import { INTENT_UNKEYED } from "../../core/diagnostic.ts"; +import { buildFactBase, type Fact } from "../../core/fact.ts"; +import type { HandlerContext } from "../../extract/handler.ts"; +import type { Row } from "../../extract/scope.ts"; +import type { StableId } from "../../core/stable-id.ts"; +import { pgCronHandler } from "./pg-cron.ts"; + +const PG_CRON: StableId = { kind: "extension", name: "pg_cron" }; +const PUBLIC_SCHEMA: StableId = { kind: "schema", name: "public" }; +const pgCronFact: Fact = { id: PG_CRON, payload: {} }; +const publicSchemaFact: Fact = { id: PUBLIC_SCHEMA, payload: {} }; + +interface JobRow { + jobid: number; + jobname: string | null; + schedule: string; + command: string; + database: string; + username: string; + active: boolean; +} + +/** Build a fake ctx: detect() query returns the extension's namespace (or no + * rows when not installed); any other query returns the supplied job rows. */ +function fakeCtx(jobs: JobRow[], installed = true): HandlerContext { + return { + query: async (sql: string): Promise => { + if (/pg_extension/i.test(sql)) { + return installed ? [{ schema: "pg_catalog" }] : []; + } + if (/cron\.job/i.test(sql)) { + return jobs as unknown as Row[]; + } + throw new Error(`fakeCtx: unexpected query: ${sql}`); + }, + }; +} + +const baseJob = (overrides: Partial): JobRow => ({ + jobid: 1, + jobname: "nightly", + schedule: "0 0 * * *", + command: "select 1", + database: "postgres", + username: "postgres", + active: true, + ...overrides, +}); + +const jobIntentId = (key: string): StableId => ({ + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key, +}); + +describe("pgCronHandler.capture", () => { + test("not installed → no facts, no edges", async () => { + const ctx = fakeCtx([], false); + const current = buildFactBase([], []); + const result = await pgCronHandler.capture(ctx, current); + expect(result.facts).toEqual([]); + expect(result.edges).toEqual([]); + }); + + test("two named jobs → two intent facts with correct ids + payloads + depends edges", async () => { + const jobs = [ + baseJob({ jobid: 1, jobname: "nightly", schedule: "0 0 * * *" }), + baseJob({ jobid: 2, jobname: "hourly", schedule: "0 * * * *" }), + ]; + const ctx = fakeCtx(jobs); + const current = buildFactBase([pgCronFact], []); + + const result = await pgCronHandler.capture(ctx, current); + + expect(result.facts).toHaveLength(2); + const nightly = result.facts.find( + (f) => (f.id as { key: string }).key === "nightly", + ); + expect(nightly?.id).toEqual(jobIntentId("nightly")); + expect(nightly?.payload).toEqual({ + schedule: "0 0 * * *", + command: "select 1", + database: "postgres", + username: "postgres", + active: true, + }); + // jobname must not leak into the payload (it's the key) + expect(nightly?.payload).not.toHaveProperty("jobname"); + + expect(result.edges).toHaveLength(2); + expect(result.edges).toContainEqual({ + from: jobIntentId("nightly"), + to: PG_CRON, + kind: "depends", + }); + expect(result.edges).toContainEqual({ + from: jobIntentId("hourly"), + to: PG_CRON, + kind: "depends", + }); + expect(result.diagnostics ?? []).toEqual([]); + }); + + test("depends edge is guarded by current.has(pg_cron) — omitted when the extension fact is absent", async () => { + const ctx = fakeCtx([baseJob({})]); + const currentWithoutExtension = buildFactBase([publicSchemaFact], []); + + const result = await pgCronHandler.capture(ctx, currentWithoutExtension); + + expect(result.facts).toHaveLength(1); + expect(result.edges).toEqual([]); + }); + + test("supabase_read_only_user is normalized to postgres (CLI-1435)", async () => { + const ctx = fakeCtx([baseJob({ username: "supabase_read_only_user" })]); + const current = buildFactBase([pgCronFact], []); + + const result = await pgCronHandler.capture(ctx, current); + + expect(result.facts).toHaveLength(1); + expect(result.facts[0]?.payload["username"]).toBe("postgres"); + }); + + test("unnamed job (null jobname) → no fact, one INTENT_UNKEYED diagnostic", async () => { + const longCommand = + "delete from public.some_very_long_table_name_here where created_at < now() - interval '1 day'"; + const ctx = fakeCtx([ + baseJob({ jobid: 42, jobname: null, command: longCommand }), + ]); + const current = buildFactBase([pgCronFact], []); + + const result = await pgCronHandler.capture(ctx, current); + + expect(result.facts).toEqual([]); + expect(result.edges).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + const diag = result.diagnostics?.[0]; + expect(diag?.code).toBe(INTENT_UNKEYED); + expect(diag?.severity).toBe("warning"); + expect(diag?.message).toContain("42"); + expect(diag?.message).toContain("has no jobname"); + expect(diag?.context).toEqual({ ext: "pg_cron", intentKind: "job" }); + }); + + test("unnamed job (empty-string jobname) → no fact, one INTENT_UNKEYED diagnostic", async () => { + const ctx = fakeCtx([baseJob({ jobid: 7, jobname: "" })]); + const current = buildFactBase([pgCronFact], []); + + const result = await pgCronHandler.capture(ctx, current); + + expect(result.facts).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics?.[0]?.code).toBe(INTENT_UNKEYED); + }); + + test("duplicate jobname (two rows, different usernames) → no facts, one diagnostic naming the collision", async () => { + const jobs = [ + baseJob({ jobid: 1, jobname: "nightly", username: "postgres" }), + baseJob({ + jobid: 2, + jobname: "nightly", + username: "supabase_read_only_user", + }), + ]; + const ctx = fakeCtx(jobs); + const current = buildFactBase([pgCronFact], []); + + const result = await pgCronHandler.capture(ctx, current); + + expect(result.facts).toEqual([]); + expect(result.edges).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + const diag = result.diagnostics?.[0]; + expect(diag?.code).toBe(INTENT_UNKEYED); + expect(diag?.severity).toBe("warning"); + expect(diag?.message).toContain("nightly"); + expect(diag?.context).toEqual({ ext: "pg_cron", intentKind: "job" }); + + // never reaches the FactBase (which would throw on a duplicate id) + expect(() => + buildFactBase([pgCronFact, ...result.facts], result.edges), + ).not.toThrow(); + }); + + test("duplicate jobname mixed with a distinct, valid job → only the distinct job becomes a fact", async () => { + const jobs = [ + baseJob({ jobid: 1, jobname: "nightly", username: "postgres" }), + baseJob({ jobid: 2, jobname: "nightly", username: "postgres" }), + baseJob({ jobid: 3, jobname: "hourly" }), + ]; + const ctx = fakeCtx(jobs); + const current = buildFactBase([pgCronFact], []); + + const result = await pgCronHandler.capture(ctx, current); + + expect(result.facts).toHaveLength(1); + expect(result.facts[0]?.id).toEqual(jobIntentId("hourly")); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics?.[0]?.message).toContain("nightly"); + }); +}); + +describe("pgCronHandler.intentKinds.job", () => { + const jobRule = pgCronHandler.intentKinds?.["job"]; + + test("payloadAttrs matches the §3.2 payload shape", () => { + expect(jobRule?.payloadAttrs).toEqual([ + "schedule", + "command", + "database", + "username", + "active", + ]); + }); + + test("create: renders select cron.schedule_in_database(...) with quoted literals", () => { + const fact: Fact = { + id: jobIntentId("nightly"), + payload: { + schedule: "0 0 * * *", + command: "select 1", + database: "postgres", + username: "postgres", + active: true, + }, + }; + const actions = jobRule?.create(fact, undefined as never); + expect(actions).toHaveLength(1); + expect(actions?.[0]?.sql).toMatchInlineSnapshot( + `"select cron.schedule_in_database('nightly', '0 0 * * *', 'select 1', 'postgres', 'postgres', true)"`, + ); + }); + + test("create: replays database, username, and active via cron.schedule_in_database", () => { + const fact: Fact = { + id: jobIntentId("reports"), + payload: { + schedule: "0 0 * * *", + command: "select 1", + database: "analytics", + username: "reporter", + active: false, + }, + }; + const actions = jobRule?.create(fact, undefined as never); + expect(actions).toHaveLength(1); + expect(actions?.[0]?.sql).toMatchInlineSnapshot( + `"select cron.schedule_in_database('reports', '0 0 * * *', 'select 1', 'analytics', 'reporter', false)"`, + ); + }); + + test("create: doubles embedded single quotes in jobname, schedule, and command", () => { + const fact: Fact = { + id: jobIntentId("bob's job"), + payload: { + schedule: "0 0 * * *", + command: "select 'hi' from t where x = 'y'", + database: "postgres", + username: "postgres", + active: true, + }, + }; + const actions = jobRule?.create(fact, undefined as never); + expect(actions?.[0]?.sql).toMatchInlineSnapshot( + `"select cron.schedule_in_database('bob''s job', '0 0 * * *', 'select ''hi'' from t where x = ''y''', 'postgres', 'postgres', true)"`, + ); + }); + + test("create: a command containing $$ is rendered as a plain quoted literal (never dollar-quoted, which would collide)", () => { + const fact: Fact = { + id: jobIntentId("dollar"), + payload: { + schedule: "0 0 * * *", + command: "select '$$not a dollar quote$$'", + database: "postgres", + username: "postgres", + active: true, + }, + }; + const actions = jobRule?.create(fact, undefined as never); + // the whole statement is never wrapped in $$ ... $$ dollar-quoting + expect( + actions?.[0]?.sql.startsWith("select cron.schedule_in_database("), + ).toBe(true); + expect(actions?.[0]?.sql).toMatchInlineSnapshot( + `"select cron.schedule_in_database('dollar', '0 0 * * *', 'select ''$$not a dollar quote$$''', 'postgres', 'postgres', true)"`, + ); + }); + + test("drop: renders select cron.unschedule(...) with dataLoss none", () => { + const fact: Fact = { + id: jobIntentId("nightly"), + payload: { + schedule: "0 0 * * *", + command: "select 1", + database: "postgres", + username: "postgres", + active: true, + }, + }; + const action = jobRule?.drop(fact); + expect(action?.dataLoss).toBe("none"); + expect(action?.sql).toMatchInlineSnapshot( + `"select cron.unschedule('nightly')"`, + ); + }); + + test("drop: doubles embedded single quotes in jobname", () => { + const fact: Fact = { + id: jobIntentId("bob's job"), + payload: { + schedule: "0 0 * * *", + command: "select 1", + database: "postgres", + username: "postgres", + active: true, + }, + }; + const action = jobRule?.drop(fact); + expect(action?.sql).toMatchInlineSnapshot( + `"select cron.unschedule('bob''s job')"`, + ); + }); +}); diff --git a/packages/pg-delta/src/policy/extensions/pg-cron.ts b/packages/pg-delta/src/policy/extensions/pg-cron.ts new file mode 100644 index 000000000..0dcf15221 --- /dev/null +++ b/packages/pg-delta/src/policy/extensions/pg-cron.ts @@ -0,0 +1,235 @@ +/** + * pg_cron handler (docs/architecture/extension-intent.md §3.2). + * + * pg_cron always creates its `cron.job` table in a fixed `cron` schema, + * regardless of which schema the extension itself is installed into (often + * `pg_catalog` on Supabase) — so capture detects the extension via + * `pg_extension`/`pg_namespace` (like partman) but always reads `cron.job` + * directly. + * + * Each row becomes an `extensionIntent` fact keyed by `jobname` (the only + * stable, user-chosen identity pg_cron offers — `jobid` is a runtime + * sequence value and cannot be used as a declarative key). A job with no + * name, or two jobs sharing a name, cannot be keyed at all: both cases are + * surfaced as an `INTENT_UNKEYED` diagnostic instead of a fact, and NEVER + * reach the `FactBase` (which throws on a duplicate id). + */ +import type { DependencyEdge, Fact, FactBase } from "../../core/fact.ts"; +import { INTENT_UNKEYED, type Diagnostic } from "../../core/diagnostic.ts"; +import type { + CaptureResult, + ExtensionHandler, + HandlerContext, +} from "../../extract/handler.ts"; +import type { IntentKindRule } from "../../plan/rules.ts"; +import { lit } from "../../plan/render.ts"; +import type { StableId } from "../../core/stable-id.ts"; + +const PG_CRON: StableId = { kind: "extension", name: "pg_cron" }; + +/** CLI-1435: jobs scheduled before the ownership fix were recorded under + * `supabase_read_only_user`. Normalize to `postgres` on capture so a + * rebuild (unschedule + reschedule) never reproduces the legacy owner. */ +const LEGACY_JOB_OWNER = "supabase_read_only_user"; +const NORMALIZED_JOB_OWNER = "postgres"; + +/** Resolve whether pg_cron is installed (any schema — often `pg_catalog` on + * Supabase). Returns true/false; the handler always queries `cron.job` + * directly since pg_cron creates that schema regardless of install schema. */ +async function detect(ctx: HandlerContext): Promise { + const rows = await ctx.query( + `SELECT n.nspname AS schema + FROM pg_extension e + JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'pg_cron'`, + ); + return rows.length > 0; +} + +interface CronJobRow { + jobid: number; + jobname: string | null; + schedule: string; + command: string; + database: string; + username: string; + active: boolean; +} + +function jobIntentId(jobname: string): StableId { + return { + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: jobname, + }; +} + +/** First ~60 chars of a command, for diagnostic messages (never full text — + * commands can be arbitrarily large SQL). */ +function commandPreview(command: string): string { + return command.length > 60 ? `${command.slice(0, 60)}…` : command; +} + +export const pgCronHandler: ExtensionHandler = { + extension: "pg_cron", + + async capture( + ctx: HandlerContext, + current: FactBase, + ): Promise { + const installed = await detect(ctx); + if (!installed) return { facts: [], edges: [] }; + + const rows = (await ctx.query( + `SELECT jobid, jobname, schedule, command, database, username, active + FROM cron.job`, + )) as unknown as CronJobRow[]; + + const diagnostics: Diagnostic[] = []; + // group named rows by jobname to detect duplicates before ever building a + // fact — the FactBase throws on a duplicate id, so a collision must be + // caught here. + const byName = new Map(); + + for (const row of rows) { + const jobname = row.jobname; + if (jobname === null || jobname === "") { + diagnostics.push({ + code: INTENT_UNKEYED, + severity: "warning", + message: + `pg_cron job ${row.jobid} (command: ${commandPreview(row.command)}) ` + + `has no jobname and cannot be managed declaratively; name it via ` + + `cron.schedule('', …)`, + context: { ext: "pg_cron", intentKind: "job" }, + }); + continue; + } + const group = byName.get(jobname) ?? []; + group.push(row); + byName.set(jobname, group); + } + + const facts: Fact[] = []; + const edges: DependencyEdge[] = []; + const dependsOnExtension = current.has(PG_CRON); + + for (const [jobname, group] of byName) { + if (group.length > 1) { + diagnostics.push({ + code: INTENT_UNKEYED, + severity: "warning", + message: + `pg_cron jobname '${jobname}' is used by ${group.length} jobs ` + + `(jobids ${group.map((r) => r.jobid).join(", ")}) and cannot be ` + + `managed declaratively; jobnames must be unique to be keyed`, + context: { ext: "pg_cron", intentKind: "job" }, + }); + continue; + } + + const row = group[0] as CronJobRow; + const username = + row.username === LEGACY_JOB_OWNER ? NORMALIZED_JOB_OWNER : row.username; + const id = jobIntentId(jobname); + facts.push({ + id, + payload: { + schedule: row.schedule, + command: row.command, + database: row.database, + username, + active: row.active, + }, + }); + if (dependsOnExtension) { + edges.push({ from: id, to: PG_CRON, kind: "depends" }); + } + } + + return { facts, edges, diagnostics }; + }, + + intentKinds: { + job: { + payloadAttrs: ["schedule", "command", "database", "username", "active"], + create(fact) { + const key = (fact.id as Extract) + .key; + const p = fact.payload as { + schedule: string; + command: string; + database: string; + username: string; + active: boolean; + }; + // Replay ALL captured fields deterministically via the 6-arg + // `cron.schedule_in_database(job_name, schedule, command, database, + // username, active)`. The 3-arg `cron.schedule` form would always + // (re)create the job in the CURRENT database, active, owned by the + // executing user — so a job that is inactive, targets another + // database, or has a non-current username would never converge. The + // 6-arg signature has been stable since pg_cron 1.4 (2021), which + // every supported PostgreSQL image and the supabase/postgres image + // ship, so this stays compatible across the pg_cron versions we + // target. String args keep `lit()` quoting; `active` is a bare + // boolean literal. + return [ + { + sql: + `select cron.schedule_in_database(${lit(key)}, ${lit(p.schedule)}, ` + + `${lit(p.command)}, ${lit(p.database)}, ${lit(p.username)}, ${p.active})`, + }, + ]; + }, + drop(fact) { + const key = (fact.id as Extract) + .key; + return { + sql: `select cron.unschedule(${lit(key)})`, + dataLoss: "none", + }; + }, + } satisfies IntentKindRule, + }, + + // pg_cron's schedule* functions run ONLY in the cluster's `cron.database_name` + // (default `postgres`). A co-located shadow database `schema apply` creates is + // never that database, so a declarative dir containing cron intent could never + // load there. Detect it and fail early with a clear remediation instead of a + // mid-load "function cron.schedule_in_database does not exist" stuck error. + shadowPrecheck: { + matchesStatement(masked) { + // `cron.(` survives literal masking (unquoted schema + function name); + // this is the intent replay a cron export/schema always contains. + return /\bcron\s*\.\s*(schedule|schedule_in_database|unschedule|alter_job)\s*\(/i.test( + masked, + ); + }, + async capable(query) { + const rows = await query( + `SELECT current_setting('cron.database_name', true) AS db, + current_database() AS cur, + EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_cron') AS avail`, + ); + const row = rows[0] as + | { db: string | null; cur: string; avail: boolean } + | undefined; + if (row === undefined || !row.avail) { + return { + capable: false, + reason: + "pg_cron is not available in the shadow (not in shared_preload_libraries)", + }; + } + if (row.db === null || row.db !== row.cur) { + return { + capable: false, + reason: `the shadow database "${row.cur}" is not the cron database (cron.database_name = ${row.db === null ? "unset" : `"${row.db}"`})`, + }; + } + return { capable: true }; + }, + }, +}; diff --git a/packages/pg-delta/src/policy/extensions/pg-partman.ts b/packages/pg-delta/src/policy/extensions/pg-partman.ts new file mode 100644 index 000000000..3a0892070 --- /dev/null +++ b/packages/pg-delta/src/policy/extensions/pg-partman.ts @@ -0,0 +1,90 @@ +/** + * pg_partman handler (docs/architecture/extension-intent.md §3.3, Deliverable A). + * + * pg_partman child partitions are real user-schema tables that carry NO + * `pg_depend` edge to the extension (so the core extractor's `deptype='e'` + * anti-join keeps them as facts) and cannot be told apart from a user-declared + * `PARTITION OF` by `relispartition` alone (CLI-1591). The ONLY authoritative + * signal is `.part_config`: a table is partman-managed iff its + * `pg_inherits` parent (transitively) is registered there. + * + * `part_config` is not `pg_catalog`, so this lives in the integration layer. + * Phase A emits a `managedBy` edge from each managed child to the pg_partman + * extension fact; `excludeManaged` then drops those children from the diff so + * a declarative sync never `DROP`s them (CLI-1555). Native AND legacy + * (trigger-based) partitioning both use `pg_inherits`, so the recursive walk + * covers every level, including `*_default` and premade children. + */ +import type { DependencyEdge, FactBase } from "../../core/fact.ts"; +import type { + CaptureResult, + ExtensionHandler, + HandlerContext, +} from "../../extract/handler.ts"; +import type { StableId } from "../../core/stable-id.ts"; + +const PG_PARTMAN: StableId = { kind: "extension", name: "pg_partman" }; + +/** Double-quote a SQL identifier (the partman schema is dynamic). */ +function quoteIdent(name: string): string { + return `"${name.replaceAll('"', '""')}"`; +} + +/** Resolve the schema pg_partman is installed into, or null if absent. */ +async function detect(ctx: HandlerContext): Promise { + const rows = await ctx.query( + `SELECT n.nspname AS schema + FROM pg_extension e + JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'pg_partman'`, + ); + return (rows[0]?.["schema"] as string | undefined) ?? null; +} + +export const pgPartmanHandler: ExtensionHandler = { + extension: "pg_partman", + + async capture( + ctx: HandlerContext, + current: FactBase, + ): Promise { + const schema = await detect(ctx); + if (schema === null) return { facts: [], edges: [] }; + + // Every table inheriting (directly or transitively) from a parent + // registered in part_config is partman-managed. + const rows = await ctx.query( + `WITH RECURSIVE managed_parents AS ( + SELECT to_regclass(parent_table)::oid AS oid + FROM ${quoteIdent(schema)}.part_config + WHERE to_regclass(parent_table) IS NOT NULL + ), + descendants AS ( + SELECT i.inhrelid AS oid + FROM pg_inherits i + WHERE i.inhparent IN (SELECT oid FROM managed_parents) + UNION ALL + SELECT i.inhrelid + FROM pg_inherits i + JOIN descendants d ON i.inhparent = d.oid + ) + SELECT n.nspname AS schema, c.relname AS name + FROM descendants d + JOIN pg_class c ON c.oid = d.oid + JOIN pg_namespace n ON n.oid = c.relnamespace`, + ); + + const edges: DependencyEdge[] = []; + for (const row of rows) { + const child: StableId = { + kind: "table", + schema: String(row["schema"]), + name: String(row["name"]), + }; + // only tag children that are actually facts (avoid dangling edges) + if (!current.has(child)) continue; + edges.push({ from: child, to: PG_PARTMAN, kind: "managedBy" }); + } + return { facts: [], edges }; + }, +}; diff --git a/packages/pg-delta/src/policy/managed.test.ts b/packages/pg-delta/src/policy/managed.test.ts new file mode 100644 index 000000000..af9784a04 --- /dev/null +++ b/packages/pg-delta/src/policy/managed.test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for managed-object exclusion (src/policy/managed.ts). + * No Docker / database required. + * + * Stateful-extension intent, Deliverable A (docs/architecture/extension-intent.md §4.3): + * objects an extension created operationally (pg_partman child partitions, + * pgmq queue tables) carry a `managedBy` edge and must be excluded from the + * schema fact base on BOTH sides — never diffed, so the plan never drops them + * (CLI-1555). Fact-level exclusion (not delta-level) keeps the proof honest + * (docs §6): the plan you prove == the plan you run. + */ + +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type DependencyEdge, type Fact } from "../core/fact.ts"; +import type { Payload } from "../core/hash.ts"; +import { diff } from "../core/diff.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { excludeManaged } from "./managed.ts"; + +// --------------------------------------------------------------------------- +// Helpers: a partitioned parent + one partman-managed child partition +// --------------------------------------------------------------------------- + +const schemaPublic: StableId = { kind: "schema", name: "public" }; +const extPartman: StableId = { kind: "extension", name: "pg_partman" }; +const parentTable: StableId = { + kind: "table", + schema: "public", + name: "events", +}; +const childPartition: StableId = { + kind: "table", + schema: "public", + name: "events_p20260101", +}; +const childColumn: StableId = { + kind: "column", + schema: "public", + table: "events_p20260101", + name: "id", +}; + +function makeFact( + id: StableId, + payload: Payload = {}, + parent?: StableId, +): Fact { + return parent ? { id, parent, payload } : { id, payload }; +} + +/** Source: the live DB — partitioned parent + a partman child (with a column), + * the child tagged `managedBy` the pg_partman extension + a partition + * `depends` edge to the parent. */ +function sourceBase() { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(extPartman, {}, schemaPublic), + makeFact( + parentTable, + { persistence: "p", partitioned: true }, + schemaPublic, + ), + makeFact( + childPartition, + { persistence: "p", partitioned: false }, + schemaPublic, + ), + makeFact(childColumn, { type: "integer" }, childPartition), + ]; + const edges: DependencyEdge[] = [ + { from: childPartition, to: extPartman, kind: "managedBy" }, + { from: childPartition, to: parentTable, kind: "depends" }, + ]; + return buildFactBase(facts, edges); +} + +/** Desired: the declarative source — only the parent is declared; pg_partman + * creates children at runtime, so the shadow has no child. */ +function desiredBase() { + const facts: Fact[] = [ + makeFact(schemaPublic), + makeFact(extPartman, {}, schemaPublic), + makeFact( + parentTable, + { persistence: "p", partitioned: true }, + schemaPublic, + ), + ]; + return buildFactBase(facts, []); +} + +const removesChild = (deltas: ReturnType) => + deltas.some( + (d) => + d.verb === "remove" && encodeId(d.fact.id) === encodeId(childPartition), + ); + +// --------------------------------------------------------------------------- + +describe("excludeManaged — Deliverable A (stop dropping managed partitions)", () => { + test("control: a raw diff DROPS the partman child (the CLI-1555 bug)", () => { + // proves the scenario actually reproduces the destructive drop + expect(removesChild(diff(sourceBase(), desiredBase()))).toBe(true); + }); + + test("excluding managed facts removes the child + its descendants from the base", () => { + const pruned = excludeManaged(sourceBase()); + expect(pruned.has(childPartition)).toBe(false); + expect(pruned.has(childColumn)).toBe(false); // descendant pruned too + }); + + test("the partitioned PARENT and the extension survive exclusion", () => { + const pruned = excludeManaged(sourceBase()); + expect(pruned.has(parentTable)).toBe(true); + expect(pruned.has(extPartman)).toBe(true); + expect(pruned.has(schemaPublic)).toBe(true); + }); + + test("after exclusion on both sides, the diff no longer drops the child", () => { + const deltas = diff( + excludeManaged(sourceBase()), + excludeManaged(desiredBase()), + ); + expect(removesChild(deltas)).toBe(false); + }); + + test("a NON-managed table absent from desired is still dropped (no false suppression)", () => { + // a user-declared partition removed on the desired side MUST still drop + const userPartition: StableId = { + kind: "table", + schema: "public", + name: "user_part_2025", + }; + const src = buildFactBase( + [ + makeFact(schemaPublic), + makeFact(parentTable, { persistence: "p" }, schemaPublic), + makeFact(userPartition, { persistence: "p" }, schemaPublic), + ], + [{ from: userPartition, to: parentTable, kind: "depends" }], + ); + const desired = buildFactBase( + [ + makeFact(schemaPublic), + makeFact(parentTable, { persistence: "p" }, schemaPublic), + ], + [], + ); + const deltas = diff(excludeManaged(src), excludeManaged(desired)); + const dropsUser = deltas.some( + (d) => + d.verb === "remove" && encodeId(d.fact.id) === encodeId(userPartition), + ); + expect(dropsUser).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/policy/managed.ts b/packages/pg-delta/src/policy/managed.ts new file mode 100644 index 000000000..24d4c64ca --- /dev/null +++ b/packages/pg-delta/src/policy/managed.ts @@ -0,0 +1,34 @@ +/** + * Managed-object exclusion (docs/architecture/extension-intent.md §4.3, Deliverable A). + * + * Objects a stateful extension created operationally — pg_partman child + * partitions, pgmq `q_*`/`a_*` queue tables — carry a `managedBy` edge + * (emitted by an extension handler's capture, src/policy/extensions). The + * extension owns their lifecycle, so they must NOT be diffed as schema: a diff + * would drop them as drift and destroy data (CLI-1555). + * + * Exclusion is at the FACT level (both sides + the proof re-extract), NOT the + * delta level — a delta-only filter would make the proof drift (the clone + * keeps the children, `desired` lacks them). Removing them from the fact base + * keeps the proof honest: the plan you prove == the plan you run == the + * data-preserving plan (docs §6). This mirrors baseline subtraction + * (src/policy/baseline.ts). + * + * A `managedBy`-tagged fact and its entire descendant subtree (the child + * table's columns/constraints/indexes) are removed; edges with a removed + * endpoint are pruned (they would otherwise dangle). Facts with no managedBy + * provenance — e.g. a user-declared `PARTITION OF` — are untouched, so their + * intended drops still fire (no false suppression). + */ +import type { FactBase } from "../core/fact.ts"; +import { excludeByProvenance } from "./view.ts"; + +/** + * Return a new FactBase with every operationally-managed fact removed: a fact + * carrying an outgoing `managedBy` edge, plus all of its descendants. Edges + * with a removed endpoint are dropped. If nothing is managed, `fb` is returned + * unchanged. Thin wrapper over the shared projection primitive (view.ts). + */ +export function excludeManaged(fb: FactBase): FactBase { + return excludeByProvenance(fb, "managedBy"); +} diff --git a/packages/pg-delta/src/policy/policy-typed-predicates.test.ts b/packages/pg-delta/src/policy/policy-typed-predicates.test.ts new file mode 100644 index 000000000..69f18c5f7 --- /dev/null +++ b/packages/pg-delta/src/policy/policy-typed-predicates.test.ts @@ -0,0 +1,123 @@ +/** + * Unit tests for typed policy predicates (hardening Item 3 / review #7): + * `edgeTo` can filter by edge KIND (provenance: managedBy / memberOfExtension / + * owner / depends), and `validatePolicy` rejects a typo'd `idField` instead of + * silently never matching. No Docker / database required. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type DependencyEdge, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { factMatches, validatePolicy, type Policy } from "./policy.ts"; + +const ext: StableId = { kind: "extension", name: "pg_partman" }; +const parent: StableId = { kind: "table", schema: "public", name: "events" }; +const child: StableId = { kind: "table", schema: "public", name: "events_p1" }; + +function makeFact(id: StableId): Fact { + return { id, payload: {} }; +} + +const edges: DependencyEdge[] = [ + { from: child, to: ext, kind: "managedBy" }, + { from: child, to: parent, kind: "depends" }, +]; +const fb = buildFactBase( + [makeFact(ext), makeFact(parent), makeFact(child)], + edges, +); +const childFact = fb.get(child) as Fact; + +describe("edgeTo — filter by edge kind (review #7)", () => { + test("matches the depends edge", () => { + expect( + factMatches({ edgeTo: { edgeKind: "depends" } }, childFact, fb), + ).toBe(true); + }); + + test("matches a managedBy edge to an extension", () => { + expect( + factMatches( + { edgeTo: { edgeKind: "managedBy", kind: "extension" } }, + childFact, + fb, + ), + ).toBe(true); + }); + + test("does NOT match an edge kind that is absent (owner)", () => { + // without edge-kind filtering this would wrongly match (any outgoing edge) + expect(factMatches({ edgeTo: { edgeKind: "owner" } }, childFact, fb)).toBe( + false, + ); + }); + + test("edgeKind + target kind together: managedBy to a table is absent", () => { + expect( + factMatches( + { edgeTo: { edgeKind: "managedBy", kind: "table" } }, + childFact, + fb, + ), + ).toBe(false); + }); +}); + +describe("validatePolicy — reject typo'd idField (review #7)", () => { + test("a real id field is accepted", () => { + const good: Policy = { + id: "ok", + filter: [ + { + match: { idField: { field: "member", glob: "x" } }, + action: "exclude", + }, + ], + }; + expect(() => validatePolicy(good)).not.toThrow(); + }); + + test("extensionIntent id fields (ext/intentKind/key) are accepted", () => { + // a custom profile policy must be able to scope extension-intent facts + // (e.g. exclude a specific pg_cron job) by the fields of its stable id. + for (const field of ["ext", "intentKind", "key"]) { + const p: Policy = { + id: `intent-${field}`, + filter: [ + { match: { idField: { field, glob: "x" } }, action: "exclude" }, + ], + }; + expect(() => validatePolicy(p)).not.toThrow(); + } + }); + + test("a typo'd id field throws (would otherwise silently never match)", () => { + const bad: Policy = { + id: "typo", + filter: [ + { + match: { idField: { field: "membr", glob: "x" } }, + action: "exclude", + }, + ], + }; + expect(() => validatePolicy(bad)).toThrow(/membr/); + }); + + test("typo'd idField nested under all/not is still caught", () => { + const bad: Policy = { + id: "nested", + filter: [ + { + match: { + all: [ + { kind: "table" }, + { not: { idField: { field: "tbl", glob: "x" } } }, + ], + }, + action: "exclude", + }, + ], + }; + expect(() => validatePolicy(bad)).toThrow(/tbl/); + }); +}); diff --git a/packages/pg-delta/src/policy/policy.test.ts b/packages/pg-delta/src/policy/policy.test.ts new file mode 100644 index 000000000..093c46357 --- /dev/null +++ b/packages/pg-delta/src/policy/policy.test.ts @@ -0,0 +1,1275 @@ +/** + * Unit tests for the Policy DSL v2 (src/policy/policy.ts). + * No Docker / database required. + */ + +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type DependencyEdge, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { + deltaMatches, + factMatches, + filterDeltas, + flattenPolicy, + validatePolicy, + type Policy, + type Predicate, +} from "./policy.ts"; +import type { Delta } from "../core/diff.ts"; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const schemaPublic: StableId = { kind: "schema", name: "public" }; +const schemaAuth: StableId = { kind: "schema", name: "auth" }; +const tableUsers: StableId = { kind: "table", schema: "public", name: "users" }; +const tableRoles: StableId = { + kind: "table", + schema: "auth", + name: "roles", +}; +const colId: StableId = { + kind: "column", + schema: "public", + table: "users", + name: "id", +}; +const extPostgis: StableId = { kind: "extension", name: "postgis" }; +const roleOwner: StableId = { kind: "role", name: "owner" }; + +function makeFactBase(facts: Fact[], edges: DependencyEdge[] = []) { + return buildFactBase(facts, edges); +} + +function baseFacts(): Fact[] { + return [ + { id: schemaPublic, payload: {} }, + { id: schemaAuth, payload: {} }, + { id: tableUsers, parent: schemaPublic, payload: { persistence: "p" } }, + { id: tableRoles, parent: schemaAuth, payload: { persistence: "p" } }, + { id: colId, parent: tableUsers, payload: { type: "integer" } }, + { id: extPostgis, payload: { version: "3.0" } }, + { id: roleOwner, payload: { login: true } }, + ]; +} + +// --------------------------------------------------------------------------- +// describe: factMatches — primitive predicates +// --------------------------------------------------------------------------- + +describe("factMatches — kind", () => { + const fb = makeFactBase(baseFacts()); + + test("single kind matches", () => { + const fact = fb.get(tableUsers)!; + expect(factMatches({ kind: "table" }, fact, fb)).toBe(true); + }); + + test("single kind does not match different kind", () => { + const fact = fb.get(schemaPublic)!; + expect(factMatches({ kind: "table" }, fact, fb)).toBe(false); + }); + + test("array kind matches any of listed kinds", () => { + const fact = fb.get(schemaPublic)!; + expect(factMatches({ kind: ["table", "schema"] }, fact, fb)).toBe(true); + }); + + test("array kind does not match if none listed", () => { + const fact = fb.get(roleOwner)!; + expect(factMatches({ kind: ["table", "schema"] }, fact, fb)).toBe(false); + }); +}); + +describe("factMatches — schema", () => { + const fb = makeFactBase(baseFacts()); + + test("exact schema matches", () => { + const fact = fb.get(tableUsers)!; + expect(factMatches({ schema: "public" }, fact, fb)).toBe(true); + }); + + test("exact schema does not match wrong schema", () => { + const fact = fb.get(tableRoles)!; + expect(factMatches({ schema: "public" }, fact, fb)).toBe(false); + }); + + test("glob * matches any schema", () => { + const fact = fb.get(tableUsers)!; + expect(factMatches({ schema: "*" }, fact, fb)).toBe(true); + }); + + test("glob prefix matches", () => { + const fact = fb.get(tableUsers)!; + expect(factMatches({ schema: "pub*" }, fact, fb)).toBe(true); + }); + + test("glob does not match unrelated schema", () => { + const fact = fb.get(tableRoles)!; + expect(factMatches({ schema: "pub*" }, fact, fb)).toBe(false); + }); + + test("fact without schema field returns false", () => { + const fact = fb.get(roleOwner)!; + expect(factMatches({ schema: "*" }, fact, fb)).toBe(false); + }); +}); + +describe("factMatches — name", () => { + const fb = makeFactBase(baseFacts()); + + test("exact name matches", () => { + const fact = fb.get(tableUsers)!; + expect(factMatches({ name: "users" }, fact, fb)).toBe(true); + }); + + test("glob name matches", () => { + const fact = fb.get(tableUsers)!; + expect(factMatches({ name: "user*" }, fact, fb)).toBe(true); + }); + + test("name does not match on fact without name field", () => { + // membership facts have role/member, no name + const fact: Fact = { + id: { kind: "membership", role: "a", member: "b" }, + payload: {}, + }; + const local = makeFactBase([fact]); + expect(factMatches({ name: "*" }, fact, local)).toBe(false); + }); +}); + +describe("factMatches — verb", () => { + const fb = makeFactBase(baseFacts()); + + test("verb predicate always returns false on factMatches (no verb on facts)", () => { + const fact = fb.get(tableUsers)!; + expect(factMatches({ verb: "add" }, fact, fb)).toBe(false); + expect(factMatches({ verb: ["add", "remove"] }, fact, fb)).toBe(false); + }); +}); + +describe("factMatches — ownedByExtension", () => { + test("matches when fact has memberOfExtension edge to named extension", () => { + const geomCol: StableId = { + kind: "column", + schema: "public", + table: "geo", + name: "geom", + }; + const geoTable: StableId = { + kind: "table", + schema: "public", + name: "geo", + }; + const facts: Fact[] = [ + { id: schemaPublic, payload: {} }, + { id: extPostgis, payload: {} }, + { id: geoTable, parent: schemaPublic, payload: {} }, + { id: geomCol, parent: geoTable, payload: {} }, + ]; + const edges: DependencyEdge[] = [ + { from: geoTable, to: extPostgis, kind: "memberOfExtension" }, + ]; + const fb = makeFactBase(facts, edges); + const col = fb.get(geomCol)!; + // Column's parent (geoTable) is owned by postgis + expect(factMatches({ ownedByExtension: "postgis" }, col, fb)).toBe(true); + }); + + test("does not match when no memberOfExtension edge exists", () => { + const fb = makeFactBase(baseFacts()); + const fact = fb.get(tableUsers)!; + expect(factMatches({ ownedByExtension: "postgis" }, fact, fb)).toBe(false); + }); + + test("does not match a different extension name", () => { + const facts: Fact[] = [ + { id: schemaPublic, payload: {} }, + { id: extPostgis, payload: {} }, + { id: tableUsers, parent: schemaPublic, payload: {} }, + ]; + const edges: DependencyEdge[] = [ + { from: tableUsers, to: extPostgis, kind: "memberOfExtension" }, + ]; + const fb = makeFactBase(facts, edges); + const fact = fb.get(tableUsers)!; + expect(factMatches({ ownedByExtension: "pgcrypto" }, fact, fb)).toBe(false); + }); +}); + +describe("factMatches — parentKind", () => { + const fb = makeFactBase(baseFacts()); + + test("matches when parent has the given kind", () => { + const col = fb.get(colId)!; + expect(factMatches({ parentKind: "table" }, col, fb)).toBe(true); + }); + + test("does not match when parent has a different kind", () => { + const col = fb.get(colId)!; + expect(factMatches({ parentKind: "schema" }, col, fb)).toBe(false); + }); + + test("returns false for root facts (no parent)", () => { + const schema = fb.get(schemaPublic)!; + expect(factMatches({ parentKind: "table" }, schema, fb)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// describe: factMatches — combinators +// --------------------------------------------------------------------------- + +describe("factMatches — combinators", () => { + const fb = makeFactBase(baseFacts()); + const tableUsersFact = fb.get(tableUsers)!; + + test("all: both true → true", () => { + const p: Predicate = { + all: [{ kind: "table" }, { schema: "public" }], + }; + expect(factMatches(p, tableUsersFact, fb)).toBe(true); + }); + + test("all: one false → false", () => { + const p: Predicate = { + all: [{ kind: "table" }, { schema: "auth" }], + }; + expect(factMatches(p, tableUsersFact, fb)).toBe(false); + }); + + test("all: empty array → true (vacuously)", () => { + expect(factMatches({ all: [] }, tableUsersFact, fb)).toBe(true); + }); + + test("any: one true → true", () => { + const p: Predicate = { + any: [{ kind: "schema" }, { kind: "table" }], + }; + expect(factMatches(p, tableUsersFact, fb)).toBe(true); + }); + + test("any: all false → false", () => { + const p: Predicate = { + any: [{ kind: "schema" }, { kind: "role" }], + }; + expect(factMatches(p, tableUsersFact, fb)).toBe(false); + }); + + test("any: empty array → false (vacuously)", () => { + expect(factMatches({ any: [] }, tableUsersFact, fb)).toBe(false); + }); + + test("not: negates true to false", () => { + expect(factMatches({ not: { kind: "table" } }, tableUsersFact, fb)).toBe( + false, + ); + }); + + test("not: negates false to true", () => { + expect(factMatches({ not: { kind: "schema" } }, tableUsersFact, fb)).toBe( + true, + ); + }); + + test("nested combinators", () => { + const p: Predicate = { + all: [ + { not: { kind: "schema" } }, + { any: [{ schema: "public" }, { schema: "auth" }] }, + ], + }; + // tableUsers: not schema ✓, schema=public ✓ + expect(factMatches(p, tableUsersFact, fb)).toBe(true); + // schemaPublic: is schema → not-schema fails + expect(factMatches(p, fb.get(schemaPublic)!, fb)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// describe: deltaMatches +// --------------------------------------------------------------------------- + +describe("deltaMatches — verb predicate", () => { + const fb = makeFactBase(baseFacts()); + const emptyFb = makeFactBase([]); + + const addDelta: Delta = { + verb: "add", + fact: { id: tableUsers, payload: {} }, + }; + const removeDelta: Delta = { + verb: "remove", + fact: { id: tableUsers, payload: {} }, + }; + + test("single verb matches", () => { + expect(deltaMatches({ verb: "add" }, addDelta, emptyFb, fb)).toBe(true); + }); + + test("single verb does not match other verb", () => { + expect(deltaMatches({ verb: "remove" }, addDelta, emptyFb, fb)).toBe(false); + }); + + test("array verb matches any", () => { + expect(deltaMatches({ verb: ["add", "set"] }, addDelta, emptyFb, fb)).toBe( + true, + ); + }); + + test("array verb does not match if none in list", () => { + expect( + deltaMatches({ verb: ["remove", "set"] }, addDelta, emptyFb, fb), + ).toBe(false); + }); + + test("verb predicate on remove delta", () => { + expect(deltaMatches({ verb: "remove" }, removeDelta, fb, emptyFb)).toBe( + true, + ); + }); +}); + +describe("deltaMatches — fact predicates on various delta verbs", () => { + const source = makeFactBase(baseFacts()); + const desired = makeFactBase(baseFacts()); + const emptyFb = makeFactBase([]); + + test("add delta subject resolved from desired", () => { + const addDelta: Delta = { + verb: "add", + fact: { id: tableUsers, parent: schemaPublic, payload: {} }, + }; + expect(deltaMatches({ kind: "table" }, addDelta, emptyFb, desired)).toBe( + true, + ); + expect(deltaMatches({ schema: "public" }, addDelta, emptyFb, desired)).toBe( + true, + ); + }); + + test("remove delta subject resolved from source", () => { + const removeDelta: Delta = { + verb: "remove", + fact: { id: tableUsers, parent: schemaPublic, payload: {} }, + }; + expect(deltaMatches({ kind: "table" }, removeDelta, source, emptyFb)).toBe( + true, + ); + }); + + test("set delta subject resolved from desired", () => { + const setDelta: Delta = { + verb: "set", + id: tableUsers, + attr: "persistence", + from: "p", + to: "u", + }; + expect(deltaMatches({ kind: "table" }, setDelta, source, desired)).toBe( + true, + ); + }); + + test("link delta subject resolved from desired.from", () => { + const linkDelta: Delta = { + verb: "link", + edge: { from: tableUsers, to: roleOwner, kind: "owner" }, + }; + expect(deltaMatches({ kind: "table" }, linkDelta, source, desired)).toBe( + true, + ); + }); + + test("unlink delta subject resolved from source.from", () => { + const unlinkDelta: Delta = { + verb: "unlink", + edge: { from: tableUsers, to: roleOwner, kind: "owner" }, + }; + expect(deltaMatches({ kind: "table" }, unlinkDelta, source, desired)).toBe( + true, + ); + }); +}); + +describe("deltaMatches — combinators on deltas", () => { + const source = makeFactBase(baseFacts()); + const desired = makeFactBase(baseFacts()); + + test("all combinator on delta", () => { + const addDelta: Delta = { + verb: "add", + fact: { id: tableUsers, parent: schemaPublic, payload: {} }, + }; + const p: Predicate = { + all: [{ verb: "add" }, { kind: "table" }, { schema: "public" }], + }; + expect(deltaMatches(p, addDelta, source, desired)).toBe(true); + }); + + test("not combinator on delta verb", () => { + const addDelta: Delta = { + verb: "add", + fact: { id: tableUsers, payload: {} }, + }; + expect( + deltaMatches({ not: { verb: "remove" } }, addDelta, source, desired), + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// describe: filterDeltas — first-match-wins, include/exclude, kept/filtered +// --------------------------------------------------------------------------- + +describe("filterDeltas", () => { + const source = makeFactBase(baseFacts()); + + // Desired has a new table in auth schema + const newTable: StableId = { + kind: "table", + schema: "auth", + name: "sessions", + }; + const desiredFacts: Fact[] = [ + ...baseFacts(), + { id: newTable, parent: schemaAuth, payload: {} }, + ]; + const desired = makeFactBase(desiredFacts); + + const addUsers: Delta = { + verb: "add", + fact: { id: tableUsers, parent: schemaPublic, payload: {} }, + }; + const addAuthSessions: Delta = { + verb: "add", + fact: { id: newTable, parent: schemaAuth, payload: {} }, + }; + const removeRole: Delta = { + verb: "remove", + fact: { id: roleOwner, payload: {} }, + }; + + const deltas: Delta[] = [addUsers, addAuthSessions, removeRole]; + + test("no rules → all deltas kept", () => { + const policy: Policy = { id: "empty" }; + const { kept, filtered } = filterDeltas(deltas, policy, source, desired); + expect(kept).toHaveLength(3); + expect(filtered).toHaveLength(0); + }); + + test("exclude auth schema — tables in auth are filtered", () => { + const policy: Policy = { + id: "no-auth", + filter: [{ match: { schema: "auth" }, action: "exclude" }], + }; + const { kept, filtered } = filterDeltas(deltas, policy, source, desired); + expect(kept).toHaveLength(2); + expect(filtered).toHaveLength(1); + expect(filtered[0]).toBe(addAuthSessions); + }); + + test("first-match-wins: include before exclude", () => { + // include public tables first, then exclude all tables + const policy: Policy = { + id: "first-match", + filter: [ + { + match: { all: [{ kind: "table" }, { schema: "public" }] }, + action: "include", + }, + { match: { kind: "table" }, action: "exclude" }, + ], + }; + const { kept, filtered } = filterDeltas(deltas, policy, source, desired); + // addUsers (table+public) → matched by rule 1 → include + // addAuthSessions (table+auth) → not matched by rule 1, matched by rule 2 → exclude + // removeRole → no match → include + expect(kept).toHaveLength(2); + expect(kept).toContain(addUsers); + expect(kept).toContain(removeRole); + expect(filtered).toHaveLength(1); + expect(filtered[0]).toBe(addAuthSessions); + }); + + test("exclude then include (include-after-exclude): the exclude wins first", () => { + // exclude auth schema first, then re-include everything + const policy: Policy = { + id: "excl-then-incl", + filter: [ + { match: { schema: "auth" }, action: "exclude" }, + { match: { all: [] }, action: "include" }, + ], + }; + const { filtered } = filterDeltas(deltas, policy, source, desired); + // addAuthSessions matches rule 1 → excluded (first-match-wins) + expect(filtered).toHaveLength(1); + expect(filtered[0]).toBe(addAuthSessions); + }); + + test("filtered deltas are returned, not silently dropped", () => { + const policy: Policy = { + id: "exclude-all", + filter: [{ match: { all: [] }, action: "exclude" }], + }; + const { kept, filtered } = filterDeltas(deltas, policy, source, desired); + expect(kept).toHaveLength(0); + expect(filtered).toHaveLength(3); + }); + + test("exclude by verb", () => { + const policy: Policy = { + id: "no-remove", + filter: [{ match: { verb: "remove" }, action: "exclude" }], + }; + const { kept, filtered } = filterDeltas(deltas, policy, source, desired); + expect(kept).toHaveLength(2); + expect(filtered).toHaveLength(1); + expect(filtered[0]).toBe(removeRole); + }); +}); + +// --------------------------------------------------------------------------- +// describe: extends composition +// --------------------------------------------------------------------------- + +describe("flattenPolicy — extends composition", () => { + test("own rules before parent rules", () => { + const parent: Policy = { + id: "parent", + filter: [{ match: { kind: "schema" }, action: "exclude" }], + }; + const child: Policy = { + id: "child", + filter: [{ match: { kind: "role" }, action: "exclude" }], + extends: [parent], + }; + const flat = flattenPolicy(child); + expect(flat.filter).toHaveLength(2); + // child rule first + expect(flat.filter[0]?.match).toEqual({ kind: "role" }); + // parent rule second + expect(flat.filter[1]?.match).toEqual({ kind: "schema" }); + }); + + test("multiple parents: rules appended in extends array order", () => { + const p1: Policy = { + id: "p1", + filter: [{ match: { kind: "role" }, action: "exclude" }], + }; + const p2: Policy = { + id: "p2", + filter: [{ match: { kind: "schema" }, action: "exclude" }], + }; + const child: Policy = { id: "child", extends: [p1, p2] }; + const flat = flattenPolicy(child); + expect(flat.filter[0]?.match).toEqual({ kind: "role" }); + expect(flat.filter[1]?.match).toEqual({ kind: "schema" }); + }); + + test("deep extends: own → child → grandparent order", () => { + const gp: Policy = { + id: "grandparent", + filter: [{ match: { kind: "extension" }, action: "exclude" }], + }; + const parent: Policy = { + id: "parent-deep", + filter: [{ match: { kind: "role" }, action: "exclude" }], + extends: [gp], + }; + const child: Policy = { + id: "child-deep", + filter: [{ match: { kind: "schema" }, action: "exclude" }], + extends: [parent], + }; + const flat = flattenPolicy(child); + expect(flat.filter.map((r) => (r.match as { kind: string }).kind)).toEqual([ + "schema", + "role", + "extension", + ]); + }); + + test("serialize rules from extends are also appended", () => { + const parent: Policy = { + id: "parent-serialize", + serialize: [{ match: { all: [] }, params: { concurrentIndexes: true } }], + }; + const child: Policy = { id: "child-serialize", extends: [parent] }; + const flat = flattenPolicy(child); + expect(flat.serialize).toHaveLength(1); + expect(flat.serialize[0]?.params).toEqual({ concurrentIndexes: true }); + }); + + test("baseline from own policy is preserved", () => { + const policy: Policy = { + id: "with-baseline", + baseline: "supabase-17.6", + }; + const flat = flattenPolicy(policy); + expect(flat.baseline).toBe("supabase-17.6"); + }); + + test("assumedRoles compose across extends and de-duplicate", () => { + const parent: Policy = { + id: "parent-assumed", + assumedRoles: ["anon", "authenticated"], + }; + const child: Policy = { + id: "child-assumed", + assumedRoles: ["anon", "service_role"], + extends: [parent], + }; + const flat = flattenPolicy(child); + expect([...flat.assumedRoles].sort()).toEqual([ + "anon", + "authenticated", + "service_role", + ]); + }); + + test("assumedRoles defaults to empty array when unset", () => { + const flat = flattenPolicy({ id: "no-assumed" }); + expect(flat.assumedRoles).toEqual([]); + }); + + test("assumedSchemas compose across extends and de-duplicate", () => { + const parent: Policy = { + id: "parent-assumed-schemas", + assumedSchemas: ["extensions", "auth"], + }; + const child: Policy = { + id: "child-assumed-schemas", + assumedSchemas: ["extensions", "storage"], + extends: [parent], + }; + const flat = flattenPolicy(child); + expect([...flat.assumedSchemas].sort()).toEqual([ + "auth", + "extensions", + "storage", + ]); + }); + + test("assumedSchemas defaults to empty array when unset", () => { + const flat = flattenPolicy({ id: "no-assumed-schemas" }); + expect(flat.assumedSchemas).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// describe: cycle detection +// --------------------------------------------------------------------------- + +describe("flattenPolicy / validatePolicy — cycle detection", () => { + test("direct self-cycle throws", () => { + const policy: Policy = { id: "cyclic" }; + // Wire up a cycle manually by using the object itself in its extends + (policy as { extends?: Policy[] }).extends = [policy]; + expect(() => flattenPolicy(policy)).toThrow(/cycle/i); + }); + + test("indirect cycle throws", () => { + const a: Policy = { id: "a" }; + const b: Policy = { id: "b", extends: [a] }; + (a as { extends?: Policy[] }).extends = [b]; + expect(() => flattenPolicy(a)).toThrow(/cycle/i); + }); + + test("diamond inheritance (no cycle) does not throw", () => { + const base: Policy = { + id: "base", + filter: [{ match: { kind: "extension" }, action: "exclude" }], + }; + const left: Policy = { id: "left", extends: [base] }; + const right: Policy = { id: "right", extends: [base] }; + const top: Policy = { id: "top", extends: [left, right] }; + // Should not throw + expect(() => flattenPolicy(top)).not.toThrow(); + // base rules appear twice (once through each branch) — acceptable + const flat = flattenPolicy(top); + expect(flat.filter.length).toBeGreaterThanOrEqual(2); + }); +}); + +// --------------------------------------------------------------------------- +// describe: validatePolicy — unknown param names +// --------------------------------------------------------------------------- + +describe("validatePolicy — serialize param validation", () => { + test("known param does not throw", () => { + const policy: Policy = { + id: "valid", + serialize: [{ match: { all: [] }, params: { concurrentIndexes: true } }], + }; + expect(() => validatePolicy(policy)).not.toThrow(); + }); + + test("unknown param throws with the param name in the message", () => { + const policy: Policy = { + id: "invalid", + serialize: [{ match: { all: [] }, params: { skipEverything: true } }], + }; + expect(() => validatePolicy(policy)).toThrow(/skipEverything/); + }); + + test("unknown param in extended policy also throws", () => { + const parent: Policy = { + id: "parent-invalid", + serialize: [{ match: { all: [] }, params: { unknownParam: 42 } }], + }; + const child: Policy = { + id: "child-inherit", + extends: [parent], + }; + expect(() => validatePolicy(child)).toThrow(/unknownParam/); + }); +}); + +// --------------------------------------------------------------------------- +// describe: factMatches — new predicates (stage-8 vocabulary extensions) +// --------------------------------------------------------------------------- + +describe("factMatches — owner predicate", () => { + test("matches when payload owner is the given string", () => { + const fact: Fact = { + id: { kind: "table", schema: "public", name: "t" }, + payload: { owner: "postgres" }, + }; + const fb = buildFactBase( + [{ id: { kind: "schema", name: "public" }, payload: {} }, fact], + [], + ); + expect(factMatches({ owner: "postgres" }, fact, fb)).toBe(true); + }); + + test("matches any glob in array", () => { + const fact: Fact = { + id: { kind: "table", schema: "public", name: "t" }, + payload: { owner: "supabase_admin" }, + }; + const fb = buildFactBase( + [{ id: { kind: "schema", name: "public" }, payload: {} }, fact], + [], + ); + expect(factMatches({ owner: ["anon", "supabase_admin"] }, fact, fb)).toBe( + true, + ); + }); + + test("does not match when owner differs", () => { + const fact: Fact = { + id: { kind: "table", schema: "public", name: "t" }, + payload: { owner: "app_user" }, + }; + const fb = buildFactBase( + [{ id: { kind: "schema", name: "public" }, payload: {} }, fact], + [], + ); + expect(factMatches({ owner: "postgres" }, fact, fb)).toBe(false); + }); + + test("returns false when payload has no owner field", () => { + const fact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const fb = buildFactBase([fact], []); + expect(factMatches({ owner: "postgres" }, fact, fb)).toBe(false); + }); + + test("glob pattern in owner predicate", () => { + const fact: Fact = { + id: { kind: "table", schema: "public", name: "t" }, + payload: { owner: "supabase_storage_admin" }, + }; + const fb = buildFactBase( + [{ id: { kind: "schema", name: "public" }, payload: {} }, fact], + [], + ); + expect(factMatches({ owner: "supabase_*" }, fact, fb)).toBe(true); + }); +}); + +describe("factMatches — idField predicate", () => { + test("matches membership.role when role is in list", () => { + const fact: Fact = { + id: { kind: "membership", role: "supabase_admin", member: "postgres" }, + payload: {}, + }; + const fb = buildFactBase([fact], []); + expect( + factMatches( + { idField: { field: "role", glob: ["anon", "supabase_admin"] } }, + fact, + fb, + ), + ).toBe(true); + }); + + test("matches membership.member with glob", () => { + const fact: Fact = { + id: { + kind: "membership", + role: "postgres", + member: "supabase_storage_admin", + }, + payload: {}, + }; + const fb = buildFactBase([fact], []); + expect( + factMatches( + { idField: { field: "member", glob: "supabase_*" } }, + fact, + fb, + ), + ).toBe(true); + }); + + test("does not match when field value differs from glob", () => { + const fact: Fact = { + id: { kind: "membership", role: "app_role", member: "app_user" }, + payload: {}, + }; + const fb = buildFactBase([fact], []); + expect( + factMatches({ idField: { field: "role", glob: "supabase_*" } }, fact, fb), + ).toBe(false); + }); + + test("scopes an extensionIntent fact by ext / intentKind / key", () => { + const fact: Fact = { + id: { + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "nightly_prune", + }, + payload: {}, + }; + const fb = buildFactBase([fact], []); + expect( + factMatches({ idField: { field: "ext", glob: "pg_cron" } }, fact, fb), + ).toBe(true); + expect( + factMatches({ idField: { field: "intentKind", glob: "job" } }, fact, fb), + ).toBe(true); + expect( + factMatches({ idField: { field: "key", glob: "nightly_*" } }, fact, fb), + ).toBe(true); + expect( + factMatches({ idField: { field: "key", glob: "hourly_*" } }, fact, fb), + ).toBe(false); + }); + + test("returns false when field does not exist on id", () => { + const fact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const fb = buildFactBase([fact], []); + expect( + factMatches({ idField: { field: "member", glob: "*" } }, fact, fb), + ).toBe(false); + }); + + test("matches trigger table name via idField", () => { + const fact: Fact = { + id: { + kind: "trigger", + schema: "pgmq", + table: "q_myqueue", + name: "my_trigger", + }, + payload: {}, + }; + const fb = buildFactBase( + [ + { id: { kind: "schema", name: "pgmq" }, payload: {} }, + { + id: { kind: "table", schema: "pgmq", name: "q_myqueue" }, + payload: {}, + }, + fact, + ], + [], + ); + expect( + factMatches({ idField: { field: "table", glob: "q_*" } }, fact, fb), + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// describe: factMatches — target predicate (unified replacement for +// targetKind / targetSchema / targetName) +// --------------------------------------------------------------------------- + +describe("factMatches — target predicate (kind sub-field)", () => { + test("matches acl fact targeting fdw via target.kind", () => { + const fdwId: StableId = { kind: "fdw", name: "postgres_fdw" }; + const aclId: StableId = { kind: "acl", target: fdwId, grantee: "postgres" }; + const fact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([{ id: fdwId, payload: {} }, fact], []); + expect(factMatches({ target: { kind: "fdw" } }, fact, fb)).toBe(true); + }); + + test("does not match when target kind differs", () => { + const tableId: StableId = { kind: "table", schema: "public", name: "t" }; + const aclId: StableId = { + kind: "acl", + target: tableId, + grantee: "postgres", + }; + const fact: Fact = { id: aclId, payload: {} }; + const schemaFact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "public" }, + payload: {}, + }; + const fb = buildFactBase([schemaFact, tableFact, fact], []); + expect(factMatches({ target: { kind: "fdw" } }, fact, fb)).toBe(false); + }); + + test("matches array of target kinds via target.kind", () => { + const fdwId: StableId = { kind: "fdw", name: "my_fdw" }; + const aclId: StableId = { kind: "acl", target: fdwId, grantee: "pg" }; + const fact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([{ id: fdwId, payload: {} }, fact], []); + expect(factMatches({ target: { kind: ["fdw", "server"] } }, fact, fb)).toBe( + true, + ); + }); + + test("returns false when id has no target field", () => { + const fact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const fb = buildFactBase([fact], []); + expect(factMatches({ target: { kind: "fdw" } }, fact, fb)).toBe(false); + }); +}); + +describe("factMatches — target predicate (schema sub-field)", () => { + test("matches acl whose target lives in a system schema via target.schema", () => { + const tableId: StableId = { kind: "table", schema: "auth", name: "users" }; + const aclId: StableId = { kind: "acl", target: tableId, grantee: "anon" }; + const schemaFact: Fact = { + id: { kind: "schema", name: "auth" }, + payload: {}, + }; + const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "auth" }, + payload: {}, + }; + const fact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([schemaFact, tableFact, fact], []); + expect(factMatches({ target: { schema: "auth" } }, fact, fb)).toBe(true); + }); + + test("does not match when target schema differs", () => { + const tableId: StableId = { kind: "table", schema: "public", name: "t" }; + const aclId: StableId = { kind: "acl", target: tableId, grantee: "anon" }; + const schemaFact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "public" }, + payload: {}, + }; + const fact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([schemaFact, tableFact, fact], []); + expect(factMatches({ target: { schema: "auth" } }, fact, fb)).toBe(false); + }); + + test("matches array of target schemas via target.schema", () => { + const tableId: StableId = { + kind: "table", + schema: "storage", + name: "objects", + }; + const aclId: StableId = { kind: "acl", target: tableId, grantee: "anon" }; + const schemaFact: Fact = { + id: { kind: "schema", name: "storage" }, + payload: {}, + }; + const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "storage" }, + payload: {}, + }; + const fact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([schemaFact, tableFact, fact], []); + expect( + factMatches({ target: { schema: ["auth", "storage"] } }, fact, fb), + ).toBe(true); + }); + + test("returns false for facts without target in id", () => { + const fact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const fb = buildFactBase([fact], []); + expect(factMatches({ target: { schema: "auth" } }, fact, fb)).toBe(false); + }); +}); + +describe("factMatches — target predicate (name sub-field)", () => { + test("matches acl whose target has the given name (schema-kind target)", () => { + const schemaId: StableId = { kind: "schema", name: "auth" }; + const aclId: StableId = { kind: "acl", target: schemaId, grantee: "anon" }; + const schemaFact: Fact = { id: schemaId, payload: {} }; + const aclFact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([schemaFact, aclFact], []); + expect(factMatches({ target: { name: "auth" } }, aclFact, fb)).toBe(true); + }); + + test("does not match when target name differs", () => { + const schemaId: StableId = { kind: "schema", name: "public" }; + const aclId: StableId = { kind: "acl", target: schemaId, grantee: "anon" }; + const schemaFact: Fact = { id: schemaId, payload: {} }; + const aclFact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([schemaFact, aclFact], []); + expect(factMatches({ target: { name: "auth" } }, aclFact, fb)).toBe(false); + }); + + test("matches array of target names", () => { + const schemaId: StableId = { kind: "schema", name: "storage" }; + const aclId: StableId = { kind: "acl", target: schemaId, grantee: "anon" }; + const schemaFact: Fact = { id: schemaId, payload: {} }; + const aclFact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([schemaFact, aclFact], []); + expect( + factMatches({ target: { name: ["auth", "storage"] } }, aclFact, fb), + ).toBe(true); + }); + + test("returns false for facts without target in id", () => { + const fact: Fact = { id: { kind: "schema", name: "public" }, payload: {} }; + const fb = buildFactBase([fact], []); + expect(factMatches({ target: { name: "auth" } }, fact, fb)).toBe(false); + }); +}); + +describe("factMatches — target predicate (combined sub-fields)", () => { + test("matches when all provided sub-fields match (kind + name)", () => { + const schemaId: StableId = { kind: "schema", name: "auth" }; + const aclId: StableId = { kind: "acl", target: schemaId, grantee: "anon" }; + const schemaFact: Fact = { id: schemaId, payload: {} }; + const aclFact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([schemaFact, aclFact], []); + expect( + factMatches({ target: { kind: "schema", name: "auth" } }, aclFact, fb), + ).toBe(true); + }); + + test("fails when only one of two sub-fields matches (kind + name mismatch)", () => { + const schemaId: StableId = { kind: "schema", name: "public" }; + const aclId: StableId = { kind: "acl", target: schemaId, grantee: "anon" }; + const schemaFact: Fact = { id: schemaId, payload: {} }; + const aclFact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([schemaFact, aclFact], []); + // kind matches ("schema") but name does not ("public" != "auth") + expect( + factMatches({ target: { kind: "schema", name: "auth" } }, aclFact, fb), + ).toBe(false); + }); + + test("empty target predicate {} matches any fact with a target field", () => { + const fdwId: StableId = { kind: "fdw", name: "my_fdw" }; + const aclId: StableId = { kind: "acl", target: fdwId, grantee: "pg" }; + const aclFact: Fact = { id: aclId, payload: {} }; + const fb = buildFactBase([{ id: fdwId, payload: {} }, aclFact], []); + // No sub-fields provided → vacuously true for any fact with a `target` field + expect(factMatches({ target: {} }, aclFact, fb)).toBe(true); + }); + + test("empty target predicate {} returns false for facts without target field", () => { + const fact: Fact = { id: { kind: "schema", name: "public" }, payload: {} }; + const fb = buildFactBase([fact], []); + expect(factMatches({ target: {} }, fact, fb)).toBe(false); + }); +}); + +describe("factMatches — edgeTo predicate", () => { + test("matches when outgoing edge goes to fact with given kind", () => { + const extId: StableId = { kind: "extension", name: "postgis" }; + const tableId: StableId = { kind: "table", schema: "public", name: "geo" }; + const schemaFact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const extFact: Fact = { id: extId, payload: {} }; + const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "public" }, + payload: {}, + }; + const edge: DependencyEdge = { + from: tableId, + to: extId, + kind: "memberOfExtension", + }; + const fb = buildFactBase([schemaFact, extFact, tableFact], [edge]); + expect(factMatches({ edgeTo: { kind: "extension" } }, tableFact, fb)).toBe( + true, + ); + }); + + test("does not match when no outgoing edge exists", () => { + const tableId: StableId = { kind: "table", schema: "public", name: "t" }; + const schemaFact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "public" }, + payload: {}, + }; + const fb = buildFactBase([schemaFact, tableFact], []); + expect(factMatches({ edgeTo: { kind: "extension" } }, tableFact, fb)).toBe( + false, + ); + }); + + test("does not match when edge goes to different kind", () => { + const roleId: StableId = { kind: "role", name: "owner" }; + const tableId: StableId = { kind: "table", schema: "public", name: "t" }; + const schemaFact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const roleFact: Fact = { id: roleId, payload: {} }; + const tableFact: Fact = { + id: tableId, + parent: { kind: "schema", name: "public" }, + payload: {}, + }; + const edge: DependencyEdge = { from: tableId, to: roleId, kind: "owner" }; + const fb = buildFactBase([schemaFact, roleFact, tableFact], [edge]); + expect(factMatches({ edgeTo: { kind: "extension" } }, tableFact, fb)).toBe( + false, + ); + }); + + test("matches by schema of the target fact", () => { + const procId: StableId = { + kind: "function", + schema: "public", + name: "my_func", + args: [], + }; + const trigId: StableId = { + kind: "trigger", + schema: "auth", + table: "users", + name: "my_trigger", + }; + const schemaAuthFact: Fact = { + id: { kind: "schema", name: "auth" }, + payload: {}, + }; + const schemaPubFact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const tableAuthFact: Fact = { + id: { kind: "table", schema: "auth", name: "users" }, + parent: { kind: "schema", name: "auth" }, + payload: {}, + }; + const procFact: Fact = { + id: procId, + parent: { kind: "schema", name: "public" }, + payload: {}, + }; + const trigFact: Fact = { + id: trigId, + parent: { kind: "table", schema: "auth", name: "users" }, + payload: {}, + }; + const edge: DependencyEdge = { from: trigId, to: procId, kind: "depends" }; + const fb = buildFactBase( + [schemaAuthFact, schemaPubFact, tableAuthFact, procFact, trigFact], + [edge], + ); + // trigger has an edge to a function in "public" (non-system schema) + expect( + factMatches( + { edgeTo: { kind: "function", schema: "public" } }, + trigFact, + fb, + ), + ).toBe(true); + // does NOT match when we look for edge to function in "auth" + expect( + factMatches( + { edgeTo: { kind: "function", schema: "auth" } }, + trigFact, + fb, + ), + ).toBe(false); + }); + + test("edgeTo with only schema constraint (no kind filter)", () => { + const procId: StableId = { + kind: "function", + schema: "public", + name: "fn", + args: [], + }; + const trigId: StableId = { + kind: "trigger", + schema: "auth", + table: "users", + name: "tr", + }; + const schemaAuthFact: Fact = { + id: { kind: "schema", name: "auth" }, + payload: {}, + }; + const schemaPubFact: Fact = { + id: { kind: "schema", name: "public" }, + payload: {}, + }; + const tableAuthFact: Fact = { + id: { kind: "table", schema: "auth", name: "users" }, + parent: { kind: "schema", name: "auth" }, + payload: {}, + }; + const procFact: Fact = { + id: procId, + parent: { kind: "schema", name: "public" }, + payload: {}, + }; + const trigFact: Fact = { + id: trigId, + parent: { kind: "table", schema: "auth", name: "users" }, + payload: {}, + }; + const edge: DependencyEdge = { from: trigId, to: procId, kind: "depends" }; + const fb = buildFactBase( + [schemaAuthFact, schemaPubFact, tableAuthFact, procFact, trigFact], + [edge], + ); + expect(factMatches({ edgeTo: { schema: "public" } }, trigFact, fb)).toBe( + true, + ); + expect(factMatches({ edgeTo: { schema: "auth" } }, trigFact, fb)).toBe( + false, + ); + }); +}); diff --git a/packages/pg-delta/src/policy/policy.ts b/packages/pg-delta/src/policy/policy.ts new file mode 100644 index 000000000..fefbba7c1 --- /dev/null +++ b/packages/pg-delta/src/policy/policy.ts @@ -0,0 +1,959 @@ +/** + * Policy DSL v2: filtering, serialization parameters, and policy composition + * (target-architecture §3.9, stage-08-policy). + * + * Predicates are PURE DATA — no function-valued fields — so policies are + * fully serializable to JSON and embeddable in plan artifacts (policyId + + * inline policy for reproducibility). + * + * Evaluation model: + * - Filter rules: first-match-wins; no match → include. + * - Serialize rules: first-match-wins; contributes params once matched. + * - extends composition: own rules first, parent rules appended after. + * - Cycle detection by policy id; detected cycles throw. + * + * ## Deliberate vocabulary extensions (stage-8 pitfall: extend deliberately and log) + * + * The following predicates were added to support the Supabase policy rules that + * could not be expressed with the initial vocabulary: + * + * ### `{ owner: string | string[] }` + * Matches when the fact's `owner` edge (object --owner--> role, move 2) points + * to a role whose name matches any of the given globs. Needed for: excluding + * objects whose owner is a Supabase system role (the old "star/owner" deny-list + * rule in supabase.ts). + * + * ### `{ idField: { field: string; glob: string | string[] } }` + * Generic identity-field matcher: reads `(fact.id as Record)[field]`, + * then checks it is a string matching any glob. Covers: + * - `member` on membership facts (exclude memberships where member is a system role) + * - `role` on membership facts (exclude memberships where role is a system role) + * - `table` on trigger/policy/etc. (filter triggers on pgmq queue tables) + * + * ### `{ target: { kind?: string|string[]; schema?: string|string[]; name?: string|string[] } }` + * For satellite facts (acl, comment, securityLabel) whose id has a `target: StableId` + * field: matches when ALL provided sub-fields match the target's corresponding fields. + * Each sub-field is optional, glob-matched, and accepts a single value or an array. + * - `kind` matches target.kind (exact, no glob — kinds are enum values) + * - `schema` matches target.schema via glob + * - `name` matches target.name via glob + * Replaces the three earlier satellite predicates targetKind/targetSchema/targetName. + * + * ### `{ edgeTo: { edgeKind?: EdgeKind; kind?: string; schema?: string | string[] } }` + * Matches when the fact has an outgoing edge of the given `edgeKind` + * (provenance: depends / owner / memberOfExtension / managedBy) and/or to a + * fact whose id.kind equals `kind` and/or whose id `schema` matches a glob. + * Needed for: detecting user-created triggers whose function lives in a + * non-managed schema (edgeTo {kind: "function", schema: not in + * SYSTEM_SCHEMAS}), and for provenance filtering of operationally-managed + * objects (edgeTo {edgeKind: "managedBy"}). + * + * `validatePolicy` rejects an `idField` naming an unknown identity field, so a + * typo fails loudly instead of silently never matching (review #7). + */ + +import type { Delta } from "../core/diff.ts"; +import type { DependencyEdge, EdgeKind, Fact, FactBase } from "../core/fact.ts"; +import { buildFactBase, retainOwnerRoleDangling } from "../core/fact.ts"; +import type { FactKind, StableId } from "../core/stable-id.ts"; +import { encodeId } from "../core/stable-id.ts"; +import { KNOWN_PARAMS, type PlanParams } from "../plan/rules.ts"; +import { subtractBaseline } from "./baseline.ts"; +import { + excludeByProvenance, + excludeFactsAndDescendants, + extensionMemberReferenceOnly, +} from "./view.ts"; +import { + capabilityExcludedRoots, + type ApplierCapability, +} from "./capability.ts"; + +// --------------------------------------------------------------------------- +// Predicate vocabulary +// --------------------------------------------------------------------------- + +/** Match by fact kind (one or many). */ +export type KindPredicate = { kind: string | string[] }; + +/** + * Match by identity field "schema" (glob supported, * = any sequence). + * Accepts a single glob string OR an array of globs (matches if any glob matches). + */ +export type SchemaPredicate = { schema: string | string[] }; + +/** + * Match by identity field "name" (glob supported). + * Accepts a single glob string OR an array of globs (matches if any glob matches). + */ +export type NamePredicate = { name: string | string[] }; + +/** Match by delta verb. */ +export type VerbPredicate = { + verb: + | "add" + | "remove" + | "set" + | "link" + | "unlink" + | Array<"add" | "remove" | "set" | "link" | "unlink">; +}; + +/** + * Match when the fact (or for satellite facts, some fact in its parent chain) + * has an outgoing "memberOfExtension" edge to an extension fact with the given + * name. Uses FactBase.outgoingEdges. + */ +export type OwnedByExtensionPredicate = { ownedByExtension: string }; + +/** Match when the fact's parent id has the given kind. */ +export type ParentKindPredicate = { parentKind: string }; + +/** All sub-predicates must match. */ +export type AllPredicate = { all: Predicate[] }; + +/** At least one sub-predicate must match. */ +export type AnyPredicate = { any: Predicate[] }; + +/** Negate the sub-predicate. */ +export type NotPredicate = { not: Predicate }; + +/** + * Match when the fact's `owner` edge points to a role whose name matches any of + * the given globs (move 2: owner is an edge, not a payload field). Added for the + * Supabase system-role owner exclusion rule. + */ +export type OwnerPredicate = { owner: string | string[] }; + +/** + * Generic identity-field matcher: reads `(fact.id as Record)[field]`, + * then checks it is a string matching any of the given globs. + * Added for membership.member, membership.role, trigger.table, etc. + * + * NOTE: This is the one intentional dynamic-field escape hatch in the DSL. + * There is no compile-time check on `field`, but it is NOT silent: a field name + * outside `KNOWN_ID_FIELDS` is rejected by validatePolicy (validateIdFields) + * with a "references unknown identity field" error, so a typo fails fast. + */ +export type IdFieldPredicate = { + idField: { field: string; glob: string | string[] }; +}; + +/** + * For satellite facts (acl, comment, securityLabel) whose id has a + * `target: StableId` field: matches when ALL provided sub-fields match the + * corresponding fields of the target StableId. + * + * All sub-fields are optional; only the provided ones are tested. + * `kind` is tested by exact equality (kinds are enum values, not user strings). + * `schema` and `name` are glob-matched; each accepts a single value or an array + * (matches if any element matches). + * + * Replaces the three separate targetKind / targetSchema / targetName predicates. + */ +export type TargetPredicate = { + target: { + kind?: string | string[]; + schema?: string | string[]; + name?: string | string[]; + }; +}; + +/** + * Matches when the fact has an outgoing dependency edge (fb.outgoingEdges) + * to a fact whose id.kind equals `kind` (if given) and/or whose id `schema` + * field matches any glob (if given). + * Added for user-trigger detection (edgeTo non-system procedure schema) and + * extension-provenance filtering. + */ +export type EdgeToPredicate = { + edgeTo: { + /** the edge's OWN kind (provenance): "depends" | "owner" | + * "memberOfExtension" | "managedBy". Without it, edges of any kind match. */ + edgeKind?: EdgeKind; + kind?: string; + schema?: string | string[]; + }; +}; + +export type Predicate = + | KindPredicate + | SchemaPredicate + | NamePredicate + | VerbPredicate + | OwnedByExtensionPredicate + | ParentKindPredicate + | AllPredicate + | AnyPredicate + | NotPredicate + | OwnerPredicate + | IdFieldPredicate + | TargetPredicate + | EdgeToPredicate; + +// --------------------------------------------------------------------------- +// Rules +// --------------------------------------------------------------------------- + +/** + * A filter rule: if the predicate matches, the delta is either excluded or + * included (first-match-wins; no rule matches → include). + */ +export interface FilterRule { + match: Predicate; + action: "exclude" | "include"; +} + +/** + * A serialize rule: if the predicate matches, the given params are contributed + * to the effective PlanParams (first-matching rule wins per param key). + */ +export interface SerializeRule { + match: Predicate; + params: Record; +} + +// --------------------------------------------------------------------------- +// Policy +// --------------------------------------------------------------------------- + +export interface Policy { + id: string; + filter?: FilterRule[]; + serialize?: SerializeRule[]; + baseline?: string; + extends?: Policy[]; + /** + * Role names assumed to exist at apply time but NOT managed by this policy — + * platform-preset roles (e.g. Supabase's `anon`, `authenticated`) whose role + * OBJECT is filtered out of the managed view, yet which remain valid grant / + * ownership targets. The planner treats these like `pg_*` / `PUBLIC`: a + * `consumes` edge to one does not strand the missing-requirement guard. This + * lets the engine emit `GRANT … TO anon` (which old pg-delta and dbdev rely + * on) without re-admitting the role into the diff. + */ + assumedRoles?: string[]; + /** + * Schema names assumed to exist at apply time but NOT managed by this policy — + * platform-managed schemas (e.g. Supabase's `extensions`) whose schema OBJECT + * is filtered out of the managed view, yet which remain valid dependency + * targets. The planner treats these like assumed roles / `pg_*` / `PUBLIC`: a + * `consumes` edge to one does not strand the missing-requirement guard. This + * lets the engine emit `CREATE EXTENSION … SCHEMA extensions` (which old + * pg-delta and dbdev rely on) without re-admitting the schema into the diff. + */ + assumedSchemas?: string[]; + /** + * The role whose object ownership stays IMPLICIT in a database-scope export: + * `schema export` suppresses `ALTER … OWNER TO ` (that role is the + * expected applier), while every object owned by another role serializes its + * owner. Consumed as the profile-declared tier of the `--default-owner` chain + * (flag > profile default > database `datdba`). Undefined → fall through to the + * database owner. Supabase sets this to `postgres`. + */ + defaultOwner?: string; +} + +// --------------------------------------------------------------------------- +// Glob helpers (no regex library; implement ourselves) +// --------------------------------------------------------------------------- + +/** Escape all regex meta-characters except `*`, then replace `*` with `.*`. */ +function globToRegex(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + const regexSource = escaped.replace(/\*/g, ".*"); + return new RegExp(`^${regexSource}$`); +} + +function globMatch(pattern: string, value: string): boolean { + return globToRegex(pattern).test(value); +} + +// --------------------------------------------------------------------------- +// Identity field extraction helpers +// --------------------------------------------------------------------------- + +function getSchema(id: StableId): string | undefined { + if ("schema" in id && typeof id.schema === "string") return id.schema; + return undefined; +} + +function getName(id: StableId): string | undefined { + if ("name" in id && typeof id.name === "string") return id.name; + return undefined; +} + +// --------------------------------------------------------------------------- +// Parent chain traversal (for ownedByExtension, parentKind) +// --------------------------------------------------------------------------- + +/** + * Walk the parent chain of a fact id upward in `fb`. + * Returns all facts in the chain including the fact itself. + */ +function parentChain(id: StableId, fb: FactBase): Fact[] { + const chain: Fact[] = []; + let current: StableId | undefined = id; + while (current !== undefined) { + const fact = fb.get(current); + if (!fact) break; + chain.push(fact); + current = fact.parent; + } + return chain; +} + +/** + * Check if any fact in the parent chain (inclusive) has an outgoing + * "memberOfExtension" edge to an extension with the given name. + */ +function isOwnedByExtension( + id: StableId, + extensionName: string, + fb: FactBase, +): boolean { + const chain = parentChain(id, fb); + for (const fact of chain) { + const outgoing = fb.outgoingEdges(fact.id); + for (const edge of outgoing) { + if ( + edge.kind === "memberOfExtension" && + edge.to.kind === "extension" && + (edge.to as { kind: "extension"; name: string }).name === extensionName + ) { + return true; + } + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Core predicate evaluation +// --------------------------------------------------------------------------- + +/** + * Evaluate a predicate against a Fact in a FactBase. + * + * For satellite (child) facts, `ownedByExtension` walks the parent chain so + * that objects belonging to extension-owned parents are also covered. + */ +export function factMatches( + predicate: Predicate, + fact: Fact, + view: FactBase, + opts?: { verbAssumed?: boolean }, +): boolean { + // Combinators + if ("all" in predicate) { + return predicate.all.every((p) => factMatches(p, fact, view, opts)); + } + if ("any" in predicate) { + return predicate.any.some((p) => factMatches(p, fact, view, opts)); + } + if ("not" in predicate) { + return !factMatches(predicate.not, fact, view, opts); + } + + // kind + if ("kind" in predicate) { + const kinds = Array.isArray(predicate.kind) + ? predicate.kind + : [predicate.kind]; + return kinds.some((k) => fact.id.kind === k); + } + + // schema + if ("schema" in predicate) { + const schema = getSchema(fact.id); + if (schema === undefined) return false; + const patterns = Array.isArray(predicate.schema) + ? predicate.schema + : [predicate.schema]; + return patterns.some((p) => globMatch(p, schema)); + } + + // name + if ("name" in predicate) { + const name = getName(fact.id); + if (name === undefined) return false; + const patterns = Array.isArray(predicate.name) + ? predicate.name + : [predicate.name]; + return patterns.some((p) => globMatch(p, name)); + } + + // verb — not a property of a bare fact. By default no-match; under + // `verbAssumed` (the resolveView protection check) treat as satisfiable. + if ("verb" in predicate) { + return opts?.verbAssumed === true; + } + + // ownedByExtension + if ("ownedByExtension" in predicate) { + return isOwnedByExtension(fact.id, predicate.ownedByExtension, view); + } + + // parentKind + if ("parentKind" in predicate) { + if (fact.parent === undefined) return false; + return fact.parent.kind === predicate.parentKind; + } + + // owner — resolves via the `owner` edge (object --owner--> role) emitted by + // the extractor (move 2). Falls back to payload["owner"] for callers that + // still pass synthetic fact bases without edges (backward compat / tests). + if ("owner" in predicate) { + const ownerEdge = view + .outgoingEdges(fact.id) + .find((e) => e.kind === "owner"); + const ownerVal = + ownerEdge?.to.kind === "role" + ? (ownerEdge.to as { kind: "role"; name: string }).name + : (fact.payload["owner"] as string | undefined); + if (typeof ownerVal !== "string") return false; + const patterns = Array.isArray(predicate.owner) + ? predicate.owner + : [predicate.owner]; + return patterns.some((p) => globMatch(p, ownerVal)); + } + + // idField — reads (fact.id as Record)[field], matches any glob + // NOTE: Intentional dynamic-field escape hatch. Typo'd field names silently + // never match — there is no compile-time check for field name correctness. + if ("idField" in predicate) { + const rawId = fact.id as Record; + const fieldVal = rawId[predicate.idField.field]; + if (typeof fieldVal !== "string") return false; + const globs = Array.isArray(predicate.idField.glob) + ? predicate.idField.glob + : [predicate.idField.glob]; + return globs.some((g) => globMatch(g, fieldVal)); + } + + // target — unified satellite-target predicate (replaces targetKind/targetSchema/targetName) + // For satellite facts whose id has a `target: StableId` field. + // All provided sub-fields must match (AND semantics); absent sub-fields are ignored. + if ("target" in predicate) { + const rawId = fact.id as Record; + const targetRaw = rawId["target"]; + if (targetRaw === null || typeof targetRaw !== "object") return false; + const targetObj = targetRaw as Record; + + const { + kind: kindFilter, + schema: schemaFilter, + name: nameFilter, + } = predicate.target; + + // kind sub-field: exact match (kinds are enum values) + if (kindFilter !== undefined) { + const targetKindVal = targetObj["kind"]; + if (typeof targetKindVal !== "string") return false; + const kinds = Array.isArray(kindFilter) ? kindFilter : [kindFilter]; + if (!kinds.some((k) => k === targetKindVal)) return false; + } + + // schema sub-field: glob match + if (schemaFilter !== undefined) { + const targetSchemaVal = targetObj["schema"]; + if (typeof targetSchemaVal !== "string") return false; + const patterns = Array.isArray(schemaFilter) + ? schemaFilter + : [schemaFilter]; + if (!patterns.some((p) => globMatch(p, targetSchemaVal))) return false; + } + + // name sub-field: glob match + if (nameFilter !== undefined) { + const targetNameVal = targetObj["name"]; + if (typeof targetNameVal !== "string") return false; + const patterns = Array.isArray(nameFilter) ? nameFilter : [nameFilter]; + if (!patterns.some((p) => globMatch(p, targetNameVal))) return false; + } + + return true; + } + + // edgeTo — matches when outgoing edges contain one of the given edge kind + // (provenance) and/or to a target of the given kind/schema + if ("edgeTo" in predicate) { + const outgoing = view.outgoingEdges(fact.id); + for (const edge of outgoing) { + if ( + predicate.edgeTo.edgeKind !== undefined && + edge.kind !== predicate.edgeTo.edgeKind + ) { + continue; + } + const toId = edge.to as Record; + if ( + predicate.edgeTo.kind !== undefined && + toId["kind"] !== predicate.edgeTo.kind + ) { + continue; + } + if (predicate.edgeTo.schema !== undefined) { + const toSchema = toId["schema"]; + if (typeof toSchema !== "string") continue; + const schemaPatterns = Array.isArray(predicate.edgeTo.schema) + ? predicate.edgeTo.schema + : [predicate.edgeTo.schema]; + if (!schemaPatterns.some((p) => globMatch(p, toSchema))) continue; + } + return true; + } + return false; + } + + // exhaustive — TypeScript narrows to never here in strict mode + const _exhaustive: never = predicate; + return _exhaustive; +} + +/** + * Resolve the "subject fact" for a delta: + * add → delta.fact in desired + * remove → delta.fact in source + * set → desired.get(delta.id) + * link / unlink → the edge's `from` fact, resolved from the appropriate base + * + * Returns undefined when the fact cannot be resolved (dangling). + */ +function subjectFact( + delta: Delta, + source: FactBase, + desired: FactBase, +): { fact: Fact; view: FactBase } | undefined { + switch (delta.verb) { + case "add": { + const f = desired.get(delta.fact.id) ?? delta.fact; + return { fact: f, view: desired }; + } + case "remove": { + const f = source.get(delta.fact.id) ?? delta.fact; + return { fact: f, view: source }; + } + case "set": { + const f = desired.get(delta.id); + if (!f) return undefined; + return { fact: f, view: desired }; + } + case "link": { + const f = desired.get(delta.edge.from) ?? source.get(delta.edge.from); + if (!f) return undefined; + return { fact: f, view: desired }; + } + case "unlink": { + const f = source.get(delta.edge.from) ?? desired.get(delta.edge.from); + if (!f) return undefined; + return { fact: f, view: source }; + } + } +} + +/** + * Evaluate a predicate against a Delta, using both source and desired bases. + * + * The "verb" predicate tests delta.verb; all other predicates test the + * subject fact (resolved per the convention above). + */ +export function deltaMatches( + predicate: Predicate, + delta: Delta, + source: FactBase, + desired: FactBase, +): boolean { + // Combinators — recurse before resolving subject + if ("all" in predicate) { + return predicate.all.every((p) => deltaMatches(p, delta, source, desired)); + } + if ("any" in predicate) { + return predicate.any.some((p) => deltaMatches(p, delta, source, desired)); + } + if ("not" in predicate) { + return !deltaMatches(predicate.not, delta, source, desired); + } + + // verb predicate matches directly on delta.verb + if ("verb" in predicate) { + const verbs = Array.isArray(predicate.verb) + ? predicate.verb + : [predicate.verb]; + return verbs.some((v) => v === delta.verb); + } + + // All other predicates delegate to factMatches on the subject fact + const subject = subjectFact(delta, source, desired); + if (subject === undefined) return false; + return factMatches(predicate, subject.fact, subject.view); +} + +// --------------------------------------------------------------------------- +// Policy flattening + validation +// --------------------------------------------------------------------------- + +/** + * Flatten a policy with cycle detection. + * Own rules appear before parent rules (own-before-extends order). + */ +export function flattenPolicy(policy: Policy): { + id: string; + filter: FilterRule[]; + serialize: SerializeRule[]; + assumedRoles: string[]; + assumedSchemas: string[]; + baseline?: string; + defaultOwner?: string; +} { + const visited = new Set(); + return flattenInner(policy, visited); +} + +function flattenInner( + policy: Policy, + visited: Set, +): { + id: string; + filter: FilterRule[]; + serialize: SerializeRule[]; + assumedRoles: string[]; + assumedSchemas: string[]; + baseline?: string; + defaultOwner?: string; +} { + if (visited.has(policy.id)) { + throw new Error( + `Policy cycle detected: policy "${policy.id}" extends itself (cycle)`, + ); + } + visited.add(policy.id); + + const ownFilter: FilterRule[] = policy.filter ?? []; + const ownSerialize: SerializeRule[] = policy.serialize ?? []; + const ownAssumedRoles: string[] = policy.assumedRoles ?? []; + const ownAssumedSchemas: string[] = policy.assumedSchemas ?? []; + const parentFilter: FilterRule[] = []; + const parentSerialize: SerializeRule[] = []; + const parentAssumedRoles: string[] = []; + const parentAssumedSchemas: string[] = []; + // defaultOwner is scalar: own value wins, else the first parent that declares + // one (own-before-extends, matching the rule-ordering convention). + let parentDefaultOwner: string | undefined; + + if (policy.extends) { + for (const parent of policy.extends) { + // Each parent needs its own visited-set branch to allow diamond + // inheritance between siblings while still catching cycles through + // recursive extends chains. + const branch = new Set(visited); + const flat = flattenInner(parent, branch); + parentFilter.push(...flat.filter); + parentSerialize.push(...flat.serialize); + parentAssumedRoles.push(...flat.assumedRoles); + parentAssumedSchemas.push(...flat.assumedSchemas); + if (parentDefaultOwner === undefined && flat.defaultOwner !== undefined) { + parentDefaultOwner = flat.defaultOwner; + } + } + } + + visited.delete(policy.id); + + const result: { + id: string; + filter: FilterRule[]; + serialize: SerializeRule[]; + assumedRoles: string[]; + assumedSchemas: string[]; + baseline?: string; + defaultOwner?: string; + } = { + id: policy.id, + filter: [...ownFilter, ...parentFilter], + serialize: [...ownSerialize, ...parentSerialize], + // own ∪ parent, de-duplicated (membership test only — order irrelevant). + assumedRoles: [...new Set([...ownAssumedRoles, ...parentAssumedRoles])], + assumedSchemas: [ + ...new Set([...ownAssumedSchemas, ...parentAssumedSchemas]), + ], + }; + if (policy.baseline !== undefined) { + result.baseline = policy.baseline; + } + const defaultOwner = policy.defaultOwner ?? parentDefaultOwner; + if (defaultOwner !== undefined) { + result.defaultOwner = defaultOwner; + } + return result; +} + +/** + * Validate a policy: throws on unknown serialize param names and extends + * cycles. + */ +/** + * Every field name that appears in some StableId variant (src/core/stable-id.ts). + * `idField` reads `(fact.id as Record)[field]`, so a `field` not in this set is + * a typo that would silently never match — validatePolicy rejects it (review + * #7). Keep in sync with the StableId union. + */ +const KNOWN_ID_FIELDS = new Set([ + "name", + "schema", + "table", + "args", + "role", + "member", + "server", + "type", + "publication", + "grantee", + "objtype", + "provider", + "target", + // extensionIntent id fields (docs/architecture/extension-intent.md §3): let a + // custom profile policy scope extension-intent facts (e.g. a pg_cron job) by + // its stable-id parts. + "ext", + "intentKind", + "key", +]); + +/** Recursively reject any `idField` naming an unknown identity field. */ +function validateIdFields(predicate: Predicate, policyId: string): void { + if ("idField" in predicate) { + if (!KNOWN_ID_FIELDS.has(predicate.idField.field)) { + throw new Error( + `Policy "${policyId}": idField references unknown identity field ` + + `"${predicate.idField.field}". Known fields: ` + + `${[...KNOWN_ID_FIELDS].sort().join(", ")}`, + ); + } + } else if ("all" in predicate) { + for (const p of predicate.all) validateIdFields(p, policyId); + } else if ("any" in predicate) { + for (const p of predicate.any) validateIdFields(p, policyId); + } else if ("not" in predicate) { + validateIdFields(predicate.not, policyId); + } +} + +export function validatePolicy(policy: Policy): void { + // Cycle detection via flatten (throws on cycles) + const flat = flattenPolicy(policy); + + // Validate serialize param names + for (const rule of flat.serialize) { + for (const paramName of Object.keys(rule.params)) { + if (!KNOWN_PARAMS.has(paramName)) { + throw new Error( + `Policy "${policy.id}": unknown serialize parameter "${paramName}". ` + + `Known parameters: ${[...KNOWN_PARAMS].join(", ")}`, + ); + } + } + } + + // Validate identity-field names in every filter + serialize predicate + for (const rule of flat.filter) validateIdFields(rule.match, policy.id); + for (const rule of flat.serialize) validateIdFields(rule.match, policy.id); +} + +// --------------------------------------------------------------------------- +// Fact-level scope projection (the managed view) +// --------------------------------------------------------------------------- + +/** True if the predicate references `verb` anywhere (an operation rule). */ +function containsVerb(predicate: Predicate): boolean { + if ("verb" in predicate) return true; + if ("all" in predicate) return predicate.all.some(containsVerb); + if ("any" in predicate) return predicate.any.some(containsVerb); + if ("not" in predicate) return containsVerb(predicate.not); + return false; +} + +/** + * Decide whether a fact is excluded from the view by the policy's SCOPE rules, + * respecting first-match-wins and over-projection safety + * (docs/architecture/managed-view-architecture.md move 3). + * + * Only pure-scope (no `verb`) rules can remove a fact wholesale. An operation + * (`verb`) `include` earlier in the list protects a fact whose non-verb part it + * matches — its deltas may be included, so we must keep the fact and let the + * delta-level filter handle the rest. A `verb` `exclude` never removes a fact + * wholesale (it bites a single verb), so it is skipped here. Erring toward + * KEEP (under-projection) is safe: the existing delta-level filter still runs; + * erring toward remove would silently drop managed objects. + */ +function factScopeExcluded( + fact: Fact, + rules: readonly FilterRule[], + view: FactBase, +): boolean { + for (const rule of rules) { + if (containsVerb(rule.match)) { + // operation rule: only an include that could match (with the verb free) + // protects this fact; otherwise it cannot remove the fact wholesale. + if ( + rule.action === "include" && + (factMatches(rule.match, fact, view, { verbAssumed: true }) || + factMatches(rule.match, fact, view)) + ) { + return false; + } + continue; + } + if (factMatches(rule.match, fact, view)) { + return rule.action === "exclude"; + } + } + return false; +} + +/** + * Resolve the managed VIEW that the engine diffs: extension members are kept + * REFERENCE-ONLY (present so their satellite customizations diff, but the member + * object is never a create/drop/alter action — CREATE EXTENSION manages it); + * operationally-managed (`managedBy`) objects are hard-projected out; then the + * policy's scope (non-`verb`) rules remove the facts they exclude — at the FACT + * level, on both sides and the proof re-extract, so `plan == prove == run` holds + * by construction. `verb` rules are left to the delta-level filter (filterDeltas). + * With no policy and no provenance edges this is the identity projection, so the + * corpus path is unchanged. This is the SINGLE projection point for the managed view. + */ +export function resolveView( + fb: FactBase, + policy: Policy | undefined, + capability?: ApplierCapability, + baseline?: FactBase, +): FactBase { + // Extension members become REFERENCE-ONLY, not pruned: the member OBJECT (and + // its non-satellite descendants) is kept but never diffed, while its satellite + // customizations (acl/comment/securityLabel) stay diffable. Computed on the RAW + // input, BEFORE baseline subtraction — subtractBaseline prunes a member's + // `memberOfExtension` edge when it removes the (baseline-identical) extension + // endpoint, which would otherwise leave a surviving customized member looking + // like a plain diffable object. Intersected with survivors below. + const memberRefOnly = extensionMemberReferenceOnly(fb); + // baseline subtraction (§3.9): facts present-and-identical in the platform + // baseline drop out before anything else, so platform-managed objects are + // invisible without a filter rule per object. Same fact-level projection as + // extension-member / managed-object exclusion → the proof stays honest. + let base = baseline ? subtractBaseline(fb, baseline) : fb; + // managed-object projection (P0): objects a stateful extension created + // operationally (pg_partman children, pgmq queue tables) carry a `managedBy` + // edge from a handler's snapshot-bound capture. They are HARD-projected out so + // the default plan/prove/apply path never diffs them as drift (CLI-1555). + // Unconditional: with bare extraction there are no `managedBy` edges, so this + // is a no-op (corpus path unchanged). + base = excludeByProvenance(base, "managedBy"); + // capability restriction (move 6): project out facts whose action the applier + // cannot execute. Additive; default unrestricted. FDW ACLs are superuser-only + // GRANTs and a leaf fact, so they project out cleanly. (The owner residue is + // NOT projected — it can't be skipped without an ACL ripple — it fail-fasts + // in plan() instead; see capability.canSetOwner.) + if (capability !== undefined) { + const capRoots = capabilityExcludedRoots(base, capability); + if (capRoots.size > 0) base = excludeFactsAndDescendants(base, capRoots); + } + + // policy scope (non-`verb`) rules: hard-prune the facts they exclude, EXCEPT + // an excluded fact whose schema is an assumedSchema (e.g. Supabase's `auth`), + // which is kept REFERENCE-ONLY so a managed dependent (a user trigger on + // `auth.users`) resolves its parent. `verb` rules are left to the delta filter. + const flat = policy ? flattenPolicy(policy) : undefined; + const rules = flat?.filter ?? []; + const assumed = new Set(flat?.assumedSchemas ?? []); + const assumedSchemaOf = (id: StableId): string | undefined => + id.kind === "schema" ? getName(id) : getSchema(id); + const hardRoots = new Set(); + const policyRefOnly = new Set(); + for (const fact of base.facts()) { + if (!factScopeExcluded(fact, rules, base)) continue; + const schema = assumedSchemaOf(fact.id); + if (schema !== undefined && assumed.has(schema)) { + policyRefOnly.add(encodeId(fact.id)); + } else { + hardRoots.add(encodeId(fact.id)); + } + } + const pruned = excludeFactsAndDescendants(base, hardRoots); + + // Merge the reference-only sets (extension members + assumed-schema), keeping + // only facts that actually survived pruning. This is the SINGLE projection + // point for the managed view; `referenceOnly` is per-side deterministic (no + // cross-side dependency → fingerprint/proof stay consistent). + if (memberRefOnly.size === 0 && policyRefOnly.size === 0) return pruned; + const surviving = new Set(pruned.facts().map((f) => encodeId(f.id))); + const referenceOnly = new Set(); + for (const key of memberRefOnly) + if (surviving.has(key)) referenceOnly.add(key); + for (const key of policyRefOnly) + if (surviving.has(key)) referenceOnly.add(key); + if (referenceOnly.size === 0) return pruned; + // Rebuild to attach the reference-only marks. This runs whenever the view has + // extension members / assumed-schema facts — including when `resolveView` is + // re-invoked (via `plan()`) on an ALREADY scope-projected view (the export + // path), which carries retained dangling owner→role edges. Propagate the + // ownership carve-out so the rebuild does not silently re-prune them (a + // public-only, extension-free view returns early above and never reaches here, + // which is why the regression hid until an extension forced this rebuild). + return buildFactBase( + pruned.facts(), + [...pruned.edges], + pruned.source, + referenceOnly, + { allowDangling: retainOwnerRoleDangling }, + ); +} + +// --------------------------------------------------------------------------- +// Filter application +// --------------------------------------------------------------------------- + +/** + * Apply policy filter rules (first-match-wins) to a list of deltas. + * + * No rule matches → include. Filtered (excluded) deltas are returned in + * `filtered` — never silently dropped. "Drift the user chose not to manage + * is still drift they can ask about." (§3.9) + */ +export function filterDeltas( + deltas: Delta[], + policy: Policy, + source: FactBase, + desired: FactBase, +): { kept: Delta[]; filtered: Delta[] } { + const flat = flattenPolicy(policy); + const kept: Delta[] = []; + const filtered: Delta[] = []; + + for (const delta of deltas) { + let matched = false; + let action: "exclude" | "include" = "include"; + for (const rule of flat.filter) { + if (deltaMatches(rule.match, delta, source, desired)) { + matched = true; + action = rule.action; + break; + } + } + if (!matched || action === "include") { + kept.push(delta); + } else { + filtered.push(delta); + } + } + + return { kept, filtered }; +} + +// --------------------------------------------------------------------------- +// Re-exports for convenience +// --------------------------------------------------------------------------- + +export type { DependencyEdge, Fact, FactBase }; +export type { Delta }; +export type { FactKind, StableId }; +export type { PlanParams }; diff --git a/packages/pg-delta/src/policy/reconstruct.test.ts b/packages/pg-delta/src/policy/reconstruct.test.ts new file mode 100644 index 000000000..922f19e7e --- /dev/null +++ b/packages/pg-delta/src/policy/reconstruct.test.ts @@ -0,0 +1,153 @@ +/** + * Guard + pin (V1): the full managed-view composition + * (`resolveView` → `projectManagementScope`) must live in exactly one module. + * Call sites that need both steps go through `reconstructManagedView`; bare + * `resolveView` alone remains allowed (diff / seed paths). + * + * Import/call-based per module — not a nested-call grep. schema-export used to + * compose via an intermediate variable, which a + * `projectManagementScope(resolveView(` search would miss. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "bun:test"; +import { + buildFactBase, + type DependencyEdge, + type Fact, + type FactBase, +} from "../core/fact.ts"; +import { encodeId } from "../core/stable-id.ts"; +import { resolveView, type Policy } from "./policy.ts"; +import { reconstructManagedView } from "./reconstruct.ts"; +import { projectManagementScope } from "./view.ts"; + +const SRC_ROOT = fileURLToPath(new URL("..", import.meta.url)); +const HELPER_REL = "policy/reconstruct.ts"; + +function listProductionTs(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + out.push(...listProductionTs(path)); + continue; + } + if (!name.endsWith(".ts") || name.endsWith(".test.ts")) continue; + out.push(path); + } + return out; +} + +function stripComments(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); +} + +function mentionsBoth(code: string): boolean { + return ( + /\bresolveView\b/.test(code) && /\bprojectManagementScope\b/.test(code) + ); +} + +/** Full shape pin — rootHash alone omits diagnostics / referenceOnly / source. */ +function expectSameFactBase(a: FactBase, b: FactBase): void { + expect(a.source).toBe(b.source); + expect(a.rootHash).toBe(b.rootHash); + expect(a.diagnostics).toEqual(b.diagnostics); + expect([...a.referenceOnly].sort()).toEqual([...b.referenceOnly].sort()); + const factKey = (f: Fact) => encodeId(f.id); + const factsA = a + .facts() + .slice() + .sort((x, y) => (factKey(x) < factKey(y) ? -1 : 1)); + const factsB = b + .facts() + .slice() + .sort((x, y) => (factKey(x) < factKey(y) ? -1 : 1)); + expect(factsA).toEqual(factsB); + const edgeKey = (e: DependencyEdge) => + `${encodeId(e.from)}-${e.kind}->${encodeId(e.to)}`; + expect( + a.edges.slice().sort((x, y) => (edgeKey(x) < edgeKey(y) ? -1 : 1)), + ).toEqual(b.edges.slice().sort((x, y) => (edgeKey(x) < edgeKey(y) ? -1 : 1))); +} + +describe("reconstructManagedView is the sole full composition site", () => { + test("no production module outside the helper imports/calls both steps", () => { + const offenders: string[] = []; + for (const path of listProductionTs(SRC_ROOT)) { + const rel = relative(SRC_ROOT, path).replaceAll("\\", "/"); + if (rel === HELPER_REL) continue; + const code = stripComments(readFileSync(path, "utf8")); + if (mentionsBoth(code)) offenders.push(rel); + } + expect(offenders).toEqual([]); + }); +}); + +describe("reconstructManagedView — composition pin", () => { + const tableId = { kind: "table" as const, schema: "app", name: "t" }; + const facts: Fact[] = [ + { id: { kind: "schema", name: "app" }, payload: {} }, + { id: tableId, parent: { kind: "schema", name: "app" }, payload: {} }, + { id: { kind: "role", name: "supabase_admin" }, payload: {} }, + { id: { kind: "role", name: "app_owner" }, payload: {} }, + ]; + const edges: DependencyEdge[] = [ + { + from: tableId, + to: { kind: "role", name: "supabase_admin" }, + kind: "owner", + }, + ]; + // owner-exclusion (Supabase Rule 6 shape). `defaultOwner: "supabase_admin"` + // is load-bearing for the ORDER pin: database scope prunes that owner edge, + // so resolveView-after-scope can no longer match `{ owner }` and the table + // wrongly survives. With `defaultOwner: "app_owner"` the dangling edge is + // retained and reverse order still excludes — a false green. + const policy: Policy = { + id: "owner-exclude", + filter: [{ match: { owner: "supabase_admin" }, action: "exclude" }], + }; + const databaseOpts = { + policy, + scope: "database" as const, + defaultOwner: "supabase_admin", + }; + + test("matches resolveView then projectManagementScope (full FactBase shape)", () => { + const fb = buildFactBase(facts, edges); + const viaHelper = reconstructManagedView(fb, databaseOpts); + const viaOpen = projectManagementScope( + resolveView(fb, databaseOpts.policy), + databaseOpts.scope, + { defaultOwner: databaseOpts.defaultOwner }, + ); + expectSameFactBase(viaHelper, viaOpen); + expect(viaHelper.get(tableId)).toBeUndefined(); + }); + + test("reversed scope→resolveView leaves the owner-excluded table (order pin)", () => { + const fb = buildFactBase(facts, edges); + const viaHelper = reconstructManagedView(fb, databaseOpts); + const viaReversed = resolveView( + projectManagementScope(fb, databaseOpts.scope, { + defaultOwner: databaseOpts.defaultOwner, + }), + databaseOpts.policy, + ); + // Correct order excludes the system-owned table; reverse order cannot. + expect(viaHelper.get(tableId)).toBeUndefined(); + expect(viaReversed.get(tableId)).toBeDefined(); + expect(viaHelper.rootHash).not.toBe(viaReversed.rootHash); + }); + + test("default scope is cluster (identity after resolveView)", () => { + const fb = buildFactBase(facts, edges); + const viaHelper = reconstructManagedView(fb, { policy }); + const viaResolve = resolveView(fb, policy); + expectSameFactBase(viaHelper, viaResolve); + expect(viaHelper.get({ kind: "role", name: "app_owner" })).toBeDefined(); + }); +}); diff --git a/packages/pg-delta/src/policy/reconstruct.ts b/packages/pg-delta/src/policy/reconstruct.ts new file mode 100644 index 000000000..6c7a6fd63 --- /dev/null +++ b/packages/pg-delta/src/policy/reconstruct.ts @@ -0,0 +1,48 @@ +/** + * Single reconstruction entry point for the managed view under management scope + * (docs/architecture/managed-view-architecture.md; agent track V1). + * + * Order is fixed: `resolveView` THEN `projectManagementScope`. A policy + * owner-exclusion rule reads the `owner` edge, and database-scope role pruning + * removes those edges with the role facts — projecting scope first would strip + * the edge the policy needs and wrongly plan a DROP of a platform object owned + * by a system role. Plan, prove, apply, and schema export all call this helper + * so `plan == prove == run` cannot drift by open-coding the composition. + * + * Internal only — not re-exported from the package index or the `./policy` + * public subpath. `ResolvedProfile` remains the public safe-composition surface. + * Bare `resolveView` (without scope) stays legitimate for diff/seed paths. + */ +import type { FactBase } from "../core/fact.ts"; +import type { ApplierCapability } from "./capability.ts"; +import { resolveView, type Policy } from "./policy.ts"; +import { projectManagementScope, type ManagementScope } from "./view.ts"; + +interface ReconstructManagedViewOptions { + // `| undefined` so call sites can forward optional plan/profile fields under + // exactOptionalPropertyTypes without conditional spreads at every site. + policy?: Policy | undefined; + capability?: ApplierCapability | undefined; + baseline?: FactBase | undefined; + /** Default `"cluster"` (identity scope projection). */ + scope?: ManagementScope | undefined; + /** Under database scope: owner edges to this role stay implicit (no OWNER TO). */ + defaultOwner?: string | undefined; +} + +/** + * Rebuild the managed-view-under-scope: policy/capability/baseline projection, + * then management-scope role pruning. + */ +export function reconstructManagedView( + fb: FactBase, + opts: ReconstructManagedViewOptions = {}, +): FactBase { + const scope = opts.scope ?? "cluster"; + const view = resolveView(fb, opts.policy, opts.capability, opts.baseline); + return projectManagementScope( + view, + scope, + opts.defaultOwner !== undefined ? { defaultOwner: opts.defaultOwner } : {}, + ); +} diff --git a/packages/pg-delta/src/policy/reference-only-view.test.ts b/packages/pg-delta/src/policy/reference-only-view.test.ts new file mode 100644 index 000000000..66d7d19ad --- /dev/null +++ b/packages/pg-delta/src/policy/reference-only-view.test.ts @@ -0,0 +1,99 @@ +/** + * Reference-only assumed-schema objects (A1' / trigger-gap fix, scoped). + * + * A platform schema like `auth` is declared in `assumedSchemas` (present at + * apply time, not managed). Its objects must be kept in the managed view as + * REFERENCE-ONLY — present so a managed dependent (a user trigger on + * `auth.users`) can resolve its parent, but never diffed (no CREATE/ALTER/DROP). + * + * This pins the core mechanism without a database: + * - resolveView keeps the assumed-schema table (not pruned) AND the user + * trigger attached to it (include rule), instead of pruning the whole subtree. + * - diff emits the trigger delta but NO delta for the reference-only table, + * even when the table is asymmetric across the two sides. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import { diff } from "../core/diff.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import type { Policy } from "./policy.ts"; +import { resolveView } from "./policy.ts"; + +const schemaAuth: StableId = { kind: "schema", name: "auth" }; +const tableUsers: StableId = { kind: "table", schema: "auth", name: "users" }; +const trigger: StableId = { + kind: "trigger", + schema: "auth", + table: "users", + name: "on_auth_user_created", +}; + +function f(id: StableId, parent?: StableId): Fact { + return parent ? { id, parent, payload: {} } : { id, payload: {} }; +} + +// auth is assumed-present; triggers are user-managed (kept) even in auth. +const policy: Policy = { + id: "ref-only-test", + assumedSchemas: ["auth"], + filter: [ + { match: { kind: "trigger" }, action: "include" }, + { match: { schema: ["auth"] }, action: "exclude" }, + { + match: { all: [{ kind: "schema" }, { name: ["auth"] }] }, + action: "exclude", + }, + ], +}; + +describe("reference-only assumed-schema objects", () => { + // both sides have the platform table; only the desired side has the trigger. + const sourceRaw = buildFactBase( + [f(schemaAuth), f(tableUsers, schemaAuth)], + [], + ); + const desiredRaw = buildFactBase( + [f(schemaAuth), f(tableUsers, schemaAuth), f(trigger, tableUsers)], + [], + ); + + test("resolveView keeps the user trigger attached to an assumed-schema table", () => { + const view = resolveView(desiredRaw, policy); + expect(view.has(trigger)).toBe(true); + // the platform table is kept too (reference-only), so the trigger's parent + // resolves — no orphan. + expect(view.has(tableUsers)).toBe(true); + }); + + test("diff emits the trigger but never a delta for the reference-only table", () => { + const source = resolveView(sourceRaw, policy); + const desired = resolveView(desiredRaw, policy); + const deltas = diff(source, desired); + const triggerKey = encodeId(trigger); + const tableKey = encodeId(tableUsers); + const schemaKey = encodeId(schemaAuth); + const subj = (d: (typeof deltas)[number]): string => + d.verb === "add" || d.verb === "remove" + ? encodeId(d.fact.id) + : d.verb === "set" + ? encodeId(d.id) + : encodeId(d.edge.from); + expect(deltas.some((d) => d.verb === "add" && subj(d) === triggerKey)).toBe( + true, + ); + expect(deltas.some((d) => subj(d) === tableKey)).toBe(false); + expect(deltas.some((d) => subj(d) === schemaKey)).toBe(false); + }); + + test("a reference-only object asymmetric across sides still emits no delta", () => { + // desired lacks the platform table entirely; source has it. A plain diff + // would emit `remove auth.users` — reference-only must suppress it. + const onlySource = resolveView( + buildFactBase([f(schemaAuth), f(tableUsers, schemaAuth)], []), + policy, + ); + const empty = resolveView(buildFactBase([], []), policy); + const deltas = diff(onlySource, empty); + expect(deltas).toEqual([]); + }); +}); diff --git a/packages/pg-delta/src/policy/resolve-view.test.ts b/packages/pg-delta/src/policy/resolve-view.test.ts new file mode 100644 index 000000000..8e57656c2 --- /dev/null +++ b/packages/pg-delta/src/policy/resolve-view.test.ts @@ -0,0 +1,233 @@ +/** + * resolveView (docs/architecture/managed-view-architecture.md move 3): the policy's + * non-`verb` scope rules are applied as a FACT-LEVEL projection (both sides + + * proof reextract), so the proof stays honest by construction. First-match-wins + * is respected, including the safety case where an operation (`verb`) include + * earlier in the list protects a fact a later scope exclude would remove — + * over-projecting would silently drop managed objects, so the rule is: only + * project a fact out when certain ALL its deltas are excluded; otherwise keep + * it (the existing delta-level filter still applies). + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import type { Policy } from "./policy.ts"; +import { resolveView } from "./policy.ts"; +import { excludeByProvenance } from "./view.ts"; + +const f = (id: StableId, payload: Fact["payload"] = {}): Fact => ({ + id, + payload, +}); +const schema = (name: string): StableId => ({ kind: "schema", name }); +const table = (s: string, name: string): StableId => ({ + kind: "table", + schema: s, + name, +}); +const role = (name: string): StableId => ({ kind: "role", name }); +const ext = (name: string): StableId => ({ kind: "extension", name }); + +describe("resolveView — fact-level scope projection", () => { + test("a pure scope exclude removes matching facts; others survive", () => { + const policy: Policy = { + id: "p", + filter: [{ match: { schema: "auth" }, action: "exclude" }], + }; + const fb = buildFactBase( + [f(schema("auth")), f(table("auth", "users")), f(table("public", "app"))], + [], + ); + const view = resolveView(fb, policy); + expect(view.get(table("auth", "users"))).toBeUndefined(); + expect(view.get(table("public", "app"))).toBeDefined(); + }); + + test("an earlier scope include protects a fact from a later scope exclude", () => { + const policy: Policy = { + id: "p", + filter: [ + { match: { name: "keepme" }, action: "include" }, + { match: { schema: "auth" }, action: "exclude" }, + ], + }; + const fb = buildFactBase( + [f(schema("auth")), f(table("auth", "keepme")), f(table("auth", "drop"))], + [], + ); + const view = resolveView(fb, policy); + expect(view.get(table("auth", "keepme"))).toBeDefined(); + expect(view.get(table("auth", "drop"))).toBeUndefined(); + }); + + test("SAFETY: an operation (verb) include earlier protects a fact a later scope exclude matches", () => { + // Mirrors the Supabase policy: rule 1 includes extension add/remove; a later + // rule excludes objects owned by a system role. An extension owned by that + // role must NOT be projected out (its add/remove is included). + const policy: Policy = { + id: "p", + filter: [ + { + match: { all: [{ kind: "extension" }, { verb: ["add", "remove"] }] }, + action: "include", + }, + { match: { owner: "sys" }, action: "exclude" }, + ], + }; + const fb = buildFactBase( + [f(role("sys")), f(ext("pgmq"), { owner: "sys", relocatable: false })], + [], + ); + const view = resolveView(fb, policy); + // protected by the operation-include → still present at the fact level + expect(view.get(ext("pgmq"))).toBeDefined(); + }); + + test("a verb exclude alone never projects a fact out wholesale", () => { + const policy: Policy = { + id: "p", + filter: [{ match: { verb: "remove" }, action: "exclude" }], + }; + const fb = buildFactBase([f(table("public", "t"))], []); + expect(resolveView(fb, policy).get(table("public", "t"))).toBeDefined(); + }); + + test("no policy → extension members kept REFERENCE-ONLY (not hard-pruned)", () => { + const member = table("public", "q_jobs"); + const fb = buildFactBase( + [f(schema("public")), f(ext("pgmq")), f(member)], + [{ from: member, to: ext("pgmq"), kind: "memberOfExtension" }], + ); + const viaResolve = resolveView(fb, undefined); + const viaExclude = excludeByProvenance(fb, "memberOfExtension"); + // The raw primitive hard-prunes the member; resolveView keeps it + // REFERENCE-ONLY so its satellite customizations (acl/comment/securityLabel) + // stay diffable while the member object itself is never diffed. + expect(viaExclude.get(member)).toBeUndefined(); + expect(viaResolve.get(member)).toBeDefined(); + expect(viaResolve.referenceOnly.has(encodeId(member))).toBe(true); + // a non-member (the schema) is neither pruned nor reference-only + expect(viaResolve.get(schema("public"))).toBeDefined(); + expect(viaResolve.referenceOnly.has(encodeId(schema("public")))).toBe( + false, + ); + }); + + test("member stays reference-only when baseline subtraction prunes its extension edge", () => { + const netSchema = schema("net"); + const pgNet = ext("pg_net"); + const memberFn: StableId = { + kind: "function", + schema: "net", + name: "http_get", + args: [], + }; + const aclFact: Fact = { + id: { kind: "acl", target: memberFn, grantee: "r" }, + parent: memberFn, + payload: { privileges: ["EXECUTE"], grantable: [] }, + }; + const edge = { + from: memberFn, + to: pgNet, + kind: "memberOfExtension" as const, + }; + // fb has the extension, its member function, and a user GRANT on the member. + const fb = buildFactBase( + [f(netSchema), f(pgNet, netSchema), f(memberFn, netSchema), aclFact], + [edge], + ); + // Baseline is identical EXCEPT the user grant → subtractBaseline drops the + // extension + function, but force-keeps the function (its acl survives) and + // PRUNES the now-dangling member edge. + const baseline = buildFactBase( + [f(netSchema), f(pgNet, netSchema), f(memberFn, netSchema)], + [edge], + ); + const view = resolveView(fb, undefined, undefined, baseline); + // RED today: the member closure is computed AFTER subtraction, when the edge + // is already gone, so the surviving function is NOT reference-only and would + // be planned as a spurious CREATE FUNCTION. + expect(view.get(memberFn)).toBeDefined(); + expect(view.referenceOnly.has(encodeId(memberFn))).toBe(true); + }); + + test("managedBy facts are projected out (no policy) — single projection point", () => { + // P0: resolveView must be the single projection point for BOTH provenance + // kinds. Operationally-managed objects (pg_partman children) carry a + // `managedBy` edge and must drop out of the diffed view exactly like + // extension members, otherwise the default plan path drops them as drift. + const child = table("public", "events_p20260101"); + const childCol: StableId = { + kind: "column", + schema: "public", + table: "events_p20260101", + name: "id", + }; + const fb = buildFactBase( + [ + f(schema("public")), + f(ext("pg_partman")), + f(table("public", "events"), { partitioned: true }), + f(child, { partitioned: false }), + { id: childCol, parent: child, payload: { type: "integer" } }, + ], + [ + { from: child, to: ext("pg_partman"), kind: "managedBy" }, + { from: child, to: table("public", "events"), kind: "depends" }, + ], + ); + const view = resolveView(fb, undefined); + expect(view.get(child)).toBeUndefined(); // managed child removed + expect(view.get(childCol)).toBeUndefined(); // descendant pruned too + expect(view.get(table("public", "events"))).toBeDefined(); // parent survives + expect(view.get(ext("pg_partman"))).toBeDefined(); + }); + + test("an extension intent fact survives managedBy projection; managed objects don't", () => { + // An intent fact carries only an OUTGOING `depends` edge to its extension — + // it must NOT carry an outgoing `managedBy` edge (that marks operationally- + // created objects for projection). So it survives resolveView, while a table + // the extension created operationally (outgoing `managedBy`) is projected out + // with its subtree. Pins the one handler-authoring rule: never attach a + // `managedBy` edge FROM the intent fact. + const cronJob: StableId = { + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "nightly", + }; + const managedTable = table("public", "q_events"); + const fb = buildFactBase( + [f(schema("public")), f(ext("pg_cron")), f(cronJob), f(managedTable)], + [ + { from: cronJob, to: ext("pg_cron"), kind: "depends" }, + { from: managedTable, to: ext("pg_cron"), kind: "managedBy" }, + ], + ); + const view = resolveView(fb, undefined); + expect(view.get(cronJob)).toBeDefined(); // intent fact survives + expect(view.get(managedTable)).toBeUndefined(); // operational object projected + // and the primitive directly, for the invariant: + expect(excludeByProvenance(fb, "managedBy").get(cronJob)).toBeDefined(); + }); + + test("the { owner } predicate resolves via the owner edge (move 2)", () => { + // owner left the payload; an { owner } scope rule must match through the + // `owner` edge (object --owner--> role). This is the Supabase Rule 6 path. + const sys = role("sys"); + const owned = table("public", "owned"); + const free = table("public", "free"); + const policy: Policy = { + id: "p", + filter: [{ match: { owner: "sys" }, action: "exclude" }], + }; + const fb = buildFactBase( + [f(schema("public")), f(sys), f(owned), f(free)], + [{ from: owned, to: sys, kind: "owner" }], + ); + const view = resolveView(fb, policy); + expect(view.get(owned)).toBeUndefined(); // matched by { owner } via the edge + expect(view.get(free)).toBeDefined(); // no owner edge → not matched + }); +}); diff --git a/packages/pg-delta/src/policy/scope.test.ts b/packages/pg-delta/src/policy/scope.test.ts new file mode 100644 index 000000000..7607ccd88 --- /dev/null +++ b/packages/pg-delta/src/policy/scope.test.ts @@ -0,0 +1,94 @@ +/** + * projectManagementScope: database scope drops role/membership facts (so a + * shared/co-located shadow's ambient cluster roles never diff) but RETAINS the + * `owner` edges pointing at them as dangling ASSUMED references, so ownership + * still serializes as `ALTER … OWNER TO`. The edge whose target role name equals + * the resolved `defaultOwner` is pruned (that role is the implicit/applier owner + * → no `OWNER TO` noise). `cluster` scope is identity. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type DependencyEdge, type Fact } from "../core/fact.ts"; +import { encodeId } from "../core/stable-id.ts"; +import { projectManagementScope } from "./view.ts"; + +const facts: Fact[] = [ + { id: { kind: "schema", name: "app" }, payload: {} }, + { + id: { kind: "table", schema: "app", name: "t" }, + parent: { kind: "schema", name: "app" }, + payload: {}, + }, + { id: { kind: "role", name: "app_owner" }, payload: {} }, + { id: { kind: "role", name: "reader" }, payload: {} }, + { + id: { kind: "membership", role: "app_owner", member: "reader" }, + payload: { admin: false }, + }, +]; +const edges: DependencyEdge[] = [ + { + from: { kind: "table", schema: "app", name: "t" }, + to: { kind: "role", name: "app_owner" }, + kind: "owner", + }, +]; + +describe("projectManagementScope", () => { + test("database scope drops roles/memberships but RETAINS owner edges as dangling", () => { + const out = projectManagementScope(buildFactBase(facts, edges), "database"); + const kinds = out.facts().map((f) => f.id.kind); + expect(kinds.sort()).toEqual(["schema", "table"]); + // the owner edge to the (removed) role SURVIVES as a dangling assumed + // reference — ownership must still serialize as ALTER … OWNER TO. + expect(out.edges).toHaveLength(1); + expect(out.edges[0]!.kind).toBe("owner"); + // retained deliberately, so no dangling-edge warning is raised. + expect(out.diagnostics.filter((d) => d.code === "dangling_edge")).toEqual( + [], + ); + }); + + test("database scope prunes the owner edge whose target is the defaultOwner", () => { + const out = projectManagementScope( + buildFactBase(facts, edges), + "database", + { + defaultOwner: "app_owner", + }, + ); + // app_owner is the implicit owner → its owner edge is dropped (no OWNER TO). + expect(out.edges).toHaveLength(0); + expect(out.diagnostics.filter((d) => d.code === "dangling_edge")).toEqual( + [], + ); + }); + + test("cluster scope is identity (roles/ownership managed)", () => { + const fb = buildFactBase(facts, edges); + const out = projectManagementScope(fb, "cluster"); + expect(out).toBe(fb); + expect(out.get({ kind: "role", name: "app_owner" })).toBeDefined(); + }); + + test("keeps ACL facts (grantee is ambient, resolved via assumedRoles)", () => { + const withAcl: Fact[] = [ + ...facts, + { + id: { + kind: "acl", + target: { kind: "table", schema: "app", name: "t" }, + grantee: "reader", + }, + parent: { kind: "table", schema: "app", name: "t" }, + payload: { privileges: ["SELECT"], grantable: [] }, + }, + ]; + const out = projectManagementScope( + buildFactBase(withAcl, edges), + "database", + ); + expect(out.facts().some((f) => encodeId(f.id).startsWith("acl:"))).toBe( + true, + ); + }); +}); diff --git a/packages/pg-delta/src/policy/supabase-default-privileges.test.ts b/packages/pg-delta/src/policy/supabase-default-privileges.test.ts new file mode 100644 index 000000000..93d60f500 --- /dev/null +++ b/packages/pg-delta/src/policy/supabase-default-privileges.test.ts @@ -0,0 +1,74 @@ +/** + * A real Supabase user connects as `postgres` (a non-superuser) and can NEVER + * execute `ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin …` — that requires + * membership in `supabase_admin`, which is reserved. Those default-privilege + * entries are platform-managed, the same provenance judgment Rule 6 already + * makes for objects OWNED by a system role — but `defaultPrivilege` facts + * carry no owner edge/payload, so Rule 6 never matches them; the FOR-role + * lives in the fact id (`id.role`) instead. Pure policy level — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { resolveView } from "./policy.ts"; +import { supabasePolicy } from "./supabase.ts"; + +const schema = (name: string): Fact => ({ + id: { kind: "schema", name }, + payload: {}, +}); + +const defaultPrivilege = (role: string, grantee: string): Fact => ({ + id: { + kind: "defaultPrivilege", + role, + schema: "public", + objtype: "r", + grantee, + }, + payload: { privileges: ["SELECT"], grantable: [] }, +}); + +const forSupabaseAdminToAnon: StableId = { + kind: "defaultPrivilege", + role: "supabase_admin", + schema: "public", + objtype: "r", + grantee: "anon", +}; +const forPostgresToAnon: StableId = { + kind: "defaultPrivilege", + role: "postgres", + schema: "public", + objtype: "r", + grantee: "anon", +}; +const forPostgresToAuthenticated: StableId = { + kind: "defaultPrivilege", + role: "postgres", + schema: "public", + objtype: "r", + grantee: "authenticated", +}; + +describe("supabase policy — default privileges declared FOR a system role", () => { + test("excludes ADP declared FOR ROLE supabase_admin; keeps user-owned FOR ROLE postgres ADP", () => { + const fb = buildFactBase( + [ + schema("public"), + defaultPrivilege("supabase_admin", "anon"), + defaultPrivilege("postgres", "anon"), + defaultPrivilege("postgres", "authenticated"), + ], + [], + ); + const view = resolveView(fb, supabasePolicy); + // platform-managed (FOR ROLE supabase_admin) → a non-superuser postgres can + // never execute this ADP → must be invisible to the managed view. + expect(view.get(forSupabaseAdminToAnon)).toBeUndefined(); + // user-owned API-role defaults (FOR ROLE postgres) survive regardless of + // which system role is the GRANTEE. + expect(view.get(forPostgresToAnon)).toBeDefined(); + expect(view.get(forPostgresToAuthenticated)).toBeDefined(); + }); +}); diff --git a/packages/pg-delta/src/policy/supabase-extensions.test.ts b/packages/pg-delta/src/policy/supabase-extensions.test.ts new file mode 100644 index 000000000..b150e0f3c --- /dev/null +++ b/packages/pg-delta/src/policy/supabase-extensions.test.ts @@ -0,0 +1,35 @@ +/** + * supabase/cli#5555: a declarative sync must not drop platform-managed + * extensions (the reported `DROP EXTENSION pg_graphql`). The supabase policy + * projects those extensions out of the managed view entirely — so an extension + * present on the target but absent from the declarative source produces no + * remove delta and thus no DROP — while leaving user-declarable extensions + * (pg_trgm, …) fully managed. Before the fix, pg_graphql was kept in the view + * and would be dropped. Pure policy level — no DB. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { resolveView } from "./policy.ts"; +import { supabasePolicy } from "./supabase.ts"; + +const ext = (name: string, schema: string): Fact => ({ + id: { kind: "extension", name }, + payload: { schema, relocatable: false }, +}); +const pgGraphql: StableId = { kind: "extension", name: "pg_graphql" }; +const pgTrgm: StableId = { kind: "extension", name: "pg_trgm" }; + +describe("supabase policy — platform extensions", () => { + test("projects out pg_graphql but keeps a user extension", () => { + const fb = buildFactBase( + [ext("pg_graphql", "graphql"), ext("pg_trgm", "extensions")], + [], + ); + const view = resolveView(fb, supabasePolicy); + // platform-managed → invisible → never dropped (the #5555 fix) + expect(view.get(pgGraphql)).toBeUndefined(); + // user-declarable → still managed + expect(view.get(pgTrgm)).toBeDefined(); + }); +}); diff --git a/packages/pg-delta/src/policy/supabase.ts b/packages/pg-delta/src/policy/supabase.ts new file mode 100644 index 000000000..2b43ab6b0 --- /dev/null +++ b/packages/pg-delta/src/policy/supabase.ts @@ -0,0 +1,453 @@ +/** + * Supabase policy package: filtering rules for Supabase-managed PostgreSQL + * databases (target-architecture §3.9, stage-08-policy). + * + * Port of packages/pg-delta/src/core/integrations/supabase.ts into DSL v2. + * v2 semantics: rules evaluated in order, first match wins, no match → include. + * Rule ordering: specific INCLUDE rules first, then EXCLUDE rules. + * + * OLD RULE → NEW RULE MAPPING (for audit completeness) + * + * Old-1: objectType extension + operation create + * → { kind:"extension", verb:"add" } → include + * + * Old-2: objectType extension + operation drop + * → { kind:"extension", verb:"remove" } → include + * + * Old-3: objectType trigger AND trigger-schema IN SYSTEM + * AND NOT trigger-function_schema IN SYSTEM + * AND NOT (schema=pgmq AND table matches q_/a_ prefix) + * → { kind:"trigger", schema IN SYSTEM, not edgeTo{kind:"function", schema IN SYSTEM}, + * not (schema=pgmq AND table glob q_* or a_*) } → include + * + * Old-4: "star-slash-schema" IN SUPABASE_SYSTEM_SCHEMAS + * → { schema: SUPABASE_SYSTEM_SCHEMAS } → exclude + * + * Old-5: "schema/name" IN SUPABASE_SYSTEM_SCHEMAS + * → { kind:"schema", name: SUPABASE_SYSTEM_SCHEMAS } → exclude + * + * Old-6: "star-slash-owner" IN SUPABASE_SYSTEM_ROLES + * → { owner: SUPABASE_SYSTEM_ROLES } → exclude + * + * Old-7: "role/name" IN SUPABASE_SYSTEM_ROLES + * → { kind:"role", name: SUPABASE_SYSTEM_ROLES } → exclude + * + * Old-8: membership where member OR role is a system role + * → { kind:"membership", idField{field:"member"} IN SYSTEM_ROLES } → exclude + * { kind:"membership", idField{field:"role"} IN SYSTEM_ROLES } → exclude + * + * Old-9: objectType foreign_data_wrapper + scope privilege + * → { kind:"acl", target:{kind:"fdw"} } → exclude + * (GRANT/REVOKE on FDW requires superuser; non-superuser postgres + * cannot replay this ACL regardless of FDW ownership) + * + * Old-10: implicitly filtered in old engine via parent-object exclusion + * → { kind:["acl","comment","securityLabel"], target:{schema IN SYSTEM} } → exclude + * { kind:["acl","comment","securityLabel"], target:{kind:"schema", name IN SYSTEM_SCHEMAS} } → exclude + * Two sub-rules under the unified target predicate: target.schema covers + * qualified-kind targets (table, view, sequence...) while target.kind+name + * covers simple-kind targets like schema itself (in v2, satellite deltas + * are independent; must be excluded explicitly) + * + * Old-11: emptyCatalog: undefined (TODO in old engine) + * → baseline: "supabase-baseline" + * (snapshot generated by scripts/generate-supabase-baseline.ts) + * + * Old-12: Wasm FDW filtering via wasm_fdw_handler/validator function names + * → OBSOLETE in new engine: extraction-time pg_depend 'e' rows + * exclude extension-member FDWs, servers, foreign tables, user + * mappings at extract time. The precise Wasm-name match is not + * ported; if extension-member objects slip through, the owner + * predicate or ownedByExtension predicate covers them. + * + * Old-13: serialize skipAuthorization for schema CREATE owned by system role + * → serialize rule: { kind: schema, owner: SYSTEM_ROLES } sets + * skipAuthorization — the planner applies serialize params PER + * MATCHED FACT (first-match-wins), so user schemas keep their + * AUTHORIZATION clause + * + * Old-14: serialize skipSchema for extensions installing their own schema + * → REMOVED. The SCHEMA clause is now derived from the extension's + * `relocatable` fact (pg_extension.extrelocatable): a non-relocatable + * extension emits a bare CREATE EXTENSION. No name list, not + * Supabase-specific. See docs/architecture/managed-view-architecture.md (move 2). + * + * BASELINE + * The baseline field names the snapshot that represents "empty" on a Supabase + * platform instance. Facts present-and-identical in the baseline are subtracted + * before diffing (via subtractBaseline), replacing the old emptyCatalog TODO. + * Regenerate: bun run scripts/generate-supabase-baseline.ts + */ + +import type { Policy } from "./policy.ts"; + +// --------------------------------------------------------------------------- +// Lists copied VERBATIM from packages/pg-delta/src/core/integrations/supabase.ts +// --------------------------------------------------------------------------- + +/** Supabase system schemas that should be excluded from user plans. */ +export const SUPABASE_SYSTEM_SCHEMAS = [ + "_analytics", + "_realtime", + "_supavisor", + "auth", + "cron", + "etl", + "extensions", + "graphql", + "graphql_public", + "information_schema", + "net", + "pgbouncer", + "pgmq", + "pgmq_public", + "pgsodium", + "pgsodium_masks", + "pgtle", + "realtime", + "storage", + "supabase_functions", + "supabase_migrations", + "vault", +] as const; + +/** Supabase system roles that should be excluded from user plans. */ +export const SUPABASE_SYSTEM_ROLES = [ + "anon", + "authenticated", + "authenticator", + "cli_login_postgres", + "dashboard_user", + "pgbouncer", + "pgsodium_keyholder", + "pgsodium_keyiduser", + "pgsodium_keymaker", + "pgtle_admin", + "service_role", + "supabase_admin", + "supabase_auth_admin", + "supabase_etl_admin", + "supabase_functions_admin", + "supabase_read_only_user", + "supabase_realtime_admin", + "supabase_replication_admin", + "supabase_storage_admin", + "supabase_superuser", +] as const; + +/** Supabase platform-managed extensions: enabled by the platform image, never + * declared in a user's `schemas/` files, so a declarative diff must neither + * create nor drop them — the reported spurious `DROP EXTENSION pg_graphql` + * (supabase/cli#5555). Deliberately conservative: user-toggleable extensions + * (pg_net, pg_cron, pgmq, pgcrypto, uuid-ossp, …) are intentionally absent so + * users can still manage them. The comprehensive mechanism is the committed + * Supabase baseline (a v1 gate — see the `baseline` note below); this name + * list mirrors SUPABASE_SYSTEM_SCHEMAS/ROLES until that lands. */ +export const SUPABASE_SYSTEM_EXTENSIONS = [ + "pg_graphql", + "pg_stat_statements", + "pgsodium", + "supabase_vault", +] as const; + +// --------------------------------------------------------------------------- +// The Supabase policy +// --------------------------------------------------------------------------- + +/** + * The Supabase policy: port of every filterable behavior from the old + * supabase.ts into DSL v2. + */ +export const supabasePolicy: Policy = { + id: "supabase", + + // Platform-preset roles assumed to exist at apply time. Rule 7 below projects + // their role OBJECT out of the managed view, but ACL / default-privilege facts + // that grant TO them are kept — old pg-delta emits those grants and dbdev + // relies on them. Declaring the names here lets the planner's ambient-role + // exemption (like pg_*/PUBLIC) accept `consumes role:anon` without re-admitting + // the role into the diff. Mirrors Rule 7's exclusion list by construction. + assumedRoles: [...SUPABASE_SYSTEM_ROLES], + + // Platform-managed schemas assumed to exist at apply time. Rules 4/5 below + // project their schema OBJECT out of the managed view, but a kept user object + // can still reference one as a dependency target — most notably a relocatable + // extension installed into `extensions` (`CREATE EXTENSION … SCHEMA + // extensions`), which old pg-delta and dbdev rely on. Declaring the names here + // lets the planner's ambient-schema exemption (like assumed roles / pg_* / + // PUBLIC) accept `consumes schema:extensions` without re-admitting the schema + // into the diff. Mirrors the system-schema exclusion list by construction. + assumedSchemas: [...SUPABASE_SYSTEM_SCHEMAS], + + // Default owner for database-scope exports: Supabase hands users the + // `postgres` role, so an object owned by `postgres` needs no `ALTER … OWNER + // TO` — its ownership is implicit. Objects owned by a system role are already + // projected out by Rule 6 (owner: SUPABASE_SYSTEM_ROLES), so this suppresses + // only redundant `owner to postgres` lines; a user object owned by a + // non-postgres user role still serializes its owner. + defaultOwner: "postgres", + + // baseline (intentionally UNSET in v1): a baseline names the snapshot that + // represents "empty" on a Supabase instance; facts present-and-identical in + // it are subtracted before diffing (resolveBaseline → plan options.baseline), + // so platform-managed objects are invisible without a filter rule per object. + // No Supabase baseline snapshot is committed yet (a separate v1 validation + // item), so this policy does NOT declare one — a declared-but-unresolved + // baseline now fail-fasts (review finding 3) rather than being silently + // ignored. The `filter` rules below already hide platform objects; when the + // baseline snapshot lands, re-add `baseline: "supabase-baseline"` here and + // resolveBaseline will subtract it. Generate with: + // bun run scripts/generate-supabase-baseline.ts + // + // ⚠️ Phase 2b (#41) depends on this being UNSET: the co-located-shadow seed + // (frontends/seed-assumed-schemas.ts) materializes assumed-schema objects from + // the target's `resolveView` reference-only set, and subtractBaseline removes + // baseline-identical facts BEFORE that marking — so a declared baseline empties + // the seed and quick-mode `schema apply` regresses. When landing the baseline, + // revisit the seed derivation; the "non-empty seed" pin in + // seed-assumed-schemas.test.ts fails loudly if this is missed. + + serialize: [ + // Old-13 (REMOVED): the skipAuthorization serialize rule is no longer needed. + // With owner-as-edge (move 2), when a schema is owned by a system role that + // is excluded from the view, the owner edge is pruned by buildFactBase before + // diffing — so no owner edge exists → no ALTER SCHEMA … OWNER TO is emitted + // → CREATE SCHEMA renders without AUTHORIZATION by construction, not via param. + // See docs/architecture/managed-view-architecture.md (move 2). + // + // Old-14 (REMOVED): the SCHEMA clause is now derived from the extension's + // `relocatable` fact (pg_extension.extrelocatable) in the extension rule — + // a non-relocatable extension (pgmq / pgsodium / pgtle) emits a bare + // CREATE EXTENSION with no name list and nothing Supabase-specific. + // See docs/architecture/managed-view-architecture.md (move 2). + ], + + filter: [ + // ------------------------------------------------------------------------- + // INCLUDE rules (specific cases that must survive system-level exclusions) + // Must appear BEFORE the exclude rules (first-match-wins). + // ------------------------------------------------------------------------- + + // Exclude platform-managed extensions (pg_graphql, …) entirely: they are + // enabled by the Supabase image, never declared in user schema files, and + // must not be dropped when absent from the declarative source — the + // reported `DROP EXTENSION pg_graphql` (supabase/cli#5555). MUST precede the + // general extension include below (first-match-wins) so it wins for these + // names; all OTHER extensions still fall through to the include and stay + // user-declarable. No `verb` clause → also suppresses spurious version + // (`set`) churn on a platform extension. The robust long-term mechanism is + // the committed Supabase baseline (the `baseline` note below). + { + match: { + all: [{ kind: "extension" }, { name: [...SUPABASE_SYSTEM_EXTENSIONS] }], + }, + action: "exclude", + }, + + // Rule 1+2 (old rules): include extension CREATE and DROP operations. + // Extensions are always user-declarable state on Supabase regardless of + // the schema they install into. Place before the system-schema/owner + // exclusions so e.g. `CREATE EXTENSION pgmq` (schema=pgmq) is not filtered. + { + match: { all: [{ kind: "extension" }, { verb: ["add", "remove"] }] }, + action: "include", + }, + + // Rule 3 (old rule): include user-created triggers on tables in managed + // schemas. A trigger lives in the schema of the table it fires on, so a + // customer trigger on `auth.users` appears in the `auth` schema and would + // otherwise be caught by the system-schema exclusion below. + // + // The discriminator is the trigger function's schema: Supabase's own + // triggers call functions inside the same managed schema, while a user + // trigger points to a function in `public` (or another non-managed schema). + // + // Defensive exclusion: pgmq dynamically creates `pgmq.q_` / + // `pgmq.a_` tables via `pgmq.create()` rather than + // `CREATE EXTENSION pgmq`, so the extraction-time pg_depend 'e' guard may + // miss them. Exclude triggers on those tables explicitly. + { + match: { + all: [ + { kind: "trigger" }, + // trigger is on a table in a managed schema + { schema: [...SUPABASE_SYSTEM_SCHEMAS] }, + // trigger function lives outside the managed schemas (user-owned) + { + not: { + edgeTo: { + kind: "function", + schema: [...SUPABASE_SYSTEM_SCHEMAS], + }, + }, + }, + // not a trigger on a pgmq queue / archive table + { + not: { + all: [ + { schema: "pgmq" }, + { + any: [ + { idField: { field: "table", glob: "q_*" } }, + { idField: { field: "table", glob: "a_*" } }, + ], + }, + ], + }, + }, + ], + }, + action: "include", + }, + + // ------------------------------------------------------------------------- + // EXCLUDE rules — system objects the user cannot / should not manage + // ------------------------------------------------------------------------- + + // Rule 4 (old rule): exclude any fact whose identity schema is a system schema. + // Covers: table, view, materializedView, foreignTable, sequence, index, + // collation, domain, type, column, constraint, trigger, rule, policy, + // procedure, aggregate — every qualified-kind or sub-entity kind. + { + match: { schema: [...SUPABASE_SYSTEM_SCHEMAS] }, + action: "exclude", + }, + + // Rule 5 (old rule): exclude schema objects whose OWN name is a system schema. + // The `schema` predicate checks `fact.id.schema`, not `fact.id.name`, so + // a `{ kind: "schema", name: "auth" }` fact has no `schema` field. + // This rule closes that gap. + { + match: { + all: [{ kind: "schema" }, { name: [...SUPABASE_SYSTEM_SCHEMAS] }], + }, + action: "exclude", + }, + + // Rule 6 (old rule): exclude objects whose payload owner is a system role. + // Covers tables, views, schemas, sequences, etc. that are managed by + // Supabase system roles. + { + match: { owner: [...SUPABASE_SYSTEM_ROLES] }, + action: "exclude", + }, + + // Rule 6b (this session): exclude default-privilege entries DECLARED FOR a + // system role (`ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin …`). Same + // provenance judgment as Rule 6, but ADP facts carry no owner edge/payload — + // the FOR-role is in the id (`id.role`). A non-superuser `postgres` can never + // execute an ADP for a reserved system role, so these are platform-managed, + // never the user's to manage. (Grantee-side, e.g. FOR ROLE postgres GRANT … + // TO anon, is intentionally NOT matched — that is the user-owned API-role + // default.) + { + match: { + all: [ + { kind: "defaultPrivilege" }, + { idField: { field: "role", glob: [...SUPABASE_SYSTEM_ROLES] } }, + ], + }, + action: "exclude", + }, + + // Rule 7 (old rule): exclude role objects whose name is a system role. + // The `owner` predicate reads payload["owner"], not the role's own name. + { + match: { + all: [{ kind: "role" }, { name: [...SUPABASE_SYSTEM_ROLES] }], + }, + action: "exclude", + }, + + // Rule 8a (old rule): exclude memberships where the MEMBER is a system role. + { + match: { + all: [ + { kind: "membership" }, + { idField: { field: "member", glob: [...SUPABASE_SYSTEM_ROLES] } }, + ], + }, + action: "exclude", + }, + + // Rule 8b (old rule): exclude memberships where the ROLE (grantee) is a system role. + { + match: { + all: [ + { kind: "membership" }, + { idField: { field: "role", glob: [...SUPABASE_SYSTEM_ROLES] } }, + ], + }, + action: "exclude", + }, + + // Rule 9 (old rule): exclude ACL facts on FDWs. + // + // GRANT/REVOKE on FOREIGN DATA WRAPPER requires superuser (verified: + // tests/capability.test.ts "a non-superuser cannot GRANT on a FOREIGN DATA + // WRAPPER"). On Supabase Cloud `postgres` has the elevated rights, but the + // local Docker image does not, so `supabase db reset` aborts with + // "permission denied for foreign-data wrapper". The owner rule above + // already covers wrappers owned by `supabase_admin`, but pg_dump rewrites + // OWNER TO to whoever ran the dump, so after a restore the FDW often ends + // up owned by `postgres` and slips past the owner gate. Since a + // non-superuser `postgres` cannot grant on any FDW regardless of ownership, + // the ACL diff is not user-replayable. We do NOT apply the same blanket + // rule to FOREIGN SERVER: server GRANT/REVOKE does not require superuser + // and user-created servers carry legitimate user ACL that must roundtrip. + // + // RETAINED, not retired (follow-up 2). The applier-capability mechanism + // (move 6, capabilityExcludedRoots) now derives this exclusion precisely: + // it drops FDW ACLs whenever the applier is non-superuser. The two are at + // parity for the real Supabase flow (local + Cloud `postgres` are both + // non-superuser). They DIVERGE for a superuser applier — capability would + // (correctly) MANAGE the FDW ACL, while this rule excludes unconditionally. + // This rule is kept because it is a SELF-CONTAINED policy guarantee: the + // exclusion holds even if a caller forgets to probe + pass capability, + // whereas capability depends on every caller supplying it. Capability is + // the general mechanism (any non-superuser applier, any policy); this rule + // is the Supabase policy's unconditional belt-and-suspenders. See + // docs/architecture/managed-view-architecture.md (follow-up 2). + { + match: { + all: [{ kind: "acl" }, { target: { kind: "fdw" } }], + }, + action: "exclude", + }, + + // Rule 10 (implicit in old engine): exclude ACL, comment, and security-label + // satellite facts whose TARGET object lives in a system schema. + // + // In the old engine, satellite facts for system-schema objects were filtered + // implicitly because the parent object was excluded first and satellites + // cascaded. In v2, each delta is evaluated independently against the full + // fact base, so we must explicitly exclude satellites targeting system objects. + // + // Two sub-rules under the unified `target` predicate: + // - target.schema covers qualified-kind targets (table, view, sequence, ...) + // which carry a `schema` field in their StableId. + // - target.kind + target.name covers simple-kind targets like schema + // which carry only a `name` field (no `schema`). We match schema-kind + // targets whose `name` is in SUPABASE_SYSTEM_SCHEMAS (covers ACLs on + // the auth schema itself, not just tables inside auth). + { + match: { + all: [ + { kind: ["acl", "comment", "securityLabel"] }, + { + any: [ + { target: { schema: [...SUPABASE_SYSTEM_SCHEMAS] } }, + { + target: { + kind: "schema", + name: [...SUPABASE_SYSTEM_SCHEMAS], + }, + }, + ], + }, + ], + }, + action: "exclude", + }, + ], +}; diff --git a/packages/pg-delta/src/policy/view.test.ts b/packages/pg-delta/src/policy/view.test.ts new file mode 100644 index 000000000..6d20e7b14 --- /dev/null +++ b/packages/pg-delta/src/policy/view.test.ts @@ -0,0 +1,104 @@ +/** + * The single fact-level projection primitive (docs/architecture/managed-view-architecture.md + * move 4): `excludeByProvenance(fb, edgeKind)` removes every fact carrying an + * outgoing edge of that kind plus its descendant subtree, and prunes edges with + * a removed endpoint. `resolveView` uses it directly for `managedBy`; future + * scope/capability projections reuse the same core. + */ +import { describe, expect, test } from "bun:test"; +import { buildFactBase, type Fact } from "../core/fact.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; +import { excludeByProvenance, projectManagementScope } from "./view.ts"; + +const schema = (name: string): StableId => ({ kind: "schema", name }); +const table = (s: string, name: string): StableId => ({ + kind: "table", + schema: s, + name, +}); +const ext: StableId = { kind: "extension", name: "pgmq" }; +const f = (id: StableId, parent?: StableId): Fact => + parent ? { id, parent, payload: {} } : { id, payload: {} }; + +describe("excludeByProvenance — generic fact-level projection", () => { + test("removes a tagged root, its descendant subtree, and prunes dangling edges", () => { + const pub = schema("public"); + const memberTable = table("public", "q_jobs"); + const memberCol: StableId = { + kind: "column", + schema: "public", + table: "q_jobs", + name: "id", + }; + const userTable = table("public", "app"); + + const fb = buildFactBase( + [ + f(pub), + f(ext, pub), + f(memberTable, pub), + f(memberCol, memberTable), + f(userTable, pub), + ], + [ + { from: memberTable, to: ext, kind: "memberOfExtension" }, + // a dangling-after-removal edge: user table depends on the member + { from: userTable, to: memberTable, kind: "depends" }, + ], + ); + + const out = excludeByProvenance(fb, "memberOfExtension"); + + // member root + its column descendant are gone; user + public + ext stay + expect(out.get(memberTable)).toBeUndefined(); + expect(out.get(memberCol)).toBeUndefined(); + expect(out.get(userTable)).toBeDefined(); + expect(out.get(pub)).toBeDefined(); + // the depends edge into the removed member is pruned + expect(out.edges.some((e) => e.kind === "depends")).toBe(false); + }); + + test("returns the same instance when no fact carries the edge (early-exit)", () => { + const fb = buildFactBase([f(schema("public"))], []); + expect(excludeByProvenance(fb, "managedBy")).toBe(fb); + }); + + test("only the named edge kind selects roots", () => { + const pub = schema("public"); + const managed = table("public", "child"); + const fb = buildFactBase( + [f(pub), f(managed, pub)], + [{ from: managed, to: pub, kind: "managedBy" }], + ); + // projecting by memberOfExtension leaves the managedBy-tagged fact in place + expect( + excludeByProvenance(fb, "memberOfExtension").get(managed), + ).toBeDefined(); + // projecting by managedBy removes it + expect(excludeByProvenance(fb, "managedBy").get(managed)).toBeUndefined(); + }); + + test("preserves reference-only marks for surviving facts across projection", () => { + // A projection that removes some facts (here database-scope role pruning) + // must carry the reference-only set forward for the facts that survive — + // otherwise extension members / assumed-schema platform objects that + // resolveView() deliberately kept reference-only become managed again and + // get planned/dropped. + const pub = schema("public"); + const memberTable = table("public", "gql_state"); + const role: StableId = { kind: "role", name: "some_role" }; + const fb = buildFactBase( + [f(pub), f(memberTable, pub), f(role)], + [], + undefined, + new Set([encodeId(memberTable)]), + ); + + const out = projectManagementScope(fb, "database"); + + // the role fact is pruned (database scope does not manage roles) ... + expect(out.get(role)).toBeUndefined(); + // ... but the surviving reference-only mark is preserved + expect(out.isReferenceOnly(memberTable)).toBe(true); + }); +}); diff --git a/packages/pg-delta/src/policy/view.ts b/packages/pg-delta/src/policy/view.ts new file mode 100644 index 000000000..6bffb85e9 --- /dev/null +++ b/packages/pg-delta/src/policy/view.ts @@ -0,0 +1,273 @@ +/** + * The single fact-level projection primitive behind the managed view + * (docs/architecture/managed-view-architecture.md). + * + * The engine diffs a *view* of the managed universe, never raw catalogs, and a + * view is closed under the proof loop: a fact removed from one side is removed + * from the other and from the proof re-extract, so `plan == prove == run` holds + * by construction. Projection is therefore always at the FACT level (both sides + * + the proof re-extract), never the delta level — a delta-only filter would + * make the proof drift. + * + * `excludeByProvenance(fb, "managedBy")` HARD-projects operationally-managed + * objects out. Extension members (`memberOfExtension`) are NOT hard-pruned — they + * are kept REFERENCE-ONLY via `extensionMemberClosure` / `extensionMemberReferenceOnly` + * so their satellite customizations still diff. Scope and applier-capability + * projections (later moves) reuse `excludeFactsAndDescendants` with roots chosen + * a different way. + */ +import { + buildFactBase, + type DependencyEdge, + type EdgeKind, + type Fact, + type FactBase, + retainOwnerRoleDangling, +} from "../core/fact.ts"; +import { encodeId, isSatelliteId, type StableId } from "../core/stable-id.ts"; + +/** + * Return a new FactBase with `rootIds` and their entire descendant subtrees + * removed; edges with a removed endpoint are pruned. If `rootIds` is empty, `fb` + * is returned unchanged (referential identity preserved for cheap no-ops). + */ +export function excludeFactsAndDescendants( + fb: FactBase, + rootIds: ReadonlySet, +): FactBase { + if (rootIds.size === 0) return fb; + + const removed = new Set(); + // a fact is removed if it is a root, or any ancestor is one + const isRemoved = (fact: Fact): boolean => { + const encoded = encodeId(fact.id); + if (removed.has(encoded)) return true; + if (rootIds.has(encoded)) { + removed.add(encoded); + return true; + } + let current = fact.parent; + while (current !== undefined) { + const key = encodeId(current); + if (rootIds.has(key) || removed.has(key)) { + removed.add(encoded); + return true; + } + current = fb.get(current)?.parent; + } + return false; + }; + + const keptFacts: Fact[] = fb.facts().filter((f) => !isRemoved(f)); + const survives = new Set(keptFacts.map((f) => encodeId(f.id))); + const keptEdges: DependencyEdge[] = fb.edges.filter((e) => { + const fromSurvives = survives.has(encodeId(e.from)); + if (fromSurvives && survives.has(encodeId(e.to))) return true; + // Ownership carve-out invariant: PRESERVE an owner→role edge that was + // ALREADY dangling on input (e.g. a scope projection re-run through this + // primitive keeps serializing OWNER TO instead of silently dropping it), + // but NEVER newly dangle an edge whose endpoint THIS exclusion removes. + // Minting a fresh dangling owner edge for a role THIS call is projecting out + // (e.g. a policy hard-exclusion) would launder the excluded role back in as + // `CREATE SCHEMA … AUTHORIZATION ` / `OWNER TO ` (auto-assumed in + // plan.ts) and silence the missing-requirement guard. Only + // `projectManagementScope` (its own edge loop) is entitled to mint dangling + // owner edges. + return fromSurvives && retainOwnerRoleDangling(e) && !fb.has(e.to); + }); + // Carry the reference-only set forward for surviving facts. Otherwise a scope + // or provenance projection (which rebuilds the FactBase) silently drops the + // reference-only marks resolveView() set, so extension members and + // assumed-schema platform objects become managed again and get planned/dropped. + const referenceOnly = new Set( + [...fb.referenceOnly].filter((key) => survives.has(key)), + ); + return buildFactBase(keptFacts, keptEdges, fb.source, referenceOnly, { + allowDangling: retainOwnerRoleDangling, + }); +} + +/** Management scope of a declarative apply (target-architecture §scope). */ +export type ManagementScope = "database" | "cluster"; + +/** + * Encoded ids removed by excluding `rootIds` and their descendant subtrees (a + * fact is removed if it is a root or has a removed ancestor). Mirrors the + * removal walk in `excludeFactsAndDescendants`; used by `projectManagementScope` + * so it agrees on the removal closure while handling edges specially. + */ +function removedClosure( + fb: FactBase, + rootIds: ReadonlySet, +): Set { + const removed = new Set(); + const isRemoved = (fact: Fact): boolean => { + const encoded = encodeId(fact.id); + if (removed.has(encoded)) return true; + if (rootIds.has(encoded)) { + removed.add(encoded); + return true; + } + let current = fact.parent; + while (current !== undefined) { + const key = encodeId(current); + if (rootIds.has(key) || removed.has(key)) { + removed.add(encoded); + return true; + } + current = fb.get(current)?.parent; + } + return false; + }; + for (const fact of fb.facts()) isRemoved(fact); + return removed; +} + +/** + * Project a fact base to the given management scope. + * + * `"cluster"` returns `fb` unchanged (roles/memberships are managed state). + * + * `"database"` (the declarative default) removes `role` and `membership` facts. + * Roles are cluster-global and shared across databases, so on a shared/co-located + * shadow the extract carries roles the declarative files never declared; diffing + * them would plan a spurious `CREATE ROLE` (shadow-only role) or a destructive + * `DROP ROLE` (target-only role). The caller instead passes the target's actual + * role names as `assumedRoles`, so a `GRANT … TO ` (and, below, an + * `ALTER … OWNER TO `) resolves against a role that exists at apply time + * (one that does NOT fails loudly at plan time). + * + * OWNERSHIP is still serialized in database scope: an `owner` edge from a + * surviving object to a (removed) role is RETAINED as a dangling ASSUMED + * reference, so ownership round-trips as `ALTER … OWNER TO`. The one exception is + * the resolved `defaultOwner`: an owner edge to it is pruned, because that role + * is the implicit/applier owner and emitting `OWNER TO ` for every + * object would be redundant noise. `defaultOwner` undefined (verbose / + * `--default-owner none`) keeps EVERY retained owner edge. + * + * Symmetric by construction (same projection — including the same `defaultOwner` + * — on both diff sides + the proof/fingerprint re-extract), so + * `plan == prove == run` holds. + */ +export function projectManagementScope( + fb: FactBase, + scope: ManagementScope, + opts: { defaultOwner?: string } = {}, +): FactBase { + if (scope === "cluster") return fb; + const roots = new Set(); + for (const fact of fb.facts()) { + if (fact.id.kind === "role" || fact.id.kind === "membership") { + roots.add(encodeId(fact.id)); + } + } + if (roots.size === 0) return fb; // identity no-op (referential identity preserved) + + const removed = removedClosure(fb, roots); + const keptFacts = fb.facts().filter((f) => !removed.has(encodeId(f.id))); + const survives = new Set(keptFacts.map((f) => encodeId(f.id))); + + const { defaultOwner } = opts; + const keptEdges: DependencyEdge[] = []; + for (const e of fb.edges) { + const fromSurvives = survives.has(encodeId(e.from)); + const toSurvives = survives.has(encodeId(e.to)); + if (fromSurvives && toSurvives) { + keptEdges.push(e); + continue; + } + // deliberate carve-out (scoped to owner→role edges): retain the owner edge + // to a removed role as a dangling assumed reference so ownership serializes, + // EXCEPT the edge to the resolved defaultOwner (implicit/applier owner). + if ( + e.kind === "owner" && + fromSurvives && + !toSurvives && + e.to.kind === "role" + ) { + const roleName = (e.to as { kind: "role"; name: string }).name; + if (defaultOwner !== undefined && roleName === defaultOwner) continue; + keptEdges.push(e); + } + // every other dangling edge is pruned (as excludeFactsAndDescendants does). + } + + const referenceOnly = new Set( + [...fb.referenceOnly].filter((key) => survives.has(key)), + ); + return buildFactBase(keptFacts, keptEdges, fb.source, referenceOnly, { + allowDangling: retainOwnerRoleDangling, + }); +} + +/** + * Extension-member closure: every fact an extension OWNS → the owning + * extension id(s). A member root is any fact with an outgoing `memberOfExtension` + * edge (pushMemberEdge tags functions/tables/types/schemas/…). Ownership then + * flows DOWN to a root's NON-satellite descendants (a member table's columns/ + * constraints are extension-managed too), EXCEPT it does NOT cross a `schema` + * root's children: an extension owns the schema it creates, but a USER object + * added inside that schema carries no member edge of its own and must diff + * normally. Satellites (acl/comment/securityLabel — `isSatelliteId`) are never + * in the closure: a user GRANT/COMMENT/SECURITY LABEL on an extension object is + * user state that the diff DOES manage. + * + * Memoized by construction (BFS visits each id once), unlike a per-fact + * ancestor walk. Used both to mark members reference-only in the view and to + * exempt them from the planner's requirement guard (they are present-at-apply + * via CREATE EXTENSION), keeping ONE definition of "member" on both sides. + */ +export function extensionMemberClosure(fb: FactBase): Map { + const closure = new Map(); + const queue: StableId[] = []; + for (const fact of fb.facts()) { + const exts = fb + .outgoingEdges(fact.id) + .filter((e) => e.kind === "memberOfExtension") + .map((e) => e.to); + if (exts.length > 0) { + closure.set(encodeId(fact.id), exts); + queue.push(fact.id); + } + } + while (queue.length > 0) { + const id = queue.pop() as StableId; + if (id.kind === "schema") continue; // schema boundary (see doc above) + const exts = closure.get(encodeId(id)) as StableId[]; + for (const child of fb.childrenOf(id)) { + if (isSatelliteId(child.id)) continue; + const key = encodeId(child.id); + if (closure.has(key)) continue; + closure.set(key, exts); + queue.push(child.id); + } + } + return closure; +} + +/** Encoded ids to mark REFERENCE-ONLY for extension members — the member + * objects (and their non-satellite descendants) from `extensionMemberClosure`. + * The diff descends into a reference-only fact's children (diff.ts), so a + * member's satellite customizations are still compared while the member object + * itself never becomes a create/drop/alter action. */ +export function extensionMemberReferenceOnly(fb: FactBase): Set { + return new Set(extensionMemberClosure(fb).keys()); +} + +/** + * Project OUT every fact carrying an outgoing edge of `edgeKind`, plus its + * descendant subtree. Roots are selected by provenance; the removal + edge + * pruning is `excludeFactsAndDescendants`. + */ +export function excludeByProvenance( + fb: FactBase, + edgeKind: EdgeKind, +): FactBase { + const roots = new Set(); + for (const fact of fb.facts()) { + if (fb.outgoingEdges(fact.id).some((e) => e.kind === edgeKind)) { + roots.add(encodeId(fact.id)); + } + } + return excludeFactsAndDescendants(fb, roots); +} diff --git a/packages/pg-delta/src/proof/prove.test.ts b/packages/pg-delta/src/proof/prove.test.ts new file mode 100644 index 000000000..2a6fcbb62 --- /dev/null +++ b/packages/pg-delta/src/proof/prove.test.ts @@ -0,0 +1,467 @@ +/** + * Unit tests for the pure proof verdict logic (src/proof/prove.ts + * detectViolations). No Docker / database required. + * + * Hardening Item 2 / review #3: row-count preservation is not content + * preservation. A content change on a table the plan did NOT touch is a + * data-preservation violation; on a table the plan alters it is expected. The + * proof reports honest per-table coverage instead of a bare boolean. + */ +import { describe, expect, test } from "bun:test"; +import { + composeAutoSeedBaseline, + detectAutoSeedSideEffects, + detectViolations, + reconcileSeedOutcomes, + relKey, + type SeedOutcome, +} from "./prove.ts"; + +// TableStat is module-internal; the tests only need its shape. +type Stat = { + rows: number; + relfilenode: string; + schemaSig: string; + content?: string; +}; + +const ctx = (over: Partial[2]> = {}) => ({ + recreatedTables: new Set(), + declaredRewriteTables: new Set(), + ...over, +}); + +// The before/after maps are keyed by relKey (a JSON [schema, name] tuple) — the +// same collision-free key provePlan uses; build them from readable parts. +const m = (entries: Array<[schema: string, name: string, stat: Stat]>) => + new Map(entries.map(([s, n, stat]) => [relKey(s, n), stat])); + +const SIG = "id:23"; // a stable column signature + +describe("detectViolations — content + coverage (review #3)", () => { + test("row count change is a data violation", () => { + const before = m([ + [ + "public", + "t", + { rows: 3, relfilenode: "1", schemaSig: SIG, content: "a" }, + ], + ]); + const after = m([ + [ + "public", + "t", + { rows: 2, relfilenode: "1", schemaSig: SIG, content: "b" }, + ], + ]); + const v = detectViolations(before, after, ctx()); + expect(v.dataViolations).toEqual([ + { table: { schema: "public", name: "t" }, before: 3, after: 2 }, + ]); + }); + + test("content change with UNCHANGED schema is a violation (count held)", () => { + const before = m([ + [ + "public", + "t", + { rows: 2, relfilenode: "1", schemaSig: SIG, content: "a" }, + ], + ]); + const after = m([ + [ + "public", + "t", + { rows: 2, relfilenode: "1", schemaSig: SIG, content: "b" }, + ], + ]); + const v = detectViolations(before, after, ctx()); + expect(v.dataViolations).toEqual([ + { + table: { schema: "public", name: "t" }, + before: 2, + after: 2, + contentChanged: true, + }, + ]); + }); + + test("content change under a SCHEMA change is expected, not a violation", () => { + // e.g. a column propagated from a partitioned parent: whole-row text + // changes but no data was lost — schemaSig differs, so only count is trusted + const before = m([ + [ + "public", + "t", + { rows: 2, relfilenode: "1", schemaSig: SIG, content: "a" }, + ], + ]); + const after = m([ + [ + "public", + "t", + { + rows: 2, + relfilenode: "2", + schemaSig: `${SIG},note:25`, + content: "b", + }, + ], + ]); + const v = detectViolations( + before, + after, + ctx({ declaredRewriteTables: new Set([relKey("public", "t")]) }), + ); + expect(v.dataViolations).toEqual([]); + expect(v.rewriteViolations).toEqual([]); + }); + + test("coverage classifies content modes honestly", () => { + const before = m([ + // non-empty, schema stable → fingerprint + [ + "public", + "checked", + { rows: 1, relfilenode: "1", schemaSig: SIG, content: "x" }, + ], + // non-empty, schema changed → count + [ + "public", + "altered", + { rows: 1, relfilenode: "1", schemaSig: SIG, content: "y" }, + ], + // empty → none + ["public", "empty", { rows: 0, relfilenode: "1", schemaSig: SIG }], + ]); + const after = m([ + [ + "public", + "checked", + { rows: 1, relfilenode: "1", schemaSig: SIG, content: "x" }, + ], + [ + "public", + "altered", + { + rows: 1, + relfilenode: "1", + schemaSig: `${SIG},note:25`, + content: "y2", + }, + ], + ["public", "empty", { rows: 0, relfilenode: "1", schemaSig: SIG }], + ]); + const v = detectViolations(before, after, ctx()); + const mode = (name: string) => + v.coverage.perTable.find( + (p) => p.table.schema === "public" && p.table.name === name, + )?.contentMode; + expect(v.coverage.tablesChecked).toBe(3); + expect(mode("checked")).toBe("fingerprint"); + expect(mode("altered")).toBe("count"); + expect(mode("empty")).toBe("none"); + }); + + test("renamed table is CHECKED under the new name; a doctored change is a violation (F7)", () => { + const OLD = relKey("public", "old"); + const NEW = relKey("public", "new"); + + // RED baseline: with NO rename map, the real planner puts the OLD relKey in + // recreatedTables (an accepted rename destroys the old subtree), and the NEW + // relKey in `after` has no before-match — so the renamed table is neither + // checked nor a violation. A silent data-preservation blind spot. + const before = m([ + [ + "public", + "old", + { rows: 3, relfilenode: "1", schemaSig: SIG, content: "a" }, + ], + ]); + // data now lives under the NEW name; doctor its content (count held) + const after = m([ + [ + "public", + "new", + { rows: 3, relfilenode: "1", schemaSig: SIG, content: "b" }, + ], + ]); + const baseline = detectViolations( + before, + after, + ctx({ recreatedTables: new Set([OLD]) }), + ); + expect(baseline.coverage.tablesChecked).toBe(0); + expect(baseline.coverage.tablesSkipped.map((s) => s.table.name)).toContain( + "old", + ); + + // GREEN: with the rename map old→new (and the source removed from + // recreatedTables, as provePlan does), the table is CHECKED under the NEW + // name and the doctored content is reported as a data violation. + const v = detectViolations( + before, + after, + ctx({ renamedTables: new Map([[OLD, NEW]]) }), + ); + expect(v.coverage.tablesChecked).toBe(1); + expect(v.coverage.perTable[0]?.table).toEqual({ + schema: "public", + name: "new", + }); + expect(v.coverage.tablesSkipped).toEqual([]); + expect(v.dataViolations).toEqual([ + { + table: { schema: "public", name: "new" }, + before: 3, + after: 3, + contentChanged: true, + }, + ]); + }); + + test("recreated tables are skipped with a reason, not checked", () => { + const before = m([ + [ + "public", + "t", + { rows: 5, relfilenode: "1", schemaSig: SIG, content: "a" }, + ], + ]); + const after = m([ + [ + "public", + "t", + { rows: 0, relfilenode: "9", schemaSig: SIG, content: "" }, + ], + ]); + const v = detectViolations( + before, + after, + ctx({ recreatedTables: new Set([relKey("public", "t")]) }), + ); + expect(v.dataViolations).toEqual([]); // recreated → row/content change expected + expect(v.coverage.tablesChecked).toBe(0); + expect(v.coverage.tablesSkipped).toEqual([ + { + table: { schema: "public", name: "t" }, + reason: "recreated by the plan", + }, + ]); + }); +}); + +describe("reconcileSeedOutcomes — provisional seeds vs the final snapshot", () => { + const stats = (entries: Array<[string, string, number]>) => + new Map( + entries.map(([s, n, rows]) => [ + relKey(s, n), + { rows, relfilenode: "1", schemaSig: SIG }, + ]), + ); + + test("a `seeded` row absent from the final snapshot is downgraded to no_row", () => { + const outcomes: SeedOutcome[] = [ + { table: { schema: "s", name: "gone" }, status: "seeded" }, + ]; + expect(reconcileSeedOutcomes(outcomes, stats([["s", "gone", 0]]))).toEqual([ + { + table: { schema: "s", name: "gone" }, + status: "skipped", + reasonCode: "no_row", + }, + ]); + }); + + test("a `seeded` row that persists stays seeded", () => { + const outcomes: SeedOutcome[] = [ + { table: { schema: "s", name: "kept" }, status: "seeded" }, + ]; + expect(reconcileSeedOutcomes(outcomes, stats([["s", "kept", 1]]))).toEqual( + outcomes, + ); + }); + + test("skipped / failed outcomes pass through unchanged", () => { + const outcomes: SeedOutcome[] = [ + { + table: { schema: "s", name: "a" }, + status: "skipped", + reasonCode: "23502", + }, + { + table: { schema: "s", name: "b" }, + status: "failed", + reasonCode: "P0001", + message: "boom", + }, + ]; + // present with rows 0, but they are already terminal — not reconciled + expect( + reconcileSeedOutcomes( + outcomes, + stats([ + ["s", "a", 0], + ["s", "b", 0], + ]), + ), + ).toEqual(outcomes); + }); + + test("a `seeded` table missing from the snapshot is left seeded (defensive)", () => { + const outcomes: SeedOutcome[] = [ + { table: { schema: "s", name: "orphan" }, status: "seeded" }, + ]; + expect(reconcileSeedOutcomes(outcomes, stats([]))).toEqual(outcomes); + }); +}); + +describe("composeAutoSeedBaseline — preserve original data across seeding", () => { + const stats = (entries: Array<[string, string, number]>) => + new Map( + entries.map(([s, n, rows]) => [ + relKey(s, n), + { rows, relfilenode: String(rows), schemaSig: SIG }, + ]), + ); + + test("keeps pre-seed stats for populated tables and post-seed stats for empty tables", () => { + const preSeed = stats([ + ["s", "populated", 2], + ["s", "empty", 0], + ]); + const postSeed = stats([ + ["s", "populated", 0], + ["s", "empty", 1], + ]); + + const baseline = composeAutoSeedBaseline(preSeed, postSeed); + expect(baseline.get(relKey("s", "populated"))?.rows).toBe(2); + expect(baseline.get(relKey("s", "empty"))?.rows).toBe(1); + }); + + test("retains a populated pre-seed table even if a seed trigger removes it", () => { + const preSeed = stats([["s", "removed", 1]]); + const baseline = composeAutoSeedBaseline(preSeed, stats([])); + expect(baseline.get(relKey("s", "removed"))?.rows).toBe(1); + }); +}); + +describe("detectAutoSeedSideEffects — pre-plan data guard", () => { + test("detects equal-row-count content changes on populated tables", () => { + const preSeed = m([ + [ + "s", + "populated", + { rows: 1, relfilenode: "1", schemaSig: SIG, content: "original" }, + ], + ]); + const postSeed = m([ + [ + "s", + "populated", + { rows: 1, relfilenode: "1", schemaSig: SIG, content: "mutated" }, + ], + ]); + + expect(detectAutoSeedSideEffects(preSeed, postSeed, new Set())).toEqual([ + { + table: { schema: "s", name: "populated" }, + before: 1, + after: 1, + contentChanged: true, + }, + ]); + }); + + test("ignores intentional synthetic rows in originally-empty tables", () => { + const preSeed = m([ + ["s", "empty", { rows: 0, relfilenode: "1", schemaSig: SIG }], + ]); + const postSeed = m([ + [ + "s", + "empty", + { rows: 1, relfilenode: "1", schemaSig: SIG, content: "synthetic" }, + ], + ]); + + expect(detectAutoSeedSideEffects(preSeed, postSeed, new Set())).toEqual([]); + }); + + test("rejects seed-time schema changes on originally-empty kept tables", () => { + const preSeed = m([ + ["s", "empty", { rows: 0, relfilenode: "1", schemaSig: SIG }], + ]); + const postSeed = m([ + [ + "s", + "empty", + { + rows: 1, + relfilenode: "2", + schemaSig: `${SIG},note:25`, + content: "synthetic", + }, + ], + ]); + + expect(detectAutoSeedSideEffects(preSeed, postSeed, new Set())).toEqual([ + { + table: { schema: "s", name: "empty" }, + before: 0, + after: 1, + schemaChanged: true, + }, + ]); + }); + + test("ignores populated tables the plan intentionally recreates", () => { + const table = relKey("s", "recreated"); + const preSeed = m([ + [ + "s", + "recreated", + { rows: 1, relfilenode: "1", schemaSig: SIG, content: "original" }, + ], + ]); + const postSeed = m([ + ["s", "recreated", { rows: 0, relfilenode: "1", schemaSig: SIG }], + ]); + + expect( + detectAutoSeedSideEffects(preSeed, postSeed, new Set([table])), + ).toEqual([]); + }); + + test("rejects seed-time schema changes on populated kept tables", () => { + const preSeed = m([ + [ + "s", + "populated", + { rows: 1, relfilenode: "1", schemaSig: SIG, content: "before" }, + ], + ]); + const postSeed = m([ + [ + "s", + "populated", + { + rows: 1, + relfilenode: "2", + schemaSig: `${SIG},note:25`, + content: "after", + }, + ], + ]); + + expect(detectAutoSeedSideEffects(preSeed, postSeed, new Set())).toEqual([ + { + table: { schema: "s", name: "populated" }, + before: 1, + after: 1, + schemaChanged: true, + }, + ]); + }); +}); diff --git a/packages/pg-delta/src/proof/prove.ts b/packages/pg-delta/src/proof/prove.ts new file mode 100644 index 000000000..cadcb4a03 --- /dev/null +++ b/packages/pg-delta/src/proof/prove.ts @@ -0,0 +1,753 @@ +/** + * The proof loop (target-architecture §3.7), as a product API. + * Materialization (template clone / render-from-fact-base) is the caller's + * concern; this module owns the checks that turn declared safety metadata + * into VERIFIED claims: + * 1. state proof — apply, re-extract, zero drift deltas + * 2. data preservation — pre-seeded rows survive in tables the plan keeps + * 3. rewrite observation — a relfilenode that changed under an action + * that did NOT declare rewriteRisk is a failed proof (§3.7: rewrite + * risk is observed on the clone, not certified by the rule) + */ +import type { Pool } from "pg"; +import { apply } from "../apply/apply.ts"; +import { diff, type Delta } from "../core/diff.ts"; +import type { FactBase } from "../core/fact.ts"; +import type { StableId } from "../core/stable-id.ts"; +import { extract } from "../extract/extract.ts"; +import type { Action, Plan } from "../plan/plan.ts"; +import { projectTarget } from "../plan/project.ts"; +import type { Policy } from "../policy/policy.ts"; +import { reconstructManagedView } from "../policy/reconstruct.ts"; +import type { ApplierCapability } from "../policy/capability.ts"; + +/** Structured table identity on the verdict: a collision-free { schema, name } + * (NOT a dotted string — identifiers can contain dots, review P2). Consumers + * render it with render.ts `rel()` for a properly-quoted, copy-pasteable ref. */ +export interface TableRef { + schema: string; + name: string; +} + +/** The result of best-effort auto-seeding one empty kept table (opt-in via + * `ProveOptions.autoSeed`). Taxonomy is by SQLSTATE class, NOT string matching: + * - `seeded` — a synthetic `DEFAULT VALUES` row landed; the table now has + * content-fingerprint coverage in the data-preservation check. + * - `skipped` — no row persisted and that is expected, in two shapes: + * (a) the insert hit a class-23 integrity-constraint violation (`reasonCode` + * = the SQLSTATE: `23502` NOT NULL w/o default, `23503` FK, `23505` unique, + * `23514` check, any `23xxx`); or (b) the insert RESOLVED but the row is + * absent from the FINAL pre-apply snapshot — a BEFORE INSERT trigger returned + * NULL, a DO INSTEAD rule suppressed it, or an AFTER INSERT trigger deleted it + * (possibly while seeding a LATER table). rowCount is only the command tag, so + * persistence is judged once by reconciling against that snapshot, not per + * insert. This carries the synthetic sentinel `reasonCode` `"no_row"`, the one + * skip code that is NOT a SQLSTATE. Either way the table keeps + * `contentMode: "none"`. + * - `failed` — anything else (a raised exception, connection/syntax/permission + * error, or a driver error with no code): a real problem the caller must see + * rather than have swallowed. `reasonCode` is the SQLSTATE when the driver + * supplied one, and `message` is the error text. */ +export type SeedOutcome = + | { table: TableRef; status: "seeded" } + | { table: TableRef; status: "skipped"; reasonCode: string } + | { table: TableRef; status: "failed"; reasonCode?: string; message: string }; + +export interface DataViolation { + table: TableRef; + before: number; + after: number; + /** count held but row CONTENT changed on an untouched table (review #3) */ + contentChanged?: boolean; + /** autoSeed changed the table's schema before the plan ran, so row content + * cannot be compared safely (including a table that started empty) */ + schemaChanged?: boolean; +} + +export interface SeedStateViolation { + /** managed-state fingerprint the plan was produced from */ + expectedFingerprint: string; + /** managed-state fingerprint observed after autoSeed, before plan apply */ + actualFingerprint: string; +} + +export interface ProofVerdict { + ok: boolean; + applyError?: { actionIndex: number; sql: string; message: string }; + driftDeltas: Delta[]; + /** a kept table whose data changed: row count differs, OR (on a table the + * plan did NOT touch) content changed though the count held — drop+recreate + * masquerading as preservation, an undeclared destructive operation, or an + * autoSeed trigger mutating pre-existing data before the plan ran */ + dataViolations: DataViolation[]; + /** subset of `dataViolations` detected before the plan ran and caused by + * autoSeed itself. Present only on that early-failure path so harnesses can + * distinguish a seed audit failure from an expected migration failure. */ + seedSideEffects?: DataViolation[]; + /** autoSeed changed extracted managed state before the plan ran (RLS, + * constraints, reloptions, replica identity, or any other modeled fact). */ + seedStateViolation?: SeedStateViolation; + /** a kept table that was physically rewritten (relfilenode changed) + * under no action declaring rewriteRisk — the rule under-declared */ + rewriteViolations: Array<{ table: TableRef }>; + /** what the proof actually verified, per table — honest coverage instead of + * a bare boolean (review #3). `ok` is backed by this. */ + coverage: ProofCoverage; + /** per-table auto-seed outcomes, present ONLY when `options.autoSeed` was set + * (seeding runs before the plan is applied, so this is populated even on the + * apply-failure early return). Lets a harness tell a genuinely-unseedable + * table (`skipped`, class-23) apart from one that failed for a reason nobody + * saw (`failed`) instead of both collapsing to `contentMode: "none"`. */ + seedOutcomes?: SeedOutcome[]; +} + +export interface TableCoverage { + table: TableRef; + /** how this table's data was checked: + * - "fingerprint": non-empty + untouched by the plan → full content compared + * - "count": non-empty but the plan alters it → only row count compared + * (a schema change legitimately changes content) + * - "none": empty before applying → nothing to check (seed it to get teeth) */ + contentMode: "fingerprint" | "count" | "none"; + recreated: boolean; + rewriteDeclared: boolean; + rowsBefore: number; + rowsAfter: number; +} + +export interface ProofCoverage { + /** tables present before+after and actually compared */ + tablesChecked: number; + /** tables not compared, with why (recreated/dropped by the plan) */ + tablesSkipped: Array<{ table: TableRef; reason: string }>; + perTable: TableCoverage[]; +} + +export interface ProveOptions { + /** best-effort seed empty kept tables with a synthetic row before + * applying, so the data-preservation check has teeth even for scenarios + * that ship no seed.sql. Default false (opt-in): enabling it surfaces + * populated-table migration hazards, which is a separate audit. */ + autoSeed?: boolean; + /** how to re-extract the clone after applying. Defaults to the core + * `extract`. An integration with extension handlers MUST pass a handler-aware + * re-extractor — `extract(pool, { handlers })`, which the resolved profile + * supplies as `proveOptions.reextract` — so the proof emits the same + * `managedBy` edges and `resolveView` projects out the same managed view it + * diffed; otherwise operationally-managed objects (pg_partman children, …) + * reappear as drift (docs/architecture/extension-intent.md §6). */ + reextract?: (pool: Pool) => Promise<{ factBase: FactBase }>; + /** the policy the plan was produced with. The proof must compare the SAME + * managed view it diffed, so `resolveView(.., policy)` is applied to both the + * re-extracted clone and the target — otherwise policy-scoped objects + * (system schemas/roles) reappear as drift (docs/architecture/managed-view-architecture.md). */ + policy?: Policy; + /** the applier capability the plan was produced with (move 6) — applied to + * the proof's view symmetrically so a capability-excluded object (e.g. an + * FDW ACL on a non-superuser target) doesn't reappear as drift. */ + capability?: ApplierCapability; + /** the resolved platform baseline the plan was produced with (§3.9). The + * baseline is NOT carried in the plan artifact, so a baseline-shaped plan + * must be re-supplied here; otherwise the proof cannot reconstruct the same + * view it diffed and fails loudly (P0-2). */ + baseline?: FactBase; +} + +interface TableStat { + rows: number; + relfilenode: string; + /** column signature (attname:atttypid, ordered) — content is only comparable + * when this is unchanged; a schema change (incl. a column propagated from a + * partitioned parent) legitimately changes whole-row text. */ + schemaSig: string; + /** deterministic content fingerprint, present only for non-empty tables + * (md5 over order-independent row text). Undefined ⇒ empty ⇒ not checked. */ + content?: string; +} + +const qte = (s: string): string => `"${s.replaceAll('"', '""')}"`; + +/** One round trip: every user table's relfilenode + exact row count. */ +async function tableStats(pool: Pool): Promise> { + const rels = await pool.query<{ + schema: string; + name: string; + relfilenode: string; + schemasig: string | null; + }>(` + SELECT n.nspname AS schema, c.relname AS name, + c.relfilenode::text AS relfilenode, + (SELECT string_agg( + -- atttypmod captures precision/scale/length (numeric(p,s), + -- varchar(n)): a typmod change rewrites stored text + -- (9.9 → 9.9000) without changing atttypid, so fold it in + -- too — an intentional ALTER COLUMN … TYPE is a schema + -- change, not a data mutation. + a.attname || ':' || a.atttypid::text || ':' + || a.atttypmod::text || ':' || COALESCE(( + -- a column of a COMPOSITE type changes stored + -- representation when the type gains/drops/retypes an + -- attribute, even though atttypid is unchanged. Fold the + -- composite's attribute signature in so such a change + -- flips content to count-only — an additive ALTER TYPE … + -- ADD ATTRIBUTE is lossless, not a data mutation (one + -- level deep; nested composites are a known gap). + SELECT string_agg( + ca.attname || ':' || ca.atttypid::text, ',' + ORDER BY ca.attnum) + FROM pg_type ct + JOIN pg_class crel ON crel.oid = ct.typrelid + JOIN pg_attribute ca ON ca.attrelid = crel.oid + AND ca.attnum > 0 AND NOT ca.attisdropped + WHERE ct.oid = a.atttypid AND ct.typtype = 'c' + ), ''), + ',' ORDER BY a.attnum) + FROM pg_attribute a + WHERE a.attrelid = c.oid AND a.attnum > 0 + AND NOT a.attisdropped) AS schemasig + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg\\_%' + ORDER BY 1, 2`); + const stats = new Map(); + if (rels.rows.length === 0) return stats; + // a single wide SELECT of all counts avoids the per-table N+1 + const counts = rels.rows + .map( + (r, i) => + `(SELECT count(*) FROM ${qte(r.schema)}.${qte(r.name)}) AS c${i}`, + ) + .join(", "); + const countRow = (await pool.query(`SELECT ${counts}`)).rows[0] as Record< + string, + string + >; + rels.rows.forEach((r, i) => { + stats.set(relKey(r.schema, r.name), { + rows: Number(countRow[`c${i}`]), + relfilenode: r.relfilenode, + schemaSig: r.schemasig ?? "", + }); + }); + + // content fingerprints for NON-EMPTY tables only (bounds the cost: empty + // tables have nothing to fingerprint; large untouched tables are scanned + // once — proof is an opt-in extra apply+extract). Order-independent so the + // digest is deterministic regardless of physical row order. + const nonEmpty = rels.rows.filter((_r, i) => Number(countRow[`c${i}`]) > 0); + if (nonEmpty.length > 0) { + const fps = nonEmpty + .map( + (r, i) => + `(SELECT md5(coalesce(string_agg(x, E'\\n'), '')) ` + + `FROM (SELECT t::text AS x FROM ${qte(r.schema)}.${qte(r.name)} t ORDER BY 1) q) AS f${i}`, + ) + .join(", "); + const fpRow = (await pool.query(`SELECT ${fps}`)).rows[0] as Record< + string, + string + >; + nonEmpty.forEach((r, i) => { + const stat = stats.get(relKey(r.schema, r.name)); + const fp = fpRow[`f${i}`]; + if (stat && fp !== undefined) stat.content = fp; + }); + } + return stats; +} + +/** Collision-free key for a (schema, name) relation: a JSON tuple, NOT a dotted + * string — PostgreSQL identifiers can contain dots, so `${schema}.${name}` is + * ambiguous (schema "a.b"/table "c" vs schema "a"/table "b.c") and a `.split` + * would mis-quote the seed target (review P2). */ +export function relKey(schema: string, name: string): string { + return JSON.stringify([schema, name]); +} +function parseRelKey(key: string): [string, string] { + return JSON.parse(key) as [string, string]; +} + +/** The table relation a fact id belongs to, as a relKey, or undefined for ids + * that are not table-scoped. */ +function tableRelationOf(id: StableId): string | undefined { + if (id.kind === "table" || id.kind === "materializedView") { + const t = id as { schema: string; name: string }; + return relKey(t.schema, t.name); + } + const t = id as { schema?: string; table?: string }; + if (typeof t.schema === "string" && typeof t.table === "string") { + return relKey(t.schema, t.table); + } + return undefined; +} + +function tablesReferencedBy(action: Action): Set { + const out = new Set(); + for (const id of [ + ...action.produces, + ...action.consumes, + ...action.destroys, + ]) { + const rel = tableRelationOf(id); + if (rel !== undefined) out.add(rel); + } + return out; +} + +async function autoSeedEmptyTables( + pool: Pool, + candidates: Iterable, +): Promise { + const outcomes: SeedOutcome[] = []; + for (const table of candidates) { + const [schema, name] = parseRelKey(table); + const ref: TableRef = { schema, name }; + // best-effort: DEFAULT VALUES only succeeds when every column is nullable + // or defaulted. Classify the failure by SQLSTATE class, not string match: + // a class-23 integrity-constraint violation (NOT NULL w/o default, FK, + // unique, check) is an EXPECTED "unseedable" → `skipped`; anything else (a + // raised exception, connection/syntax/permission error, unknown) is a real + // problem → `failed`, so it can't hide behind `contentMode: "none"`. + try { + await pool.query( + `INSERT INTO ${qte(schema)}.${qte(name)} DEFAULT VALUES`, + ); + // PROVISIONAL: a resolved insert is NOT proof of a persisted row, and + // rowCount is only the command tag. Persistence is judged once, later, by + // reconcileSeedOutcomes against the single FINAL pre-apply snapshot — that + // catches same-table suppression (BEFORE trigger → NULL), same-table undo + // (AFTER trigger delete), AND cross-table undo (seeding a LATER table + // deletes THIS row), which no per-insert probe can see. A row that ends + // that snapshot gone is downgraded to skipped("no_row"). + outcomes.push({ table: ref, status: "seeded" }); + } catch (err) { + const rawCode = (err as { code?: unknown }).code; + const code = typeof rawCode === "string" ? rawCode : undefined; + if (code !== undefined && code.startsWith("23")) { + outcomes.push({ table: ref, status: "skipped", reasonCode: code }); + } else { + outcomes.push({ + table: ref, + status: "failed", + ...(code !== undefined ? { reasonCode: code } : {}), + message: err instanceof Error ? err.message : String(err), + }); + } + } + } + return outcomes; +} + +/** + * Reconcile provisional `seeded` outcomes against the FINAL pre-apply table + * snapshot: a synthetic row that no longer exists there (a trigger/rule + * suppressed or deleted it — possibly while seeding a LATER table) was never + * really seeded, so downgrade it to `skipped("no_row")`. One source of truth + * for persistence; `skipped`/`failed` outcomes are already terminal and pass + * through unchanged. Pure (no DB) — unit-testable. (`no_row` is the one + * non-SQLSTATE skip reasonCode; see SeedOutcome.) + */ +export function reconcileSeedOutcomes( + outcomes: SeedOutcome[], + finalStats: Map, +): SeedOutcome[] { + return outcomes.map((o) => { + if (o.status !== "seeded") return o; + const stat = finalStats.get(relKey(o.table.schema, o.table.name)); + return stat !== undefined && stat.rows === 0 + ? { table: o.table, status: "skipped", reasonCode: "no_row" } + : o; + }); +} + +/** + * Build the data-proof baseline after auto-seeding without letting seed-trigger + * side effects erase the source data we meant to protect: + * - tables that were already populated stay anchored to their PRE-seed stats; + * - tables that were empty use the FINAL post-seed stats, so a surviving + * synthetic row gives the proof content coverage; + * - post-seed-only tables pass through defensively (state proof owns them). + * + * `autoSeedEmptyTables` can fire arbitrary user triggers. A trigger on an empty + * candidate may update/delete a different, populated table; using only the + * post-seed snapshot would silently accept that damage as the proof baseline. + */ +export function composeAutoSeedBaseline( + preSeed: Map, + postSeed: Map, +): Map { + const baseline = new Map(postSeed); + for (const [table, stat] of preSeed) { + if (stat.rows > 0) baseline.set(table, stat); + } + return baseline; +} + +/** + * Detect autoSeed side effects while pre/post fingerprints are directly + * comparable: no plan action has run between these snapshots. Schema is + * protected for every kept table; row/content changes are protected only for + * populated tables because originally-empty tables are expected to gain a + * synthetic row. Recreated tables carry no data-preservation claim. + */ +export function detectAutoSeedSideEffects( + preSeed: Map, + postSeed: Map, + recreatedTables: Set, +): ProofVerdict["dataViolations"] { + const violations: ProofVerdict["dataViolations"] = []; + for (const [table, before] of preSeed) { + if (recreatedTables.has(table)) continue; + const [schema, name] = parseRelKey(table); + const ref: TableRef = { schema, name }; + const after = postSeed.get(table); + if (before.rows === 0) { + if (after === undefined || after.schemaSig !== before.schemaSig) { + violations.push({ + table: ref, + before: 0, + after: after?.rows ?? 0, + schemaChanged: true, + }); + } + continue; + } + if (after === undefined || after.rows !== before.rows) { + violations.push({ + table: ref, + before: before.rows, + after: after?.rows ?? 0, + }); + } else if (after.schemaSig !== before.schemaSig) { + // No plan action has run yet: unlike the final proof comparison, there is + // no legitimate schema transition to tolerate between these snapshots. + violations.push({ + table: ref, + before: before.rows, + after: after.rows, + schemaChanged: true, + }); + } else if ( + before.content !== undefined && + after.content !== undefined && + before.content !== after.content + ) { + violations.push({ + table: ref, + before: before.rows, + after: after.rows, + contentChanged: true, + }); + } + } + return violations; +} + +/** + * Pure verdict logic over before/after table stats (testable without a DB). + * + * For every table present before applying: + * - recreated/dropped by the plan → skipped (changes are expected), reported + * - row count changed → data violation + * - count held but CONTENT changed while the SCHEMA SIGNATURE is unchanged → + * data violation (genuine data mutation; if the schema changed — e.g. a + * column propagated from a partitioned parent — content is not comparable, + * so only the count is trusted) + * - relfilenode changed with no rewriteRisk-declaring action → rewrite + * violation + * and emits an honest per-table coverage report (review #3). + */ +export function detectViolations( + before: Map, + after: Map, + ctx: { + recreatedTables: Set; + declaredRewriteTables: Set; + /** oldRelKey → newRelKey for accepted table renames. The data lives under + * the NEW key in `after`, so a renamed table is compared before(old) vs + * after(new) instead of being skipped as "dropped/recreated" (F7). Empty / + * absent ⇒ behavior is byte-identical to the non-rename path. */ + renamedTables?: Map; + }, +): { + dataViolations: ProofVerdict["dataViolations"]; + rewriteViolations: ProofVerdict["rewriteViolations"]; + coverage: ProofCoverage; +} { + const dataViolations: ProofVerdict["dataViolations"] = []; + const rewriteViolations: ProofVerdict["rewriteViolations"] = []; + const perTable: TableCoverage[] = []; + const tablesSkipped: ProofCoverage["tablesSkipped"] = []; + + for (const [table, beforeStat] of before) { + // `table` is the collision-free relKey the before/after maps are keyed by. + // For an accepted rename the data now lives under the NEW key in `after`, so + // resolve the after-side key through the rename map (identity otherwise). + const afterKey = ctx.renamedTables?.get(table) ?? table; + // The verdict carries the parsed { schema, name } so consumers never re-parse + // a JSON/dotted string (render with render.ts `rel()` for display). Use the + // AFTER key: for a rename that is the NEW name (where the data lives now); + // for every other table afterKey === table, so this is unchanged. + const [schema, name] = parseRelKey(afterKey); + const ref: TableRef = { schema, name }; + const afterStat = after.get(afterKey); + if (afterStat === undefined) { + tablesSkipped.push({ table: ref, reason: "dropped by the plan" }); + continue; + } + if (ctx.recreatedTables.has(table)) { + tablesSkipped.push({ table: ref, reason: "recreated by the plan" }); + continue; + } + + const schemaStable = beforeStat.schemaSig === afterStat.schemaSig; + if (afterStat.rows !== beforeStat.rows) { + dataViolations.push({ + table: ref, + before: beforeStat.rows, + after: afterStat.rows, + }); + } else if ( + schemaStable && + beforeStat.content !== undefined && + afterStat.content !== undefined && + beforeStat.content !== afterStat.content + ) { + dataViolations.push({ + table: ref, + before: beforeStat.rows, + after: afterStat.rows, + contentChanged: true, + }); + } + + if ( + afterStat.relfilenode !== beforeStat.relfilenode && + !ctx.declaredRewriteTables.has(table) + ) { + rewriteViolations.push({ table: ref }); + } + + const contentMode: TableCoverage["contentMode"] = + beforeStat.content === undefined + ? "none" + : schemaStable + ? "fingerprint" + : "count"; + perTable.push({ + table: ref, + contentMode, + recreated: false, + rewriteDeclared: ctx.declaredRewriteTables.has(table), + rowsBefore: beforeStat.rows, + rowsAfter: afterStat.rows, + }); + } + + return { + dataViolations, + rewriteViolations, + coverage: { tablesChecked: perTable.length, tablesSkipped, perTable }, + }; +} + +/** + * Prove a plan against a sacrificial clone of the source. The clone is + * mutated; never pass a real target. + */ +export async function provePlan( + thePlan: Plan, + clonePool: Pool, + desired: FactBase, + options: ProveOptions = {}, +): Promise { + // tables the plan tears down (drop or replace) are NOT "kept"; relfilenode + // and row-count changes on them are expected, not violations + const recreatedTables = new Set(); + const declaredRewriteTables = new Set(); + for (const action of thePlan.actions) { + for (const id of action.destroys) { + const rel = tableRelationOf(id); + if ( + rel !== undefined && + (id.kind === "table" || id.kind === "materializedView") + ) + recreatedTables.add(rel); + } + if (action.rewriteRisk) { + for (const rel of tablesReferencedBy(action)) + declaredRewriteTables.add(rel); + } + } + + // Accepted table renames: the rename action destroys the OLD subtree (so the + // old relKey landed in `recreatedTables` above), but the table is KEPT — its + // data just moved to the NEW name. Map old→new for the proof and un-mark the + // old key as recreated, so the renamed table is compared before(old) vs + // after(new) instead of being silently skipped (F7). + const renamedTables = new Map(); + for (const r of thePlan.acceptedRenames ?? []) { + if ( + (r.from.kind === "table" || r.from.kind === "materializedView") && + (r.to.kind === "table" || r.to.kind === "materializedView") + ) { + const from = tableRelationOf(r.from); + const to = tableRelationOf(r.to); + if (from !== undefined && to !== undefined) renamedTables.set(from, to); + } + } + for (const from of renamedTables.keys()) recreatedTables.delete(from); + + // Reconstruct the exact managed view the plan fingerprinted. The same helper + // serves both the post-autoSeed pre-apply guard and the final convergence + // proof, so policy/capability/baseline/scope cannot drift between them. + const policy = options.policy ?? thePlan.policy; + const capability = options.capability ?? thePlan.capability; + if (policy?.baseline !== undefined && options.baseline === undefined) { + throw new Error( + `provePlan: plan was produced with policy "${policy.id}" declaring baseline ` + + `"${policy.baseline}", but no baseline was supplied; pass the resolved baseline ` + + `as options.baseline so the proof compares the same view the plan diffed.`, + ); + } + const viewOpts = { + policy, + capability, + baseline: options.baseline, + scope: thePlan.scope, + defaultOwner: thePlan.defaultOwner, + }; + const reextractClone = (): Promise<{ factBase: FactBase }> => + options.reextract + ? options.reextract(clonePool) + : extract(clonePool, { redactSecrets: thePlan.redactSecrets ?? true }); + const managedView = (factBase: FactBase): FactBase => + reconstructManagedView(factBase, viewOpts); + + // populated only when autoSeed ran, so it stays out of the verdict entirely + // on the default opt-out path (present ⇒ autoSeed was requested). + let seedOutcomes: SeedOutcome[] | undefined; + let preSeedStats: Map | undefined; + if (options.autoSeed) { + preSeedStats = await tableStats(clonePool); + const empty = [...preSeedStats] + .filter(([t, s]) => s.rows === 0 && !recreatedTables.has(t)) + .map(([t]) => t); + seedOutcomes = await autoSeedEmptyTables(clonePool, empty); + } + + const postSeedStats = await tableStats(clonePool); + // reconcile provisional seeds against `postSeedStats` — the single FINAL pre-apply + // snapshot (taken after ALL seeding), so a row later suppressed/undone by a + // trigger or rule (even cross-table) is downgraded to skipped("no_row"). + if (seedOutcomes !== undefined) { + seedOutcomes = reconcileSeedOutcomes(seedOutcomes, postSeedStats); + } + const seedSideEffects = + preSeedStats === undefined + ? [] + : detectAutoSeedSideEffects(preSeedStats, postSeedStats, recreatedTables); + // Do not apply a plan to a clone whose pre-existing data autoSeed already + // changed. Capturing the violation now, while schemas are still comparable, + // prevents a later legitimate schema change from suppressing the content + // comparison and producing a false-green proof. + if (seedSideEffects.length > 0) { + return { + ok: false, + driftDeltas: [], + dataViolations: seedSideEffects, + seedSideEffects, + rewriteViolations: [], + coverage: { tablesChecked: 0, tablesSkipped: [], perTable: [] }, + ...(seedOutcomes !== undefined ? { seedOutcomes } : {}), + }; + } + // Table stats deliberately cover data and column representation only. A seed + // trigger can also change any other modeled state (RLS, constraints, + // reloptions, replica identity, comments, roles, ...). Re-extract the same + // managed view the plan fingerprinted and require it to remain the source + // state before applying anything. Row inserts do not affect the fact base. + if (preSeedStats !== undefined) { + const postSeedState = managedView((await reextractClone()).factBase); + if (postSeedState.rootHash !== thePlan.source.fingerprint) { + return { + ok: false, + driftDeltas: [], + dataViolations: [], + seedStateViolation: { + expectedFingerprint: thePlan.source.fingerprint, + actualFingerprint: postSeedState.rootHash, + }, + rewriteViolations: [], + coverage: { tablesChecked: 0, tablesSkipped: [], perTable: [] }, + ...(seedOutcomes !== undefined ? { seedOutcomes } : {}), + }; + } + } + // Synthetic rows need the post-seed snapshot, but data that existed before + // autoSeed must stay anchored to the pre-seed snapshot. Otherwise a trigger + // fired by seeding one empty table can mutate a populated table and have that + // damage silently accepted as the proof baseline. + const before = + preSeedStats === undefined + ? postSeedStats + : composeAutoSeedBaseline(preSeedStats, postSeedStats); + // the proof re-extracts after applying anyway; the fingerprint gate's + // extra extraction is redundant here (it has its own execution tests) + const report = await apply(thePlan, clonePool, { fingerprintGate: false }); + if (report.status !== "applied") { + return { + ok: false, + ...(report.error ? { applyError: report.error } : {}), + driftDeltas: [], + dataViolations: [], + rewriteViolations: [], + coverage: { tablesChecked: 0, tablesSkipped: [], perTable: [] }, + // seeding already happened before apply, so report it even on this + // early return (the caller's coverage gate still wants to see it). + ...(seedOutcomes !== undefined ? { seedOutcomes } : {}), + }; + } + // same redaction mode the plan was fingerprinted with (Plan.redactSecrets, + // default true) — otherwise the proven clone comes back placeholder-redacted + // while `desired` (passed in already extracted with redactSecrets:false) + // still carries real secrets, and the comparison below reports a spurious + // drift delta though nothing actually diverged. A custom `reextract` is + // trusted to already bake in the right mode. + const proven = await reextractClone(); + // Compare the SAME managed view the plan diffed: resolveView projects out + // extension members + the policy's scope rules at the fact level, on BOTH the + // proven clone and the target — otherwise an extension's internals or a + // policy-scoped object (system schema/role) read as drift + // (docs/architecture/managed-view-architecture.md). With no policy this is exactly the + // extension-member projection, so the corpus proof is unchanged. + // policy + capability default to the values the plan was produced with (both + // are inlined on the plan artifact), so a separate `prove` invocation recovers + // the exact same view without the caller re-supplying them. + // Reconstruct the same managed view on both sides through the shared helper. + const provenFb = managedView(proven.factBase); + // target the PROJECTED desired: the plan only applies kept deltas, so it + // converges to `desired` minus the policy-filtered changes (review #2). + const target = managedView(projectTarget(desired, thePlan.filteredDeltas)); + const driftDeltas = diff(provenFb, target); + const after = await tableStats(clonePool); + + const { dataViolations, rewriteViolations, coverage } = detectViolations( + before, + after, + { recreatedTables, declaredRewriteTables, renamedTables }, + ); + + return { + ok: + driftDeltas.length === 0 && + dataViolations.length === 0 && + rewriteViolations.length === 0, + driftDeltas, + dataViolations, + rewriteViolations, + coverage, + ...(seedOutcomes !== undefined ? { seedOutcomes } : {}), + }; +} diff --git a/packages/pg-delta/src/public-api.test.ts b/packages/pg-delta/src/public-api.test.ts new file mode 100644 index 000000000..0fdf4131f --- /dev/null +++ b/packages/pg-delta/src/public-api.test.ts @@ -0,0 +1,87 @@ +/** + * Public API surface guard (addresses P1 of the 2026-06-16 handoff review: + * the safety model the docs advertise must be assemblable through STABLE + * imports, not deep source paths). + * + * The headline safe path (`resolveProfile` + presets) is reachable from the + * package root; the full profile surface (capability probing, handlers, + * custom-profile building blocks) is reachable from the + * `@supabase/pg-delta/integrations` subpath, which package.json declares. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "bun:test"; +import * as root from "./index.ts"; +import * as integrations from "./integrations/index.ts"; +import * as planSubpath from "./plan/plan.ts"; + +describe("public API surface", () => { + test("root re-exports the headline profile API", () => { + expect(typeof root.resolveProfile).toBe("function"); + expect(root.supabaseProfile.id).toBe("supabase"); + expect(root.rawProfile.id).toBe("raw"); + }); + + test("the integrations subpath exposes the full profile surface", () => { + expect(typeof integrations.resolveProfile).toBe("function"); + expect(integrations.supabaseProfile.id).toBe("supabase"); + expect(integrations.rawProfile.id).toBe("raw"); + // building blocks for custom profiles + the safety helpers the docs name + expect(typeof integrations.probeApplierCapability).toBe("function"); + expect(integrations.pgPartmanHandler.extension).toBe("pg_partman"); + expect(Array.isArray(integrations.SUPABASE_EXTENSION_HANDLERS)).toBe(true); + expect(integrations.SUPABASE_EXTENSION_HANDLERS).toContain( + integrations.pgPartmanHandler, + ); + }); + + test("the ./plan subpath re-exports the plan-artifact helpers the docs name", () => { + // docs/getting-started.md imports { serializePlan, parsePlan } from + // "@supabase/pg-delta/plan"; that subpath maps to src/plan/plan.ts, so the + // helpers (which live in plan/artifact.ts) must be reachable there — not only + // from the package root. A round-trip proves the re-export is the real thing. + expect(typeof planSubpath.serializePlan).toBe("function"); + expect(typeof planSubpath.parsePlan).toBe("function"); + }); + + test("package.json declares the ./plan subpath export", () => { + const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url)); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { + exports: Record; + }; + const entry = pkg.exports["./plan"]; + expect(entry).toBeDefined(); + expect(entry?.bun).toBe("./src/plan/plan.ts"); + expect(entry?.import).toBe("./dist/plan/plan.js"); + }); + + test("package.json declares the ./integrations subpath export", () => { + const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url)); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { + exports: Record; + }; + // Dual conditional export: the `bun` condition serves TS source directly, + // while `import`/`default` serve the compiled dist for Node. + const entry = pkg.exports["./integrations"]; + expect(entry).toBeDefined(); + expect(entry?.bun).toBe("./src/integrations/index.ts"); + expect(entry?.import).toBe("./dist/integrations/index.js"); + expect(entry?.types).toBe("./dist/integrations/index.d.ts"); + }); + + // The build is ESM-only (package `type: module`, NodeNext output). A `require` + // condition that points at an ESM `.js` is a lie: a CJS consumer that matches + // it gets `ERR_REQUIRE_ESM` on Node <22. We advertise no CJS entry at all, so + // ESM-aware tooling resolves via `import`/`default` and CJS consumers must use + // dynamic import (or Node >=22, which can `require()` ESM synchronously). + test("no export entry advertises a `require` condition (ESM-only package)", () => { + const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url)); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { + exports: Record>; + }; + const offenders = Object.entries(pkg.exports) + .filter(([, conditions]) => "require" in conditions) + .map(([subpath]) => subpath); + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/pg-delta/src/typedoc.ts b/packages/pg-delta/src/typedoc.ts deleted file mode 100644 index 59ac5f494..000000000 --- a/packages/pg-delta/src/typedoc.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @supabase/pg-delta — API Reference - * - * This module is a dedicated documentation entry point. It re-exports only the - * types relevant to authoring custom integration filters and does **not** affect - * the public API surface exposed by the package's main entry point. - * - * @module - */ - -// --------------------------------------------------------------------------- -// Filter DSL -// --------------------------------------------------------------------------- - -export type { - FilterDSL, - FilterPattern, - PathPattern, -} from "./core/integrations/filter/dsl.ts"; -export type { FlatValue } from "./core/integrations/filter/flatten.ts"; - -// --------------------------------------------------------------------------- -// Integration -// --------------------------------------------------------------------------- - -export type { IntegrationDSL } from "./core/integrations/integration-dsl.ts"; -export type { SerializeDSL } from "./core/integrations/serialize/dsl.ts"; -export type { - ExtensionSerializeOptions, - SchemaSerializeOptions, - SerializeOptions, -} from "./core/integrations/serialize/serialize.types.ts"; - -// --------------------------------------------------------------------------- -// Change Types — Base & Top-Level Unions -// --------------------------------------------------------------------------- - -export type { - Change, - OBJECT_TYPE_TO_PROPERTY_KEY, -} from "./core/change.types.ts"; -// Top-level union types (each combines all change variants for an object type) -export type { AggregateChange } from "./core/objects/aggregate/changes/aggregate.types.ts"; -export { BaseChange } from "./core/objects/base.change.ts"; -export type { CollationChange } from "./core/objects/collation/changes/collation.types.ts"; -export type { DomainChange } from "./core/objects/domain/changes/domain.types.ts"; -export type { EventTriggerChange } from "./core/objects/event-trigger/changes/event-trigger.types.ts"; -export type { ExtensionChange } from "./core/objects/extension/changes/extension.types.ts"; -export type { ForeignDataWrapperChange } from "./core/objects/foreign-data-wrapper/foreign-data-wrapper.types.ts"; -export type { IndexChange } from "./core/objects/index/changes/index.types.ts"; -export type { LanguageChange } from "./core/objects/language/changes/language.types.ts"; -export type { MaterializedViewChange } from "./core/objects/materialized-view/changes/materialized-view.types.ts"; -export type { ProcedureChange } from "./core/objects/procedure/changes/procedure.types.ts"; -export type { PublicationChange } from "./core/objects/publication/changes/publication.types.ts"; -export type { RlsPolicyChange } from "./core/objects/rls-policy/changes/rls-policy.types.ts"; -export type { RoleChange } from "./core/objects/role/changes/role.types.ts"; -export type { RuleChange } from "./core/objects/rule/changes/rule.types.ts"; -export type { SchemaChange } from "./core/objects/schema/changes/schema.types.ts"; -export type { SequenceChange } from "./core/objects/sequence/changes/sequence.types.ts"; -export type { SubscriptionChange } from "./core/objects/subscription/changes/subscription.types.ts"; -export type { TableChange } from "./core/objects/table/changes/table.types.ts"; -export type { TriggerChange } from "./core/objects/trigger/changes/trigger.types.ts"; -export type { TypeChange } from "./core/objects/type/type.types.ts"; -export type { ViewChange } from "./core/objects/view/changes/view.types.ts"; - -// --------------------------------------------------------------------------- -// Change Types — FDW & Type Sub-Unions -// --------------------------------------------------------------------------- - -// Inner FDW union renamed to avoid collision with the outer ForeignDataWrapperChange -export type { ForeignDataWrapperChange as FDWChange } from "./core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.types.ts"; -export type { ForeignTableChange } from "./core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.types.ts"; -export type { ServerChange } from "./core/objects/foreign-data-wrapper/server/changes/server.types.ts"; -export type { UserMappingChange } from "./core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.types.ts"; - -export type { CompositeTypeChange } from "./core/objects/type/composite-type/changes/composite-type.types.ts"; -export type { EnumChange } from "./core/objects/type/enum/changes/enum.types.ts"; -export type { RangeChange } from "./core/objects/type/range/changes/range.types.ts"; - -// --------------------------------------------------------------------------- -// Change Types — Concrete Change Classes (all object types) -// --------------------------------------------------------------------------- - -// Aggregate -export * from "./core/objects/aggregate/changes/aggregate.alter.ts"; -export * from "./core/objects/aggregate/changes/aggregate.comment.ts"; -export * from "./core/objects/aggregate/changes/aggregate.create.ts"; -export * from "./core/objects/aggregate/changes/aggregate.drop.ts"; -export * from "./core/objects/aggregate/changes/aggregate.privilege.ts"; - -// Collation -export * from "./core/objects/collation/changes/collation.alter.ts"; -export * from "./core/objects/collation/changes/collation.comment.ts"; -export * from "./core/objects/collation/changes/collation.create.ts"; -export * from "./core/objects/collation/changes/collation.drop.ts"; - -// Domain -export * from "./core/objects/domain/changes/domain.alter.ts"; -export * from "./core/objects/domain/changes/domain.comment.ts"; -export * from "./core/objects/domain/changes/domain.create.ts"; -export * from "./core/objects/domain/changes/domain.drop.ts"; -export * from "./core/objects/domain/changes/domain.privilege.ts"; - -// Event Trigger -export * from "./core/objects/event-trigger/changes/event-trigger.alter.ts"; -export * from "./core/objects/event-trigger/changes/event-trigger.comment.ts"; -export * from "./core/objects/event-trigger/changes/event-trigger.create.ts"; -export * from "./core/objects/event-trigger/changes/event-trigger.drop.ts"; - -// Extension -export * from "./core/objects/extension/changes/extension.alter.ts"; -export * from "./core/objects/extension/changes/extension.comment.ts"; -export * from "./core/objects/extension/changes/extension.create.ts"; -export * from "./core/objects/extension/changes/extension.drop.ts"; - -// Foreign Data Wrapper — FDW wrapper -export * from "./core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts"; -export * from "./core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.comment.ts"; -export * from "./core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts"; -export * from "./core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.drop.ts"; -export * from "./core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.privilege.ts"; - -// Foreign Data Wrapper — Foreign Table -export * from "./core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts"; -export * from "./core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.comment.ts"; -export * from "./core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts"; -export * from "./core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.drop.ts"; -export * from "./core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.privilege.ts"; - -// Foreign Data Wrapper — Server -export * from "./core/objects/foreign-data-wrapper/server/changes/server.alter.ts"; -export * from "./core/objects/foreign-data-wrapper/server/changes/server.comment.ts"; -export * from "./core/objects/foreign-data-wrapper/server/changes/server.create.ts"; -export * from "./core/objects/foreign-data-wrapper/server/changes/server.drop.ts"; -export * from "./core/objects/foreign-data-wrapper/server/changes/server.privilege.ts"; - -// Foreign Data Wrapper — User Mapping -export * from "./core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts"; -export * from "./core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts"; -export * from "./core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.drop.ts"; - -// Index -export * from "./core/objects/index/changes/index.alter.ts"; -export * from "./core/objects/index/changes/index.comment.ts"; -export * from "./core/objects/index/changes/index.create.ts"; -export * from "./core/objects/index/changes/index.drop.ts"; - -// Language -export * from "./core/objects/language/changes/language.alter.ts"; -export * from "./core/objects/language/changes/language.comment.ts"; -export * from "./core/objects/language/changes/language.create.ts"; -export * from "./core/objects/language/changes/language.drop.ts"; -export * from "./core/objects/language/changes/language.privilege.ts"; - -// Materialized View -export * from "./core/objects/materialized-view/changes/materialized-view.alter.ts"; -export * from "./core/objects/materialized-view/changes/materialized-view.comment.ts"; -export * from "./core/objects/materialized-view/changes/materialized-view.create.ts"; -export * from "./core/objects/materialized-view/changes/materialized-view.drop.ts"; -export * from "./core/objects/materialized-view/changes/materialized-view.privilege.ts"; - -// Procedure -export * from "./core/objects/procedure/changes/procedure.alter.ts"; -export * from "./core/objects/procedure/changes/procedure.comment.ts"; -export * from "./core/objects/procedure/changes/procedure.create.ts"; -export * from "./core/objects/procedure/changes/procedure.drop.ts"; -export * from "./core/objects/procedure/changes/procedure.privilege.ts"; - -// Publication -export * from "./core/objects/publication/changes/publication.alter.ts"; -export * from "./core/objects/publication/changes/publication.comment.ts"; -export * from "./core/objects/publication/changes/publication.create.ts"; -export * from "./core/objects/publication/changes/publication.drop.ts"; - -// RLS Policy -export * from "./core/objects/rls-policy/changes/rls-policy.alter.ts"; -export * from "./core/objects/rls-policy/changes/rls-policy.comment.ts"; -export * from "./core/objects/rls-policy/changes/rls-policy.create.ts"; -export * from "./core/objects/rls-policy/changes/rls-policy.drop.ts"; - -// Role -export * from "./core/objects/role/changes/role.alter.ts"; -export * from "./core/objects/role/changes/role.comment.ts"; -export * from "./core/objects/role/changes/role.create.ts"; -export * from "./core/objects/role/changes/role.drop.ts"; -export * from "./core/objects/role/changes/role.privilege.ts"; - -// Rule -export * from "./core/objects/rule/changes/rule.alter.ts"; -export * from "./core/objects/rule/changes/rule.comment.ts"; -export * from "./core/objects/rule/changes/rule.create.ts"; -export * from "./core/objects/rule/changes/rule.drop.ts"; - -// Schema -export * from "./core/objects/schema/changes/schema.alter.ts"; -export * from "./core/objects/schema/changes/schema.comment.ts"; -export * from "./core/objects/schema/changes/schema.create.ts"; -export * from "./core/objects/schema/changes/schema.drop.ts"; -export * from "./core/objects/schema/changes/schema.privilege.ts"; - -// Sequence -export * from "./core/objects/sequence/changes/sequence.alter.ts"; -export * from "./core/objects/sequence/changes/sequence.comment.ts"; -export * from "./core/objects/sequence/changes/sequence.create.ts"; -export * from "./core/objects/sequence/changes/sequence.drop.ts"; -export * from "./core/objects/sequence/changes/sequence.privilege.ts"; - -// Subscription -export * from "./core/objects/subscription/changes/subscription.alter.ts"; -export * from "./core/objects/subscription/changes/subscription.comment.ts"; -export * from "./core/objects/subscription/changes/subscription.create.ts"; -export * from "./core/objects/subscription/changes/subscription.drop.ts"; - -// Table -export * from "./core/objects/table/changes/table.alter.ts"; -export * from "./core/objects/table/changes/table.comment.ts"; -export * from "./core/objects/table/changes/table.create.ts"; -export * from "./core/objects/table/changes/table.drop.ts"; -export * from "./core/objects/table/changes/table.privilege.ts"; - -// Trigger -export * from "./core/objects/trigger/changes/trigger.alter.ts"; -export * from "./core/objects/trigger/changes/trigger.comment.ts"; -export * from "./core/objects/trigger/changes/trigger.create.ts"; -export * from "./core/objects/trigger/changes/trigger.drop.ts"; - -// Type — Composite -export * from "./core/objects/type/composite-type/changes/composite-type.alter.ts"; -export * from "./core/objects/type/composite-type/changes/composite-type.comment.ts"; -export * from "./core/objects/type/composite-type/changes/composite-type.create.ts"; -export * from "./core/objects/type/composite-type/changes/composite-type.drop.ts"; -export * from "./core/objects/type/composite-type/changes/composite-type.privilege.ts"; - -// Type — Enum -export * from "./core/objects/type/enum/changes/enum.alter.ts"; -export * from "./core/objects/type/enum/changes/enum.comment.ts"; -export * from "./core/objects/type/enum/changes/enum.create.ts"; -export * from "./core/objects/type/enum/changes/enum.drop.ts"; -export * from "./core/objects/type/enum/changes/enum.privilege.ts"; - -// Type — Range -export * from "./core/objects/type/range/changes/range.alter.ts"; -export * from "./core/objects/type/range/changes/range.comment.ts"; -export * from "./core/objects/type/range/changes/range.create.ts"; -export * from "./core/objects/type/range/changes/range.drop.ts"; -export * from "./core/objects/type/range/changes/range.privilege.ts"; - -// View -export * from "./core/objects/view/changes/view.alter.ts"; -export * from "./core/objects/view/changes/view.comment.ts"; -export * from "./core/objects/view/changes/view.create.ts"; -export * from "./core/objects/view/changes/view.drop.ts"; -export * from "./core/objects/view/changes/view.privilege.ts"; diff --git a/packages/pg-delta/tests/acl-default-revoke.test.ts b/packages/pg-delta/tests/acl-default-revoke.test.ts new file mode 100644 index 000000000..2ebb4e0a6 --- /dev/null +++ b/packages/pg-delta/tests/acl-default-revoke.test.ts @@ -0,0 +1,74 @@ +/** + * Regression: an object created with a built-in PUBLIC default *revoked* must + * still converge. PostgreSQL grants the default (USAGE on types/languages, + * EXECUTE on functions) to PUBLIC automatically on CREATE, so the plan must + * emit a `REVOKE … FROM PUBLIC` to reach a desired state where that default was + * taken away. Before the fix the revoked default was simply absent from the + * fact base (aclJson coalesced NULL → acldefault, dropping the "PUBLIC has + * less than the default" signal), so no REVOKE was planned and the fresh object + * drifted with the default still granted. + * + * Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { sharedCluster } from "./containers.ts"; + +async function assertConvergesFromEmpty(seedSql: string, label: string) { + const cluster = await sharedCluster(); + const source = await cluster.createDb(`acl_rev_src_${label}`); + const desired = await cluster.createDb(`acl_rev_dst_${label}`); + try { + await desired.pool.query(seedSql); + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + + const thePlan = plan(sourceState.factBase, desiredState.factBase); + const report = await apply(thePlan, source.pool, { + fingerprintGate: false, + }); + expect(report.status).toBe("applied"); + + const afterApply = await extract(source.pool); + const drift = plan(afterApply.factBase, desiredState.factBase); + // RED before fix: a residual `REVOKE … FROM PUBLIC` because PG's create-time + // default was never cleared. + expect(drift.actions.map((a) => a.sql)).toEqual([]); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } +} + +describe("revoked built-in PUBLIC default on create", () => { + test( + "type with PUBLIC USAGE revoked converges from empty", + () => + assertConvergesFromEmpty( + ` + CREATE SCHEMA app; + CREATE TYPE app.mood AS ENUM ('sad', 'ok', 'happy'); + REVOKE USAGE ON TYPE app.mood FROM PUBLIC; + `, + "type", + ), + 60_000, + ); + + test( + "function with PUBLIC EXECUTE revoked converges from empty", + () => + assertConvergesFromEmpty( + ` + CREATE SCHEMA app; + CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'SELECT 1'; + REVOKE EXECUTE ON FUNCTION app.f() FROM PUBLIC; + `, + "func", + ), + 60_000, + ); +}); diff --git a/packages/pg-delta/tests/acl-grantor-dedup.test.ts b/packages/pg-delta/tests/acl-grantor-dedup.test.ts new file mode 100644 index 000000000..f5ddfa73c --- /dev/null +++ b/packages/pg-delta/tests/acl-grantor-dedup.test.ts @@ -0,0 +1,55 @@ +/** + * aclexplode() emits one row per (grantee, GRANTOR). When the same privilege is + * granted to one grantee by two different grantors, the per-grantee aggregation + * recorded the privilege name TWICE — the rendered `GRANT SELECT, SELECT …` is + * collapsed by Postgres on apply, so a re-extract no longer matches and the proof + * drifts. Regression: privileges/grantable must be de-duplicated across grantors. + * Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { sharedCluster } from "./containers.ts"; + +describe("acl: privileges de-duplicated across grantors", () => { + test("same privilege from two grantors yields a single entry", async () => { + const cluster = await sharedCluster(); + const db = await cluster.createDb("acl_dedup"); + const [a, b, x] = ["fix4_granta", "fix4_grantb", "fix4_grantee"]; + try { + await db.pool.query(`CREATE ROLE ${a}`); + await db.pool.query(`CREATE ROLE ${b}`); + await db.pool.query(`CREATE ROLE ${x}`); + await db.pool.query(` + CREATE TABLE public.t (id int); + GRANT SELECT ON public.t TO ${a} WITH GRANT OPTION; + GRANT SELECT ON public.t TO ${b} WITH GRANT OPTION; + SET ROLE ${a}; GRANT SELECT ON public.t TO ${x}; RESET ROLE; + SET ROLE ${b}; GRANT SELECT ON public.t TO ${x}; RESET ROLE; + `); + + const state = await extract(db.pool); + const aclFact = state.factBase + .facts() + .find( + (f) => + f.id.kind === "acl" && + (f.id as { grantee: string }).grantee === x && + (f.id as { column?: string }).column === undefined, + ); + expect(aclFact).toBeDefined(); + const privileges = aclFact!.payload["privileges"] as string[]; + // RED before the fix: ['SELECT', 'SELECT'] (one per grantor) + expect(privileges).toEqual(["SELECT"]); + } finally { + await db.drop(); + for (const r of [a, b, x]) { + await cluster.adminPool + .query(`DROP OWNED BY ${r} CASCADE`) + .catch(() => {}); + await cluster.adminPool + .query(`DROP ROLE IF EXISTS ${r}`) + .catch(() => {}); + } + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/alpine-tags.ts b/packages/pg-delta/tests/alpine-tags.ts deleted file mode 100644 index c0658eb8f..000000000 --- a/packages/pg-delta/tests/alpine-tags.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { PostgresVersion } from "./constants.ts"; - -/** - * Maps a PostgreSQL major version to the Alpine base tag that ships the - * matching `postgresql-dev` package. Needed because a given - * alpine release typically only carries the current pg-dev headers. - * - * Keep in sync with the matrix in `.github/workflows/tests.yml` - * (`pg-delta-build-test-images` job) — CI uses these same values to - * build the prebuilt `pg-delta-test:` image and push it to GHCR - * so test shards can pull instead of rebuilding locally. - * - * This file is hashed alone (with `dummy-seclabel.Dockerfile`) for the - * prebuilt image tag so edits to `postgres-alpine.ts` do not invalidate - * the cache when Alpine tags are unchanged. - */ -export const ALPINE_TAG_FOR_PG_MAJOR: Record = { - 15: "3.19", - 17: "3.23", - // v3.22 has no postgresql18 in main/community; v3.23 ships postgresql18-dev. - 18: "3.23", -}; diff --git a/packages/pg-delta/tests/apply-nontransactional.test.ts b/packages/pg-delta/tests/apply-nontransactional.test.ts new file mode 100644 index 000000000..83f635c15 --- /dev/null +++ b/packages/pg-delta/tests/apply-nontransactional.test.ts @@ -0,0 +1,76 @@ +/** + * Non-transactional apply: session settings must be reset even when the action + * fails, and a failed non-transactional action is inDoubt (not unapplied), + * because it may have left durable side effects (review P1). + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import pg from "pg"; +import { apply } from "../src/apply/apply.ts"; +import { ENGINE_VERSION, type Plan } from "../src/plan/plan.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb("apply-nontx"); +}, 120_000); + +afterAll(async () => { + await db.drop(); +}); + +function planWithFailingNonTxnAction(): Plan { + return { + formatVersion: 1, + engineVersion: ENGINE_VERSION, + source: { fingerprint: "a".repeat(64) }, + target: { fingerprint: "b".repeat(64) }, + preamble: [], + deltas: [], + filteredDeltas: [], + renameCandidates: [], + actions: [ + { + sql: "SELECT 1 / 0", // fails at runtime + verb: "alter", + produces: [], + consumes: [], + destroys: [], + releases: [], + transactionality: "nonTransactional", + lockClass: "none", + newSegmentBefore: false, + dataLoss: "none", + rewriteRisk: false, + }, + ], + safetyReport: { + destructiveActions: 0, + rewriteRiskActions: 0, + nonTransactionalActions: 1, + lockClasses: { none: 1 }, + }, + }; +} + +describe("non-transactional apply: reset + inDoubt (P1)", () => { + test("a failed non-transactional action is inDoubt and the session is reset", async () => { + // max:1 so the SAME backend serves the apply and the follow-up SHOW. + const probe = new pg.Pool({ connectionString: db.uri, max: 1 }); + probe.on("error", () => {}); + try { + const report = await apply(planWithFailingNonTxnAction(), probe, { + fingerprintGate: false, + lockTimeoutMs: 7000, // SESSION-level for a non-txn action + }); + expect(report.status).toBe("failed"); + // inDoubt, NOT unapplied — the action may have durable side effects + expect(report.actionStatuses[0]).toBe("inDoubt"); + // RESET ALL ran in finally despite the failure → back to the default + const { rows } = await probe.query("SHOW lock_timeout"); + expect((rows[0] as { lock_timeout: string }).lock_timeout).toBe("0"); + } finally { + await probe.end(); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/apply-redaction-fingerprint-gate.test.ts b/packages/pg-delta/tests/apply-redaction-fingerprint-gate.test.ts new file mode 100644 index 000000000..834f06f17 --- /dev/null +++ b/packages/pg-delta/tests/apply-redaction-fingerprint-gate.test.ts @@ -0,0 +1,82 @@ +/** + * Item 16 (issue #333): `apply()`'s fingerprint gate (apply.ts ~line 145) + * re-extracts the target with the DEFAULT redaction mode + * (`extract(target)` → `redactSecrets: true`), ignoring `Plan.redactSecrets` — + * even though the plan carries it PRECISELY so apply/prove reconstruct the + * SAME view the plan was fingerprinted from (plan.ts's `redactSecrets` doc + * comment). A plan built from `extract({ redactSecrets: false })` state is + * therefore spuriously rejected: the gate's re-extract sees the + * `__OPTION_PASSWORD__` placeholder where the plan's fingerprint was computed + * over the real secret, so the hashes never match — even for a NO-OP plan + * (source === desired, zero actions). + * + * The CLI (`src/cli/commands/apply.ts`) already works around this by building + * its own `reextract: (p) => ctx.extract(p, { redactSecrets })` option, so this + * only bites a DIRECT library caller of `apply()`/`provePlan()` that does not + * replicate that workaround — exactly the shape of a plan built via + * `plan(source, desired, { redactSecrets: false })` and applied with no custom + * `reextract`. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +const SECRET = "apply-gate-secret"; +const SETUP_SQL = `CREATE FOREIGN DATA WRAPPER redact16_fdw OPTIONS (password '${SECRET}');`; + +describe("item 16: apply() honors Plan.redactSecrets in the fingerprint gate", () => { + test("a no-op plan built with redactSecrets:false applies cleanly (no spurious fingerprint mismatch)", async () => { + const cluster = await sharedCluster(); + const target = await cluster.createDb("apply_gate_redact"); + dbs.push(target); + await target.pool.query(SETUP_SQL); + + const state = await extract(target.pool, { redactSecrets: false }); + const thePlan = plan(state.factBase, state.factBase, { + redactSecrets: false, + }); + expect(thePlan.actions.length).toBe(0); + expect(thePlan.redactSecrets).toBe(false); + + // GREEN: applies (0 actions, trivially "applied"). RED (default re-extract + // ignores thePlan.redactSecrets): rejects with `apply: fingerprint gate + // failed — the target's resolved state (…) is not the plan's source (…)`. + const report = await apply(thePlan, target.pool); + expect(report.status).toBe("applied"); + }, 60_000); + + test("provePlan's fingerprint reconstruction also honors Plan.redactSecrets", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("prove_gate_redact_src"); + dbs.push(source); + await source.pool.query(SETUP_SQL); + + const state = await extract(source.pool, { redactSecrets: false }); + const thePlan = plan(state.factBase, state.factBase, { + redactSecrets: false, + }); + expect(thePlan.actions.length).toBe(0); + + const clone = await source.clone(); + dbs.push(clone); + // GREEN: proves cleanly (ok: true). RED: provePlan's internal apply() call + // passes fingerprintGate:false (so it does not throw here), but the + // POST-apply re-extract at prove.ts's `(options.reextract ?? extract) + // (clonePool)` also ignores thePlan.redactSecrets — the proven clone comes + // back placeholder-redacted while `desired` (passed in already extracted + // with redactSecrets:false) still carries the real secret, so the diff + // reports a spurious drift delta and `ok` is false even though nothing + // actually diverged. + const verdict = await provePlan(thePlan, clone.pool, state.factBase); + expect(verdict.applyError).toBeUndefined(); + expect(verdict.ok).toBe(true); + }, 60_000); +}); diff --git a/packages/pg-delta/tests/apply-scope-owner-exclusion.test.ts b/packages/pg-delta/tests/apply-scope-owner-exclusion.test.ts new file mode 100644 index 000000000..bb59ea6e4 --- /dev/null +++ b/packages/pg-delta/tests/apply-scope-owner-exclusion.test.ts @@ -0,0 +1,105 @@ +/** + * `schema apply` under the default `database` scope must resolve the policy + * managed view BEFORE it projects the scope out — the same proven-correct order + * `schema export` uses. The reverse order (project scope first, resolve policy + * second) strips the `owner` edges that a policy's owner-exclusion rule reads, + * so a platform object owned by a system role (here an event trigger owned by a + * non-managed role) is no longer excluded and gets planned for a spurious DROP. + * + * Faithful end-to-end regression: it drives the real `cmdSchemaApply`, so the + * production ordering — not the test — decides whether the object is dropped. + * The alpine applier is a superuser, so the wrongful DROP EVENT TRIGGER SUCCEEDS + * rather than failing with "must be owner of event trigger"; we therefore assert + * on the real catalog state (the event trigger survives) rather than on an error. + * + * Docker required. Plain alpine (no Supabase gate) — a custom profile carries an + * owner-exclusion policy exactly like the Supabase profile's Rule 6. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cmdSchemaApply } from "../src/cli/commands/schema.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +describe("schema apply: database-scope owner-exclusion ordering", () => { + let target: TestDb; + let sysRole: string; + const evtName = "owner_excl_placeholder"; + + beforeAll(async () => { + const cluster = await sharedCluster(); + target = await cluster.createDb("owner_excl_tgt"); + // a cluster-global system role that the policy's owner rule excludes; it + // must be a superuser to own an event trigger. Unique per run so the shared + // cluster does not collide across test databases. + sysRole = `owner_excl_sys_${target.name}`; + await cluster.adminPool.query(`CREATE ROLE "${sysRole}" SUPERUSER`); + // an event-trigger function (owned by the admin, i.e. managed) plus an event + // trigger RE-OWNED to the system role, so its `owner` edge points at the role + // the policy excludes. + await target.pool.query(` + CREATE FUNCTION et_fn() RETURNS event_trigger LANGUAGE plpgsql AS $$ + BEGIN END $$; + CREATE EVENT TRIGGER "${evtName}" ON ddl_command_start + EXECUTE FUNCTION et_fn(); + ALTER EVENT TRIGGER "${evtName}" OWNER TO "${sysRole}"; + `); + }, 120_000); + + afterAll(async () => { + await target.drop(); + await target.cluster.adminPool + .query(`DROP ROLE IF EXISTS "${sysRole}"`) + .catch(() => {}); + }); + + test("an event trigger owned by an excluded system role is not dropped", async () => { + const work = mkdtempSync(join(tmpdir(), "pgdelta-owner-excl-")); + const dir = join(work, "schema"); + mkdirSync(dir, { recursive: true }); + // the declarative desired state: the (managed) event-trigger function, but + // NOT the event trigger (excluded by the policy) and NOT the system role + // (unmanaged under database scope). A correct apply is a no-op. + writeFileSync( + join(dir, "01_fn.sql"), + `CREATE FUNCTION et_fn() RETURNS event_trigger LANGUAGE plpgsql AS $$\n BEGIN END $$;\n`, + ); + // a custom profile whose policy excludes any object owned by the system role + // (mirrors the Supabase profile's owner-based Rule 6). + const profilePath = join(work, "profile.json"); + writeFileSync( + profilePath, + JSON.stringify({ + id: "owner-excl", + handlers: [], + policy: { + id: "owner-excl", + filter: [{ match: { owner: [sysRole] }, action: "exclude" }], + }, + }), + "utf8", + ); + + await cmdSchemaApply([ + "--dir", + dir, + "--target", + target.uri, + "--renames", + "off", + "--profile", + profilePath, + ]); + + // The excluded event trigger must still exist. RED (project-then-resolve): + // the owner edge is stripped before the policy runs, so the trigger is not + // excluded and the superuser applier drops it → count 0. GREEN: the trigger + // survives → count 1. + const res = await target.pool.query<{ n: string }>( + `SELECT count(*)::text AS n FROM pg_event_trigger WHERE evtname = $1`, + [evtName], + ); + expect(res.rows[0]?.n).toBe("1"); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/assumed-schema-requirement.test.ts b/packages/pg-delta/tests/assumed-schema-requirement.test.ts new file mode 100644 index 000000000..cae1c4169 --- /dev/null +++ b/packages/pg-delta/tests/assumed-schema-requirement.test.ts @@ -0,0 +1,56 @@ +/** + * The assumed-schema ambient exemption must not cover objects that DON'T exist + * on the target (PR #307 review #3499413404). A managed object that references a + * NEW object in an assumed schema (e.g. `auth.extra`) which is absent from the + * target is kept reference-only on the desired side, so its creation is + * suppressed; the requirement guard previously treated any id in an assumed + * schema as ambient and let the dependent plan through, only to fail at apply + * time against the missing relation. It must instead fail at PLAN time like any + * other filtered-away requirement. An existing assumed-schema object + * (`auth.users`-style, present on the target) stays satisfied via `source.has`. + * + * Docker required. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { supabasePolicy } from "../src/policy/supabase.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("assumed-schema requirement guard", () => { + test("a managed dependent on a non-existent assumed-schema object fails at plan time", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("assumed_req_src"); + const desired = await cluster.createDb("assumed_req_dst"); + dbs.push(source, desired); + + // target has the assumed schema but NOT `auth.extra`. + await source.pool.query(`CREATE SCHEMA auth`); + // desired adds `auth.extra` (filtered to reference-only by the profile) and a + // managed public view that depends on it. + await desired.pool.query(` + CREATE SCHEMA auth; + CREATE TABLE auth.extra (id integer); + CREATE VIEW public.needs_extra AS SELECT id FROM auth.extra; + `); + + const [sourceState, desiredState] = await Promise.all([ + extract(source.pool), + extract(desired.pool), + ]); + + // RED before the fix: this does NOT throw — the view plans through because + // `auth.extra` is treated as ambient, and apply would later fail against the + // missing relation. + expect(() => + plan(sourceState.factBase, desiredState.factBase, { + policy: supabasePolicy, + }), + ).toThrow(/missing requirement[\s\S]*auth.*extra/); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/autoseed-allowlist.ts b/packages/pg-delta/tests/autoseed-allowlist.ts new file mode 100644 index 000000000..ae980f443 --- /dev/null +++ b/packages/pg-delta/tests/autoseed-allowlist.ts @@ -0,0 +1,105 @@ +/** + * Allowlist of EXPECTED class-23 auto-seed skips in the corpus proof loop. + * + * The engine suite (`engine.test.ts`) runs every corpus scenario with + * `provePlan({ autoSeed: true })`, which best-effort seeds each empty kept + * table with `INSERT ... DEFAULT VALUES` so the data-preservation check has + * teeth. A table whose every column is nullable/defaulted seeds cleanly; a + * table with a NOT NULL-without-default / FK / unique / check column cannot be + * seeded that way and the driver returns a class-23 SQLSTATE. + * + * A skip has one of two `reasonCode` shapes, both expected and both gated here: + * - a class-23 SQLSTATE (`23502` NOT NULL w/o default, `23503` FK, `23505` + * unique, `23514` check, …) — the insert was rejected; or + * - the synthetic sentinel `"no_row"` (NOT a SQLSTATE) — the insert RESOLVED + * but the row is absent from the final pre-apply snapshot: a BEFORE INSERT + * trigger returned NULL, a DO INSTEAD rule suppressed it, or an AFTER INSERT + * trigger deleted it (possibly while seeding a later table). rowCount is + * only the command tag, so persistence is judged by reconciling against + * that snapshot — nothing was actually seeded. + * Both must be declared here, keyed precisely by + * `{ scenario, direction, table, reasonCode }`, so a NEW unseedable table (or a + * skip that silently appears in an unexpected scenario) fails the suite loudly + * instead of quietly losing data-preservation coverage. Anything the seeder + * classifies as `failed` (a raised exception, connection/permission error, + * etc.) is never allowlistable and always fails the scenario. + * + * Maintenance: before adding an entry here, prefer giving the scenario a hand + * -written row instead — extend `corpus//seed.sql` (forward skips) or + * `corpus//seed-b.sql` (reverse skips) with one minimal row that + * satisfies the table's constraints, which upgrades the table from EMPTY to real + * fingerprint/count coverage. Only allowlist what genuinely can't (or shouldn't) + * be seeded: a row a trigger/rule suppresses, or a scenario whose whole point is + * a constraint interplay. The harness prints a machine-readable `SEED_AUDIT + * {json}` line to stderr for every non-allowlisted skip before failing. Add the + * reported `{ scenario, direction, table, reasonCode }` here (keep the list + * sorted by scenario, then direction, then schema.name) only after confirming + * the table is genuinely unseedable-with-defaults — never to paper over a + * `failed` outcome or a real data-preservation hazard. Every entry must be + * observed when its scenario/direction runs; a now-seedable table makes the + * exemption stale and fails the coverage gate until the entry is removed. + */ + +/** A precise identity for one tolerated class-23 skip. `reasonCode` is the + * SQLSTATE (`23502` NOT NULL w/o default, `23503` FK, `23505` unique, + * `23514` check, …). No bare table names: every field is required so an + * unexpected scenario/direction/reason is NOT silently swallowed. */ +export interface SeedSkipKey { + scenario: string; + direction: "forward" | "reverse"; + table: { schema: string; name: string }; + reasonCode: string; +} + +export const AUTOSEED_SKIP_ALLOWLIST: readonly SeedSkipKey[] = [ + // 1 entry — every formerly-allowlisted class-23 (23502) table is now hand-seeded + // via a corpus seed.sql / seed-b.sql (see the module header), including + // `alter-table--generated-column`'s `test_schema.calculations`, which is seeded + // at the (2, 2) fixed point of both generation expressions (2 + 2 = 2 * 2 = 4) so + // the stored generated value is byte-identical across the expression swap and the + // content fingerprint holds. The sole remaining entry is a "no_row" case: a + // BEFORE INSERT trigger that RETURNS NULL, suppressing the row. + // Sorted by scenario, then direction, then schema.name. + { + scenario: "trigger-operations--trigger-drop-before-function-drop", + direction: "forward", + table: { schema: "test_schema", name: "foo" }, + reasonCode: "no_row", + }, +]; + +const keyOf = (k: SeedSkipKey): string => + JSON.stringify([ + k.scenario, + k.direction, + k.table.schema, + k.table.name, + k.reasonCode, + ]); + +const ALLOWED: ReadonlySet = new Set( + AUTOSEED_SKIP_ALLOWLIST.map(keyOf), +); + +/** True when this exact class-23 skip is a declared, expected non-seed. Strict: + * an unknown scenario/direction/table/reasonCode combination is NOT allowed. */ +export function isSeedSkipAllowed( + scenario: string, + direction: "forward" | "reverse", + table: { schema: string; name: string }, + reasonCode: string, +): boolean { + return ALLOWED.has(keyOf({ scenario, direction, table, reasonCode })); +} + +/** Declared skips for one executed corpus direction. The coverage gate uses + * this to reject exemptions that are no longer observed, so a future loss of + * coverage cannot silently reuse a dormant allowlist entry. */ +export function seedSkipAllowlistFor( + scenario: string, + direction: "forward" | "reverse", +): readonly SeedSkipKey[] { + return AUTOSEED_SKIP_ALLOWLIST.filter( + (entry) => entry.scenario === scenario && entry.direction === direction, + ); +} diff --git a/packages/pg-delta/tests/autoseed-outcomes.test.ts b/packages/pg-delta/tests/autoseed-outcomes.test.ts new file mode 100644 index 000000000..6b3fe2c14 --- /dev/null +++ b/packages/pg-delta/tests/autoseed-outcomes.test.ts @@ -0,0 +1,381 @@ +/** + * `provePlan({ autoSeed: true })` must report a per-table seed OUTCOME, not + * silently swallow every insert failure. The taxonomy is by SQLSTATE class: + * - a class-23 integrity-constraint violation (NOT NULL without default, FK, + * unique, check) is an EXPECTED "unseedable" → outcome `skipped` with the + * SQLSTATE as `reasonCode`; + * - anything else (a raised exception, connection/syntax/permission error, + * unknown) is a genuine failure → outcome `failed` with the message. + * A plainly-seedable table → outcome `seeded`. + * + * This pins the observability contract that the corpus seed-coverage gate + * (tests/engine.test.ts + tests/autoseed-allowlist.ts) depends on. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan, type ProofVerdict } from "../src/proof/prove.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let db: TestDb; +let verdict: ProofVerdict; +let schemaChangeDb: TestDb; +let schemaChangeVerdict: ProofVerdict; +let seedSchemaDb: TestDb; +let seedSchemaVerdict: ProofVerdict; +let emptySeedSchemaDb: TestDb; +let emptySeedSchemaVerdict: ProofVerdict; +let seedStateDb: TestDb; +let seedStateVerdict: ProofVerdict; +let seedStateSourceFingerprint: string; + +beforeAll(async () => { + db = await createTestDb("autoseed"); + await db.pool.query(` + CREATE SCHEMA s; + -- plainly seedable: every column is nullable or defaulted + CREATE TABLE s.seedable (id serial PRIMARY KEY, note text); + -- NOT NULL without a default → INSERT ... DEFAULT VALUES raises 23502 + CREATE TABLE s.notnull_nodefault (val integer NOT NULL); + -- BEFORE INSERT trigger that RAISE EXCEPTION → SQLSTATE P0001 (class 'P') + CREATE TABLE s.raiser (id integer); + CREATE FUNCTION s.boom() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'seed blocked by trigger'; END $$; + CREATE TRIGGER boom_before BEFORE INSERT ON s.raiser + FOR EACH ROW EXECUTE FUNCTION s.boom(); + -- all columns defaulted/nullable, but a BEFORE INSERT trigger RETURNS NULL: + -- the INSERT succeeds with rowCount 0, so NO row is stored — a false + -- "seeded" unless the outcome is classified by persisted state ("no_row"). + CREATE TABLE s.trigger_suppressed (id integer); + CREATE FUNCTION s.suppress() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RETURN NULL; END $$; + CREATE TRIGGER suppress_before BEFORE INSERT ON s.trigger_suppressed + FOR EACH ROW EXECUTE FUNCTION s.suppress(); + -- all columns defaulted/nullable, but an AFTER INSERT trigger DELETEs the + -- just-inserted row: PostgreSQL reports INSERT 0 1 (rowCount 1) while the + -- table stays EMPTY — rowCount is the command tag, not persisted state. + CREATE TABLE s.trigger_undone (id integer); + CREATE FUNCTION s.undo() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN DELETE FROM s.trigger_undone; RETURN NULL; END $$; + CREATE TRIGGER undo_after AFTER INSERT ON s.trigger_undone + FOR EACH ROW EXECUTE FUNCTION s.undo(); + -- CROSS-TABLE undo: aaa_victim is seeded cleanly FIRST (tableStats orders + -- candidates by schema, name, so aaa_ precedes zzz_), then seeding + -- zzz_deleter fires an AFTER INSERT trigger that DELETEs aaa_victim's row. + -- A per-insert existence probe would still call aaa_victim seeded; only + -- reconciliation against the FINAL pre-apply snapshot catches it as no_row. + CREATE TABLE s.aaa_victim (id integer); + CREATE TABLE s.zzz_deleter (id integer); + CREATE FUNCTION s.delete_victim() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN DELETE FROM s.aaa_victim; RETURN NULL; END $$; + CREATE TRIGGER delete_victim_after AFTER INSERT ON s.zzz_deleter + FOR EACH ROW EXECUTE FUNCTION s.delete_victim(); + -- PRE-EXISTING data must not become invisible just because autoSeed runs + -- before the proof baseline. This table starts populated, so it is not a + -- seed candidate; seeding the empty mutator below deletes its original row. + CREATE TABLE s.populated_victim (id integer); + INSERT INTO s.populated_victim VALUES (42); + CREATE TABLE s.seed_side_effect (id integer); + CREATE FUNCTION s.delete_populated_victim() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN DELETE FROM s.populated_victim; RETURN NULL; END $$; + CREATE TRIGGER delete_populated_victim_after + AFTER INSERT ON s.seed_side_effect + FOR EACH ROW EXECUTE FUNCTION s.delete_populated_victim(); + `); + + const { factBase } = await extract(db.pool); + // trivial (empty) plan: autoSeed runs against the live pool, seeds the three + // tables, and the no-op apply converges — the assertions are on seedOutcomes. + const thePlan = plan(factBase, factBase); + verdict = await provePlan(thePlan, db.pool, factBase, { autoSeed: true }); + + schemaChangeDb = await createTestDb("autoseed-schema-change"); + await schemaChangeDb.pool.query(` + CREATE SCHEMA s; + CREATE TABLE s.populated_victim ( + id integer PRIMARY KEY, + value text NOT NULL + ); + INSERT INTO s.populated_victim VALUES (1, 'original'); + CREATE TABLE s.seed_mutator (id integer); + CREATE FUNCTION s.mutate_populated_victim() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + UPDATE s.populated_victim SET value = 'mutated'; + RETURN NULL; + END $$; + CREATE TRIGGER mutate_populated_victim_after + AFTER INSERT ON s.seed_mutator + FOR EACH ROW EXECUTE FUNCTION s.mutate_populated_victim(); + `); + + const { factBase: schemaChangeSource } = await extract(schemaChangeDb.pool); + await schemaChangeDb.pool.query( + "ALTER TABLE s.populated_victim ADD COLUMN note text", + ); + const { factBase: schemaChangeTarget } = await extract(schemaChangeDb.pool); + await schemaChangeDb.pool.query( + "ALTER TABLE s.populated_victim DROP COLUMN note", + ); + + schemaChangeVerdict = await provePlan( + plan(schemaChangeSource, schemaChangeTarget), + schemaChangeDb.pool, + schemaChangeTarget, + { autoSeed: true }, + ); + + seedSchemaDb = await createTestDb("autoseed-trigger-schema-change"); + await seedSchemaDb.pool.query(` + CREATE SCHEMA s; + CREATE TABLE s.populated_victim ( + id integer PRIMARY KEY, + value integer NOT NULL + ); + INSERT INTO s.populated_victim VALUES (1, 10); + CREATE TABLE s.seed_mutator (id integer); + CREATE FUNCTION s.mutate_data_and_schema() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + UPDATE s.populated_victim SET value = 11; + ALTER TABLE s.populated_victim ALTER COLUMN value TYPE bigint; + RETURN NULL; + END $$; + CREATE TRIGGER mutate_data_and_schema_after + AFTER INSERT ON s.seed_mutator + FOR EACH ROW EXECUTE FUNCTION s.mutate_data_and_schema(); + `); + + const { factBase: seedSchemaSource } = await extract(seedSchemaDb.pool); + await seedSchemaDb.pool.query( + "ALTER TABLE s.populated_victim ALTER COLUMN value TYPE bigint", + ); + const { factBase: seedSchemaTarget } = await extract(seedSchemaDb.pool); + await seedSchemaDb.pool.query( + "ALTER TABLE s.populated_victim ALTER COLUMN value TYPE integer", + ); + + seedSchemaVerdict = await provePlan( + plan(seedSchemaSource, seedSchemaTarget), + seedSchemaDb.pool, + seedSchemaTarget, + { autoSeed: true }, + ); + + emptySeedSchemaDb = await createTestDb("autoseed-empty-schema-change"); + await emptySeedSchemaDb.pool.query(` + CREATE SCHEMA s; + CREATE TABLE s.aaa_seed_mutator (id integer); + CREATE TABLE s.zzz_empty_victim (value integer); + CREATE FUNCTION s.mutate_empty_schema() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + ALTER TABLE s.zzz_empty_victim ALTER COLUMN value TYPE bigint; + RETURN NULL; + END $$; + CREATE TRIGGER mutate_empty_schema_after + AFTER INSERT ON s.aaa_seed_mutator + FOR EACH ROW EXECUTE FUNCTION s.mutate_empty_schema(); + `); + + const { factBase: emptySeedSchemaSource } = await extract( + emptySeedSchemaDb.pool, + ); + await emptySeedSchemaDb.pool.query( + "ALTER TABLE s.zzz_empty_victim ALTER COLUMN value TYPE bigint", + ); + const { factBase: emptySeedSchemaTarget } = await extract( + emptySeedSchemaDb.pool, + ); + await emptySeedSchemaDb.pool.query( + "ALTER TABLE s.zzz_empty_victim ALTER COLUMN value TYPE integer", + ); + + emptySeedSchemaVerdict = await provePlan( + plan(emptySeedSchemaSource, emptySeedSchemaTarget), + emptySeedSchemaDb.pool, + emptySeedSchemaTarget, + { autoSeed: true }, + ); + + seedStateDb = await createTestDb("autoseed-full-state-change"); + await seedStateDb.pool.query(` + CREATE SCHEMA s; + CREATE TABLE s.aaa_seed_mutator (id integer); + CREATE TABLE s.zzz_empty_victim (value integer); + CREATE FUNCTION s.enable_victim_rls() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + ALTER TABLE s.zzz_empty_victim ENABLE ROW LEVEL SECURITY; + RETURN NULL; + END $$; + CREATE TRIGGER enable_victim_rls_after + AFTER INSERT ON s.aaa_seed_mutator + FOR EACH ROW EXECUTE FUNCTION s.enable_victim_rls(); + `); + + const { factBase: seedStateSource } = await extract(seedStateDb.pool); + seedStateSourceFingerprint = seedStateSource.rootHash; + await seedStateDb.pool.query( + "ALTER TABLE s.zzz_empty_victim ENABLE ROW LEVEL SECURITY", + ); + const { factBase: seedStateTarget } = await extract(seedStateDb.pool); + await seedStateDb.pool.query( + "ALTER TABLE s.zzz_empty_victim DISABLE ROW LEVEL SECURITY", + ); + + seedStateVerdict = await provePlan( + plan(seedStateSource, seedStateTarget), + seedStateDb.pool, + seedStateTarget, + { autoSeed: true }, + ); +}, 120_000); + +afterAll(async () => { + await db.drop(); + await schemaChangeDb.drop(); + await seedSchemaDb.drop(); + await emptySeedSchemaDb.drop(); + await seedStateDb.drop(); +}); + +describe("provePlan autoSeed seed-outcome reporting", () => { + function outcomeFor(name: string) { + const outcomes = verdict.seedOutcomes ?? []; + return outcomes.find( + (o) => o.table.schema === "s" && o.table.name === name, + ); + } + + test("surfaces a seedOutcomes array when autoSeed is set", () => { + expect(verdict.seedOutcomes).toBeDefined(); + }); + + test("a plainly-seedable table reports `seeded`", () => { + expect(outcomeFor("seedable")).toEqual({ + table: { schema: "s", name: "seedable" }, + status: "seeded", + }); + }); + + test("a NOT NULL-without-default table reports `skipped` with SQLSTATE 23502", () => { + expect(outcomeFor("notnull_nodefault")).toEqual({ + table: { schema: "s", name: "notnull_nodefault" }, + status: "skipped", + reasonCode: "23502", + }); + }); + + test("a RAISE EXCEPTION trigger table reports `failed` (not skipped)", () => { + const o = outcomeFor("raiser"); + expect(o?.status).toBe("failed"); + expect(o).toMatchObject({ + table: { schema: "s", name: "raiser" }, + status: "failed", + reasonCode: "P0001", + }); + expect((o as { message: string }).message).toMatch(/seed blocked/i); + }); + + test("a RETURNS NULL trigger (zero-row insert) reports `skipped` with reasonCode `no_row`", () => { + // the INSERT succeeds but stores no row, so this is NOT `seeded` — classify + // by persisted state and route it through the same allowlist gate as class-23. + expect(outcomeFor("trigger_suppressed")).toEqual({ + table: { schema: "s", name: "trigger_suppressed" }, + status: "skipped", + reasonCode: "no_row", + }); + }); + + test("an AFTER INSERT trigger that DELETEs the row (rowCount 1, table empty) reports `skipped` with reasonCode `no_row`", () => { + // rowCount is the command tag, not persisted state: `INSERT 0 1` but the + // row was undone. Final-snapshot reconciliation catches this. + expect(outcomeFor("trigger_undone")).toEqual({ + table: { schema: "s", name: "trigger_undone" }, + status: "skipped", + reasonCode: "no_row", + }); + }); + + test("a CROSS-TABLE undo reclassifies an earlier-seeded table to `no_row`", () => { + // aaa_victim seeded cleanly, then zzz_deleter's insert deletes its row: the + // final pre-apply snapshot has aaa_victim empty, so it must be `no_row` + // (a per-insert probe would have wrongly kept it `seeded`), while + // zzz_deleter — whose own row persists — stays `seeded`. + expect(outcomeFor("aaa_victim")).toEqual({ + table: { schema: "s", name: "aaa_victim" }, + status: "skipped", + reasonCode: "no_row", + }); + expect(outcomeFor("zzz_deleter")).toEqual({ + table: { schema: "s", name: "zzz_deleter" }, + status: "seeded", + }); + }); + + test("autoSeed side effects cannot erase pre-existing data before the proof baseline", () => { + // populated_victim is excluded from autoSeed because it starts with one row. + // Seeding seed_side_effect deletes that row; the proof must compare against + // the pre-seed snapshot and report the loss instead of baselining it away. + expect(verdict.dataViolations).toContainEqual({ + table: { schema: "s", name: "populated_victim" }, + before: 1, + after: 0, + }); + expect(verdict.ok).toBe(false); + }); + + test("detects equal-row-count autoSeed mutations before a schema-changing plan", () => { + // The seed trigger changes only row content, then the plan changes that + // table's schema. The final data-proof comparison cannot compare content + // across schemas, so the mutation must be captured before the plan runs. + expect(schemaChangeVerdict.dataViolations).toContainEqual({ + table: { schema: "s", name: "populated_victim" }, + before: 1, + after: 1, + contentChanged: true, + }); + expect(schemaChangeVerdict.ok).toBe(false); + }); + + test("rejects schema changes performed by an autoSeed trigger", () => { + // The trigger both mutates the existing row and performs the same type + // change as the plan. State still converges and row count stays stable, but + // the proof must fail because autoSeed changed a populated table's schema. + expect(seedSchemaVerdict.ok).toBe(false); + expect(seedSchemaVerdict.dataViolations).toContainEqual({ + table: { schema: "s", name: "populated_victim" }, + before: 1, + after: 1, + schemaChanged: true, + }); + }); + + test("rejects schema changes on originally-empty tables before applying the plan", () => { + expect(emptySeedSchemaVerdict.ok).toBe(false); + expect(emptySeedSchemaVerdict.dataViolations).toContainEqual({ + table: { schema: "s", name: "zzz_empty_victim" }, + before: 0, + after: 1, + schemaChanged: true, + }); + }); + + test("rejects non-column state changes performed by an autoSeed trigger", () => { + expect(seedStateVerdict.ok).toBe(false); + expect( + ( + seedStateVerdict as ProofVerdict & { + seedStateViolation?: { + expectedFingerprint: string; + actualFingerprint: string; + }; + } + ).seedStateViolation, + ).toMatchObject({ + expectedFingerprint: seedStateSourceFingerprint, + actualFingerprint: expect.any(String), + }); + }); +}); diff --git a/packages/pg-delta/tests/body-validation-language-scope.test.ts b/packages/pg-delta/tests/body-validation-language-scope.test.ts new file mode 100644 index 000000000..9ce6241f9 --- /dev/null +++ b/packages/pg-delta/tests/body-validation-language-scope.test.ts @@ -0,0 +1,144 @@ +/** + * Regression coverage: the post-load body-validation pass in `loadSqlFiles` + * (`src/frontends/load-sql-files.ts`) must only re-run `sql`/`plpgsql` + * routines. `check_function_bodies` has no effect on any other language + * (per the Postgres docs), so re-running e.g. `LANGUAGE internal` routines + * adds zero validation coverage — and can actively break the load, because + * `CREATE TYPE ... AS RANGE (...)` auto-creates `LANGUAGE internal` + * constructor/support functions. A non-superuser role (the production-faithful + * Supabase case) CAN create the range type itself, but re-running + * `CREATE OR REPLACE FUNCTION ... LANGUAGE internal` as that same non-superuser + * fails with `permission denied for language internal`. + */ +import { describe, expect, test } from "bun:test"; +import pg from "pg"; +import { buildFactBase } from "../src/core/fact.ts"; +import { + loadSqlFiles, + ShadowLoadError, +} from "../src/frontends/load-sql-files.ts"; +import { createTestDb } from "./containers.ts"; + +async function captureError(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} + +// `loadSqlFiles`'s default extractor (`extract()`) independently queries +// `pg_user_mapping` and fails with "permission denied for table +// pg_user_mapping" for a non-superuser role — a separate, pre-existing bug in +// the extraction path (src/extract/**), out of scope for this fix (which is +// scoped to the body-validation pass only). Stub the extractor so these tests +// isolate the body-validation behavior under test without tripping over that +// unrelated bug. +const stubExtract = async () => ({ + factBase: buildFactBase([], []), + pgVersion: "test", + diagnostics: [], +}); + +/** Build a pool connected AS `role` against the same database as `shadow`. */ +function poolAs(shadowUri: string, role: string, password: string): pg.Pool { + const url = new URL(shadowUri); + url.username = role; + url.password = password; + const pool = new pg.Pool({ connectionString: url.toString(), max: 5 }); + pool.on("error", () => {}); + return pool; +} + +describe("loadSqlFiles — body validation is scoped to sql/plpgsql routines", () => { + test("a non-superuser creating a range type must not have its auto-generated LANGUAGE internal functions re-validated", async () => { + const shadow = await createTestDb("langscope"); + let valtesterPool: pg.Pool | undefined; + try { + const admin = await shadow.pool.connect(); + try { + await admin.query( + "CREATE ROLE valtester LOGIN PASSWORD 'x' NOSUPERUSER", + ); + await admin.query( + `GRANT CREATE ON DATABASE "${shadow.name}" TO valtester`, + ); + } finally { + admin.release(); + } + + valtesterPool = poolAs(shadow.uri, "valtester", "x"); + + const files = [ + { + name: "01_range.sql", + sql: "CREATE SCHEMA s; CREATE TYPE s.score_range AS RANGE (subtype = numeric);", + }, + ]; + + const result = await loadSqlFiles(files, valtesterPool, { + extract: stubExtract, + }); + expect(result).toBeDefined(); + } finally { + await valtesterPool?.end().catch(() => {}); + await shadow.drop(); + // valtester owns objects only inside the dropped database — the role + // itself is cluster-global and must be cleaned up separately. + await shadow.cluster.adminPool + .query("DROP ROLE IF EXISTS valtester") + .catch(() => {}); + } + }, 60_000); + + // The point of this case is that sql/plpgsql routines ARE re-validated (unlike + // LANGUAGE internal above). Body-validation failures for a user routine are + // lenient by default now, so pin the "still validated" behavior via the + // `strictFunctionBodies` opt-in, which restores the fatal throw. + test("a genuinely broken sql-language routine in the same non-superuser schema still throws under strictFunctionBodies", async () => { + const shadow = await createTestDb("langscope"); + let valtesterPool: pg.Pool | undefined; + try { + const admin = await shadow.pool.connect(); + try { + await admin.query( + "CREATE ROLE valtester2 LOGIN PASSWORD 'x' NOSUPERUSER", + ); + await admin.query( + `GRANT CREATE ON DATABASE "${shadow.name}" TO valtester2`, + ); + } finally { + admin.release(); + } + + valtesterPool = poolAs(shadow.uri, "valtester2", "x"); + + const files = [ + { + name: "01_broken.sql", + sql: "CREATE SCHEMA s; CREATE FUNCTION s.user_broken() RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent';", + }, + ]; + + const err = await captureError( + loadSqlFiles(files, valtesterPool, { + extract: stubExtract, + strictFunctionBodies: true, + }), + ); + expect(err).toBeInstanceOf(ShadowLoadError); + const shadowErr = err as ShadowLoadError; + const detail = shadowErr.details.find( + (d) => d.code === "invalid_routine_body", + ); + expect(detail).toBeDefined(); + expect(detail?.message).toContain("s.user_broken:"); + expect(detail?.message).toContain("nonexistent"); + } finally { + await valtesterPool?.end().catch(() => {}); + await shadow.drop(); + await shadow.cluster.adminPool + .query("DROP ROLE IF EXISTS valtester2") + .catch(() => {}); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/capability.test.ts b/packages/pg-delta/tests/capability.test.ts new file mode 100644 index 000000000..c9f90334d --- /dev/null +++ b/packages/pg-delta/tests/capability.test.ts @@ -0,0 +1,64 @@ +/** + * probeApplierCapability against a real connection (managed-view move 6). + * The capability-restricted view's projection logic is unit-tested + * (src/policy/capability.test.ts); this proves the probe query runs and reports + * the connection's role / superuser / memberships. Docker required. + */ +import { describe, expect, test } from "bun:test"; +import pg from "pg"; +import { probeApplierCapability } from "../src/policy/capability.ts"; +import { sharedCluster } from "./containers.ts"; + +describe("probeApplierCapability (integration)", () => { + test("reports the connection's role, superuser flag, and memberships", async () => { + const cluster = await sharedCluster(); + const cap = await probeApplierCapability(cluster.adminPool); + // the container admin is a superuser + expect(cap.role.length).toBeGreaterThan(0); + expect(cap.isSuperuser).toBe(true); + // memberOf is a real parsed string[] (a role is a member of itself) — guards + // against the pg driver returning the array as an unparsed "{...}" literal. + expect(Array.isArray(cap.memberOf)).toBe(true); + expect(cap.memberOf).toContain(cap.role); + }, 60_000); + + // The capability FDW-ACL gate is keyed on isSuperuser. This pins the rule it + // rests on (Supabase Rule 9's stated rationale): a non-superuser cannot GRANT + // on a FOREIGN DATA WRAPPER, so its ACL is not user-replayable. + test("VERIFY: a non-superuser cannot GRANT on a FOREIGN DATA WRAPPER", async () => { + const cluster = await sharedCluster(); + const db = await cluster.createDb("cap_fdw_verify"); + try { + await db.pool.query(`CREATE EXTENSION IF NOT EXISTS postgres_fdw`); + await cluster.adminPool + .query(`CREATE ROLE cap_nonsuper LOGIN PASSWORD 'pw'`) + .catch(() => {}); + + const capUri = db.uri.replace("test:test@", "cap_nonsuper:pw@"); + const capPool = new pg.Pool({ connectionString: capUri, max: 1 }); + capPool.on("error", () => {}); + try { + const cap = await probeApplierCapability(capPool); + expect(cap.isSuperuser).toBe(false); // a plain LOGIN role is not super + + let grantError: string | undefined; + try { + await capPool.query( + `GRANT USAGE ON FOREIGN DATA WRAPPER postgres_fdw TO PUBLIC`, + ); + } catch (e) { + grantError = String(e); + } + // the GRANT must be rejected — confirming the isSuperuser gate + expect(grantError).toBeDefined(); + } finally { + await capPool.end().catch(() => {}); + } + } finally { + await db.drop(); + await cluster.adminPool + .query(`DROP ROLE IF EXISTS cap_nonsuper`) + .catch(() => {}); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/cli.test.ts b/packages/pg-delta/tests/cli.test.ts new file mode 100644 index 000000000..a64530617 --- /dev/null +++ b/packages/pg-delta/tests/cli.test.ts @@ -0,0 +1,1778 @@ +/** + * CLI integration tests (stage-9 deliverable 5/7/8). + * Spawns the CLI with Bun.spawn and asserts observable behaviour. + * + * All tests use the sharedCluster() fixture from containers.ts. + */ +import { describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { loadSnapshot } from "../src/frontends/snapshot-file.ts"; +import { isolatedClusterPair, sharedCluster } from "./containers.ts"; + +const PKG_DIR = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const CLI = join(PKG_DIR, "src/cli/main.ts"); + +interface SpawnResult { + stdout: string; + stderr: string; + exitCode: number; +} + +async function runCli(args: string[]): Promise { + const proc = Bun.spawn(["bun", CLI, ...args], { + cwd: PKG_DIR, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; +} + +const SCHEMA_SQL = ` + CREATE SCHEMA clitest; + CREATE TABLE clitest.items ( + id serial PRIMARY KEY, + name text NOT NULL + ); + CREATE INDEX items_name_idx ON clitest.items (name); +`; + +describe("CLI: error → exit-code mapping (main is the sole exiter)", () => { + test("a post-parse usage error maps to exit 2 with its message on stderr", async () => { + // `--no-format` + `--format-options` is a post-parse guard in cmdSchemaExport + // that used to `process.stderr.write(...) + process.exit(2)`. It now throws + // UsageError, which main() maps to exit 2 while writing the message. The + // guard runs before any DB connection, so the bogus --source is never dialed. + const res = await runCli([ + "schema", + "export", + "--source", + "postgres://unused.invalid:5432/nope", + "--out-dir", + join(tmpdir(), `pgdn-exitmap-${Date.now()}`), + "--no-format", + "--format-options", + "{}", + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr).toMatch(/mutually exclusive/); + }); + + test("an unknown flag (parse-time usage error) maps to exit 2", async () => { + const res = await runCli([ + "snapshot", + "--source", + "postgres://unused.invalid:5432/nope", + "--out", + join(tmpdir(), `pgdn-exitmap-${Date.now()}.json`), + "--totally-unknown-flag", + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr).toMatch(/Unknown flag/); + // the command-specific usage hint still rides along on the same message + expect(res.stderr).toMatch(/Usage: pgdelta snapshot/); + }); +}); + +describe("CLI: --help", () => { + test("does not recommend apply --force for unsafe plans", async () => { + const res = await runCli(["--help"]); + expect(res.exitCode).toBe(0); + // RED before the fix: help said an unredacted plan "requires apply --force". + // apply/prove now re-extract with the plan's stamped redaction mode. + expect(res.stdout).not.toMatch(/requires\s+"?apply --force/i); + expect(res.stdout).toContain("re-extract"); + }, 30_000); +}); + +describe("CLI: --version", () => { + // No container needed — argv-only path. + const PKG_VERSION = ( + JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8")) as { + version: string; + } + ).version; + + test("--version prints the package version and exits 0", async () => { + const res = await runCli(["--version"]); + // RED before the fix: no version flag, so this fell through to the default + // branch — "Unknown command: --version" on stderr, exit 2. + expect(res.exitCode).toBe(0); + expect(res.stdout.trim()).toBe(PKG_VERSION); + expect(res.stderr).not.toContain("Unknown command"); + }, 30_000); + + test("-v and version are aliases for --version", async () => { + for (const arg of ["-v", "version"]) { + const res = await runCli([arg]); + expect({ arg, code: res.exitCode }).toMatchObject({ code: 0 }); + expect(res.stdout.trim()).toBe(PKG_VERSION); + } + }, 30_000); +}); + +describe("CLI: schema --help", () => { + // No container needed — argv-only path. + test("schema --help prints schema usage to stdout and exits 0", async () => { + const res = await runCli(["schema", "--help"]); + // RED before the fix: `schema --help` hit the unknown-subcommand branch, + // printing "Unknown schema subcommand: --help" on stderr and exiting 2. + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain("schema export"); + expect(res.stdout).toContain("schema apply"); + expect(res.stdout).toContain("schema lint"); + expect(res.stderr).not.toContain("Unknown schema subcommand"); + }, 30_000); + + test("schema -h and schema help are aliases", async () => { + for (const arg of ["-h", "help"]) { + const res = await runCli(["schema", arg]); + expect({ arg, code: res.exitCode }).toMatchObject({ code: 0 }); + expect(res.stdout).toContain("schema export"); + } + }, 30_000); + + test("an unknown schema subcommand still errors (exit 2)", async () => { + const res = await runCli(["schema", "bogus"]); + expect(res.exitCode).toBe(2); + expect(res.stderr).toContain("Unknown schema subcommand"); + }, 30_000); +}); + +describe("CLI: prove redaction guard", () => { + test("rejects a snapshot whose redaction mode differs from the plan (before touching the clone)", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_prove_guard"); + try { + await source.pool.query(SCHEMA_SQL); + + // a DEFAULT-redacted plan (no --unsafe-show-secrets) → redactSecrets:true + const planFile = join(tmpdir(), `pgdn-prove-plan-${Date.now()}.json`); + const planned = await runCli([ + "plan", + "--source", + source.uri, + "--desired", + source.uri, + "--out", + planFile, + ]); + expect(planned.exitCode).toBe(0); + + // an UNSAFE snapshot (redactSecrets:false) — mismatched mode + const snapFile = join(tmpdir(), `pgdn-prove-snap-${Date.now()}.json`); + const snapped = await runCli([ + "snapshot", + "--source", + source.uri, + "--out", + snapFile, + "--unsafe-show-secrets", + ]); + expect(snapped.exitCode).toBe(0); + + // clone URL is never dialed — the guard exits before opening it. + const res = await runCli([ + "prove", + "--plan", + planFile, + "--clone", + "postgres://unused.invalid:5432/nope", + "--desired-snapshot", + snapFile, + ]); + // RED before the fix: prove would proceed, mutate the clone, and only then + // fail the proof spuriously on placeholder-vs-real secrets. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/redaction mode/i); + } finally { + await source.drop(); + } + }, 60_000); + + test("treats an unstamped (pre-metadata) snapshot as redacted and rejects an unsafe plan", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_prove_unstamped"); + try { + await source.pool.query(SCHEMA_SQL); + + // an UNSAFE plan (redactSecrets:false) + const planFile = join(tmpdir(), `pgdn-prove-unsafe-${Date.now()}.json`); + expect( + ( + await runCli([ + "plan", + "--source", + source.uri, + "--desired", + source.uri, + "--out", + planFile, + "--unsafe-show-secrets", + ]) + ).exitCode, + ).toBe(0); + + // a snapshot with the redactSecrets field STRIPPED (simulating one written + // before the metadata existed) — deserializes with redactSecrets undefined. + const snapFile = join( + tmpdir(), + `pgdn-prove-unstamped-${Date.now()}.json`, + ); + expect( + (await runCli(["snapshot", "--source", source.uri, "--out", snapFile])) + .exitCode, + ).toBe(0); + const doc = JSON.parse(readFileSync(snapFile, "utf8")); + delete doc.redactSecrets; // digest excludes it, so the file stays valid + writeFileSync(snapFile, JSON.stringify(doc), "utf8"); + + const res = await runCli([ + "prove", + "--plan", + planFile, + "--clone", + "postgres://unused.invalid:5432/nope", + "--desired-snapshot", + snapFile, + ]); + // RED before the fix: undefined snapshot mode skipped the guard, so prove + // proceeded and would fail spuriously after mutating the clone. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/redaction mode/i); + } finally { + await source.drop(); + } + }, 60_000); +}); + +describe("CLI: snapshot", () => { + test("snapshot writes a loadable file whose rootHash round-trips", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_snap_src"); + try { + await source.pool.query(SCHEMA_SQL); + + const outFile = join( + tmpdir(), + `pg-delta-next-snapshot-${Date.now()}.json`, + ); + const result = await runCli([ + "snapshot", + "--source", + source.uri, + "--out", + outFile, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Snapshot saved"); + + // round-trip: loadSnapshot should give the same rootHash + const { factBase } = loadSnapshot(outFile); + expect(factBase.facts().length).toBeGreaterThan(0); + + // verify the hash is stable (the file is a valid snapshot) + const { factBase: factBase2 } = loadSnapshot(outFile); + expect(factBase2.rootHash).toBe(factBase.rootHash); + } finally { + await source.drop(); + } + }, 60_000); +}); + +describe("CLI: diff", () => { + test("diff between two prepared DBs prints expected kinds", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_diff_src"); + const desired = await cluster.createDb("cli_diff_dst"); + try { + await source.pool.query(SCHEMA_SQL); + // desired has one extra table + await desired.pool.query(` + ${SCHEMA_SQL} + CREATE TABLE clitest.extras (id serial PRIMARY KEY); + `); + + const result = await runCli([ + "diff", + "--source", + source.uri, + "--desired", + desired.uri, + ]); + + expect(result.exitCode).toBe(0); + // extras table is an add delta + expect(result.stdout).toContain("ADD"); + expect(result.stdout).toContain("table"); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); +}); + +describe("CLI: drift", () => { + test("drift exits 0 when env matches snapshot, exits 1 after mutation", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_drift_src"); + try { + await source.pool.query(SCHEMA_SQL); + + // take a snapshot of the current state + const snapshotFile = join( + tmpdir(), + `pg-delta-next-drift-${Date.now()}.json`, + ); + const snapResult = await runCli([ + "snapshot", + "--source", + source.uri, + "--out", + snapshotFile, + ]); + expect(snapResult.exitCode).toBe(0); + + // drift against the same DB — should be no drift + const nodrif = await runCli([ + "drift", + "--env", + source.uri, + "--snapshot", + snapshotFile, + ]); + expect(nodrif.exitCode).toBe(0); + expect(nodrif.stdout).toContain("No drift"); + + // mutate the DB + await source.pool.query(`CREATE TABLE clitest.new_table (id integer);`); + + // drift again — should detect the new table + const hasdrift = await runCli([ + "drift", + "--env", + source.uri, + "--snapshot", + snapshotFile, + ]); + expect(hasdrift.exitCode).toBe(1); + expect(hasdrift.stdout).toContain("Drift detected"); + } finally { + await source.drop(); + } + }, 60_000); +}); + +describe("CLI: plan", () => { + test("plan writes a parseable artifact whose actions are non-empty", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_plan_src"); + const desired = await cluster.createDb("cli_plan_dst"); + try { + // source is empty; desired has a schema + await desired.pool.query(SCHEMA_SQL); + + const planFile = join(tmpdir(), `pg-delta-next-plan-${Date.now()}.json`); + const result = await runCli([ + "plan", + "--source", + source.uri, + "--desired", + desired.uri, + "--out", + planFile, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("actions:"); + + // parse the artifact + const { readFileSync } = await import("node:fs"); + const { parsePlan } = await import("../src/plan/artifact.ts"); + const json = readFileSync(planFile, "utf8"); + const thePlan = parsePlan(json); + + expect(thePlan.actions.length).toBeGreaterThan(0); + expect(thePlan.formatVersion).toBe(1); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); + + test("plan stamps the profile id; apply rejects a contradicting --profile (P2)", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_profile_src"); + const desired = await cluster.createDb("cli_profile_dst"); + try { + await desired.pool.query(SCHEMA_SQL); + const planFile = join( + tmpdir(), + `pg-delta-next-profilestamp-${Date.now()}.json`, + ); + // default profile is raw → the artifact is stamped { id: "raw" } + const planned = await runCli([ + "plan", + "--source", + source.uri, + "--desired", + desired.uri, + "--out", + planFile, + ]); + expect(planned.exitCode).toBe(0); + + const { readFileSync } = await import("node:fs"); + const { parsePlan } = await import("../src/plan/artifact.ts"); + const thePlan = parsePlan(readFileSync(planFile, "utf8")); + expect(thePlan.profile).toEqual({ id: "raw" }); + + // applying a raw-stamped plan with --profile supabase is a mismatch and + // must be rejected (exit 2) BEFORE opening the target connection. + const mismatch = await runCli([ + "apply", + "--plan", + planFile, + "--target", + source.uri, + "--profile", + "supabase", + ]); + expect(mismatch.exitCode).toBe(2); + expect(mismatch.stderr).toMatch(/does not match the plan's profile/); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); +}); + +describe("CLI: strict coverage (unmodeled-kind surfacing)", () => { + // a user CAST is a kind the engine does not model — it must be surfaced, and + // --strict-coverage must refuse to plan rather than silently omit it. + const UNMODELED_DDL = ` + CREATE DOMAIN clipostal AS text; + CREATE FUNCTION clipostal_to_int(clipostal) RETURNS integer + LANGUAGE sql IMMUTABLE AS 'SELECT length($1)'; + CREATE CAST (clipostal AS integer) WITH FUNCTION clipostal_to_int(clipostal); + `; + + test("plan surfaces the unmodeled warning; --strict-coverage refuses to plan", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_strict_src"); + const desired = await cluster.createDb("cli_strict_dst"); + try { + await desired.pool.query(UNMODELED_DDL); + + // default: the warning is surfaced on stderr but the plan still succeeds + const warn = await runCli([ + "plan", + "--source", + source.uri, + "--desired", + desired.uri, + ]); + expect(warn.exitCode).toBe(0); + expect(warn.stderr).toContain("unmodeled"); + expect(warn.stderr).toContain("cast"); + + // strict: refuses to plan, exits non-zero, names the reason + const strict = await runCli([ + "plan", + "--source", + source.uri, + "--desired", + desired.uri, + "--strict-coverage", + ]); + expect(strict.exitCode).toBe(3); + expect(strict.stderr).toContain("unmodeled"); + expect(strict.stderr.toLowerCase()).toContain("strict-coverage"); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 90_000); +}); + +describe("CLI: schema export --scope", () => { + test("database scope omits cluster/roles.sql; cluster scope includes it", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_export_scope_src"); + try { + await source.pool.query(SCHEMA_SQL); + const { existsSync } = await import("node:fs"); + const scopeOf = (dir: string) => + JSON.parse(readFileSync(join(dir, ".pgdelta-export.json"), "utf8")) + .scope; + + // default (database): the connection role's CREATE ROLE is projected out, + // so no cluster/roles.sql — the dir reloads on any cluster. + const dbDir = join(tmpdir(), `pgdn-exp-scope-db-${Date.now()}`); + expect( + ( + await runCli([ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + dbDir, + ]) + ).exitCode, + ).toBe(0); + expect(existsSync(join(dbDir, "cluster/roles.sql"))).toBe(false); + expect(scopeOf(dbDir)).toBe("database"); + + // cluster scope keeps roles. + const clDir = join(tmpdir(), `pgdn-exp-scope-cl-${Date.now()}`); + expect( + ( + await runCli([ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + clDir, + "--scope", + "cluster", + ]) + ).exitCode, + ).toBe(0); + expect(existsSync(join(clDir, "cluster/roles.sql"))).toBe(true); + expect(scopeOf(clDir)).toBe("cluster"); + } finally { + await source.drop(); + } + }, 90_000); +}); + +describe("CLI: schema export", () => { + test("schema export writes files to disk including schemas//tables/.sql", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_export_src"); + try { + await source.pool.query(SCHEMA_SQL); + + const outDir = join(tmpdir(), `pg-delta-next-export-${Date.now()}`); + mkdirSync(outDir, { recursive: true }); + + const result = await runCli([ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + outDir, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Exported"); + + // verify expected file exists + const { existsSync } = await import("node:fs"); + expect(existsSync(join(outDir, "schemas/clitest/tables/items.sql"))).toBe( + true, + ); + } finally { + await source.drop(); + } + }, 60_000); + + test("re-export removes the stale file of a dropped object", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_reexport_src"); + try { + await source.pool.query(SCHEMA_SQL); + await source.pool.query( + `CREATE TABLE clitest.gone (id integer PRIMARY KEY);`, + ); + + const outDir = join(tmpdir(), `pg-delta-next-reexport-${Date.now()}`); + mkdirSync(outDir, { recursive: true }); + const args = [ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + outDir, + ]; + + expect((await runCli(args)).exitCode).toBe(0); + const { existsSync } = await import("node:fs"); + const goneFile = join(outDir, "schemas/clitest/tables/gone.sql"); + expect(existsSync(goneFile)).toBe(true); + + // drop the object and re-export into the SAME dir. + await source.pool.query(`DROP TABLE clitest.gone;`); + const re = await runCli(args); + expect(re.exitCode).toBe(0); + + // RED before the fix: the loop only overwrote new paths, so gone.sql + // lingered and `schema apply --dir` would reload the dropped table. + expect(existsSync(goneFile)).toBe(false); + expect(existsSync(join(outDir, "schemas/clitest/tables/items.sql"))).toBe( + true, + ); + } finally { + await source.drop(); + } + }, 90_000); +}); + +// REVIEW_HANDOFF.md P1: schema export/apply must be profile-aware like `plan`, +// instead of always using the raw view — otherwise SQL-file workflows diverge +// from the profile-aware DB-to-DB path. These prove the `--profile` / +// `--restrict-to-applier` flags exist and thread through extract/plan/apply. +describe("CLI: schema apply --scope database (ambient roles)", () => { + test("does not create shadow-only nor drop target-only cluster roles", async () => { + // shadow and target on SEPARATE clusters (the real deployment: local shadow, + // remote target), each with a distinct ambient role the declarative files + // never mention. + const [shadowCluster, targetCluster] = await isolatedClusterPair(); + const shadow = await shadowCluster.createDb("cli_scope_shadow"); + const target = await targetCluster.createDb("cli_scope_tgt"); + const shadowRole = `only_on_shadow_${Date.now()}`; + const targetRole = `only_on_target_${Date.now()}`; + try { + await shadow.pool.query(`CREATE ROLE ${shadowRole} NOLOGIN`); + await target.pool.query(`CREATE ROLE ${targetRole} NOLOGIN`); + + const dir = join(tmpdir(), `pg-delta-next-scope-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "01_schema.sql"), + `CREATE SCHEMA app;\nCREATE TABLE app.t (id integer PRIMARY KEY);\n`, + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + ]); + + // RED before the fix: database scope did not project roles, so the plan + // CREATEd the shadow-only role on the target AND DROPped the target-only + // role (destructive). Now roles are ambient — neither happens. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + const has = async (role: string) => + ( + await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_roles WHERE rolname = $1`, + [role], + ) + ).rows[0]?.n === 1; + expect(await has(targetRole)).toBe(true); // NOT dropped + expect(await has(shadowRole)).toBe(false); // NOT created + // the actual schema objects DID apply + const { rows } = await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_tables WHERE schemaname = 'app'`, + ); + expect(rows[0]?.n).toBe(1); + } finally { + await shadow.pool + .query(`DROP ROLE IF EXISTS ${shadowRole}`) + .catch(() => {}); + await target.pool + .query(`DROP ROLE IF EXISTS ${targetRole}`) + .catch(() => {}); + await Promise.all([shadow.drop(), target.drop()]); + } + }, 120_000); + + test("rejects cluster DDL in files under database scope (with escapes)", async () => { + const dir = join(tmpdir(), `pg-delta-next-clusterddl-${Date.now()}`); + mkdirSync(join(dir, "cluster"), { recursive: true }); + writeFileSync(join(dir, "01_schema.sql"), `CREATE SCHEMA app;\n`); + writeFileSync( + join(dir, "cluster", "roles.sql"), + `CREATE ROLE app_owner NOLOGIN;\nGRANT app_owner TO current_user;\n`, + ); + // rejected before any connection (default scope is database) + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + "postgres://unused.invalid:5432/s", + "--target", + "postgres://unused.invalid:5432/t", + ]); + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/does not manage cluster-global roles/i); + expect(res.stderr).toContain("CREATE ROLE"); + expect(res.stderr).toMatch(/--skip-cluster-ddl/); + }, 30_000); + + test("--skip-cluster-ddl drops role DDL and applies the rest", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_skipddl_shadow"); + const target = await cluster.createDb("cli_skipddl_tgt"); + const skipRole = `skip_role_${Date.now()}`; + try { + const dir = join(tmpdir(), `pg-delta-next-skipddl-${Date.now()}`); + mkdirSync(join(dir, "cluster"), { recursive: true }); + writeFileSync( + join(dir, "01_schema.sql"), + `CREATE SCHEMA app;\nCREATE TABLE app.t (id integer PRIMARY KEY);\n`, + ); + writeFileSync( + join(dir, "cluster", "roles.sql"), + `CREATE ROLE ${skipRole} NOLOGIN;\n`, + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + "--skip-cluster-ddl", + ]); + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + expect(res.stderr).toMatch(/SKIP cluster DDL/i); + // schema applied; the skipped role was NOT created + const tbl = await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_tables WHERE schemaname = 'app'`, + ); + expect(tbl.rows[0]?.n).toBe(1); + const role = await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_roles WHERE rolname = $1`, + [skipRole], + ); + expect(role.rows[0]?.n).toBe(0); + } finally { + await shadow.pool + .query(`DROP ROLE IF EXISTS ${skipRole}`) + .catch(() => {}); + await Promise.all([shadow.drop(), target.drop()]); + } + }, 90_000); + + test("rejects --scope contradicting the export manifest scope", async () => { + const dir = join(tmpdir(), `pg-delta-next-scope-conflict-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01_schema.sql"), `CREATE SCHEMA app;\n`); + writeFileSync( + join(dir, ".pgdelta-export.json"), + JSON.stringify({ formatVersion: 1, scope: "cluster" }), + "utf8", + ); + // reconciled before any connection is opened + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + "postgres://unused.invalid:5432/s", + "--target", + "postgres://unused.invalid:5432/t", + "--scope", + "database", + ]); + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/contradicts the export manifest scope/i); + }, 30_000); + + test("co-located quick mode (no --shadow) provisions and drops a shadow on the target cluster", async () => { + const cluster = await sharedCluster(); + const target = await cluster.createDb("cli_colocated_tgt"); + try { + const dir = join(tmpdir(), `pg-delta-next-colocated-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "01_schema.sql"), + `CREATE SCHEMA app;\nCREATE TABLE app.t (id integer PRIMARY KEY);\n`, + ); + + // NO --shadow: a co-located shadow database is created on the target cluster. + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--target", + target.uri, + "--renames", + "off", + ]); + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + const m = /Created shadow database (pgdelta_shadow_\S+)/.exec(res.stderr); + expect(m).not.toBeNull(); + const shadowName = m![1] as string; + + // the schema applied to the target ... + const { rows: tbl } = await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_tables WHERE schemaname = 'app'`, + ); + expect(tbl[0]?.n).toBe(1); + // ... and the throwaway shadow database was dropped. + const { rows: db } = await target.pool.query( + `SELECT 1 FROM pg_database WHERE datname = $1`, + [shadowName], + ); + expect(db).toHaveLength(0); + } finally { + await target.drop(); + } + }, 90_000); + + test("--scope cluster requires --isolated-shadow", async () => { + const res = await runCli([ + "schema", + "apply", + "--dir", + tmpdir(), + "--shadow", + "postgres://unused.invalid:5432/s", + "--target", + "postgres://unused.invalid:5432/t", + "--scope", + "cluster", + ]); + // validated before any connection is opened + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/isolated-shadow/i); + }, 30_000); +}); + +describe("CLI: schema apply guards", () => { + test("refuses an empty --dir instead of planning to drop everything", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_empty_shadow"); + const target = await cluster.createDb("cli_empty_tgt"); + try { + await target.pool.query(SCHEMA_SQL); // target HAS managed objects + const emptyDir = join(tmpdir(), `pg-delta-next-emptydir-${Date.now()}`); + mkdirSync(emptyDir, { recursive: true }); + + const res = await runCli([ + "schema", + "apply", + "--dir", + emptyDir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + ]); + // RED before the fix: apply loaded an empty shadow and planned to DROP the + // target's schema/tables. Now it aborts with exit 2. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/no executable SQL/i); + // the target's objects are untouched + const { rows } = await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_tables WHERE schemaname = 'clitest'`, + ); + expect(rows[0]?.n).toBe(1); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 60_000); + + test("refuses a directory whose only .sql is comment-only", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_commentonly_shadow"); + const target = await cluster.createDb("cli_commentonly_tgt"); + try { + await target.pool.query(SCHEMA_SQL); // target HAS managed objects + const dir = join(tmpdir(), `pg-delta-next-commentonly-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + // a placeholder file with no executable SQL (line + block comments only) + writeFileSync( + join(dir, "01_note.sql"), + `-- just a placeholder\n/* nothing to apply here */\n`, + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + ]); + // RED before the fix: the filename count was > 0, so apply loaded an empty + // shadow and planned to drop the target's objects. Now it aborts (exit 2). + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/no executable SQL/i); + const { rows } = await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_tables WHERE schemaname = 'clitest'`, + ); + expect(rows[0]?.n).toBe(1); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 60_000); + + test("refuses to apply a profiled export under a contradicting --profile", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_profile_guard_shadow"); + const target = await cluster.createDb("cli_profile_guard_tgt"); + try { + // a directory whose manifest was written by `schema export --profile supabase` + const dir = join(tmpdir(), `pg-delta-next-profile-guard-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01_schema.sql"), `CREATE SCHEMA app;\n`); + writeFileSync( + join(dir, ".pgdelta-export.json"), + JSON.stringify({ + formatVersion: 1, + redactSecrets: true, + profile: "supabase", + }), + "utf8", + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--profile", + "raw", + "--renames", + "off", + ]); + // RED before the fix: the manifest profile was ignored, so a raw apply of a + // supabase export proceeded (and could drop platform state). Now the + // mismatch is rejected up front (exit 2), before opening a connection. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/profile/i); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 60_000); +}); + +describe("CLI: schema profile-awareness", () => { + test("schema export --profile raw is accepted (raw == identity)", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_exp_profile_src"); + try { + await source.pool.query(SCHEMA_SQL); + const outDir = join(tmpdir(), `pg-delta-next-exp-prof-${Date.now()}`); + mkdirSync(outDir, { recursive: true }); + const result = await runCli([ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + outDir, + "--profile", + "raw", + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Exported"); + const { existsSync } = await import("node:fs"); + expect(existsSync(join(outDir, "schemas/clitest/tables/items.sql"))).toBe( + true, + ); + } finally { + await source.drop(); + } + }, 60_000); + + test("schema apply --profile raw --restrict-to-applier applies SQL files", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_apply_prof_shadow"); + const target = await cluster.createDb("cli_apply_prof_tgt"); + try { + // hand-written declarative files (no serial/role cycles — those are an + // orthogonal export-layout concern). The point is that the profile flags + // thread through extraction, planning, and apply. + const dir = join(tmpdir(), `pg-delta-next-apply-prof-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync(join(dir, "01_schema.sql"), `CREATE SCHEMA clitest;\n`); + writeFileSync( + join(dir, "02_table.sql"), + `CREATE TABLE clitest.items (id integer PRIMARY KEY, name text NOT NULL);\n`, + ); + + const applied = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--profile", + "raw", + "--restrict-to-applier", + "--renames", + "off", + ]); + expect({ code: applied.exitCode, stderr: applied.stderr }).toMatchObject({ + code: 0, + }); + expect(applied.stderr).toMatch(/Applied \d+ action/); + + // the target now has the declared schema + table + const { rows } = await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_tables WHERE schemaname = 'clitest'`, + ); + expect(rows[0]?.n).toBe(1); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 90_000); +}); + +// A declarative dir with cluster-level role state trips the default +// databaseScratch leak guard; --isolated-shadow (dedicated shadow cluster) lets +// it load (review P2). +describe("CLI: schema apply --scope cluster --isolated-shadow", () => { + test("role-containing export reloads and creates the role under cluster scope", async () => { + const [shadowCluster, targetCluster] = await isolatedClusterPair(); + const shadow = await shadowCluster.createDb("cli_iso_shadow"); + const target = await targetCluster.createDb("cli_iso_tgt"); + try { + const dir = join(tmpdir(), `pg-delta-next-iso-${Date.now()}`); + mkdirSync(join(dir, "cluster"), { recursive: true }); + writeFileSync( + join(dir, "cluster/roles.sql"), + `CREATE ROLE cli_iso_role_xyz NOLOGIN;\n`, + ); + + // cluster scope MANAGES roles; --isolated-shadow gives the dedicated shadow + // cluster that lets the CREATE ROLE load past the leak guard. (Under the + // default database scope the role is ambient and would NOT be created.) + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + "--scope", + "cluster", + "--isolated-shadow", + ]); + + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + const { rows } = await target.pool.query<{ exists: boolean }>( + `SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cli_iso_role_xyz') AS exists`, + ); + expect(rows[0]?.exists).toBe(true); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 120_000); +}); + +// The reorder assist is on by default (target-arch §4.4.1) but must NOT silently +// degrade the desired state. Two cases force a fall back to raw, file-granular +// loading (review P1). +describe("CLI: schema apply reorder safety", () => { + test("a pg-topo parse error falls back to raw loading and surfaces the bad file (no silent drop)", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_reorder_pe_shadow"); + const target = await cluster.createDb("cli_reorder_pe_tgt"); + try { + const dir = join(tmpdir(), `pg-delta-next-reorder-pe-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01_good.sql"), `CREATE SCHEMA clitest;\n`); + // Unparseable: pg-topo returns a PARSE_ERROR and NO statement nodes for + // this file, so a reorder would silently OMIT it from the shadow. + writeFileSync( + join(dir, "02_bad.sql"), + `CREATE TABLE clitest.broken (id int;\n`, + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + ]); + + // RED before the fix: the bad file is dropped, the shadow builds from the + // good file only, and apply exits 0 — silently ignoring 02_bad.sql. + expect(res.stderr).toContain("reorder assist disabled"); + expect(res.exitCode).not.toBe(0); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 90_000); + + test("ALTER DEFAULT PRIVILEGES files warn that raw load may apply ADP after same-load objects", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_reorder_adp_shadow"); + const target = await cluster.createDb("cli_reorder_adp_tgt"); + try { + const dir = join(tmpdir(), `pg-delta-next-reorder-adp-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01_schema.sql"), `CREATE SCHEMA app;\n`); + writeFileSync( + join(dir, "02_adp.sql"), + `ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO PUBLIC;\n`, + ); + writeFileSync( + join(dir, "03_table.sql"), + `CREATE TABLE app.widget (id integer PRIMARY KEY);\n`, + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + ]); + + expect(res.exitCode).toBe(0); + // ADP present → reorder disabled, raw load, and the caveat is surfaced (no + // silent limitation): objects relying on ADP-implicit grants may miss them. + expect(res.stderr).toContain("reorder assist disabled"); + expect(res.stderr).toMatch( + /raw loading may apply ALTER DEFAULT PRIVILEGES AFTER objects/i, + ); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 90_000); + + test("--no-reorder still warns about ADP raw-load ordering (every raw path)", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_noreorder_adp_shadow"); + const target = await cluster.createDb("cli_noreorder_adp_tgt"); + try { + const dir = join(tmpdir(), `pg-delta-next-noreorder-adp-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01_schema.sql"), `CREATE SCHEMA app;\n`); + writeFileSync( + join(dir, "02_adp.sql"), + `ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO PUBLIC;\n`, + ); + writeFileSync( + join(dir, "03_table.sql"), + `CREATE TABLE app.widget (id integer PRIMARY KEY);\n`, + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + "--no-reorder", + ]); + + expect(res.exitCode).toBe(0); + // --no-reorder skips the diagnostics branch entirely (no "reorder assist + // disabled"), but the ADP caveat must STILL surface on this raw path. + expect(res.stderr).not.toContain("reorder assist disabled"); + expect(res.stderr).toMatch( + /raw loading may apply ALTER DEFAULT PRIVILEGES AFTER objects/i, + ); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 90_000); + + test("session-setting statements force raw loading, not reorder", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_reorder_ss_shadow"); + const target = await cluster.createDb("cli_reorder_ss_tgt"); + try { + const dir = join(tmpdir(), `pg-delta-next-reorder-ss-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01_schema.sql"), `CREATE SCHEMA app;\n`); + writeFileSync( + join(dir, "02_set.sql"), + `SET search_path TO app, public;\nCREATE TABLE app.widget (id integer PRIMARY KEY);\n`, + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + ]); + + // RED before the fix: stderr shows "Reordered into N statement(s)" and no + // fallback warning — the SET barrier was reordered. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + expect(res.stderr).toContain("reorder assist disabled"); + expect(res.stderr).toContain("session-setting"); + expect(res.stderr).not.toContain("Reordered into"); + + const { rows } = await target.pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_tables WHERE schemaname = 'app'`, + ); + expect(rows[0]?.n).toBe(1); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 90_000); +}); + +describe("CLI: secret redaction surface", () => { + // Custom FDW (NO HANDLER/VALIDATOR) accepts arbitrary option keys, so we can + // plant a credential in a server option on stock alpine. + const FDW_SQL = ` + CREATE FOREIGN DATA WRAPPER cli_redact_fdw; + CREATE SERVER cli_redact_srv FOREIGN DATA WRAPPER cli_redact_fdw + OPTIONS (host 'h.example.com', password 'cli-secret-xyz'); + `; + + test("snapshot redacts by default; --unsafe-show-secrets emits real values + warns", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_redact_src"); + try { + await source.pool.query(FDW_SQL); + + // default: redacted, no warning + const redactedFile = join(tmpdir(), `pgdn-redact-${Date.now()}.json`); + const r1 = await runCli([ + "snapshot", + "--source", + source.uri, + "--out", + redactedFile, + ]); + expect(r1.exitCode).toBe(0); + const redacted = readFileSync(redactedFile, "utf8"); + expect(redacted).not.toContain("cli-secret-xyz"); + expect(redacted).toContain("__OPTION_PASSWORD__"); + expect(r1.stderr).not.toContain("Secret redaction is DISABLED"); + + // opt-out: real value emitted, loud warning on stderr + const rawFile = join(tmpdir(), `pgdn-raw-${Date.now()}.json`); + const r2 = await runCli([ + "snapshot", + "--source", + source.uri, + "--out", + rawFile, + "--unsafe-show-secrets", + ]); + expect(r2.exitCode).toBe(0); + const raw = readFileSync(rawFile, "utf8"); + expect(raw).toContain("cli-secret-xyz"); + expect(raw).not.toContain("__OPTION_PASSWORD__"); + expect(r2.stderr).toContain("Secret redaction is DISABLED"); + } finally { + await source.drop(); + } + }, 60_000); + + test("schema apply --unsafe-show-secrets round-trips real credentials to the target", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_apply_secret_shadow"); + const target = await cluster.createDb("cli_apply_secret_tgt"); + try { + // a declarative dir carrying a REAL credential (e.g. produced by + // `schema export --unsafe-show-secrets`). + const dir = join(tmpdir(), `pg-delta-next-apply-secret-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "01_fdw.sql"), + `CREATE FOREIGN DATA WRAPPER cli_apply_fdw;\n` + + `CREATE SERVER cli_apply_srv FOREIGN DATA WRAPPER cli_apply_fdw\n` + + ` OPTIONS (host 'h.example.com', password 'apply-secret-xyz');\n`, + ); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + "--unsafe-show-secrets", + ]); + + // RED before the fix: the shadow re-extract redacts the credential, so the + // plan emits OPTIONS (... '__OPTION_PASSWORD__') and the target stores the + // placeholder instead of the real value. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + expect(res.stderr).toContain("Secret redaction is DISABLED"); + + const { rows } = await target.pool.query<{ srvoptions: string[] }>( + `SELECT srvoptions FROM pg_foreign_server WHERE srvname = 'cli_apply_srv'`, + ); + const opts = (rows[0]?.srvoptions ?? []).join(","); + expect(opts).toContain("password=apply-secret-xyz"); + expect(opts).not.toContain("__OPTION_PASSWORD__"); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 90_000); + + test("schema apply --unsafe-show-secrets passes the fingerprint gate when the target already has secrets", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_fp_shadow"); + const target = await cluster.createDb("cli_fp_tgt"); + try { + // the TARGET already holds an unredacted credential, so the plan source + // fingerprint is computed over an unredacted fact base. + await target.pool.query(FDW_SQL); + + // declarative dir matches the target's server but adds a new object, so + // there is a non-empty plan whose apply triggers the fingerprint gate. + const dir = join(tmpdir(), `pg-delta-next-fp-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "01_fdw.sql"), + `CREATE FOREIGN DATA WRAPPER cli_redact_fdw;\n` + + `CREATE SERVER cli_redact_srv FOREIGN DATA WRAPPER cli_redact_fdw\n` + + ` OPTIONS (host 'h.example.com', password 'cli-secret-xyz');\n`, + ); + writeFileSync(join(dir, "02_schema.sql"), `CREATE SCHEMA fp_extra;\n`); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + "--unsafe-show-secrets", + ]); + + // RED before the fix: apply's re-extract for the fingerprint still redacts, + // so it mismatches the unredacted plan source and the gate aborts (exit 1) + // unless --force is passed. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + expect(res.stderr).not.toContain("fingerprint"); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 90_000); + + test("plan --unsafe-show-secrets then apply passes the fingerprint gate (artifact carries redaction mode)", async () => { + const cluster = await sharedCluster(); + // `target` is the apply target (plan's --source); `desired` is the goal. + const target = await cluster.createDb("cli_plan_fp_tgt"); + const desired = await cluster.createDb("cli_plan_fp_desired"); + try { + // both hold the SAME unredacted credential, so the plan source fingerprint + // is over an unredacted fact base; `desired` adds a schema so the plan is + // non-empty and apply runs the fingerprint gate. + await target.pool.query(FDW_SQL); + await desired.pool.query(FDW_SQL); + await desired.pool.query(`CREATE SCHEMA fp_extra;`); + + const planFile = join(tmpdir(), `pgdn-plan-fp-${Date.now()}.json`); + const planRes = await runCli([ + "plan", + "--source", + target.uri, + "--desired", + desired.uri, + "--renames", + "off", + "--out", + planFile, + "--unsafe-show-secrets", + ]); + expect(planRes.exitCode).toBe(0); + // the plan ran with redaction disabled, so its source fingerprint is over + // the unredacted server option (the secret is folded into the hash, not + // serialized into the artifact's diff). + expect(planRes.stderr).toContain("Secret redaction is DISABLED"); + + const applyRes = await runCli([ + "apply", + "--plan", + planFile, + "--target", + target.uri, + ]); + + // RED before the fix: the artifact does not record redactSecrets and apply + // re-extracts the target with default redaction, so the placeholder-vs-real + // fingerprint mismatch aborts the gate (exit 1) without --force. + expect({ + code: applyRes.exitCode, + stderr: applyRes.stderr, + }).toMatchObject({ code: 0 }); + const { rows } = await target.pool.query<{ exists: boolean }>( + `SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'fp_extra') AS exists`, + ); + expect(rows[0]?.exists).toBe(true); + } finally { + await Promise.all([target.drop(), desired.drop()]); + } + }, 120_000); + + test("snapshot --unsafe-show-secrets then drift reports no drift for an unchanged secret (mode derived from the snapshot)", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_drift_secret"); + try { + await source.pool.query(FDW_SQL); + + const snapFile = join(tmpdir(), `pgdn-drift-secret-${Date.now()}.json`); + const snap = await runCli([ + "snapshot", + "--source", + source.uri, + "--out", + snapFile, + "--unsafe-show-secrets", + ]); + expect(snap.exitCode).toBe(0); + + // drift WITHOUT re-passing --unsafe-show-secrets: the mode must be derived + // from the snapshot, so the unredacted server option is compared against an + // equally-unredacted live extract. + const drift = await runCli([ + "drift", + "--env", + source.uri, + "--snapshot", + snapFile, + ]); + // RED before the fix: drift defaults to a redacted live extract, so the + // real-vs-placeholder server option shows as spurious drift (exit 1). + expect({ code: drift.exitCode, stdout: drift.stdout }).toMatchObject({ + code: 0, + }); + expect(drift.stdout).toContain("No drift"); + } finally { + await source.drop(); + } + }, 90_000); + + test("schema export records its redaction mode in a manifest", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_export_manifest_src"); + try { + await source.pool.query(FDW_SQL); + const { existsSync } = await import("node:fs"); + const manifestOf = (dir: string) => + JSON.parse(readFileSync(join(dir, ".pgdelta-export.json"), "utf8")); + + const redactedDir = join(tmpdir(), `pgdn-exp-red-${Date.now()}`); + expect( + ( + await runCli([ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + redactedDir, + ]) + ).exitCode, + ).toBe(0); + expect(existsSync(join(redactedDir, ".pgdelta-export.json"))).toBe(true); + expect(manifestOf(redactedDir).redactSecrets).toBe(true); + // the projection profile is recorded too (default is raw) + expect(manifestOf(redactedDir).profile).toBe("raw"); + + const unsafeDir = join(tmpdir(), `pgdn-exp-unsafe-${Date.now()}`); + expect( + ( + await runCli([ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + unsafeDir, + "--unsafe-show-secrets", + ]) + ).exitCode, + ).toBe(0); + expect(manifestOf(unsafeDir).redactSecrets).toBe(false); + } finally { + await source.drop(); + } + }, 90_000); + + test("schema apply honors the export manifest's unsafe mode without --unsafe-show-secrets", async () => { + const cluster = await sharedCluster(); + const shadow = await cluster.createDb("cli_manifest_apply_shadow"); + const target = await cluster.createDb("cli_manifest_apply_tgt"); + try { + // a declarative dir carrying a REAL credential plus a manifest recording + // the unsafe mode (as `schema export --unsafe-show-secrets` would write). + const dir = join(tmpdir(), `pg-delta-next-manifest-apply-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "01_fdw.sql"), + `CREATE FOREIGN DATA WRAPPER cli_manifest_fdw;\n` + + `CREATE SERVER cli_manifest_srv FOREIGN DATA WRAPPER cli_manifest_fdw\n` + + ` OPTIONS (host 'h.example.com', password 'manifest-secret-xyz');\n`, + ); + writeFileSync( + join(dir, ".pgdelta-export.json"), + JSON.stringify({ formatVersion: 1, redactSecrets: false }), + "utf8", + ); + + // NOTE: no --unsafe-show-secrets flag — the manifest must drive the mode. + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--shadow", + shadow.uri, + "--target", + target.uri, + "--renames", + "off", + ]); + // RED before the fix: apply re-extracted redacted, so the target stored + // '__OPTION_PASSWORD__' instead of the exported credential. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + expect(res.stderr).toContain("Secret redaction is DISABLED"); + + const { rows } = await target.pool.query<{ srvoptions: string[] }>( + `SELECT srvoptions FROM pg_foreign_server WHERE srvname = 'cli_manifest_srv'`, + ); + const opts = (rows[0]?.srvoptions ?? []).join(","); + expect(opts).toContain("password=manifest-secret-xyz"); + expect(opts).not.toContain("__OPTION_PASSWORD__"); + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 120_000); +}); + +describe("CLI: schema lint", () => { + // Pure static analysis — no database, so these run without a container. + const writeFixture = ( + label: string, + files: Record, + ): string => { + const dir = join(tmpdir(), `pg-delta-next-lint-${label}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + for (const [name, sql] of Object.entries(files)) { + writeFileSync(join(dir, name), sql, "utf8"); + } + return dir; + }; + + test("a clean schema lints successfully (exit 0)", async () => { + const dir = writeFixture("clean", { + "01_table.sql": "CREATE TABLE public.t (id integer PRIMARY KEY);", + "02_view.sql": "CREATE VIEW public.v AS SELECT id FROM public.t;", + }); + const result = await runCli(["schema", "lint", "--dir", dir]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("No issues found"); + }); + + test("a shadow-load cycle fails the lint (exit 1) with a labeled chain", async () => { + const dir = writeFixture("cycle", { + "v1.sql": "CREATE VIEW public.v1 AS SELECT * FROM public.v2;", + "v2.sql": "CREATE VIEW public.v2 AS SELECT * FROM public.v1;", + }); + const result = await runCli(["schema", "lint", "--dir", dir]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("CYCLE_DETECTED"); + expect(result.stderr).toContain("→"); + expect(result.stderr).toMatch(/error\(s\)/); + }); +}); + +describe("CLI: schema export --layout grouped", () => { + test("writes the grouped tree, honoring --group-patterns", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_export_grouped"); + try { + await source.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.auth_users (id integer PRIMARY KEY); + CREATE TABLE app.billing_invoices (id integer PRIMARY KEY); + `); + const outDir = join( + tmpdir(), + `pg-delta-next-export-grouped-${Date.now()}`, + ); + const result = await runCli([ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + outDir, + "--layout", + "grouped", + "--group-patterns", + '[{"pattern":"^auth_","name":"auth"}]', + ]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("layout: grouped"); + // auth_users consolidated under the auth group; the other table keeps its file + expect( + readFileSync(join(outDir, "schemas/app/auth/tables.sql"), "utf8"), + ).toContain("auth_users"); + expect( + readFileSync( + join(outDir, "schemas/app/tables/billing_invoices.sql"), + "utf8", + ), + ).toContain("billing_invoices"); + } finally { + await source.drop(); + } + }, 60_000); + + test("rejects a malformed --group-patterns before connecting (exit 2)", async () => { + const result = await runCli([ + "schema", + "export", + "--source", + "postgresql://localhost/unused", + "--out-dir", + join(tmpdir(), `pg-delta-next-export-bad-${Date.now()}`), + "--layout", + "grouped", + "--group-patterns", + "not json", + ]); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("--group-patterns"); + }); + + test("--format-options pretty-prints the exported SQL", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("cli_export_format"); + try { + await source.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t (id integer PRIMARY KEY, name text NOT NULL); + `); + const outDir = join(tmpdir(), `pg-delta-next-export-fmt-${Date.now()}`); + const result = await runCli([ + "schema", + "export", + "--source", + source.uri, + "--out-dir", + outDir, + "--format-options", + '{"keywordCase":"lower"}', + ]); + expect(result.exitCode).toBe(0); + const sql = readFileSync( + join(outDir, "schemas/app/tables/t.sql"), + "utf8", + ); + expect(sql).toContain("create table"); + expect(sql).not.toContain("CREATE TABLE"); + } finally { + await source.drop(); + } + }, 60_000); + + test("rejects a malformed --format-options before connecting (exit 2)", async () => { + const result = await runCli([ + "schema", + "export", + "--source", + "postgresql://localhost/unused", + "--out-dir", + join(tmpdir(), `pg-delta-next-export-badfmt-${Date.now()}`), + "--format-options", + "[1,2,3]", + ]); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("--format-options"); + }); +}); diff --git a/packages/pg-delta/tests/column-grant-fidelity.test.ts b/packages/pg-delta/tests/column-grant-fidelity.test.ts new file mode 100644 index 000000000..2df32d177 --- /dev/null +++ b/packages/pg-delta/tests/column-grant-fidelity.test.ts @@ -0,0 +1,51 @@ +/** + * Column-level ACLs (`pg_attribute.attacl`, e.g. `GRANT SELECT (col) ON t TO r`) + * must be extracted and rendered. Before this, `attacl` was not extracted at all, + * so a from-empty export silently dropped every column grant and two schemas that + * differed only by column privileges hashed equal. This pins the export/round-trip + * fidelity: the from-empty plan must emit the column-qualified GRANT. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { buildFactBase } from "../src/core/fact.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let db: TestDb; +let sql: string; + +beforeAll(async () => { + db = await createTestDb("col-grant"); + await db.pool.query(` + DO $$ BEGIN CREATE ROLE colgrant_reader NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + CREATE SCHEMA app; + CREATE TABLE app.t (a int, b int); + GRANT SELECT (a, b) ON TABLE app.t TO colgrant_reader; + GRANT UPDATE (b) ON TABLE app.t TO colgrant_reader; + `); + const state = await extract(db.pool); + // from-empty export: the plan that builds `state` from nothing must include + // the column-qualified grants. + sql = plan(buildFactBase([], []), state.factBase) + .actions.map((a) => a.sql) + .join("\n"); +}, 120_000); + +afterAll(async () => { + await db.pool.query(`DROP ROLE IF EXISTS colgrant_reader`).catch(() => {}); + await db.drop(); +}); + +describe("column-level grant export fidelity", () => { + test("from-empty export emits the SELECT column grant", () => { + expect(sql).toMatch( + /GRANT[^;]*SELECT \("?a"?\)[^;]*ON TABLE "?app"?\."?t"?[^;]*TO "?colgrant_reader"?/, + ); + }); + + test("from-empty export emits the UPDATE (b) column grant", () => { + expect(sql).toMatch( + /GRANT[^;]*UPDATE \("?b"?\)[^;]*ON TABLE "?app"?\."?t"?[^;]*TO "?colgrant_reader"?/, + ); + }); +}); diff --git a/packages/pg-delta/tests/column-grant-snapshot-drift.test.ts b/packages/pg-delta/tests/column-grant-snapshot-drift.test.ts new file mode 100644 index 000000000..0241a2c4f --- /dev/null +++ b/packages/pg-delta/tests/column-grant-snapshot-drift.test.ts @@ -0,0 +1,62 @@ +/** + * A snapshot captured from a database WITH a column-level grant + * (`GRANT SELECT (col) ON t TO r` → `pg_attribute.attacl`) must be re-loadable. + * The encoder appends an optional `.column` segment to `acl` stable-ids + * (`acl:(table:...).grantee.column`); the parser must consume it. Before the + * fix the parser stopped after the grantee and rejected the trailing `.column` + * as "trailing input", so `drift` (which re-parses every id in the snapshot + * JSON via loadSnapshot → parseId) could not load such a snapshot at all. + * + * This pins the end-to-end path: snapshot a column-granted DB, then diff the + * same DB against that snapshot → the snapshot loads and reports NO drift. + * + * Docker required. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cmdDrift } from "../src/cli/commands/drift.ts"; +import { cmdSnapshot } from "../src/cli/commands/snapshot.ts"; +import { type TestDb, sharedCluster } from "./containers.ts"; + +let db: TestDb; +let snapshotPath: string; + +beforeAll(async () => { + const cluster = await sharedCluster(); + db = await cluster.createDb("colgrant_snap"); + await db.pool.query(` + DO $$ BEGIN CREATE ROLE colgrant_snap_reader NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + CREATE SCHEMA app; + CREATE TABLE app.t (a int, b int); + GRANT SELECT (a) ON TABLE app.t TO colgrant_snap_reader; + `); + const work = mkdtempSync(join(tmpdir(), "pgdelta-colgrant-snap-")); + snapshotPath = join(work, "snap.json"); + await cmdSnapshot(["--source", db.uri, "--out", snapshotPath]); +}, 120_000); + +afterAll(async () => { + await db.pool + .query(`DROP ROLE IF EXISTS colgrant_snap_reader`) + .catch(() => {}); + await db.drop(); +}); + +describe("column-level grant snapshot round-trip", () => { + test("drift loads a snapshot with a column-qualified ACL and reports no drift", async () => { + // Before the parser fix, loadSnapshot(snapshotPath) threw + // parseId: trailing input ... in 'acl:(table:app.t).colgrant_snap_reader.a' + // and cmdDrift rejected. After the fix the snapshot loads and, because it is + // diffed against the very DB it was captured from, there is no drift → the + // command resolves normally (exit 0, no CliExit thrown). + let driftError: unknown; + try { + await cmdDrift(["--env", db.uri, "--snapshot", snapshotPath]); + } catch (e) { + driftError = e; + } + expect(driftError).toBeUndefined(); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/column-order-roundtrip.test.ts b/packages/pg-delta/tests/column-order-roundtrip.test.ts new file mode 100644 index 000000000..e8d5ddd7e --- /dev/null +++ b/packages/pg-delta/tests/column-order-roundtrip.test.ts @@ -0,0 +1,78 @@ +/** + * Table column ORDER round-trip. + * + * A table declared `(wal jsonb, is_rls_enabled boolean, subscription_ids uuid[], + * errors text[])` is row-layout state: its column ORDER matters for `SELECT *`, + * positional INSERTs, and the relation's row type. The root hash is + * order-BLIND by design (`_position` is `_`-prefixed), so convergence alone + * cannot observe a reorder — the reloaded database's PHYSICAL column order + * (pg_attribute.attnum) is what pins it. + * + * Before the fix a from-empty `CREATE TABLE` rendered its columns in encoded-id + * (name) order, so the export reload materialized them alphabetically + * (`errors, is_rls_enabled, subscription_ids, wal`). After the fix the export + * preserves declared position, so the shadow reload keeps `wal` first. + * + * Stock alpine image; Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { sharedCluster } from "./containers.ts"; + +// declared order is NOT alphabetical: wal < is_rls_enabled < subscription_ids < +// errors (alphabetical would put errors first). +const TABLE_SQL = ` + CREATE SCHEMA s; + CREATE TABLE s.wal_rls ( + wal jsonb, + is_rls_enabled boolean, + subscription_ids uuid[], + errors text[] + ); +`; + +const DECLARED_ORDER = ["wal", "is_rls_enabled", "subscription_ids", "errors"]; + +function forLoad(files: { name: string; sql: string }[]) { + // roles are cluster-global and already present in the shared cluster. + return files.filter((f) => !/cluster[_/]roles/.test(f.name)); +} + +async function columnOrder( + pool: import("pg").Pool, + relation: string, +): Promise { + const res = await pool.query( + `SELECT attname FROM pg_attribute + WHERE attrelid = $1::regclass AND attnum > 0 AND NOT attisdropped + ORDER BY attnum`, + [relation], + ); + return res.rows.map((r) => (r as { attname: string }).attname); +} + +describe("table column order round-trip", () => { + test("wal_rls reloads with columns in declared (non-alphabetical) order", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("column_order_src"); + const shadow = await cluster.createDb("column_order_shadow"); + try { + await src.pool.query(TABLE_SQL); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb, { layout: "by-object" })); + const loaded = await loadSqlFiles(files, shadow.pool); + + // convergence (order-blind) still holds … + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + // … and the reloaded database preserves the declared column order. + expect(await columnOrder(shadow.pool, "s.wal_rls")).toEqual( + DECLARED_ORDER, + ); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/compaction.test.ts b/packages/pg-delta/tests/compaction.test.ts new file mode 100644 index 000000000..c8e135f12 --- /dev/null +++ b/packages/pg-delta/tests/compaction.test.ts @@ -0,0 +1,426 @@ +/** + * Compaction (§3.6, stage 5 deliverable 4): cosmetic by contract. + * The gate: proof results are IDENTICAL with compaction on and off, and + * the compacted plan folds column clauses into CREATE TABLE (asserted as + * action-shape budgets, never SQL bytes). + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { probeApplierCapability } from "../src/policy/capability.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { sharedCluster } from "./containers.ts"; + +const RICH_SCHEMA = ` + CREATE SCHEMA app; + CREATE SEQUENCE app.id_seq; + CREATE TABLE app.users ( + id integer NOT NULL DEFAULT nextval('app.id_seq'), + email text NOT NULL, + score numeric(10,2) DEFAULT 0.0, + PRIMARY KEY (id) + ); + CREATE TABLE app.events (created_at timestamptz NOT NULL, payload text) + PARTITION BY RANGE (created_at); + CREATE TABLE app.events_2026 PARTITION OF app.events + FOR VALUES FROM ('2026-01-01') TO ('2027-01-01'); + CREATE INDEX users_email_idx ON app.users (email); + CREATE VIEW app.v AS SELECT id, email FROM app.users; +`; + +describe("compaction", () => { + test("proof results identical with compaction on and off; compacted plan is smaller", async () => { + const cluster = await sharedCluster(); + const desired = await cluster.createDb("compact_dst"); + const cloneA = await cluster.createDb("compact_a"); + const cloneB = await cluster.createDb("compact_b"); + try { + await desired.pool.query(RICH_SCHEMA); + const desiredState = await extract(desired.pool); + const emptyA = await extract(cloneA.pool); + const emptyB = await extract(cloneB.pool); + + const compacted = plan(emptyA.factBase, desiredState.factBase); + const decomposed = plan(emptyB.factBase, desiredState.factBase, { + compact: false, + }); + + // shape budget: the compacted plan folded the users columns + expect(compacted.actions.length).toBeLessThan(decomposed.actions.length); + const addColumns = compacted.actions.filter( + (a) => a.verb === "create" && a.produces[0]?.kind === "column", + ); + // partitioned-parent columns were already inlined pre-compaction; + // the plain table's columns must now be folded too + expect(addColumns).toHaveLength(0); + + const [verdictA, verdictB] = [ + await provePlan(compacted, cloneA.pool, desiredState.factBase), + await provePlan(decomposed, cloneB.pool, desiredState.factBase), + ]; + expect(verdictA.ok).toBe(true); + expect(verdictB.ok).toBe(true); + } finally { + await Promise.all([desired.drop(), cloneA.drop(), cloneB.drop()]); + } + }, 120_000); + + test("a column whose dependency lands between CREATE TABLE and the column stays unfolded", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("compact_nf_src"); + const desired = await cluster.createDb("compact_nf_dst"); + try { + // the enum value-set migration alters the type AFTER the new table + // would be created — a column of that type cannot fold across it + await source.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.status AS ENUM ('a', 'b', 'c'); + CREATE TABLE app.existing (s app.status); + `); + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.status AS ENUM ('a', 'c'); + CREATE TABLE app.existing (s app.status); + CREATE TABLE app.fresh (s app.status, note text); + `); + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + const thePlan = plan(sourceState.factBase, desiredState.factBase); + const verdict = await provePlan( + thePlan, + source.pool, + desiredState.factBase, + ); + expect(verdict.ok).toBe(true); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); + + test("ACL replace: redundant REVOKE elided when compacted; same proof on/off", async () => { + const cluster = await sharedCluster(); + const srcA = await cluster.createDb("compact_acl_a"); + const srcB = await cluster.createDb("compact_acl_b"); + const desired = await cluster.createDb("compact_acl_dst"); + await cluster.adminPool + .query(`CREATE ROLE compact_acl_grantee NOLOGIN`) + .catch(() => {}); + try { + const seed = ` + CREATE SCHEMA app; + CREATE TABLE app.t (id int); + GRANT SELECT ON app.t TO compact_acl_grantee; + `; + await srcA.pool.query(seed); + await srcB.pool.query(seed); + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t (id int); + GRANT SELECT, INSERT ON app.t TO compact_acl_grantee; + `); + const [aState, bState, desiredState] = [ + await extract(srcA.pool), + await extract(srcB.pool), + await extract(desired.pool), + ]; + + const compacted = plan(aState.factBase, desiredState.factBase); + const decomposed = plan(bState.factBase, desiredState.factBase, { + compact: false, + }); + + const revokes = (p: typeof compacted) => + p.actions.filter((x) => x.sql.includes("REVOKE ALL ON TABLE")).length; + // the create's self-resetting REVOKE makes the replace's drop redundant + expect(revokes(decomposed)).toBe(2); + expect(revokes(compacted)).toBe(1); + + // …and the elided plan is still correct: same clean proof, on and off + const [verdictA, verdictB] = [ + await provePlan(compacted, srcA.pool, desiredState.factBase), + await provePlan(decomposed, srcB.pool, desiredState.factBase), + ]; + expect(verdictA.ok).toBe(true); + expect(verdictB.ok).toBe(true); + } finally { + await Promise.all([srcA.drop(), srcB.drop(), desired.drop()]); + } + }, 120_000); + + test("default-ACL elision: a fresh CREATE emits no default REVOKE/GRANT; same proof on/off", async () => { + const cluster = await sharedCluster(); + const cloneA = await cluster.createDb("compact_defacl_a"); + const cloneB = await cluster.createDb("compact_defacl_b"); + const desired = await cluster.createDb("compact_defacl_dst"); + try { + // a type (PUBLIC USAGE + owner USAGE are PG defaults) and a table + // (owner gets the full default set, PUBLIC gets nothing) — both fresh. + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.mood AS ENUM ('sad', 'ok', 'happy'); + CREATE TABLE app.t (id int); + `); + const desiredState = await extract(desired.pool); + const emptyA = await extract(cloneA.pool); + const emptyB = await extract(cloneB.pool); + + const compacted = plan(emptyA.factBase, desiredState.factBase); + const decomposed = plan(emptyB.factBase, desiredState.factBase, { + compact: false, + }); + + // the decomposed plan spells out the default REVOKE/GRANT pairs… + const defaultAclNoise = (p: typeof compacted) => + p.actions.filter( + (a) => + a.sql.startsWith("REVOKE ALL ON") || a.sql.startsWith("GRANT "), + ).length; + expect(defaultAclNoise(decomposed)).toBeGreaterThan(0); + // …and the compacted plan elides every one of them (all grants here are + // built-in defaults on co-created objects). + expect(defaultAclNoise(compacted)).toBe(0); + expect(compacted.actions.length).toBeLessThan(decomposed.actions.length); + + // cosmetic by contract: identical clean proof with elision on and off. + const [verdictA, verdictB] = [ + await provePlan(compacted, cloneA.pool, desiredState.factBase), + await provePlan(decomposed, cloneB.pool, desiredState.factBase), + ]; + expect(verdictA.ok).toBe(true); + expect(verdictB.ok).toBe(true); + } finally { + await Promise.all([cloneA.drop(), cloneB.drop(), desired.drop()]); + } + }, 120_000); + + test("generated columns stay as ADD COLUMN so dependency columns exist first", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("compact_gen_src"); + const desired = await cluster.createDb("compact_gen_dst"); + try { + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t ( + base int NOT NULL, + doubled int GENERATED ALWAYS AS (base * 2) STORED + ); + `); + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + const thePlan = plan(sourceState.factBase, desiredState.factBase); + const createTable = thePlan.actions.find( + (a) => a.verb === "create" && a.produces[0]?.kind === "table", + ); + expect(createTable?.sql).not.toContain("GENERATED ALWAYS AS"); + const addGenerated = thePlan.actions.find((a) => + a.sql.includes("GENERATED ALWAYS AS"), + ); + expect(addGenerated?.sql).toMatch(/ADD COLUMN.*GENERATED ALWAYS AS/); + + const verdict = await provePlan( + thePlan, + source.pool, + desiredState.factBase, + ); + expect(verdict.ok).toBe(true); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); + + test("co-create ownership fold: schema AUTHORIZATION + applier-owner ALTER elided (4→2); same proof on/off", async () => { + const cluster = await sharedCluster(); + const cloneA = await cluster.createDb("compact_own_a"); + const cloneB = await cluster.createDb("compact_own_b"); + const desired = await cluster.createDb("compact_own_dst"); + try { + // a bare schema + table, both freshly created and owned by the applier + // (`test`). Decomposed: CREATE SCHEMA, ALTER SCHEMA OWNER, CREATE TABLE, + // ALTER TABLE OWNER (4). Compacted: CREATE SCHEMA … AUTHORIZATION test, + // CREATE TABLE (2) — schema owner folded, table owner ALTER elided. + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t (id int); + `); + const desiredState = await extract(desired.pool); + const emptyA = await extract(cloneA.pool); + const emptyB = await extract(cloneB.pool); + // probe the applier (connection user `test`) so the no-op owner ALTER is + // elided exactly as it would be at apply time. + const capability = await probeApplierCapability(cloneA.pool); + + const compacted = plan(emptyA.factBase, desiredState.factBase, { + capability, + }); + const decomposed = plan(emptyB.factBase, desiredState.factBase, { + compact: false, + capability, + }); + + const owns = (p: typeof compacted) => + p.actions.filter((a) => a.sql.includes(" OWNER TO ")).length; + // decomposed spells out both owner ALTERs; compacted has none + expect(owns(decomposed)).toBe(2); + expect(owns(compacted)).toBe(0); + // the headline win: schema + table, two statements, no owner residue + expect(compacted.actions).toHaveLength(2); + expect(compacted.actions[0]?.sql).toBe( + `CREATE SCHEMA "app" AUTHORIZATION "test"`, + ); + expect(compacted.actions[1]?.sql).toContain(`CREATE TABLE "app"."t"`); + expect(decomposed.actions.length).toBeGreaterThan( + compacted.actions.length, + ); + + const [verdictA, verdictB] = [ + await provePlan(compacted, cloneA.pool, desiredState.factBase), + await provePlan(decomposed, cloneB.pool, desiredState.factBase), + ]; + expect(verdictA.ok).toBe(true); + expect(verdictB.ok).toBe(true); + } finally { + await Promise.all([cloneA.drop(), cloneB.drop(), desired.drop()]); + } + }, 120_000); + + test("co-create REVOKE elision: third-party grant keeps GRANT, drops leading REVOKE; same proof on/off", async () => { + const cluster = await sharedCluster(); + const cloneA = await cluster.createDb("compact_corevoke_a"); + const cloneB = await cluster.createDb("compact_corevoke_b"); + const desired = await cluster.createDb("compact_corevoke_dst"); + await cluster.adminPool + .query(`CREATE ROLE compact_co_grantee NOLOGIN`) + .catch(() => {}); + try { + // a fresh type with a third-party USAGE grant (the grantee has no default + // privilege) — the leading REVOKE ALL is cosmetic. + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.mood AS ENUM ('a', 'b'); + GRANT USAGE ON TYPE app.mood TO compact_co_grantee; + `); + const desiredState = await extract(desired.pool); + const emptyA = await extract(cloneA.pool); + const emptyB = await extract(cloneB.pool); + const capability = await probeApplierCapability(cloneA.pool); + + const compacted = plan(emptyA.factBase, desiredState.factBase, { + capability, + }); + const decomposed = plan(emptyB.factBase, desiredState.factBase, { + compact: false, + capability, + }); + + const grantSql = `GRANT USAGE ON TYPE "app"."mood" TO "compact_co_grantee"`; + const revokeSql = `REVOKE ALL ON TYPE "app"."mood" FROM "compact_co_grantee"`; + // decomposed keeps the REVOKE+GRANT pair… + expect(decomposed.actions.map((a) => a.sql)).toContain(revokeSql); + expect(decomposed.actions.map((a) => a.sql)).toContain(grantSql); + // …compacted drops the REVOKE but keeps the load-bearing GRANT + expect(compacted.actions.map((a) => a.sql)).not.toContain(revokeSql); + expect(compacted.actions.map((a) => a.sql)).toContain(grantSql); + + const [verdictA, verdictB] = [ + await provePlan(compacted, cloneA.pool, desiredState.factBase), + await provePlan(decomposed, cloneB.pool, desiredState.factBase), + ]; + expect(verdictA.ok).toBe(true); + expect(verdictB.ok).toBe(true); + } finally { + await Promise.all([cloneA.drop(), cloneB.drop(), desired.drop()]); + } + }, 120_000); + + test("co-create REVOKE elision: subset default keeps the load-bearing REVOKE; converges on/off", async () => { + const cluster = await sharedCluster(); + const cloneA = await cluster.createDb("compact_subset_a"); + const cloneB = await cluster.createDb("compact_subset_b"); + const desired = await cluster.createDb("compact_subset_dst"); + await cluster.adminPool + .query(`CREATE ROLE compact_subset_grantee NOLOGIN`) + .catch(() => {}); + try { + // applier (`test`) carries a default privilege granting SELECT+INSERT on + // tables in `app` to the grantee; the table's explicit ACL keeps only + // SELECT. The leading REVOKE ALL is load-bearing — without it the + // create-time default INSERT would survive. The superset guard must keep + // it (capability.role === test === the default's role). + await desired.pool.query(` + CREATE SCHEMA app; + ALTER DEFAULT PRIVILEGES FOR ROLE test IN SCHEMA app + GRANT SELECT, INSERT ON TABLES TO compact_subset_grantee; + CREATE TABLE app.t (id int); + REVOKE INSERT ON app.t FROM compact_subset_grantee; + `); + const desiredState = await extract(desired.pool); + const emptyA = await extract(cloneA.pool); + const emptyB = await extract(cloneB.pool); + const capability = await probeApplierCapability(cloneA.pool); + + const compacted = plan(emptyA.factBase, desiredState.factBase, { + capability, + }); + const decomposed = plan(emptyB.factBase, desiredState.factBase, { + compact: false, + capability, + }); + + const revokeSql = `REVOKE ALL ON TABLE "app"."t" FROM "compact_subset_grantee"`; + // the guard keeps the REVOKE even under compaction + expect(compacted.actions.map((a) => a.sql)).toContain(revokeSql); + + const [verdictA, verdictB] = [ + await provePlan(compacted, cloneA.pool, desiredState.factBase), + await provePlan(decomposed, cloneB.pool, desiredState.factBase), + ]; + expect(verdictA.ok).toBe(true); + expect(verdictB.ok).toBe(true); + } finally { + await Promise.all([cloneA.drop(), cloneB.drop(), desired.drop()]); + } + }, 120_000); + + test("co-create REVOKE elision: dropping the kept REVOKE would diverge (guard is load-bearing)", async () => { + // The subset test above proves the engine KEEPS the REVOKE. This proves the + // necessity, deterministically at the catalog level: with a create-time + // default of SELECT+INSERT for the grantee, applying the kept GRANT SELECT + // WITHOUT the leading REVOKE leaves the default INSERT behind, so the state + // does NOT converge to the SELECT-only desired. (Explicit SQL order — the + // default privilege is established before the table — so this never depends + // on plan action ordering.) + const cluster = await sharedCluster(); + const clone = await cluster.createDb("compact_subsetneg_clone"); + const desired = await cluster.createDb("compact_subsetneg_dst"); + await cluster.adminPool + .query(`CREATE ROLE compact_subsetneg_grantee NOLOGIN`) + .catch(() => {}); + try { + const setup = (withRevoke: boolean) => ` + CREATE SCHEMA app; + ALTER DEFAULT PRIVILEGES FOR ROLE test IN SCHEMA app + GRANT SELECT, INSERT ON TABLES TO compact_subsetneg_grantee; + CREATE TABLE app.t (id int); + ${withRevoke ? "REVOKE ALL ON app.t FROM compact_subsetneg_grantee;" : ""} + GRANT SELECT ON app.t TO compact_subsetneg_grantee; + `; + // desired = the engine's kept-REVOKE form (converges to SELECT only) + await desired.pool.query(setup(true)); + // the REVOKE-dropped form leaves the default-granted INSERT in place + await clone.pool.query(setup(false)); + const [desiredState, cloneState] = [ + await extract(desired.pool), + await extract(clone.pool), + ]; + expect(cloneState.factBase.rootHash).not.toBe( + desiredState.factBase.rootHash, + ); + } finally { + await Promise.all([clone.drop(), desired.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/composite-attr-type-drop.test.ts b/packages/pg-delta/tests/composite-attr-type-drop.test.ts new file mode 100644 index 000000000..389a755d9 --- /dev/null +++ b/packages/pg-delta/tests/composite-attr-type-drop.test.ts @@ -0,0 +1,59 @@ +/** + * When a composite type's ATTRIBUTE stops using a user type that the SAME plan + * drops, the `ALTER TYPE … ALTER ATTRIBUTE … TYPE …` that releases the user + * type must be ordered BEFORE the `DROP TYPE` of that user type. The + * attribute→type dependency is extracted onto the enclosing composite `type` + * fact, but the alter action is keyed to the `typeAttribute` child fact, so + * without an explicit release the graph can emit `DROP TYPE` first and + * PostgreSQL rejects it ("cannot drop type … because other objects depend on + * it"). + */ +import { describe, expect, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { diff } from "../src/core/diff.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { sharedCluster } from "./containers.ts"; + +describe("composite attribute drops its user type in one plan", () => { + test("the attribute type change is ordered before DROP TYPE and converges", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("comp_attr_src"); + const dst = await cluster.createDb("comp_attr_dst"); + try { + // src: a standalone composite whose attribute uses a user enum. + await src.pool.query("CREATE TYPE public.usr AS ENUM ('a', 'b')"); + await src.pool.query( + "CREATE TYPE public.comp AS (f public.usr, g integer)", + ); + // dst: the attribute is plain text and the enum is gone. + await dst.pool.query("CREATE TYPE public.comp AS (f text, g integer)"); + + const [s, d] = [await extract(src.pool), await extract(dst.pool)]; + const thePlan = plan(s.factBase, d.factBase); + const sql = thePlan.actions.map((a) => a.sql); + + // both statements are present … + const alterIdx = sql.findIndex( + (t) => t.includes("ALTER ATTRIBUTE") && t.includes('"comp"'), + ); + const dropIdx = sql.findIndex((t) => + t.startsWith('DROP TYPE "public"."usr"'), + ); + expect(alterIdx).toBeGreaterThanOrEqual(0); + expect(dropIdx).toBeGreaterThanOrEqual(0); + // … and the attribute releases the enum before it is dropped. + expect(alterIdx).toBeLessThan(dropIdx); + + const report = await apply(thePlan, src.pool, { + fingerprintGate: false, + }); + expect(report.status).toBe("applied"); + + const after = await extract(src.pool); + expect(diff(after.factBase, d.factBase)).toEqual([]); + } finally { + await Promise.all([src.drop(), dst.drop()]); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/composite-attribute-dataloss.test.ts b/packages/pg-delta/tests/composite-attribute-dataloss.test.ts new file mode 100644 index 000000000..9d998434c --- /dev/null +++ b/packages/pg-delta/tests/composite-attribute-dataloss.test.ts @@ -0,0 +1,100 @@ +/** + * A composite type's attribute DROP is destructive when the type is in use. + * + * `ALTER TYPE … DROP ATTRIBUTE … CASCADE` nulls the stored value of that field + * across every row of every table whose column is of the composite type — yet + * the change carried no `dataLoss` flag, so the safety report called it + * non-destructive and the renderer (which gates on `dataLoss`) emitted it with + * no warning. The proof loop cannot catch this either: a composite attribute + * change folds into `schemaSig`, degrading the content comparison to count-only + * (an additive ADD ATTRIBUTE is genuinely lossless), so the `dataLoss` flag is + * the ONLY protection. + * + * A collation-only attribute change routes through the attribute "replace" + * strategy (drop + recreate the attribute), so it renders the SAME + * `DROP ATTRIBUTE … CASCADE` and must be marked destructive too. + * + * Stock alpine image; Docker required. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let dropSrc: TestDb; +let dropDesired: TestDb; +let collSrc: TestDb; +let collDesired: TestDb; + +beforeAll(async () => { + dropSrc = await createTestDb("composite-dataloss-drop-src"); + dropDesired = await createTestDb("composite-dataloss-drop-desired"); + // source: composite (a, b) used by a POPULATED table column. + await dropSrc.pool.query(` + CREATE SCHEMA s; + CREATE TYPE s.ct AS (a integer, b text); + CREATE TABLE s.tbl (id integer PRIMARY KEY, c s.ct); + INSERT INTO s.tbl VALUES (1, ROW(1, 'keep')), (2, ROW(2, 'drop')); + `); + // desired: attribute b removed — DROP ATTRIBUTE b CASCADE nulls s.tbl.c.b. + await dropDesired.pool.query(` + CREATE SCHEMA s; + CREATE TYPE s.ct AS (a integer); + CREATE TABLE s.tbl (id integer PRIMARY KEY, c s.ct); + `); + + collSrc = await createTestDb("composite-dataloss-coll-src"); + collDesired = await createTestDb("composite-dataloss-coll-desired"); + // source: composite whose text attribute carries an explicit collation. + await collSrc.pool.query(` + CREATE SCHEMA s; + CREATE TYPE s.ct AS (a integer, b text COLLATE "C"); + CREATE TABLE s.tbl (id integer PRIMARY KEY, c s.ct); + INSERT INTO s.tbl VALUES (1, ROW(1, 'x')); + `); + // desired: collation-only change on attribute b → attribute "replace". + await collDesired.pool.query(` + CREATE SCHEMA s; + CREATE TYPE s.ct AS (a integer, b text COLLATE "POSIX"); + CREATE TABLE s.tbl (id integer PRIMARY KEY, c s.ct); + `); +}, 180_000); + +afterAll(async () => { + await Promise.all([ + dropSrc.drop(), + dropDesired.drop(), + collSrc.drop(), + collDesired.drop(), + ]); +}); + +describe("composite attribute drop is destructive when in use", () => { + test("DROP ATTRIBUTE on an in-use composite is marked destructive", async () => { + const [a, b] = [ + await extract(dropSrc.pool), + await extract(dropDesired.pool), + ]; + const p = plan(a.factBase, b.factBase); + const dropAttr = p.actions.find((x) => + /ALTER TYPE .* DROP ATTRIBUTE/i.test(x.sql), + ); + expect(dropAttr).toBeDefined(); + expect(dropAttr?.dataLoss).toBe("destructive"); + expect(p.safetyReport.destructiveActions).toBeGreaterThanOrEqual(1); + }); + + test("collation-only attribute change (replace) is marked destructive", async () => { + const [a, b] = [ + await extract(collSrc.pool), + await extract(collDesired.pool), + ]; + const p = plan(a.factBase, b.factBase); + const dropAttr = p.actions.find((x) => + /ALTER TYPE .* DROP ATTRIBUTE/i.test(x.sql), + ); + expect(dropAttr).toBeDefined(); + expect(dropAttr?.dataLoss).toBe("destructive"); + expect(p.safetyReport.destructiveActions).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/pg-delta/tests/composite-order-roundtrip.test.ts b/packages/pg-delta/tests/composite-order-roundtrip.test.ts new file mode 100644 index 000000000..47828adce --- /dev/null +++ b/packages/pg-delta/tests/composite-order-roundtrip.test.ts @@ -0,0 +1,66 @@ +/** + * Composite attribute ORDER round-trip (the realtime.wal_rls chain). + * + * A composite type declared `(wal jsonb, is_rls_enabled boolean, + * subscription_ids uuid[], errors text[])` is row-layout state: its attribute + * ORDER matters. Dependents observe it — a function `RETURNS SETOF wal_rls` + * feeding a SQL-language function `RETURNS TABLE(wal jsonb, …)` that `SELECT *`s + * from it validates its body against the composite's column order/types. + * + * Before the fix the composite `CREATE TYPE … AS (…)` rendered its attributes + * in encoded-id (name) order, so `errors` sorted before `wal`. The export + * silently reordered the columns and the reload's `check_function_bodies = on` + * pass failed the SQL function with `… returns text[] instead of jsonb at + * column 1` → `1 routine body failed validation`. + * + * After the fix the export preserves declared position, so `load(export(fb))` + * completes and a re-extract of the shadow hash-matches the source. + * + * Stock alpine image; Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { sharedCluster } from "./containers.ts"; + +// declared order is NOT alphabetical: wal < is_rls_enabled < subscription_ids < +// errors (alphabetical would put errors first). The dependent SQL function +// pins that order at body-validation time. +const WAL_RLS_CHAIN_SQL = ` + CREATE SCHEMA s; + CREATE TYPE s.wal_rls AS ( + wal jsonb, + is_rls_enabled boolean, + subscription_ids uuid[], + errors text[] + ); + CREATE FUNCTION s.apply_rls() RETURNS SETOF s.wal_rls + LANGUAGE sql AS $$ SELECT NULL::jsonb, NULL::boolean, NULL::uuid[], NULL::text[] $$; + CREATE FUNCTION s.list_changes() + RETURNS TABLE(wal jsonb, is_rls_enabled boolean, subscription_ids uuid[], errors text[]) + LANGUAGE sql AS $$ SELECT * FROM s.apply_rls() $$; +`; + +function forLoad(files: { name: string; sql: string }[]) { + // roles are cluster-global and already present in the shared cluster. + return files.filter((f) => !/cluster[_/]roles/.test(f.name)); +} + +describe("composite attribute order round-trip", () => { + test("wal_rls chain reloads with attributes in declared order", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("composite_order_src"); + const shadow = await cluster.createDb("composite_order_shadow"); + try { + await src.pool.query(WAL_RLS_CHAIN_SQL); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb, { layout: "by-object" })); + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/constants.ts b/packages/pg-delta/tests/constants.ts deleted file mode 100644 index e9e7654fd..000000000 --- a/packages/pg-delta/tests/constants.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG = { - 15: "15.14.1.107", - 17: "17.6.1.107", -} as const; - -export const POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG = { - 15: "15.14-alpine", - 17: "17.6-alpine", - 18: "18.3-alpine", -} as const; - -export type PostgresVersion = - keyof typeof POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG; -export type SupabasePostgresVersion = - keyof typeof POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG; - -// Alpine images define the default pg-delta integration matrix because they are -// available for PostgreSQL 18, while Supabase test images are only available -// for a subset of supported versions. -export const POSTGRES_VERSIONS = process.env.PGDELTA_TEST_POSTGRES_VERSIONS - ? process.env.PGDELTA_TEST_POSTGRES_VERSIONS.split(",").map( - (v) => Number(v) as PostgresVersion, - ) - : (Object.keys(POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG).map( - Number, - ) as PostgresVersion[]); - -export const SUPABASE_POSTGRES_VERSIONS = POSTGRES_VERSIONS.filter( - (version): version is SupabasePostgresVersion => - version in POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG, -); diff --git a/packages/pg-delta/tests/container-manager.ts b/packages/pg-delta/tests/container-manager.ts deleted file mode 100644 index e9ed0a803..000000000 --- a/packages/pg-delta/tests/container-manager.ts +++ /dev/null @@ -1,276 +0,0 @@ -import debug from "debug"; -import type { Pool } from "pg"; -import { createPool } from "../src/core/postgres-config.ts"; -import type { PostgresVersion } from "./constants.ts"; -import { - buildPostgresTestImage, - PostgresAlpineContainer, - type StartedPostgresAlpineContainer, -} from "./postgres-alpine.ts"; - -const debugContainer = debug("pg-delta:container"); - -/** - * Suppress expected errors from idle pool connections. - * 57P01 = admin_shutdown (container stopped while connection open) - * 53100 = disk_full (container out of disk under heavy concurrent tests) - */ -function suppressShutdownError(err: Error & { code?: string }) { - if (err.code === "57P01" || err.code === "53100") { - return; - } - console.error("Pool error:", err); -} - -class ContainerManager { - private containers: Map = - new Map(); - private adminPools: Map = new Map(); - private dbCounter = 0; - private initializedVersions: Set = new Set(); - private initializationPromises: Map> = - new Map(); - - /** - * Initialize with a single container for each PostgreSQL version - */ - async initialize(versions: PostgresVersion[]): Promise { - // Filter out versions that are already initialized - const versionsToInitialize = versions.filter( - (version) => !this.initializedVersions.has(version), - ); - - if (versionsToInitialize.length === 0) { - return; - } - - // Start initialization for all versions that need it - const initializationPromises = versionsToInitialize.map((version) => - this._initializeVersion(version), - ); - await Promise.all(initializationPromises); - } - - private async _initializeVersion(version: PostgresVersion): Promise { - // Check if already initialized - if (this.initializedVersions.has(version)) { - return; - } - - // Check if initialization is already in progress for this version - const existingPromise = this.initializationPromises.get(version); - if (existingPromise) { - return existingPromise; - } - - // Start initialization for this version - const initPromise = this._doInitializeVersion(version); - this.initializationPromises.set(version, initPromise); - - try { - await initPromise; - this.initializedVersions.add(version); - } finally { - // Clean up the promise once done - this.initializationPromises.delete(version); - } - } - - private async _doInitializeVersion(version: PostgresVersion): Promise { - const image = await buildPostgresTestImage(version); - - try { - debugContainer( - "[ContainerManager] Starting container for PostgreSQL %d...", - version, - ); - const container = await new PostgresAlpineContainer(image).start(); - this.containers.set(version, container); - - // Create an admin pool for database management (CREATE/DROP DATABASE). - // Uses pg Pool instead of container.exec() because testcontainers exec() - // hangs under Bun. - const adminPool = createPool(container.getConnectionUri(), { - onError: suppressShutdownError, - }); - this.adminPools.set(version, adminPool); - - debugContainer( - "[ContainerManager] Successfully started container for PostgreSQL %d", - version, - ); - } catch (error) { - console.error( - `Failed to start container for PostgreSQL ${version}:`, - error, - ); - throw error; - } - } - - /** - * Ensure the manager is initialized with the given versions - */ - private async ensureInitialized(versions: PostgresVersion[]): Promise { - const versionsToInitialize = versions.filter( - (version) => !this.initializedVersions.has(version), - ); - if (versionsToInitialize.length > 0) { - await this.initialize(versionsToInitialize); - } - } - - /** - * Get a database pair (main, branch) for testing from the container - */ - async getDatabasePair(version: PostgresVersion): Promise<{ - main: Pool; - branch: Pool; - cleanup: () => Promise; - }> { - debugContainer( - "[ContainerManager] Getting database pair for PostgreSQL %d", - version, - ); - await this.ensureInitialized([version]); - - const container = this.containers.get(version); - if (!container) { - throw new Error(`No container available for PostgreSQL ${version}`); - } - - const adminPool = this.adminPools.get(version); - if (!adminPool) { - throw new Error(`No admin pool available for PostgreSQL ${version}`); - } - - // Generate unique database names - const dbNameMain = `test_db_${this.dbCounter++}_${Date.now()}_main`; - const dbNameBranch = `test_db_${this.dbCounter++}_${Date.now()}_branch`; - - // Create both databases via pg Pool (not container.exec which hangs in Bun) - await Promise.all([ - adminPool.query( - `CREATE DATABASE "${dbNameMain}" OWNER "${container.getUsername()}"`, - ), - adminPool.query( - `CREATE DATABASE "${dbNameBranch}" OWNER "${container.getUsername()}"`, - ), - ]); - - // Create SQL connections to both databases on the same container - // Use onError to suppress expected shutdown errors from idle connections - const poolMain = createPool( - container.getConnectionUriForDatabase(dbNameMain), - { onError: suppressShutdownError }, - ); - const poolBranch = createPool( - container.getConnectionUriForDatabase(dbNameBranch), - { onError: suppressShutdownError }, - ); - - const cleanup = async () => { - try { - // Close connections - await Promise.all([poolMain.end(), poolBranch.end()]); - - // Drop subscriptions then databases via pg Pool - for (const dbName of [dbNameMain, dbNameBranch]) { - try { - // Connect to the database to drop subscriptions - const dbPool = createPool( - container.getConnectionUriForDatabase(dbName), - { onError: suppressShutdownError }, - ); - try { - const subsResult = await dbPool.query( - "SELECT quote_ident(subname) as subname FROM pg_catalog.pg_subscription WHERE subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())", - ); - for (const row of subsResult.rows) { - await dbPool.query( - `ALTER SUBSCRIPTION ${row.subname} SET (slot_name = NONE)`, - ); - await dbPool.query(`DROP SUBSCRIPTION ${row.subname}`); - } - } catch { - // Best-effort subscription cleanup - } finally { - await dbPool.end(); - } - } catch { - // Best-effort subscription cleanup - } - // Drop the database - await adminPool.query( - `DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`, - ); - } - } catch (error) { - console.error("Error during database cleanup:", error); - } - }; - - return { main: poolMain, branch: poolBranch, cleanup }; - } - - /** - * Get isolated containers (creates new containers for the test) - */ - async getIsolatedContainers(version: PostgresVersion): Promise<{ - main: Pool; - branch: Pool; - cleanup: () => Promise; - }> { - const image = await buildPostgresTestImage(version); - - const [containerMain, containerBranch] = await Promise.all([ - new PostgresAlpineContainer(image).start(), - new PostgresAlpineContainer(image).start(), - ]); - - const poolMain = createPool(containerMain.getConnectionUri(), { - onError: suppressShutdownError, - connectionTimeoutMillis: 20_000, - }); - const poolBranch = createPool(containerBranch.getConnectionUri(), { - onError: suppressShutdownError, - connectionTimeoutMillis: 20_000, - }); - - const cleanup = async () => { - try { - await Promise.all([poolMain.end(), poolBranch.end()]); - await Promise.all([containerMain.stop(), containerBranch.stop()]); - } catch (error) { - console.error("Error during isolated container cleanup:", error); - } - }; - - return { main: poolMain, branch: poolBranch, cleanup }; - } - - /** - * Cleanup all containers - */ - async cleanup(): Promise { - // Close admin pools first - await Promise.all(this.adminPools.values().map((pool) => pool.end())); - this.adminPools.clear(); - await Promise.all( - this.containers.values().map((container) => container.stop()), - ); - this.containers.clear(); - this.initializedVersions.clear(); - this.initializationPromises.clear(); - } -} - -// Global container manager instance - using globalThis to ensure singleton across modules -declare global { - var __containerManager: ContainerManager | undefined; -} - -export const containerManager = - globalThis.__containerManager || - // biome-ignore lint/suspicious/noAssignInExpressions: this is a singleton - (globalThis.__containerManager = new ContainerManager()); diff --git a/packages/pg-delta/tests/containers.ts b/packages/pg-delta/tests/containers.ts new file mode 100644 index 000000000..c52dfcf3d --- /dev/null +++ b/packages/pg-delta/tests/containers.ts @@ -0,0 +1,510 @@ +/** + * Test-container manager: one shared PostgreSQL cluster (databases as the + * isolation unit) plus a lazily started PAIR of clusters for scenarios whose + * point is cluster-level state (roles/memberships/default privileges) — those + * run state A and state B on different clusters. + * + * `isolatedClusterPair()` is a SINGLETON pair shared by every cluster-level + * test, and roles are cluster-global, so roles ACCUMULATE across scenarios. + * There is NO automatic role cleanup — `dropRolesExcept` is exposed but each + * caller must call it. Cleanup is best-effort by default because a role owning + * objects in another still-live test database cannot be dropped until that + * database is gone; callers that require isolation can enable strict + * postcondition verification. The established pattern for role-heavy tests + * otherwise avoids pollution by construction: use distinctive role names/configs + * so rename detection stays unambiguous, and prove plans that drop/rename roles + * against the SACRIFICIAL source database directly (never a clone — a clone + * leaves the original source pinning the old role; see owner-edge.test.ts). + */ +import { + GenericContainer, + Wait, + type StartedTestContainer, +} from "testcontainers"; +import pg from "pg"; + +const PG_IMAGE = process.env["PGDELTA_TEST_IMAGE"] ?? "postgres:17-alpine"; + +/** Supabase image (ships pg_partman / pgmq / pg_cron) for extension-intent + * integration tests (docs/architecture/extension-intent.md). Exported so the + * baseline-fixture pipeline (scripts/sync-supabase-base-images.ts) boots the + * exact same tag it validates against. */ +export const SUPABASE_IMAGE = + process.env["PGDELTA_SUPABASE_TEST_IMAGE"] ?? "supabase/postgres:17.6.1.135"; + +/** + * Self-gate for heavy bare-Supabase-image tests (`supabaseCluster()`). + * + * The bare image is a fixed major (17), independent of the matrix leg's + * `PGDELTA_TEST_IMAGE`. Without a gate a new Supabase integration file would + * spin that heavy image on ALL five CI legs (14/15/16/17/18) every PR for zero + * extra coverage — the image major never changes. So run these once, on the leg + * whose major matches the image; honor a hard opt-out + * (`PGDELTA_NEXT_SUPABASE_TESTS=0`, e.g. forked PRs without image access) and a + * force override (`=1`) for local single-file runs on a non-matching leg. + * + * Use as `describe.skipIf(!runSupabaseBareTests)(...)`. + */ +const LEG_PG_MAJOR = Number(/postgres:(\d+)/.exec(PG_IMAGE)?.[1] ?? "17"); +/** Major version of the pinned Supabase image — names the baseline fixture + * (tests/fixtures/supabase-base-init/.sql) and its regeneration. */ +export const SUPABASE_BARE_MAJOR = Number( + /postgres:(\d+)/.exec(SUPABASE_IMAGE)?.[1] ?? "17", +); +export const runSupabaseBareTests = + process.env["PGDELTA_NEXT_SUPABASE_TESTS"] !== "0" && + (process.env["PGDELTA_NEXT_SUPABASE_TESTS"] === "1" || + LEG_PG_MAJOR === SUPABASE_BARE_MAJOR); + +let dbCounter = 0; + +export interface TestDb { + name: string; + pool: pg.Pool; + uri: string; + /** A `postgres`-role connection URI for this database, present only on the + * Supabase cluster (`supabaseCluster()`), where a faithful non-superuser + * `postgres` role is provisioned at cluster start. Use this for anything that + * simulates real `--target` usage (Supabase hands users `postgres`, never + * `supabase_admin`). Undefined on the stock/seclabel clusters. */ + postgresUri?: string | undefined; + cluster: Cluster; + /** Create a clone of this database via CREATE DATABASE … TEMPLATE. */ + clone(): Promise; + drop(): Promise; +} + +export class Cluster { + #pgMajor: number | undefined; + + constructor( + readonly container: StartedTestContainer, + readonly adminPool: pg.Pool, + readonly uriFor: (db: string) => string, + /** Optional parallel URI builder for a non-admin `postgres` role (Supabase + * cluster only); populates `TestDb.postgresUri`. */ + readonly postgresUriFor?: (db: string) => string, + ) {} + + async pgMajor(): Promise { + if (this.#pgMajor === undefined) { + const res = await this.adminPool.query( + `SELECT current_setting('server_version_num')::int AS v`, + ); + this.#pgMajor = Math.floor((res.rows[0] as { v: number }).v / 10000); + } + return this.#pgMajor; + } + + async createDb(prefix = "t"): Promise { + const name = `${prefix}_${dbCounter++}`; + await this.adminPool.query(`CREATE DATABASE "${name}"`); + return this.#makeDb(name); + } + + #makeDb(name: string): TestDb { + const uri = this.uriFor(name); + const pool = new pg.Pool({ connectionString: uri, max: 5 }); + pool.on("error", () => {}); + const cluster = this as Cluster; + return { + name, + pool, + uri, + postgresUri: this.postgresUriFor?.(name), + cluster, + async clone() { + // TEMPLATE requires zero connections on the source + await this.pool.end().catch(() => {}); + const cloneName = `${name}_c${dbCounter++}`; + await cluster.adminPool.query( + `CREATE DATABASE "${cloneName}" TEMPLATE "${name}"`, + ); + const fresh = cluster.#makeDb(cloneName); + const reopened = new pg.Pool({ connectionString: uri, max: 5 }); + reopened.on("error", () => {}); + (this as { pool: pg.Pool }).pool = reopened; + return fresh; + }, + async drop() { + // DROP DATABASE refuses databases that still own subscriptions + try { + const subs = await this.pool.query( + `SELECT subname FROM pg_subscription + WHERE subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())`, + ); + for (const row of subs.rows as { subname: string }[]) { + const sub = `"${row.subname.replaceAll('"', '""')}"`; + await this.pool + .query(`ALTER SUBSCRIPTION ${sub} DISABLE`) + .catch(() => {}); + await this.pool + .query(`ALTER SUBSCRIPTION ${sub} SET (slot_name = NONE)`) + .catch(() => {}); + await this.pool.query(`DROP SUBSCRIPTION ${sub}`).catch(() => {}); + } + } catch { + // no subscriptions or already gone — fine + } + await this.pool.end().catch(() => {}); + await cluster.adminPool.query( + `DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`, + ); + }, + }; + } + + async listRoles(): Promise> { + const res = await this.adminPool.query( + `SELECT rolname FROM pg_roles WHERE rolname NOT LIKE 'pg\\_%'`, + ); + return new Set(res.rows.map((r) => (r as { rolname: string }).rolname)); + } + + /** Drop roles created since `baseline`; strict mode verifies the postcondition. */ + async dropRolesExcept( + baseline: Set, + options: { strict?: boolean } = {}, + ): Promise { + const current = await this.listRoles(); + const cleanupErrors: string[] = []; + for (const role of current) { + if (baseline.has(role)) continue; + const quoted = `"${role.replaceAll('"', '""')}"`; + try { + await this.adminPool.query(`DROP OWNED BY ${quoted} CASCADE`); + } catch (error) { + cleanupErrors.push(`${role} DROP OWNED: ${String(error)}`); + } + try { + await this.adminPool.query(`DROP ROLE IF EXISTS ${quoted}`); + } catch (error) { + cleanupErrors.push(`${role} DROP ROLE: ${String(error)}`); + } + } + + if (options.strict) { + const remaining = [...(await this.listRoles())] + .filter((role) => !baseline.has(role)) + .sort(); + if (remaining.length > 0) { + const detail = + cleanupErrors.length > 0 + ? `; cleanup errors: ${cleanupErrors.join("; ")}` + : ""; + throw new Error( + `Role cleanup incomplete; non-baseline roles remain: ${remaining.join(", ")}${detail}`, + ); + } + } + } + + /** Tear down the cluster: close the admin pool and stop the container. */ + async stop(): Promise { + await this.adminPool.end().catch(() => {}); + await this.container.stop().catch(() => {}); + } +} + +async function startCluster(): Promise { + const container = await new GenericContainer(PG_IMAGE) + .withEnvironment({ + POSTGRES_USER: "test", + POSTGRES_PASSWORD: "test", + POSTGRES_DB: "postgres", + }) + .withCommand([ + "postgres", + "-c", + "fsync=off", + "-c", + "full_page_writes=off", + "-c", + "max_connections=300", + "-c", + "wal_level=logical", + ]) + .withExposedPorts(5432) + .withWaitStrategy( + Wait.forLogMessage(/database system is ready to accept connections/, 2), + ) + .start(); + const uriFor = (db: string) => + `postgres://test:test@${container.getHost()}:${container.getMappedPort(5432)}/${db}`; + const adminPool = new pg.Pool({ + connectionString: uriFor("postgres"), + max: 3, + }); + adminPool.on("error", () => {}); + return new Cluster(container, adminPool, uriFor); +} + +let shared: Promise | null = null; +export async function sharedCluster(): Promise { + shared ??= startCluster(); + return shared; +} + +let isolatedPair: Promise<[Cluster, Cluster]> | null = null; +/** Two extra clusters for cluster-level-difference scenarios (A-side, B-side). */ +export async function isolatedClusterPair(): Promise<[Cluster, Cluster]> { + isolatedPair ??= Promise.all([startCluster(), startCluster()]); + return isolatedPair; +} + +export async function createTestDb(prefix = "t"): Promise { + return (await sharedCluster()).createDb(prefix); +} + +/** + * Start a Supabase-image cluster (`supabase/postgres`, which ships pg_partman / + * pgmq / pg_cron). Used by extension-intent integration tests; the image is + * heavy, so this is a separate lazy singleton from the stock alpine cluster. + * Connects as `supabase_admin`; databases are the isolation unit, as usual. + */ +async function startSupabaseCluster(): Promise { + const container = await new GenericContainer(SUPABASE_IMAGE) + .withEnvironment({ + POSTGRES_USER: "supabase_admin", + POSTGRES_PASSWORD: "postgres", + POSTGRES_DB: "postgres", + }) + .withExposedPorts(5432) + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(180_000) + .withTmpFs({ "/var/lib/postgresql/data": "rw,noexec,nosuid,size=512m" }) + .start(); + const host = container.getHost(); + const port = container.getMappedPort(5432); + const uriFor = (db: string) => + `postgres://supabase_admin:postgres@${host}:${port}/${db}`; + // Real Supabase projects hand users a `postgres`-role connection, never + // `supabase_admin` (that's Supabase's internal platform-admin role). Expose a + // parallel per-db builder so tests that simulate real `--target` usage own the + // shadow load + apply as `postgres`. + const postgresUriFor = (db: string) => + `postgres://postgres:postgres@${host}:${port}/${db}`; + const adminPool = new pg.Pool({ + connectionString: uriFor("postgres"), + max: 3, + }); + adminPool.on("error", () => {}); + // Make `postgres` connectable exactly the way real Supabase Cloud exposes it: + // a NON-superuser member of `supabase_privileged_role`. The image's `supautils` + // (session-preloaded, `supautils.privileged_role = 'supabase_privileged_role'`) + // elevates that role just enough — session-level GUCs in + // `privileged_role_allowed_configs` (e.g. `log_min_messages`), and CREATE EVENT + // TRIGGER via a switch-to-`supautils.superuser`-then-reassign-owner path, so + // objects a privileged-role member creates are owned by that member + // (`postgres`), matching Cloud. Granting real SUPERUSER instead would be + // unfaithful: superuser-created event triggers take supautils' genuine-superuser + // path, which coerces ownership to `supabase_admin`, so user event triggers + // (e.g. Studio's `ensure_rls`) drift owner and the Supabase profile then + // excludes them from export. + // + // The role + grant mirror the image's own migration + // `20260211120934_supabase_privileged_role.sql` (newer tags ship it; the pinned + // tag predates it) — idempotent, a no-op once the image includes it. Roles are + // cluster-global, so this runs ONCE at cluster start (a per-test CREATE ROLE + // would race across the shared databases). + await adminPool.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_privileged_role') THEN + CREATE ROLE supabase_privileged_role; + END IF; + END $$;`); + await adminPool.query("GRANT supabase_privileged_role TO postgres"); + await adminPool.query( + "ALTER ROLE postgres WITH LOGIN NOSUPERUSER PASSWORD 'postgres'", + ); + return new Cluster(container, adminPool, uriFor, postgresUriFor); +} + +let supabaseShared: Promise | null = null; +export async function supabaseCluster(): Promise { + supabaseShared ??= startSupabaseCluster(); + return supabaseShared; +} + +/** A fresh, standalone Supabase-image container (its own cluster, NOT the shared + * singleton). The dbdev-roundtrip fixture needs two independent containers + * because Supabase only permits CREATE EXTENSION in the `postgres` database, + * so the shared cluster's per-test databases won't do. Connects as + * `supabase_admin`, matching the committed base-init fixture. */ +export interface StartedStandaloneSupabase { + connectionUri(db?: string): string; + /** Real Supabase projects hand users a `postgres`-role connection, never + * `supabase_admin` (that's Supabase's internal platform-admin role). Use + * this for anything meant to simulate real `--target` usage. */ + postgresConnectionUri(db?: string): string; + stop(): Promise; +} + +export async function startStandaloneSupabase(): Promise { + const container = await new GenericContainer(SUPABASE_IMAGE) + .withEnvironment({ + POSTGRES_USER: "supabase_admin", + POSTGRES_PASSWORD: "postgres", + POSTGRES_DB: "postgres", + }) + .withExposedPorts(5432) + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(180_000) + .withTmpFs({ "/var/lib/postgresql/data": "rw,noexec,nosuid,size=512m" }) + .start(); + const host = container.getHost(); + const port = container.getMappedPort(5432); + + // Make `postgres` connectable exactly the way real Supabase Cloud exposes it: + // a NON-superuser member of `supabase_privileged_role`. The image's `supautils` + // (session-preloaded, `supautils.privileged_role = 'supabase_privileged_role'`) + // elevates that role just enough — session-level GUCs in + // `privileged_role_allowed_configs` (e.g. `log_min_messages`), and CREATE EVENT + // TRIGGER via a switch-to-`supautils.superuser`-then-reassign-owner path, so + // objects a privileged-role member creates are owned by that member + // (`postgres`), matching Cloud. Granting real SUPERUSER instead would be + // unfaithful: superuser-created event triggers take supautils' genuine-superuser + // path, which coerces ownership to `supabase_admin`, so user event triggers + // (e.g. Studio's `ensure_rls`) drift owner and the Supabase profile then + // excludes them from export. + // + // The role + grant mirror the image's own migration + // `20260211120934_supabase_privileged_role.sql` (newer tags ship it; the pinned + // tag predates it) — idempotent, a no-op once the image includes it. + // The co-located-shadow seed replays as this non-superuser role since the + // seed hardening in seed-assumed-schemas.ts (SUSET SET clauses stripped, + // platform ADP entries omitted). + const adminPool = new pg.Pool({ + connectionString: `postgres://supabase_admin:postgres@${host}:${port}/postgres`, + max: 1, + }); + adminPool.on("error", () => {}); + try { + await adminPool.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_privileged_role') THEN + CREATE ROLE supabase_privileged_role; + END IF; + END $$;`); + await adminPool.query("GRANT supabase_privileged_role TO postgres"); + await adminPool.query( + "ALTER ROLE postgres WITH LOGIN NOSUPERUSER PASSWORD 'postgres'", + ); + } finally { + await adminPool.end(); + } + + return { + connectionUri: (db = "postgres") => + `postgres://supabase_admin:postgres@${host}:${port}/${db}`, + postgresConnectionUri: (db = "postgres") => + `postgres://postgres:postgres@${host}:${port}/${db}`, + stop: async () => { + await container.stop(); + }, + }; +} + +/** The security-label end-to-end proof needs a loaded label provider. We build + * a `postgres:-alpine` image with the `dummy_seclabel` test module + * compiled in (tests/dummy-seclabel.Dockerfile) and preload it. Sandboxes that + * cannot reach the Alpine / GitHub CDNs at build time set + * `PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1`; the proof test skips itself. */ +export const skipSeclabelProof = + process.env["PGDELTA_SKIP_DUMMY_SECLABEL_BUILD"] === "1" || + process.env["PGDELTA_SKIP_DUMMY_SECLABEL_BUILD"] === "true"; + +const SECLABEL_PG_MAJOR = Number(/postgres:(\d+)/.exec(PG_IMAGE)?.[1] ?? "17"); +// Alpine base that ships the matching postgresql-dev headers for the +// dummy_seclabel build. 14 reuses 3.19 (postgresql14-dev 14.17) and 16 reuses +// 3.23 (postgresql16-dev 16.14), so no new Alpine base images are introduced. +const ALPINE_TAG_FOR_PG_MAJOR: Record = { + 14: "3.19", + 15: "3.19", + 16: "3.23", + 17: "3.23", + 18: "3.23", +}; + +async function startSeclabelCluster(): Promise { + const major = SECLABEL_PG_MAJOR; + // build-or-reuse the dummy_seclabel image (Docker layer cache makes repeat + // runs cheap; the first build compiles the module from PG source) + const built = await GenericContainer.fromDockerfile( + import.meta.dir, + "dummy-seclabel.Dockerfile", + ) + .withBuildArgs({ + PG_MAJOR: String(major), + PG_BRANCH: `REL_${major}_STABLE`, + ALPINE_TAG: ALPINE_TAG_FOR_PG_MAJOR[major] ?? "3.23", + }) + .withCache(true) + .build(`pg-delta-next-seclabel:${major}`, { deleteOnExit: false }); + const container = await built + .withEnvironment({ + POSTGRES_USER: "test", + POSTGRES_PASSWORD: "test", + POSTGRES_DB: "postgres", + }) + .withCommand([ + "postgres", + "-c", + "fsync=off", + "-c", + "full_page_writes=off", + "-c", + "max_connections=300", + "-c", + "wal_level=logical", + "-c", + "shared_preload_libraries=dummy_seclabel", + ]) + .withExposedPorts(5432) + .withWaitStrategy( + Wait.forLogMessage(/database system is ready to accept connections/, 2), + ) + .withStartupTimeout(240_000) + .start(); + const uriFor = (db: string) => + `postgres://test:test@${container.getHost()}:${container.getMappedPort(5432)}/${db}`; + const adminPool = new pg.Pool({ + connectionString: uriFor("postgres"), + max: 3, + }); + adminPool.on("error", () => {}); + return new Cluster(container, adminPool, uriFor); +} + +let seclabelShared: Promise | null = null; +export async function seclabelCluster(): Promise { + seclabelShared ??= startSeclabelCluster(); + return seclabelShared; +} + +/** + * Stop every started singleton cluster and reset the singletons so a later call + * re-starts fresh. The `withDb`-style test teardown only drops databases, never + * the shared containers; standalone scripts (e.g. the dogfood suite) call this + * in a `finally` so a run leaks no containers. Ryuk still reaps on process + * death — this is the clean in-process path. + */ +export async function stopAllClusters(): Promise { + const pending = [shared, supabaseShared, seclabelShared].filter( + (p): p is Promise => p !== null, + ); + const pairPending = isolatedPair; + shared = null; + supabaseShared = null; + seclabelShared = null; + isolatedPair = null; + + const clusters = ( + await Promise.all(pending.map((p) => p.catch(() => null))) + ).filter((c): c is Cluster => c !== null); + if (pairPending) { + const pair = await pairPending.catch(() => null); + if (pair) clusters.push(...pair); + } + await Promise.all(clusters.map((c) => c.stop())); +} diff --git a/packages/pg-delta/tests/corpus-scheduling.test.ts b/packages/pg-delta/tests/corpus-scheduling.test.ts new file mode 100644 index 000000000..c5c21a637 --- /dev/null +++ b/packages/pg-delta/tests/corpus-scheduling.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import type { Scenario } from "./corpus.ts"; +import { mustRunSerially } from "./corpus-scheduling.ts"; + +const scenario = (overrides: Partial = {}): Scenario => ({ + name: "s", + a: "CREATE TABLE public.t (id integer);", + b: "CREATE TABLE public.t (id bigint);", + meta: {}, + ...overrides, +}); + +describe("mustRunSerially", () => { + test("serializes cluster-global role DDL in a reverse seed", () => { + expect( + mustRunSerially(scenario({ seedB: 'CREATE ROLE "reverse_seed_role";' })), + ).toBe(true); + }); + + test("leaves database-local scenarios eligible for concurrency", () => { + expect(mustRunSerially(scenario())).toBe(false); + }); +}); diff --git a/packages/pg-delta/tests/corpus-scheduling.ts b/packages/pg-delta/tests/corpus-scheduling.ts new file mode 100644 index 000000000..432f7b3ab --- /dev/null +++ b/packages/pg-delta/tests/corpus-scheduling.ts @@ -0,0 +1,16 @@ +import type { Scenario } from "./corpus.ts"; + +// Roles, role memberships, and other cluster-level objects are GLOBAL on the +// shared cluster. A scenario that touches them in either state or either +// direction's seed must not share the opt-in concurrent corpus pool. +const ROLE_DDL = /\b(?:create|drop|alter)\s+(?:role|user|group)\b/i; + +export function mustRunSerially(scenario: Scenario): boolean { + return ( + scenario.meta.isolatedCluster === true || + ROLE_DDL.test(scenario.a) || + ROLE_DDL.test(scenario.b) || + (scenario.seed !== undefined && ROLE_DDL.test(scenario.seed)) || + (scenario.seedB !== undefined && ROLE_DDL.test(scenario.seedB)) + ); +} diff --git a/packages/pg-delta/tests/corpus.test.ts b/packages/pg-delta/tests/corpus.test.ts new file mode 100644 index 000000000..308882c8a --- /dev/null +++ b/packages/pg-delta/tests/corpus.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import { loadCorpus } from "./corpus.ts"; + +describe("loadCorpus direction-specific seeds", () => { + test("loads seed-b.sql for the reverse direction", () => { + const scenario = loadCorpus().find( + (entry) => entry.name === "constraint-ops--convert-pk-to-temporal", + ); + + expect(scenario?.seed).toContain("INSERT INTO test_schema.bookings"); + expect(scenario?.seedB).toContain("INSERT INTO test_schema.bookings"); + }); +}); diff --git a/packages/pg-delta/tests/corpus.ts b/packages/pg-delta/tests/corpus.ts new file mode 100644 index 000000000..cc7200d1d --- /dev/null +++ b/packages/pg-delta/tests/corpus.ts @@ -0,0 +1,74 @@ +/** Corpus loader (stage 0): one directory per scenario, a.sql + b.sql. */ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +export interface ScenarioMeta { + /** Cluster-level state differs (roles/memberships/default privileges): + * each side gets its own freshly started cluster. */ + isolatedCluster?: boolean; + /** Minimum PostgreSQL major version this scenario's DDL needs. */ + minVersion?: number; +} + +export interface Scenario { + name: string; + a: string; + b: string; + seed?: string; + seedB?: string; + meta: ScenarioMeta; +} + +const CORPUS_DIR = new URL("../corpus", import.meta.url).pathname; + +/** Narrow the corpus while iterating: + * - PGDELTA_NEXT_ONLY: comma-separated scenario-name substrings + * - PGDELTA_NEXT_SHARD: "i/n" (0-based) — deterministic slice for parallel runs + * Both are dev/CI conveniences; an unset env runs everything. */ +function selectScenarios(names: string[]): string[] { + const only = process.env["PGDELTA_NEXT_ONLY"]; + let selected = names; + if (only) { + const patterns = only + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + selected = selected.filter((n) => patterns.some((p) => n.includes(p))); + } + const shard = process.env["PGDELTA_NEXT_SHARD"]; + if (shard) { + const match = /^(\d+)\/(\d+)$/.exec(shard); + if (!match) + throw new Error(`PGDELTA_NEXT_SHARD must be "i/n", got "${shard}"`); + const [index, total] = [Number(match[1]), Number(match[2])]; + if (index >= total) + throw new Error(`shard index ${index} out of range for total ${total}`); + selected = selected.filter((_, i) => i % total === index); + } + return selected; +} + +export function loadCorpus(): Scenario[] { + const names = readdirSync(CORPUS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + return selectScenarios(names).map((name) => { + const dir = join(CORPUS_DIR, name); + const seedPath = join(dir, "seed.sql"); + const seedBPath = join(dir, "seed-b.sql"); + const metaPath = join(dir, "meta.json"); + return { + name, + a: readFileSync(join(dir, "a.sql"), "utf8"), + b: readFileSync(join(dir, "b.sql"), "utf8"), + ...(existsSync(seedPath) ? { seed: readFileSync(seedPath, "utf8") } : {}), + ...(existsSync(seedBPath) + ? { seedB: readFileSync(seedBPath, "utf8") } + : {}), + meta: existsSync(metaPath) + ? (JSON.parse(readFileSync(metaPath, "utf8")) as ScenarioMeta) + : {}, + }; + }); +} diff --git a/packages/pg-delta/tests/dbdev-roundtrip.test.ts b/packages/pg-delta/tests/dbdev-roundtrip.test.ts new file mode 100644 index 000000000..4fd2bdf30 --- /dev/null +++ b/packages/pg-delta/tests/dbdev-roundtrip.test.ts @@ -0,0 +1,81 @@ +/** + * Integration test: dbdev core schema roundtrip under the Supabase profile. + * + * Mirrors packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts for the + * new engine: plan main (Supabase base-init only) → branch (base-init + dbdev + * core migrations), apply on main, assert zero drift under supabasePolicy. + * + * Managed-schema objects (auth.*, storage.*) are intentionally excluded from + * the plan; convergence is checked via a profile-scoped re-plan, not raw diff. + * + * Docker required (two Supabase PG15 containers + dbdev fixture migrations). + */ +import { describe, expect, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { resolveCliProfile } from "../src/cli/profile.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { bootstrapDbdevFixture } from "../scripts/lib/bootstrap-dbdev-fixture.ts"; +import { runSupabaseBareTests } from "./containers.ts"; + +// Heavy Supabase-image test: boots two supabase/postgres containers. Gated like +// the other Supabase-image suites so it runs only on the matching PG leg (or +// with PGDELTA_NEXT_SUPABASE_TESTS=1), not on all CI legs. +describe.skipIf(!runSupabaseBareTests)( + "dbdev core roundtrip (supabase profile)", + () => { + test( + "supabase-profile plan applies on main and converges to branch state", + async () => { + const fixture = await bootstrapDbdevFixture("core"); + + try { + const ctx = await resolveCliProfile(fixture.mainPool, "supabase", { + restrictToApplier: false, + }); + const extractFn = ctx.extract ?? extract; + + const [sourceState, desiredState] = await Promise.all([ + extractFn(fixture.mainPool), + extractFn(fixture.branchPool), + ]); + + const thePlan = plan(sourceState.factBase, desiredState.factBase, { + compact: true, + ...ctx.planOptions, + }); + expect(thePlan.actions.length).toBeGreaterThan(0); + + const report = await apply(thePlan, fixture.mainPool, { + fingerprintGate: false, + ...ctx.applyOptions, + }); + if (report.status !== "applied") { + const action = report.error; + throw new Error( + `apply failed at action ${action?.actionIndex ?? "?"}: ${action?.message ?? report.status}\nSQL: ${action?.sql ?? "(none)"}`, + ); + } + + const afterApply = await extractFn(fixture.mainPool); + const driftPlan = plan( + afterApply.factBase, + desiredState.factBase, + ctx.planOptions, + ); + if (driftPlan.actions.length > 0) { + const driftSql = driftPlan.actions.map((a) => a.sql).join("\n\n"); + throw new Error( + `${driftPlan.actions.length} drift action(s) after apply:\n${driftSql}`, + ); + } + + expect(driftPlan.actions).toEqual([]); + } finally { + await fixture.cleanup(); + } + }, + 5 * 60 * 1000, + ); + }, +); diff --git a/packages/pg-delta/tests/default-privileges-owner-self-revoke.test.ts b/packages/pg-delta/tests/default-privileges-owner-self-revoke.test.ts new file mode 100644 index 000000000..50c9bc579 --- /dev/null +++ b/packages/pg-delta/tests/default-privileges-owner-self-revoke.test.ts @@ -0,0 +1,171 @@ +/** + * Regression: a GLOBAL (no IN SCHEMA) default-privileges row whose owner + * self-entry was revoked while OTHER grantees remain must be extracted as a + * real `revoked_default` marker. + * + * `pg_default_acl` stores the RESULTING default ACL, and for a GLOBAL row a + * grant to another role MATERIALIZES the owner's own acldefault self-entry + * (verified on postgres:17): + * + * ALTER DEFAULT PRIVILEGES FOR ROLE alice GRANT SELECT ON TABLES TO bob; + * -- global row: {alice=arwdDxtm/alice, bob=r/alice} (alice PRESENT) + * + * Explicitly revoking the owner then drops that self-entry: + * + * ALTER DEFAULT PRIVILEGES FOR ROLE alice REVOKE ALL ON TABLES FROM alice; + * -- global row now: {bob=r/alice} (alice ABSENT) + * + * and Postgres uses that stored ACL VERBATIM at object creation, so a table + * later created by alice really lacks alice's own privileges. The owner-revoke + * is therefore a genuine customization that must survive export/apply/reverse. + * + * This blind spot is invisible to the corpus proof loop: extraction is symmetric + * (source, desired and re-extracted clone all run through the same `extract`), so + * an extraction that cannot SEE the owner-revoke declares false convergence. This + * test asserts on the fact base directly, which is where the bug lives. + * + * The suppression IS correct in two other shapes, which this test pins: + * - PER-SCHEMA rows: Postgres always re-merges the owner's acldefault at object + * creation, so owner-absence there is a behavioral no-op → no marker. + * - A BARE GLOBAL self-revoke with nothing else granted: the stored row is + * EMPTY (`{}`), the created table's relacl is NULL and the owner keeps its + * privileges → still a no-op → no marker. + * + * Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { createTestDb } from "./containers.ts"; + +/** The revoked-owner-default marker for `owner`'s own self-entry, if extracted: + * a `defaultPrivilege` fact whose role and grantee are both `owner`, carrying + * `_revokedDefault` (the built-in privileges the revoke removed). */ +function ownerRevokedMarker( + facts: readonly { id: unknown; payload: Record }[], + owner: string, + schema: string | null, +): (typeof facts)[number] | undefined { + return facts.find((f) => { + const id = f.id as { + kind: string; + role?: string; + schema?: string | null; + grantee?: string; + }; + return ( + id.kind === "defaultPrivilege" && + id.role === owner && + id.grantee === owner && + (id.schema ?? null) === schema && + f.payload["_revokedDefault"] != null + ); + }); +} + +describe("default privileges — owner self-revoke extraction", () => { + test("GLOBAL owner self-revoke alongside a grant-to-other is extracted as a marker", async () => { + const db = await createTestDb("dpsr_global"); + try { + await db.pool.query("CREATE ROLE dpsr_g_owner NOLOGIN"); + await db.pool.query("CREATE ROLE dpsr_g_reader NOLOGIN"); + await db.pool.query( + "ALTER DEFAULT PRIVILEGES FOR ROLE dpsr_g_owner REVOKE ALL ON TABLES FROM dpsr_g_owner", + ); + await db.pool.query( + "ALTER DEFAULT PRIVILEGES FOR ROLE dpsr_g_owner GRANT SELECT ON TABLES TO dpsr_g_reader", + ); + + const { factBase } = await extract(db.pool); + const facts = factBase.facts() as unknown as { + id: unknown; + payload: Record; + }[]; + const marker = ownerRevokedMarker(facts, "dpsr_g_owner", null); + + // RED before fix: the blanket owner exclusion drops this marker, so the + // real global owner-revoke vanishes from the fact base. + expect(marker).toBeDefined(); + // The exact acldefault privilege set is version-dependent (PG17 adds + // MAINTAIN), so assert the revoked table defaults contain the stable + // core privileges rather than pinning the whole list. + const revoked = marker?.payload["_revokedDefault"] as + | string[] + | undefined; + expect(revoked).toEqual( + expect.arrayContaining([ + "DELETE", + "INSERT", + "REFERENCES", + "SELECT", + "TRIGGER", + "TRUNCATE", + "UPDATE", + ]), + ); + } finally { + await db.drop(); + await db.cluster.adminPool + .query("DROP ROLE IF EXISTS dpsr_g_owner") + .catch(() => {}); + await db.cluster.adminPool + .query("DROP ROLE IF EXISTS dpsr_g_reader") + .catch(() => {}); + } + }, 60_000); + + test("PER-SCHEMA owner self-revoke is a no-op (owner re-merged at creation) — no marker", async () => { + const db = await createTestDb("dpsr_schema"); + try { + await db.pool.query("CREATE ROLE dpsr_s_owner NOLOGIN"); + await db.pool.query("CREATE ROLE dpsr_s_reader NOLOGIN"); + await db.pool.query("CREATE SCHEMA dpsr_s AUTHORIZATION dpsr_s_owner"); + await db.pool.query( + "ALTER DEFAULT PRIVILEGES FOR ROLE dpsr_s_owner IN SCHEMA dpsr_s REVOKE ALL ON TABLES FROM dpsr_s_owner", + ); + await db.pool.query( + "ALTER DEFAULT PRIVILEGES FOR ROLE dpsr_s_owner IN SCHEMA dpsr_s GRANT SELECT ON TABLES TO dpsr_s_reader", + ); + + const { factBase } = await extract(db.pool); + const facts = factBase.facts() as unknown as { + id: unknown; + payload: Record; + }[]; + + expect( + ownerRevokedMarker(facts, "dpsr_s_owner", "dpsr_s"), + ).toBeUndefined(); + } finally { + await db.drop(); + await db.cluster.adminPool + .query("DROP ROLE IF EXISTS dpsr_s_owner") + .catch(() => {}); + await db.cluster.adminPool + .query("DROP ROLE IF EXISTS dpsr_s_reader") + .catch(() => {}); + } + }, 60_000); + + test("BARE GLOBAL owner self-revoke (empty row) is a no-op — no marker", async () => { + const db = await createTestDb("dpsr_bare"); + try { + await db.pool.query("CREATE ROLE dpsr_b_owner NOLOGIN"); + await db.pool.query( + "ALTER DEFAULT PRIVILEGES FOR ROLE dpsr_b_owner REVOKE ALL ON TABLES FROM dpsr_b_owner", + ); + + const { factBase } = await extract(db.pool); + const facts = factBase.facts() as unknown as { + id: unknown; + payload: Record; + }[]; + + expect(ownerRevokedMarker(facts, "dpsr_b_owner", null)).toBeUndefined(); + } finally { + await db.drop(); + await db.cluster.adminPool + .query("DROP ROLE IF EXISTS dpsr_b_owner") + .catch(() => {}); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/depend-edges-oracle.test.ts b/packages/pg-delta/tests/depend-edges-oracle.test.ts new file mode 100644 index 000000000..cd2394478 --- /dev/null +++ b/packages/pg-delta/tests/depend-edges-oracle.test.ts @@ -0,0 +1,234 @@ +/** + * Characterization oracle for the `pg_depend` dependency resolver + * (`extract.ts` `${resolver}` + `dependRows`). Milestone A rewrites that + * correlated CASE subquery into a set-based form for performance; the rewrite + * MUST produce a byte-identical edge set, because edges drive sort/plan + * ordering. This test pins the CURRENT edge set so any drift fails fast. + * + * The fixture exercises the resolver's non-extension branches (extension-member + * branches are pinned separately by tests/extension-member-*.test.ts): + * pg_class (table/view/matview/index/sequence + constraint-backed index), + * pg_class objsubid>0 (column), pg_proc, pg_constraint (PK/UNIQUE/FK/CHECK on + * table and domain), pg_type (composite + domain), pg_policy, pg_event_trigger, + * pg_publication(_rel), pg_attrdef, pg_rewrite, pg_trigger, pg_inherits. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { encodeId } from "../src/core/stable-id.ts"; +import { extract, type ExtractResult } from "../src/extract/extract.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +const FIXTURE_DDL = /* sql */ ` + CREATE SCHEMA app; + CREATE SEQUENCE app.id_seq; + CREATE TYPE app.addr AS (street text, city text); + CREATE DOMAIN app.pos AS integer CHECK (VALUE > 0); + + CREATE TABLE app.users ( + id integer PRIMARY KEY DEFAULT nextval('app.id_seq'), + email text NOT NULL, + qty app.pos, + home app.addr, + score numeric DEFAULT 0, + CONSTRAINT users_email_uq UNIQUE (email), + CONSTRAINT score_nonneg CHECK (score >= 0) + ); + + CREATE TABLE app.orders ( + id integer PRIMARY KEY, + user_id integer NOT NULL, + note text, + CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES app.users (id) + ); + CREATE INDEX orders_user_idx ON app.orders (user_id); + CREATE TABLE app.archived_orders () INHERITS (app.orders); + + CREATE FUNCTION app.inc(a integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS 'SELECT a + 1'; + CREATE FUNCTION app.user_count() RETURNS bigint + LANGUAGE sql STABLE AS 'SELECT count(*) FROM app.users'; + CREATE FUNCTION app.touch() RETURNS trigger + LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END'; + CREATE TRIGGER orders_touch BEFORE UPDATE ON app.orders + FOR EACH ROW EXECUTE FUNCTION app.touch(); + + CREATE VIEW app.user_emails AS SELECT id, email FROM app.users; + CREATE VIEW app.user_emails2 AS SELECT id FROM app.user_emails; + CREATE MATERIALIZED VIEW app.order_counts AS + SELECT user_id, count(*) AS n FROM app.orders GROUP BY user_id; + + ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; + CREATE POLICY users_pos ON app.users USING (app.user_count() >= 0); + + CREATE FUNCTION app.evt() RETURNS event_trigger + LANGUAGE plpgsql AS 'BEGIN END'; + CREATE EVENT TRIGGER app_evt ON ddl_command_end EXECUTE FUNCTION app.evt(); +`; + +let db: TestDb; +let result: ExtractResult; +let pgMajor: number; + +beforeAll(async () => { + db = await createTestDb("depend-oracle"); + await db.pool.query(FIXTURE_DDL); + pgMajor = await db.cluster.pgMajor(); + // publication column lists are PG15+; on PG14 publish the whole table. + await db.pool.query( + pgMajor >= 15 + ? `CREATE PUBLICATION app_pub FOR TABLE app.users (id, email)` + : `CREATE PUBLICATION app_pub FOR TABLE app.users`, + ); + result = await extract(db.pool); +}, 120_000); + +afterAll(async () => { + await db.drop(); +}); + +/** Stable, human-reviewable rendering of one edge. */ +function renderEdges(kind: string): string[] { + return result.factBase.edges + .filter((e) => e.kind === kind) + .map((e) => `${encodeId(e.from)} -> ${encodeId(e.to)}`) + .sort(); +} + +describe("pg_depend resolver: edge-set oracle", () => { + test("depends edges are exactly as resolved today", () => { + // Some pg_depend rows differ across the supported version matrix and are + // excluded so this oracle is stable from PG14-18: object self-dependencies + // (PG14 records view/matview _RETURN self-deps; PG15+ does not) and + // publication column-list edges (PG15+ only — pinned separately below). + const dependsEdges = renderEdges("depends").filter((e) => { + const [from, to] = e.split(" -> "); + return from !== to && !e.startsWith("publication:app_pub -> column:"); + }); + expect(dependsEdges).toMatchInlineSnapshot(` + [ + "column:app.users.home -> type:app.addr", + "column:app.users.qty -> domain:app.pos", + "constraint:app.orders.orders_pkey -> column:app.orders.id", + "constraint:app.orders.orders_user_fk -> column:app.orders.user_id", + "constraint:app.orders.orders_user_fk -> column:app.users.id", + "constraint:app.orders.orders_user_fk -> constraint:app.users.users_pkey", + "constraint:app.pos.pos_check -> domain:app.pos", + "constraint:app.users.score_nonneg -> column:app.users.score", + "constraint:app.users.users_email_uq -> column:app.users.email", + "constraint:app.users.users_pkey -> column:app.users.id", + "default:app.users.id -> column:app.users.id", + "default:app.users.id -> sequence:app.id_seq", + "default:app.users.score -> column:app.users.score", + "domain:app.pos -> schema:app", + "eventTrigger:app_evt -> function:app.evt()", + "function:app.evt() -> schema:app", + "function:app.inc(integer) -> schema:app", + "function:app.touch() -> schema:app", + "function:app.user_count() -> schema:app", + "index:app.orders_user_idx -> column:app.orders.user_id", + "materializedView:app.order_counts -> column:app.orders.user_id", + "materializedView:app.order_counts -> schema:app", + "policy:app.users.users_pos -> function:app.user_count()", + "policy:app.users.users_pos -> table:app.users", + "publication:app_pub -> table:app.users", + "sequence:app.id_seq -> schema:app", + "table:app.archived_orders -> schema:app", + "table:app.archived_orders -> table:app.orders", + "table:app.archived_orders -> table:app.orders", + "table:app.orders -> schema:app", + "table:app.users -> schema:app", + "trigger:app.orders.orders_touch -> function:app.touch()", + "trigger:app.orders.orders_touch -> table:app.orders", + "type:app.addr -> schema:app", + "view:app.user_emails -> column:app.users.email", + "view:app.user_emails -> column:app.users.id", + "view:app.user_emails -> schema:app", + "view:app.user_emails2 -> schema:app", + "view:app.user_emails2 -> view:app.user_emails", + ] + `); + }); + + test("publication column-list edges are PG15+", () => { + if (pgMajor < 15) return; // publication column lists don't exist before PG15 + expect( + renderEdges("depends").filter((e) => + e.startsWith("publication:app_pub -> column:"), + ), + ).toEqual([ + "publication:app_pub -> column:app.users.email", + "publication:app_pub -> column:app.users.id", + ]); + }); + + test("owner edges are exactly as resolved today", () => { + // the `public` schema is owned by the bootstrap role on PG14 but by + // pg_database_owner (a pg_* role, filtered out) on PG15+; exclude it so + // this oracle is stable across the version matrix. + const ownerEdges = renderEdges("owner").filter( + (e) => !e.startsWith("schema:public -> "), + ); + expect(ownerEdges).toMatchInlineSnapshot(` + [ + "domain:app.pos -> role:test", + "eventTrigger:app_evt -> role:test", + "function:app.evt() -> role:test", + "function:app.inc(integer) -> role:test", + "function:app.touch() -> role:test", + "function:app.user_count() -> role:test", + "materializedView:app.order_counts -> role:test", + "publication:app_pub -> role:test", + "schema:app -> role:test", + "sequence:app.id_seq -> role:test", + "table:app.archived_orders -> role:test", + "table:app.orders -> role:test", + "table:app.users -> role:test", + "type:app.addr -> role:test", + "view:app.user_emails -> role:test", + "view:app.user_emails2 -> role:test", + ] + `); + }); +}); + +/** + * Foreign tables (`pg_class.relkind = 'f'`) must resolve at the RELATION level, + * not only at the column level. The set-based resolver's `rel` CTE skipped 'f', + * so a view depending on the foreign-table relation shape (e.g. `count(*)`) lost + * its dependency edge entirely (review P1 #3). Pinned in its own container so the + * cross-version oracle snapshot above stays untouched. + */ +describe("pg_depend resolver: foreign-table relation-level dependencies (review P1 #3)", () => { + let ftDb: TestDb; + let ftResult: ExtractResult; + + beforeAll(async () => { + ftDb = await createTestDb("depend-oracle-ft"); + // A handler-less FDW is enough: CREATE VIEW only parses/rewrites its query + // (it never invokes the FDW), so the relation-level pg_depend edge is still + // recorded — mirroring corpus/foreign-table-constraints--add-check. + await ftDb.pool.query(` + CREATE SCHEMA ftapp; + CREATE FOREIGN DATA WRAPPER ftapp_fdw; + CREATE SERVER ftapp_srv FOREIGN DATA WRAPPER ftapp_fdw; + CREATE FOREIGN TABLE ftapp.ft (id integer) SERVER ftapp_srv; + CREATE VIEW ftapp.v_count AS SELECT count(*) AS n FROM ftapp.ft; + CREATE VIEW ftapp.v_cols AS SELECT id FROM ftapp.ft; + `); + ftResult = await extract(ftDb.pool); + }, 120_000); + + afterAll(async () => { + await ftDb.drop(); + }); + + test("a view referencing only the relation shape keeps its foreignTable dependency", () => { + const depends = ftResult.factBase.edges + .filter((e) => e.kind === "depends") + .map((e) => `${encodeId(e.from)} -> ${encodeId(e.to)}`); + // count(*) references the relation, not any column: the edge must resolve to + // the foreignTable id rather than being dropped as a NULL endpoint. + expect(depends).toContain("view:ftapp.v_count -> foreignTable:ftapp.ft"); + // a column reference still resolves to the column (unchanged behaviour). + expect(depends).toContain("view:ftapp.v_cols -> column:ftapp.ft.id"); + }); +}); diff --git a/packages/pg-delta/tests/diagnostic-noise.test.ts b/packages/pg-delta/tests/diagnostic-noise.test.ts new file mode 100644 index 000000000..6160e65e8 --- /dev/null +++ b/packages/pg-delta/tests/diagnostic-noise.test.ts @@ -0,0 +1,63 @@ +/** + * Extraction must not flood the diagnostic stream with dangling edges to + * built-in (system-schema) objects — those bury real user-facing warnings + * (review P1). A schema whose views/functions reference built-ins (count(), + * upper(), pg_catalog types) should produce ZERO system dangling_edge + * diagnostics, while still emitting the real user→user dependency edges. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { extract, type ExtractResult } from "../src/extract/extract.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let db: TestDb; +let result: ExtractResult; + +beforeAll(async () => { + db = await createTestDb("diag-noise"); + await db.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t (id integer PRIMARY KEY, name text); + -- view + function leaning on built-in functions/types (pg_catalog) + CREATE VIEW app.summary AS + SELECT count(*) AS n, max(upper(name)) AS hi FROM app.t; + CREATE FUNCTION app.label(x integer) RETURNS text + LANGUAGE sql STABLE AS 'SELECT upper(''row'' || x::text)'; + `); + result = await extract(db.pool); +}, 120_000); + +afterAll(async () => { + await db.drop(); +}); + +describe("extraction diagnostic noise (P1)", () => { + test("no dangling_edge diagnostics for built-in / system-schema objects", () => { + const systemDangling = result.diagnostics.filter( + (d) => + d.code === "dangling_edge" && + /pg_catalog|information_schema|pg_toast|pg_temp/.test(d.message), + ); + expect(systemDangling).toHaveLength(0); + }); + + test("real user→user dependency edges are still emitted", () => { + const depends = result.factBase.edges.filter((e) => e.kind === "depends"); + // the view depends on the table's columns; that edge must survive + expect(depends.length).toBeGreaterThan(0); + }); + + test("no dangling_edge diagnostics for built-in (pg_*) owner roles", () => { + // The `public` schema is owned by the built-in role `pg_database_owner` + // (PG14+), which extraction never emits as a role fact — so an owner edge + // to it would always dangle. pushOwnerEdge now skips built-in owners + // (isBuiltinRoleName), killing the recurring `role:pg_database_owner` + // dangling_edge warning. + const builtinRoleDangling = result.diagnostics.filter( + (d) => + d.code === "dangling_edge" && + ((d.subject?.kind === "role" && d.subject.name.startsWith("pg_")) || + /-\[owner\]-> role:pg_/.test(d.message)), + ); + expect(builtinRoleDangling).toHaveLength(0); + }); +}); diff --git a/packages/pg-delta/tests/domain-in-use-guard.test.ts b/packages/pg-delta/tests/domain-in-use-guard.test.ts new file mode 100644 index 000000000..bb4aec975 --- /dev/null +++ b/packages/pg-delta/tests/domain-in-use-guard.test.ts @@ -0,0 +1,47 @@ +/** + * A domain's `baseType`/`collation` attributes are "replace" (drop+create), + * so any change drops and recreates the domain. A table COLUMN is not a + * rebuildable kind, so if a surviving user column depends on the domain + * PostgreSQL rejects the DROP at apply time ("cannot drop type … other + * objects depend on it"). The planner must fail LOUD at plan time instead of + * emitting a plan that crashes at apply — mirroring the in-use range-type + * guard. Full in-place column migration for domains is tracked separately. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let dbA: TestDb; +let dbB: TestDb; + +beforeAll(async () => { + dbA = await createTestDb("domain-a"); + dbB = await createTestDb("domain-b"); + // A: domain over integer, used by a surviving table column. + await dbA.pool.query(` + CREATE SCHEMA app; + CREATE DOMAIN app.d AS integer; + CREATE TABLE app.bookings (id integer PRIMARY KEY, span app.d); + `); + // B: same names, but the domain's base type changed to bigint — a + // "replace" of the domain while the app.bookings.span column still uses it. + await dbB.pool.query(` + CREATE SCHEMA app; + CREATE DOMAIN app.d AS bigint; + CREATE TABLE app.bookings (id integer PRIMARY KEY, span app.d); + `); +}, 120_000); + +afterAll(async () => { + await Promise.all([dbA.drop(), dbB.drop()]); +}); + +describe("domain replace while in use", () => { + test("throws a clear plan-time error when a surviving column depends on the domain", async () => { + const [a, b] = [await extract(dbA.pool), await extract(dbB.pool)]; + expect(() => plan(a.factBase, b.factBase)).toThrow( + /in-use domain|domain .* cannot .* replace|depend on it/i, + ); + }); +}); diff --git a/packages/pg-delta/tests/dummy-seclabel.Dockerfile b/packages/pg-delta/tests/dummy-seclabel.Dockerfile index 40e35b7a3..72165ada6 100644 --- a/packages/pg-delta/tests/dummy-seclabel.Dockerfile +++ b/packages/pg-delta/tests/dummy-seclabel.Dockerfile @@ -1,16 +1,18 @@ -# Custom test image that extends postgres:-alpine with the -# `dummy_seclabel` test contrib module installed. This module registers -# the "dummy" security label provider so that integration tests can -# exercise PostgreSQL's SECURITY LABEL statement end-to-end without -# needing SELinux or any other platform-specific provider. +# Custom test image: postgres:-alpine + the `dummy_seclabel` test +# contrib module. That module registers the "dummy" SECURITY LABEL provider, +# which stores labels VERBATIM and accepts any string — exactly what an +# end-to-end roundtrip proof needs (a real provider such as pgsodium validates +# and normalizes labels against its own grammar, so the apply→re-extract→compare +# proof would couple to that grammar). It also needs no SELinux. Used only by +# tests/security-label-proof.test.ts via tests/containers.ts::seclabelCluster. # # Build args: -# PG_MAJOR — PostgreSQL major version (15 or 17) -# PG_BRANCH — PostgreSQL git branch (e.g. REL_17_STABLE) +# PG_MAJOR — PostgreSQL major version (15 / 17 / 18) +# PG_BRANCH — matching PostgreSQL git branch (e.g. REL_17_STABLE) # ALPINE_TAG — Alpine base tag that ships postgresql-dev -# (pg15 needs alpine 3.19, pg17 uses the runtime's own 3.23) +# (pg15 → 3.19; pg17/18 → 3.23) -# Global build args (must be re-declared inside each stage to use them) +# Global build args (re-declared inside each stage that uses them) ARG PG_MAJOR=17 ARG PG_BRANCH=REL_17_STABLE ARG ALPINE_TAG=3.23 diff --git a/packages/pg-delta/tests/engine.test.ts b/packages/pg-delta/tests/engine.test.ts new file mode 100644 index 000000000..c50ba3e5c --- /dev/null +++ b/packages/pg-delta/tests/engine.test.ts @@ -0,0 +1,445 @@ +/** + * The engine suite (stage 0 + stage 3): every corpus scenario through the + * proof loop, in BOTH directions. Cluster-level scenarios (meta.isolatedCluster) + * place state A and state B on separate clusters with role cleanup. + * EXPECTED_RED pins scenarios whose engine support hasn't landed: a pinned + * test must fail; a pinned test that passes fails the suite. + */ +import { writeSync } from "node:fs"; +import os from "node:os"; +import { describe, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { encodeId } from "../src/core/stable-id.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { probeApplierCapability } from "../src/policy/capability.ts"; +import { rel } from "../src/plan/render.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { enforceSeedCoverage, runPinnedDirection } from "./seed-coverage.ts"; +import { loadCorpus, type Scenario } from "./corpus.ts"; +import { mustRunSerially } from "./corpus-scheduling.ts"; +import { + isolatedClusterPair, + sharedCluster, + type Cluster, +} from "./containers.ts"; +import { EXPECTED_RED } from "./expected-red.ts"; + +const COMPACT_MODES = [true, false] as const; +type ModeRunner = (key: string, run: () => Promise) => Promise; + +function compactLabel(compact: boolean): string { + return compact ? "compact" : "uncompact"; +} + +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function withModeContext( + key: string, + run: () => Promise, +): Promise { + try { + await run(); + } catch (error) { + if (error instanceof Error) { + if (!error.message.includes(`[${key}]`)) { + error.message = `[${key}] ${error.message}`; + } + throw error; + } + throw new Error(`[${key}] ${String(error)}`); + } +} + +async function runCompactModes( + run: (compact: boolean) => Promise, +): Promise { + const failures: unknown[] = []; + for (const compact of COMPACT_MODES) { + try { + await run(compact); + } catch (error) { + failures.push(error); + } + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, failures.map(failureMessage).join("\n")); + } +} + +async function proveOn( + name: string, + scenarioName: string, + direction: "forward" | "reverse", + compact: boolean, + clusterA: Cluster, + clusterB: Cluster, + fromSql: string, + toSql: string, + seed: string | undefined, +): Promise { + const source = await clusterA.createDb("src"); + const desired = await clusterB.createDb("dst"); + try { + await source.pool.query(fromSql); + await desired.pool.query(toSql); + if (seed) await source.pool.query(seed); + + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + // probe the applier (connection user `test`, a superuser here) so the corpus + // exercises the capability-gated compaction (Rule 2 owner-ALTER elision). + // Superuser → canSetOwner never fail-fasts, so this only adds the cosmetic + // elision; the proof still validates convergence, not SQL bytes. + const capability = await probeApplierCapability(source.pool); + const thePlan = plan(sourceState.factBase, desiredState.factBase, { + capability, + compact, + }); + + const clone = await source.clone(); + // the original source DB would block cluster-wide DROP ROLE actions + // (the role still owns its objects there); the clone is the proof target + await source.drop(); + try { + // TEMPLATE cloning skips shared-catalog state (subscriptions): presync + // the clone to the source's fact base before proving the real plan + const cloneState = await extract(clone.pool); + if (cloneState.factBase.rootHash !== sourceState.factBase.rootHash) { + const presync = plan(cloneState.factBase, sourceState.factBase); + const presyncReport = await apply(presync, clone.pool, { + fingerprintGate: false, + }); + if (presyncReport.status !== "applied") { + throw new Error( + `[${name}] clone presync failed: ${presyncReport.error?.message}`, + ); + } + } + const verdict = await provePlan( + thePlan, + clone.pool, + desiredState.factBase, + { + // corpus-only flip (P3): the library default stays opt-in. Seeding every + // empty kept table gives the data-preservation proof teeth even for + // scenarios that ship no seed.sql; the coverage contract below then + // requires every non-seed to be an EXPECTED class-23 skip. + autoSeed: true, + }, + ); + enforceSeedCoverage(scenarioName, direction, name, verdict); + if (!verdict.ok) { + const planText = thePlan.actions + .map((a, i) => ` ${i}: ${a.sql}`) + .join("\n"); + if (verdict.applyError) { + throw new Error( + `[${name}] apply failed at action ${verdict.applyError.actionIndex}: ${verdict.applyError.message}\n${planText}`, + ); + } + const drift = verdict.driftDeltas + .map((d) => + d.verb === "set" + ? ` set ${encodeId(d.id)}.${d.attr}: ${JSON.stringify(d.from)} -> ${JSON.stringify(d.to)}` + : d.verb === "add" || d.verb === "remove" + ? ` ${d.verb} ${encodeId(d.fact.id)}` + : ` ${d.verb} ${encodeId(d.edge.from)} -> ${encodeId(d.edge.to)}`, + ) + .join("\n"); + const data = verdict.dataViolations + .map( + (v) => + ` ${rel(v.table.schema, v.table.name)}: ${v.before} -> ${v.after} rows`, + ) + .join("\n"); + const rewrites = verdict.rewriteViolations + .map( + (v) => + ` ${rel(v.table.schema, v.table.name)}: relfilenode changed, no rewriteRisk declared`, + ) + .join("\n"); + throw new Error( + `[${name}] proof failed\ndrift:\n${drift}\ndata:\n${data}\nrewrites:\n${rewrites}\nplan:\n${planText}`, + ); + } + } finally { + await clone.drop(); + } + } finally { + await Promise.all([source.drop(), desired.drop()]); + } +} + +async function runDirection( + scenario: Scenario, + direction: "forward" | "reverse", + modeRunner: ModeRunner = async (_key, run) => run(), +): Promise { + const [fromSql, toSql, seed] = + direction === "forward" + ? [scenario.a, scenario.b, scenario.seed] + : [scenario.b, scenario.a, scenario.seedB]; + const label = `${scenario.name}:${direction}`; + + const proveMode = async ( + compact: boolean, + clusterA: Cluster, + clusterB: Cluster, + cleanup: () => Promise = async () => {}, + ): Promise => { + const key = `${label} [${compactLabel(compact)}]`; + let modeFailed = false; + let modeError: unknown; + try { + await modeRunner(key, () => + withModeContext(key, () => + proveOn( + key, + scenario.name, + direction, + compact, + clusterA, + clusterB, + fromSql, + toSql, + seed, + ), + ), + ); + } catch (error) { + modeFailed = true; + modeError = error; + } + + // Cleanup is intentionally outside modeRunner: EXPECTED_RED classifies + // planner/proof failures only and must never swallow a broken teardown. + let cleanupFailed = false; + let cleanupError: unknown; + try { + await withModeContext(key, async () => { + await cleanup(); + }); + } catch (error) { + cleanupFailed = true; + cleanupError = error; + } + + if (modeFailed && cleanupFailed) { + // Preserve the mode error's concrete type: SeedCoverageError must remain + // distinguishable so EXPECTED_RED can never swallow it. + if (modeError instanceof Error) { + modeError.message += `\ncleanup also failed: ${failureMessage(cleanupError)}`; + throw modeError; + } + throw new AggregateError( + [modeError, cleanupError], + `proof failed: ${failureMessage(modeError)}\ncleanup also failed: ${failureMessage(cleanupError)}`, + ); + } + if (modeFailed) throw modeError; + if (cleanupFailed) throw cleanupError; + }; + + if (scenario.meta.isolatedCluster) { + const [clusterA, clusterB] = await isolatedClusterPair(); + if (scenario.meta.minVersion !== undefined) { + if ((await clusterA.pgMajor()) < scenario.meta.minVersion) return; + } + const [baseA, baseB] = await Promise.all([ + clusterA.listRoles(), + clusterB.listRoles(), + ]); + await runCompactModes((compact) => + proveMode(compact, clusterA, clusterB, async () => { + // proveOn drops every scenario database before returning. Only then is + // it safe to reset cluster-global roles for the next mode's replay. + await Promise.all([ + clusterA.dropRolesExcept(baseA, { strict: true }), + clusterB.dropRolesExcept(baseB, { strict: true }), + ]); + }), + ); + return; + } + + const cluster = await sharedCluster(); + if (scenario.meta.minVersion !== undefined) { + if ((await cluster.pgMajor()) < scenario.meta.minVersion) return; + } + + if (mustRunSerially(scenario)) { + const baselineRoles = await cluster.listRoles(); + await runCompactModes((compact) => + proveMode(compact, cluster, cluster, async () => { + // Database teardown lives inside proveOn and therefore completes before + // role cleanup. This ordering avoids DROP ROLE ownership/grant errors. + await cluster.dropRolesExcept(baselineRoles, { strict: true }); + }), + ); + return; + } + + await runCompactModes((compact) => proveMode(compact, cluster, cluster)); +} + +async function runPinnedOrProve( + scenario: Scenario, + direction: "forward" | "reverse", +): Promise { + // Pin granularity: bare `` pins BOTH directions; `:forward` / + // `:reverse` pin only that direction (so the other may legitimately pass). + const key = + direction === "forward" + ? `${scenario.name}:forward` + : `${scenario.name}:reverse`; + const pin = EXPECTED_RED.get(key) ?? EXPECTED_RED.get(scenario.name); + let pinned = pin !== undefined; + if (pinned && pin?.minMajor !== undefined) { + // Version-gated pin: on older majors the server behavior that makes the + // scenario red doesn't exist, so it runs normally and must pass. + const cluster = await sharedCluster(); + if ((await cluster.pgMajor()) < pin.minMajor) pinned = false; + } + const modeRunner = pinned + ? (modeKey: string, run: () => Promise) => + // runPinnedDirection owns the pinned semantics (incl. the seed-coverage + // rethrow), so the guard is bound by seed-coverage.test.ts. Applying it + // per mode ensures both artifacts run and each stale pin is detected. + runPinnedDirection(modeKey, run) + : undefined; + await runDirection(scenario, direction, modeRunner); +} + +// Live progress (opt-in via PGDELTA_NEXT_PROGRESS=1). `bun test` buffers its +// own reporter when stdout is a pipe (background / CI), so a piped corpus run +// shows nothing until it finishes. A raw write to fd 2 bypasses that capture and +// streams a `[done/total]` line per scenario as it completes. Off by default so +// an interactive TTY run keeps bun's native reporter clean. +const CORPUS = loadCorpus(); +const CORPUS_TOTAL = CORPUS.length * 2; +const SHOW_PROGRESS = /^(1|true)$/i.test( + process.env["PGDELTA_NEXT_PROGRESS"] ?? "", +); +let corpusDone = 0; +function corpusProgress(label: string, ok: boolean): void { + corpusDone++; + if (!SHOW_PROGRESS) return; + const pct = Math.round((corpusDone / CORPUS_TOTAL) * 100); + const img = process.env["PGDELTA_TEST_IMAGE"] ?? "default"; + writeSync( + 2, + `corpus ${img} [${corpusDone}/${CORPUS_TOTAL} ${pct}%] ${ok ? "PASS" : "FAIL"} ${label}\n`, + ); +} + +// Bounded-concurrency fast path (opt-in via PGDELTA_NEXT_CONCURRENCY=K). Default +// (unset / 1) keeps the per-case serial tests below — unchanged for CI, clean +// reporting, and EXPECTED_RED granularity. With K>1, a single driver runs the +// SHARED-cluster cases through a pool of K (they use independent databases on +// one cluster, so they're safe to run concurrently — `max_connections=300` is +// provisioned for exactly this), while `isolatedCluster` cases run SERIALLY +// (they mutate cluster-level role state and would corrupt each other's role +// snapshots). K is capped to CPU cores so the host / PostgreSQL container is not +// oversubscribed — the failure mode that inflates wall-time. The corpus is +// I/O-bound on Postgres, so this trades the runner's serial dispatch for the +// container's real concurrency ceiling. +const CONCURRENCY = Math.min( + Math.max( + 1, + Math.floor(Number(process.env["PGDELTA_NEXT_CONCURRENCY"] ?? "1")) || 1, + ), + os.availableParallelism?.() ?? os.cpus().length, +); + +interface Case { + scenario: Scenario; + direction: "forward" | "reverse"; + label: string; +} + +const ALL_CASES: Case[] = CORPUS.flatMap((scenario) => [ + { scenario, direction: "forward", label: `${scenario.name} (a->b)` }, + { scenario, direction: "reverse", label: `${scenario.name} (b->a)` }, +]); + +// Roles, role memberships, and other cluster-level objects are GLOBAL on the +// shared cluster — they are NOT confined to a scenario's per-case databases. +// Scenarios that touch them (CREATE/DROP/ALTER ROLE/USER/GROUP) reuse role +// names across cases and rely on serial execution; running two concurrently +// collides ("role already exists", "duplicate key pg_authid", "cannot be +// dropped"). Such cases (plus the explicitly cluster-level isolatedCluster ones) +// run SERIALLY; only genuinely DB-local scenarios go in the concurrent pool. +if (CONCURRENCY > 1) { + describe("engine: corpus proof loop (concurrent)", () => { + test(`all ${CORPUS_TOTAL} cases (concurrency=${CONCURRENCY})`, async () => { + const failures: string[] = []; + const runOne = async (c: Case): Promise => { + let ok = false; + try { + await runPinnedOrProve(c.scenario, c.direction); + ok = true; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + failures.push(c.label); + // full detail to stderr so a concurrent failure isn't lost in the + // single driver test's aggregated output + writeSync(2, `\nFAIL ${c.label}: ${msg}\n`); + } finally { + corpusProgress(c.label, ok); + } + }; + + // DB-local cases: bounded pool of K workers pulling from a shared + // cursor (single-threaded JS → `index++` needs no lock) + const concurrent = ALL_CASES.filter((c) => !mustRunSerially(c.scenario)); + let cursor = 0; + await Promise.all( + Array.from({ length: Math.min(CONCURRENCY, concurrent.length) }, () => + (async () => { + for (let i = cursor++; i < concurrent.length; i = cursor++) { + await runOne(concurrent[i] as Case); + } + })(), + ), + ); + + // cluster-level cases (roles/memberships, isolatedCluster): serial + for (const c of ALL_CASES.filter((c) => mustRunSerially(c.scenario))) { + await runOne(c); + } + + if (failures.length > 0) { + throw new Error( + `${failures.length}/${CORPUS_TOTAL} corpus cases failed (details above):\n` + + failures.map((l) => ` ${l}`).join("\n"), + ); + } + }, 1_800_000); + }); +} else { + describe("engine: corpus proof loop", () => { + for (const c of ALL_CASES) { + test( + c.direction === "forward" + ? `${c.scenario.name} (a -> b)` + : `${c.scenario.name} (b -> a, teardown direction)`, + async () => { + let ok = false; + try { + await runPinnedOrProve(c.scenario, c.direction); + ok = true; + } finally { + corpusProgress(c.label, ok); + } + }, + 360_000, + ); + } + }); +} diff --git a/packages/pg-delta/tests/enum-rebuild-nonchild-dependents-guard.test.ts b/packages/pg-delta/tests/enum-rebuild-nonchild-dependents-guard.test.ts new file mode 100644 index 000000000..e348fffc9 --- /dev/null +++ b/packages/pg-delta/tests/enum-rebuild-nonchild-dependents-guard.test.ts @@ -0,0 +1,79 @@ +/** + * Removing or reordering enum values rebuilds the type: rename the old enum + * aside, create the new value set, migrate COLUMN dependents through a text + * cast, then DROP the renamed old type. Only column dependents are migrated — + * a DOMAIN over the enum, a COMPOSITE attribute using it, or a RANGE over it is + * NOT rebuildable and stays bound to the renamed old type, so the final + * DROP TYPE fails at apply. The planner must fail LOUD at plan time instead of + * emitting a plan that crashes at apply — mirroring the in-use domain / range / + * composite ALTER ATTRIBUTE guards. Full migration of such dependents is + * tracked separately. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let domainA: TestDb; +let domainB: TestDb; +let compositeA: TestDb; +let compositeB: TestDb; + +beforeAll(async () => { + domainA = await createTestDb("enum-dom-a"); + domainB = await createTestDb("enum-dom-b"); + compositeA = await createTestDb("enum-comp-a"); + compositeB = await createTestDb("enum-comp-b"); + + // domain over the enum, surviving on both sides; the enum drops a value. + await domainA.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.color AS ENUM ('r', 'g', 'b'); + CREATE DOMAIN app.cd AS app.color; + `); + await domainB.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.color AS ENUM ('r', 'g'); + CREATE DOMAIN app.cd AS app.color; + `); + + // composite attribute of the enum, surviving on both sides. + await compositeA.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.color AS ENUM ('r', 'g', 'b'); + CREATE TYPE app.ct AS (c app.color, note text); + `); + await compositeB.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.color AS ENUM ('r', 'g'); + CREATE TYPE app.ct AS (c app.color, note text); + `); +}, 120_000); + +afterAll(async () => { + await Promise.all([ + domainA.drop(), + domainB.drop(), + compositeA.drop(), + compositeB.drop(), + ]); +}); + +describe("enum value-set rebuild with non-column dependents", () => { + test("throws a clear plan-time error when a surviving DOMAIN depends on the enum", async () => { + const [a, b] = [await extract(domainA.pool), await extract(domainB.pool)]; + expect(() => plan(a.factBase, b.factBase)).toThrow( + /depend on it|non-column|DOMAIN|cannot .* enum/i, + ); + }); + + test("throws a clear plan-time error when a surviving COMPOSITE attribute depends on the enum", async () => { + const [a, b] = [ + await extract(compositeA.pool), + await extract(compositeB.pool), + ]; + expect(() => plan(a.factBase, b.factBase)).toThrow( + /depend on it|non-column|COMPOSITE|TYPE|cannot .* enum/i, + ); + }); +}); diff --git a/packages/pg-delta/tests/enum-rebuild-tempname-collision.test.ts b/packages/pg-delta/tests/enum-rebuild-tempname-collision.test.ts new file mode 100644 index 000000000..87c2453e1 --- /dev/null +++ b/packages/pg-delta/tests/enum-rebuild-tempname-collision.test.ts @@ -0,0 +1,99 @@ +/** + * Enum value-set rebuild — the temp name the old enum is RENAMEd aside to must + * be namespace- AND length-safe. + * + * Removing an enum value rebuilds the type: `ALTER TYPE e RENAME TO + * e__pgdelta_replaced`, create the new value set, migrate column dependents, + * DROP the renamed old type. The temp name must collide with NO occupant of the + * type namespace (pg_type): + * + * 1. Before the fix the collision check consulted only managed `type` facts, + * so a TABLE named `__pgdelta_replaced` (whose implicit row type + * occupies pg_type) slipped through and the initial RENAME failed at apply + * with "type … already exists". + * 2. PostgreSQL clips identifiers to 63 BYTES, so a long enum name + suffix + * was truncated by the server and could land back on the ORIGINAL name (a + * 63-byte enum whose temp truncates to itself → RENAME to itself). The temp + * name is now clipped to ≤ 63 bytes ourselves so it is stored verbatim. + * + * Each case proves end-to-end: the plan APPLIES to a clone of the source and + * converges on the desired state (before the fix apply crashes on the RENAME). + * + * Stock alpine image; Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { sharedCluster } from "./containers.ts"; + +async function proveConverges( + srcSql: string, + desiredSql: string, +): Promise<{ ok: boolean; detail: string }> { + const cluster = await sharedCluster(); + const src = await cluster.createDb("enum_tmp_src"); + const desired = await cluster.createDb("enum_tmp_dst"); + try { + await src.pool.query(srcSql); + await desired.pool.query(desiredSql); + const [srcState, desiredState] = [ + await extract(src.pool), + await extract(desired.pool), + ]; + const thePlan = plan(srcState.factBase, desiredState.factBase); + const clone = await src.clone(); + try { + const verdict = await provePlan( + thePlan, + clone.pool, + desiredState.factBase, + ); + const detail = verdict.applyError + ? `apply failed at action ${verdict.applyError.actionIndex}: ${verdict.applyError.message}` + : `drift ${verdict.driftDeltas.length}`; + return { ok: verdict.ok, detail }; + } finally { + await clone.drop(); + } + } finally { + await Promise.all([src.drop(), desired.drop()]); + } +} + +describe("enum value-set rebuild temp-name safety", () => { + test("avoids a TABLE occupying the temp name (namespace collision)", async () => { + // a table named exactly `__pgdelta_replaced` reserves that name in + // pg_type via its implicit row type; the rebuild must step past it. + const src = ` + CREATE SCHEMA app; + CREATE TYPE app.status AS ENUM ('a', 'b', 'c'); + CREATE TABLE app.thing (s app.status); + CREATE TABLE app."status__pgdelta_replaced" (x int); + `; + const desired = ` + CREATE SCHEMA app; + CREATE TYPE app.status AS ENUM ('a', 'b'); + CREATE TABLE app.thing (s app.status); + CREATE TABLE app."status__pgdelta_replaced" (x int); + `; + const { ok, detail } = await proveConverges(src, desired); + expect(ok, detail).toBe(true); + }, 120_000); + + test("produces a length-safe temp name for a 63-byte enum", async () => { + const longName = "e".repeat(63); // max identifier length (63 bytes) + const src = ` + CREATE SCHEMA app; + CREATE TYPE app."${longName}" AS ENUM ('a', 'b', 'c'); + CREATE TABLE app.thing (s app."${longName}"); + `; + const desired = ` + CREATE SCHEMA app; + CREATE TYPE app."${longName}" AS ENUM ('a', 'b'); + CREATE TABLE app.thing (s app."${longName}"); + `; + const { ok, detail } = await proveConverges(src, desired); + expect(ok, detail).toBe(true); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/example-usage.test.ts b/packages/pg-delta/tests/example-usage.test.ts deleted file mode 100644 index df34e2d09..000000000 --- a/packages/pg-delta/tests/example-usage.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Example usage of the three different test utilities - */ - -import { describe, test } from "bun:test"; -import { sql } from "@ts-safeql/sql-tag"; -import { POSTGRES_VERSIONS, SUPABASE_POSTGRES_VERSIONS } from "./constants.ts"; -import { withDb, withDbIsolated, withDbSupabaseIsolated } from "./utils.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe.skip(`test utilities demo (pg${pgVersion})`, () => { - test( - "fast pooled test - uses shared Alpine containers with database isolation", - withDb(pgVersion, async (db) => { - // This is the fastest option - uses a pool of Alpine PostgreSQL containers - // and creates/drops databases for isolation instead of creating new containers - await db.main.query( - sql`CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)`, - ); - await db.main.query(sql`INSERT INTO test_table (name) VALUES ('test')`); - - // Just a simple test to verify the setup works - }), - ); - - test( - "isolated test - creates fresh Alpine containers for each database", - withDbIsolated(pgVersion, async (db) => { - // This creates brand new Alpine PostgreSQL containers for complete isolation - // Slower than pooled but faster than Supabase containers - await db.main.query( - sql`CREATE TABLE isolated_table (id SERIAL PRIMARY KEY, data TEXT)`, - ); - await db.main.query( - sql`INSERT INTO isolated_table (data) VALUES ('isolated')`, - ); - - // Just a simple test to verify the setup works - }), - ); - }); -} - -for (const pgVersion of SUPABASE_POSTGRES_VERSIONS) { - describe.skip(`supabase test utility demo (pg${pgVersion})`, () => { - test( - "supabase test - for tests requiring Supabase features with full isolation between databases", - withDbSupabaseIsolated(pgVersion, async (db) => { - // This uses Supabase PostgreSQL containers with all extensions - // Slowest but has all Supabase-specific functionality - await db.main.query( - sql`CREATE TABLE supabase_table (id SERIAL PRIMARY KEY, content TEXT)`, - ); - await db.main.query( - sql`INSERT INTO supabase_table (content) VALUES ('supabase')`, - ); - - // Just a simple test to verify the setup works - }), - ); - }); -} diff --git a/packages/pg-delta/tests/execution.test.ts b/packages/pg-delta/tests/execution.test.ts new file mode 100644 index 000000000..8ed8a650b --- /dev/null +++ b/packages/pg-delta/tests/execution.test.ts @@ -0,0 +1,289 @@ +/** + * Stage 6 integration suite: segmented execution, mid-plan failure + * reporting, the fingerprint gate, artifact round-trip through a real + * apply, CREATE INDEX CONCURRENTLY, and render-from-fact-base + * materialization. + */ +import { describe, expect, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { extract } from "../src/extract/extract.ts"; +import { parsePlan, serializePlan } from "../src/plan/artifact.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { sharedCluster } from "./containers.ts"; + +describe("stage 6: execution", () => { + test("mid-plan failure reports applied/unapplied per action", async () => { + const cluster = await sharedCluster(); + const db = await cluster.createDb("exec_fail"); + try { + // build a plan, then sabotage a mid-plan action so a later segment + // boundary has already committed segment 1 + const desired = await cluster.createDb("exec_fail_desired"); + try { + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.status AS ENUM ('a'); + CREATE TABLE app.t (id integer); + `); + const [sourceState, desiredState] = [ + await extract(db.pool), + await extract(desired.pool), + ]; + const thePlan = plan(sourceState.factBase, desiredState.factBase); + // sabotage the LAST action; everything before it is one segment + const last = thePlan.actions.length - 1; + thePlan.actions[last]!.sql = "SELECT 1/0"; + const report = await apply(thePlan, db.pool, { + fingerprintGate: false, + }); + expect(report.status).toBe("failed"); + expect(report.error?.actionIndex).toBe(last); + // single transactional segment → everything rolled back + expect(report.actionStatuses.every((s) => s === "unapplied")).toBe( + true, + ); + expect(report.appliedActions).toBe(0); + } finally { + await desired.drop(); + } + } finally { + await db.drop(); + } + }, 60_000); + + test("a failure after a committed segment leaves earlier actions applied", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("exec_seg_src"); + const desired = await cluster.createDb("exec_seg_dst"); + try { + await source.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.status AS ENUM ('a'); + CREATE TABLE app.t (s app.status); + `); + // adding a value AND a view that uses it forces a commit boundary + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.status AS ENUM ('a', 'b'); + CREATE TABLE app.t (s app.status); + CREATE VIEW app.v AS SELECT s FROM app.t WHERE s = 'b'; + `); + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + const thePlan = plan(sourceState.factBase, desiredState.factBase); + const boundaryPos = thePlan.actions.findIndex((a) => a.newSegmentBefore); + expect(boundaryPos).toBeGreaterThan(0); + // sabotage an action AFTER the boundary: segment 1 must stay applied + thePlan.actions[boundaryPos]!.sql = "SELECT 1/0"; + const report = await apply(thePlan, source.pool, { + fingerprintGate: false, + }); + expect(report.status).toBe("failed"); + expect( + report.actionStatuses + .slice(0, boundaryPos) + .every((s) => s === "applied"), + ).toBe(true); + expect( + report.actionStatuses + .slice(boundaryPos) + .every((s) => s === "unapplied"), + ).toBe(true); + expect(report.appliedActions).toBe(boundaryPos); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); + + test("fingerprint gate refuses a stale plan", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("exec_gate_src"); + const desired = await cluster.createDb("exec_gate_dst"); + try { + await desired.pool.query(`CREATE SCHEMA app`); + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + const thePlan = plan(sourceState.factBase, desiredState.factBase); + // mutate the target AFTER planning: the gate must refuse + await source.pool.query(`CREATE SCHEMA sneaky`); + expect(apply(thePlan, source.pool)).rejects.toThrow( + /fingerprint gate failed/, + ); + // un-mutate; the gate passes and the plan applies + await source.pool.query(`DROP SCHEMA sneaky`); + const report = await apply(thePlan, source.pool); + expect(report.status).toBe("applied"); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); + + test("plans survive artifact serialization and apply from the parsed form", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("exec_art_src"); + const desired = await cluster.createDb("exec_art_dst"); + try { + await desired.pool.query(` + CREATE SCHEMA app; + CREATE SEQUENCE app.seq START 42; + CREATE TABLE app.t (id bigint DEFAULT nextval('app.seq')); + `); + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + const thePlan = plan(sourceState.factBase, desiredState.factBase); + const reparsed = parsePlan(serializePlan(thePlan)); + expect(reparsed).toEqual(thePlan); + const verdict = await provePlan( + reparsed, + source.pool, + desiredState.factBase, + ); + expect(verdict.ok).toBe(true); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); + + test("concurrentIndexes param emits CREATE INDEX CONCURRENTLY outside transactions", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("exec_cic_src"); + const desired = await cluster.createDb("exec_cic_dst"); + try { + await source.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t (id integer, x text); + INSERT INTO app.t SELECT i, i::text FROM generate_series(1, 50) i; + `); + await desired.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t (id integer, x text); + CREATE INDEX t_x_idx ON app.t (x); + `); + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + const thePlan = plan(sourceState.factBase, desiredState.factBase, { + params: { concurrentIndexes: true }, + }); + const indexAction = thePlan.actions.find((a) => + a.sql.includes("INDEX CONCURRENTLY"), + ); + expect(indexAction?.transactionality).toBe("nonTransactional"); + expect(indexAction?.lockClass).toBe("shareUpdateExclusive"); + const report = await apply(thePlan, source.pool, { + fingerprintGate: false, + }); + expect(report.status).toBe("applied"); + // the state converges to the SAME fact base as a plain CREATE INDEX + const proven = await extract(source.pool); + expect(proven.factBase.rootHash).toBe(desiredState.factBase.rootHash); + // and the seeded rows survived + const rows = await source.pool.query( + `SELECT count(*)::int AS n FROM app.t`, + ); + expect((rows.rows[0] as { n: number }).n).toBe(50); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); + + test("concurrentIndexes leaves a partitioned parent index non-concurrent (PG rejects CONCURRENTLY there)", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("exec_cic_part_src"); + const desired = await cluster.createDb("exec_cic_part_dst"); + try { + const seed = ` + CREATE SCHEMA app; + CREATE TABLE app.p (id integer, ts date) PARTITION BY RANGE (ts); + CREATE TABLE app.p_2024 PARTITION OF app.p + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + INSERT INTO app.p + SELECT i, DATE '2024-01-01' + i FROM generate_series(1, 50) i; + `; + await source.pool.query(seed); + await desired.pool.query(`${seed} + CREATE INDEX p_ts_idx ON app.p (ts); + `); + const [sourceState, desiredState] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + const thePlan = plan(sourceState.factBase, desiredState.factBase, { + params: { concurrentIndexes: true }, + }); + // PostgreSQL rejects CREATE INDEX CONCURRENTLY on a partitioned table's + // parent index (relkind='p'), so that create must stay plain. + const parentIndexAction = thePlan.actions.find((a) => + a.sql.includes("p_ts_idx"), + ); + expect(parentIndexAction?.sql).not.toContain("CONCURRENTLY"); + const report = await apply(thePlan, source.pool, { + fingerprintGate: false, + }); + expect(report.status).toBe("applied"); + // converges to the same fact base as the direct CREATE INDEX + const proven = await extract(source.pool); + expect(proven.factBase.rootHash).toBe(desiredState.factBase.rootHash); + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + }, 60_000); + + test("unknown serialize parameters are a plan-time error", async () => { + const cluster = await sharedCluster(); + const db = await cluster.createDb("exec_param"); + try { + const state = await extract(db.pool); + expect(() => + plan(state.factBase, state.factBase, { params: { typo: true } }), + ).toThrow(/unknown serialize parameter 'typo'/); + } finally { + await db.drop(); + } + }, 60_000); + + test("render-from-fact-base materialization: extract -> plan from scratch -> hash-identical", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("exec_mat_src"); + const scratch = await cluster.createDb("exec_mat_scratch"); + try { + await source.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.level AS ENUM ('low', 'high'); + CREATE TABLE app.t ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + lvl app.level DEFAULT 'low', + note text + ); + CREATE INDEX t_note_idx ON app.t (note); + CREATE VIEW app.v AS SELECT id, lvl FROM app.t; + COMMENT ON TABLE app.t IS 'materialization target'; + `); + const sourceState = await extract(source.pool); + // the §3.7 second materialization form: re-create the MODEL of the + // source on an empty scratch (template cloning unavailable on live + // sources). "Empty" includes the platform defaults the scratch + // already carries, so the plan starts from the scratch's own state. + const scratchState = await extract(scratch.pool); + const thePlan = plan(scratchState.factBase, sourceState.factBase); + const report = await apply(thePlan, scratch.pool, { + fingerprintGate: false, + }); + expect(report.status).toBe("applied"); + const materialized = await extract(scratch.pool); + expect(materialized.factBase.rootHash).toBe( + sourceState.factBase.rootHash, + ); + } finally { + await Promise.all([source.drop(), scratch.drop()]); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/expected-red.ts b/packages/pg-delta/tests/expected-red.ts new file mode 100644 index 000000000..83437f976 --- /dev/null +++ b/packages/pg-delta/tests/expected-red.ts @@ -0,0 +1,30 @@ +/** + * The EXPECTED_RED ledger (stage 0): scenarios whose engine support has not + * landed yet. A listed test MUST fail (red = engine missing, pinned); an + * accidentally-green listed test fails the suite so flipping an entry is + * always a deliberate one-line diff. + * + * Keys are scenario directory names; a `:reverse` suffix pins only the + * teardown direction. A pin with `minMajor` applies only on PostgreSQL + * majors >= that version — on older majors the scenario runs normally and + * must PASS (version-dependent server behavior, e.g. PG16 dependent-privilege + * tracking). + */ +export interface RedPin { + /** Pin applies only when the server major is >= this (absent = all). */ + minMajor?: number; +} + +export const EXPECTED_RED: ReadonlyMap = new Map< + string, + RedPin +>([ + // F3: the membership drop now emits a plain REVOKE (no CASCADE). Tearing down + // a multi-grantor membership where the removed grant has a dependent onward + // grant fails LOUDLY on PG16+ ("dependent privileges exist") instead of + // silently CASCADE-destroying the kept grant. Convergent regrant that would + // let this teardown converge is tracked separately (#333); until it lands the + // reverse (teardown) direction is expected-red ON PG16+ ONLY — pre-16 has no + // dependent tracking, plain REVOKE succeeds, and the scenario passes. + ["role-membership-dedup--multi-grantor:reverse", { minMajor: 16 }], +]); diff --git a/packages/pg-delta/tests/export-constraint-on-deferred-column.test.ts b/packages/pg-delta/tests/export-constraint-on-deferred-column.test.ts new file mode 100644 index 000000000..76bae2568 --- /dev/null +++ b/packages/pg-delta/tests/export-constraint-on-deferred-column.test.ts @@ -0,0 +1,84 @@ +/** + * Export constraint-fold guard: a table UNIQUE/PK/CHECK constraint must NOT be + * folded inline into `CREATE TABLE` when its own same-table column was deferred + * out of the CREATE into a later `ALTER TABLE … ADD COLUMN` statement. + * + * Two deferral causes are exercised at once: + * - `slug s.slug_text` — a domain-typed column. Its ADD COLUMN depends on the + * domain CREATE, an edge that crosses the table CREATE, so the column fold is + * rejected and the column is deferred. + * - `slug_key … GENERATED ALWAYS AS (lower(slug))` — a generated column, which + * never gets a fold hint (see src/plan/rules/tables.ts). + * + * Before the fix `compactColumnFolds` folded the two UNIQUE constraints inline + * (`!isConstraintFold` bypassed the crossing guard), so the exported CREATE TABLE + * referenced `slug` / `slug_key` that were not yet columns, and the reload failed + * with `column "slug" named in key does not exist`. + * + * After the fix the constraints render as standalone `ALTER TABLE … ADD + * CONSTRAINT … UNIQUE (…)`, the export reloads, and the shadow re-extract + * hash-matches the source. + * + * Stock alpine image; Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { createTestDb } from "./containers.ts"; + +const SCHEMA_SQL = ` + CREATE SCHEMA s; + CREATE DOMAIN s.slug_text AS text CHECK (length(VALUE) > 0); + CREATE TABLE s.organizations ( + id uuid PRIMARY KEY, + slug s.slug_text NOT NULL, + slug_key text GENERATED ALWAYS AS (lower(slug::text)) STORED, + CONSTRAINT organizations_slug_key UNIQUE (slug), + CONSTRAINT organizations_slug_key_key UNIQUE (slug_key) + ); +`; + +function forLoad(files: { name: string; sql: string }[]) { + // roles are cluster-global and already present in the shared cluster. + return files.filter((f) => !/cluster[_/]roles/.test(f.name)); +} + +describe("export: key constraint on a deferred column", () => { + test("UNIQUE constraints on domain/generated columns reload", async () => { + const src = await createTestDb("keycon_src"); + const shadow = await createTestDb("keycon_shadow"); + try { + await src.pool.query(SCHEMA_SQL); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb, { layout: "by-object" })); + + // the exported organizations table must render its UNIQUE constraints as + // standalone ALTER TABLE … ADD CONSTRAINT — not inline in the CREATE. + const tableSql = files + .filter((f) => /organizations/.test(f.sql)) + .map((f) => f.sql) + .join("\n"); + expect(tableSql).toMatchInlineSnapshot(` + "CREATE TABLE "s"."organizations" ("id" uuid NOT NULL, CONSTRAINT "organizations_pkey" PRIMARY KEY (id)); + + ALTER TABLE "s"."organizations" OWNER TO "test"; + + ALTER TABLE "s"."organizations" ADD COLUMN "slug" s.slug_text NOT NULL; + + ALTER TABLE "s"."organizations" ADD COLUMN "slug_key" text GENERATED ALWAYS AS (lower((slug)::text)) STORED; + + ALTER TABLE "s"."organizations" ADD CONSTRAINT "organizations_slug_key_key" UNIQUE (slug_key); + + ALTER TABLE "s"."organizations" ADD CONSTRAINT "organizations_slug_key" UNIQUE (slug); + " + `); + + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/export-extension-member-parent.test.ts b/packages/pg-delta/tests/export-extension-member-parent.test.ts new file mode 100644 index 000000000..e9b5b71b3 --- /dev/null +++ b/packages/pg-delta/tests/export-extension-member-parent.test.ts @@ -0,0 +1,87 @@ +/** + * `schema export` must not crash when an extension is installed into a + * non-`public` schema (e.g. pg_partman → `partman`, or hstore → `ext` here). + * + * The extension's member objects (the `hstore` type, its functions/operators) + * are marked REFERENCE-ONLY by `resolveView` on every profile, but their parent + * — the install schema — is a normal MANAGED fact. The export baseline seeded + * every reference-only fact without its ancestors, so `buildFactBase(pristine)` + * found a member with a missing parent schema and threw before writing any file + * (Codex #3537607461): + * + * FactBase: fact type:ext.hstore references missing parent schema:ext + * + * The fix excludes extension members from the pristine baseline (they never need + * seeding — `CREATE EXTENSION` materializes them and the requirement guard's + * `memberExtensionPresent` satisfies any consumer). The managed install schema + * must STILL be exported (reload fidelity: `CREATE EXTENSION … WITH SCHEMA ext` + * requires the schema to exist first). + * + * Docker required. Stock alpine — hstore is a relocatable contrib extension that + * ships in the base `postgres:*-alpine` image (see extension-relocatable.test.ts). + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { resolveView } from "../src/policy/policy.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("export: extension member under a managed (non-public) schema", () => { + test("exports the managed install schema + CREATE EXTENSION, not the members", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("export_ext_member"); + dbs.push(src); + + await src.pool.query(` + CREATE SCHEMA ext; + CREATE EXTENSION hstore WITH SCHEMA ext; + `); + + const { factBase } = await extract(src.pool); + // raw profile: extension members are still projected reference-only, and + // their parent `ext` schema is managed → the missing-parent crash. + const view = resolveView(factBase, undefined); + + // RED before the fix: throws + // "FactBase: fact type:ext.hstore references missing parent schema:ext" + const files = exportSqlFiles(view); + const sql = files.map((f) => f.sql).join("\n"); + + // the MANAGED install schema is exported (not suppressed) ... + expect(sql).toMatch(/CREATE SCHEMA[^\n]*ext/i); + // ... the extension is created into it ... + expect(sql).toMatch(/CREATE EXTENSION[^\n]*hstore/i); + // ... and its members are NOT recreated as standalone DDL. + expect(sql).not.toMatch(/CREATE TYPE/i); + }, 60_000); + + test("a user comment on an extension member still exports", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("export_ext_member_satellite"); + dbs.push(src); + + await src.pool.query(` + CREATE SCHEMA ext; + CREATE EXTENSION hstore WITH SCHEMA ext; + -- comment on a MEMBER FUNCTION (a modeled member; the hstore type itself + -- is an unmodeled base type, so it carries no fact to comment on). + COMMENT ON FUNCTION ext.akeys(ext.hstore) IS 'user note on an extension member'; + `); + + const { factBase } = await extract(src.pool); + const view = resolveView(factBase, undefined); + + const files = exportSqlFiles(view); + const sql = files.map((f) => f.sql).join("\n"); + + // the member satellite (a user COMMENT on an extension member function) + // exports; its requirement on the reference-only member is satisfied by the + // CREATE EXTENSION the export emits (memberExtensionPresent). + expect(sql).toMatch(/COMMENT ON FUNCTION[^\n]*akeys[^\n]*user note/i); + }, 60_000); +}); diff --git a/packages/pg-delta/tests/export-fidelity.test.ts b/packages/pg-delta/tests/export-fidelity.test.ts new file mode 100644 index 000000000..7e5ec22ae --- /dev/null +++ b/packages/pg-delta/tests/export-fidelity.test.ts @@ -0,0 +1,324 @@ +/** + * Declarative-export ROUND-TRIP FIDELITY across all three layouts. + * + * The contract `load(export(fb)) ≡ fb` must hold for `by-object`, `ordered`, + * AND `grouped` — export is only trustworthy as a source of truth if every + * advertised layout reloads to the identical fact base. `export-format.test.ts` + * already gates the formatter on a simple schema; this file gates two shapes + * surfaced while dogfooding a real DB: + * + * 1. **Cross-schema mutual FKs** (the bug this stage fixes) — before the FK + * split, `by-object`/`grouped` filed each table's `ALTER TABLE … ADD + * CONSTRAINT FOREIGN KEY` into the table's own file, so two tables with + * mutual FKs landed in two files that each failed atomically (each + * references a table the other file creates) → the loader got stuck. + * Routing FK constraints into a sibling `
.fk.sql` fixes it. + * 2. **ALTER DEFAULT PRIVILEGES order-independence** (a pin, not a fix) — a + * table created BEFORE an ADP change has a different ACL than one created + * after. This round-trips regardless of the order the reload replays the ADP + * statement, because the exporter emits EXPLICIT per-object REVOKE/GRANT for + * every object (an already-enforced invariant: `plan/internal.ts` keeps + * those groups load-bearing whenever an ADP customizes an objtype). This + * case guards that invariant against regression across all three layouts. + * + * Uses PUBLIC (a pseudo-role always present, never emitted as CREATE ROLE) for + * the ADP grant so the fixture needs no cluster-scoped role and the reload can + * run in a fresh database of the shared cluster (like export-format.test.ts, + * cluster/roles* files are filtered out). + * + * Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { + exportSqlFiles, + type ExportOptions, +} from "../src/frontends/export-sql-files.ts"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { sharedCluster } from "./containers.ts"; + +const LAYOUTS: NonNullable[] = [ + "by-object", + "ordered", + "grouped", +]; + +// Two schemas, a mutual FK across them, and a comment on one FK constraint. +const MUTUAL_FK_SQL = ` + CREATE SCHEMA a; + CREATE SCHEMA b; + CREATE TABLE a.orders (id integer PRIMARY KEY, customer_id integer); + CREATE TABLE b.customers (id integer PRIMARY KEY, last_order_id integer); + ALTER TABLE a.orders + ADD CONSTRAINT fk_cust FOREIGN KEY (customer_id) REFERENCES b.customers (id); + ALTER TABLE b.customers + ADD CONSTRAINT fk_order FOREIGN KEY (last_order_id) REFERENCES a.orders (id); + COMMENT ON CONSTRAINT fk_cust ON a.orders IS 'links to customer'; +`; + +// t1 is created BEFORE the ADP change, t2 AFTER — so t2 carries an explicit +// SELECT-to-PUBLIC ACL and t1 does not. Replaying the ADP first would wrongly +// grant t1 too. +const ADP_ASYMMETRY_SQL = ` + CREATE SCHEMA c; + CREATE TABLE c.t1 (id integer); + ALTER DEFAULT PRIVILEGES IN SCHEMA c GRANT SELECT ON TABLES TO PUBLIC; + CREATE TABLE c.t2 (id integer); +`; + +// ADP order-independence must hold across OBJTYPES, not just tables, and in the +// restrictive direction (REVOKE of a built-in default). Sequences: s1 (pre-ADP) +// has no PUBLIC usage, s2 (post-ADP) does — an additive grant. Functions: +// EXECUTE is granted to PUBLIC by DEFAULT, so f1 (pre-ADP) keeps it and f2 +// (post-ADP) has it revoked — the restrictive direction that exercises the +// empty-PUBLIC-entry synthesis in the ACL extractor. Both must round-trip on +// every layout with no code change (the explicit per-object grants the exporter +// emits are load-bearing regardless of when the ADP statement replays). +const ADP_OBJTYPES_SQL = ` + CREATE SCHEMA d; + CREATE SEQUENCE d.s1; + ALTER DEFAULT PRIVILEGES IN SCHEMA d GRANT USAGE ON SEQUENCES TO PUBLIC; + CREATE SEQUENCE d.s2; + CREATE FUNCTION d.f1() RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT 1'; + ALTER DEFAULT PRIVILEGES IN SCHEMA d REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; + CREATE FUNCTION d.f2() RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT 2'; +`; + +// Regression pin for the scratchpad handoff's "Finding 4" (suspected extractor +// gaps, unconfirmed while the load never completed): table autovacuum reloptions +// and non-owner function GRANTs. Now that export round-trips, confirm they are +// captured, rendered, AND reload identically. `pg_read_all_data` is a built-in +// predefined role present on every PG14+ cluster (never emitted as CREATE ROLE), +// so the non-owner grant needs no fixture role and pollutes no shared cluster. +const RELOPTIONS_AND_GRANTS_SQL = ` + CREATE SCHEMA e; + CREATE TABLE e.t (id integer) + WITH (autovacuum_vacuum_scale_factor = 0.2, fillfactor = 70); + CREATE FUNCTION e.f() RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT 1'; + REVOKE ALL ON FUNCTION e.f() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION e.f() TO pg_read_all_data; +`; + +function forLoad(files: { name: string; sql: string }[]) { + // roles are cluster-global and already present in the shared cluster; drop + // the CREATE ROLE file exactly as export-format.test.ts does. The `ordered` + // layout prefixes a sequence number (`0000_cluster_roles.sql`), so match the + // roles file across every layout's naming. + return files.filter((f) => !/cluster[_/]roles/.test(f.name)); +} + +describe("export: round-trip fidelity (all layouts)", () => { + for (const layout of LAYOUTS) { + test(`cross-schema mutual FKs round-trip (${layout})`, async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb(`fid_fk_src_${layout}`); + const shadow = await cluster.createDb(`fid_fk_shadow_${layout}`); + try { + await src.pool.query(MUTUAL_FK_SQL); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb, { layout })); + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); + + test(`ALTER DEFAULT PRIVILEGES applied last preserves per-table ACLs (${layout})`, async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb(`fid_adp_src_${layout}`); + const shadow = await cluster.createDb(`fid_adp_shadow_${layout}`); + try { + await src.pool.query(ADP_ASYMMETRY_SQL); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb, { layout })); + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); + + test(`ADP order-independence holds for sequences + functions, incl. the restrictive REVOKE (${layout})`, async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb(`fid_adpobj_src_${layout}`); + const shadow = await cluster.createDb(`fid_adpobj_shadow_${layout}`); + try { + await src.pool.query(ADP_OBJTYPES_SQL); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb, { layout })); + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); + + test(`autovacuum reloptions + non-owner function grants round-trip (${layout})`, async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb(`fid_relopt_src_${layout}`); + const shadow = await cluster.createDb(`fid_relopt_shadow_${layout}`); + try { + await src.pool.query(RELOPTIONS_AND_GRANTS_SQL); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb, { layout })); + // both must actually appear in the export (not just round-trip to a + // matching-but-empty state) + const all = files.map((f) => f.sql).join("\n"); + expect(all).toMatch(/autovacuum_vacuum_scale_factor/); + expect(all).toMatch(/GRANT EXECUTE[\s\S]*pg_read_all_data/); + + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); + } + + // grouped layout with `--flat-schemas` must STILL split FKs into `.fk.sql`: + // flat regrouping collapses a schema to one file per category, which would + // otherwise fold cross-schema mutual FKs back together and re-stick the load. + test("grouped + flat-schemas keeps the FK split for cross-schema mutual FKs", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("fid_flatfk_src"); + const shadow = await cluster.createDb("fid_flatfk_shadow"); + try { + await src.pool.query(MUTUAL_FK_SQL); + const fb = (await extract(src.pool)).factBase; + const files = forLoad( + exportSqlFiles(fb, { + layout: "grouped", + grouping: { flatSchemas: ["a", "b"] }, + }), + ); + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); + + // Export-only constraint folding: validated table constraints render INLINE + // inside their CREATE TABLE parens (`CONSTRAINT name `), like + // hand-written SQL — instead of a trail of ALTER TABLE ADD CONSTRAINT. + // NOT VALID constraints cannot inline (inline constraints always validate) + // and stay as ALTERs; cyclic FKs keep the .fk.sql split (covered above). + test("validated constraints fold inline into CREATE TABLE; NOT VALID stays an ALTER", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("fid_fold_src"); + const shadow = await cluster.createDb("fid_fold_shadow"); + try { + await src.pool.query(` + CREATE SCHEMA f; + CREATE TABLE f.customers (id integer PRIMARY KEY); + CREATE TABLE f.orders ( + id integer, + customer_id integer, + qty integer, + email text, + CONSTRAINT orders_pk PRIMARY KEY (id), + CONSTRAINT orders_qty_ck CHECK (qty > 0), + CONSTRAINT orders_email_uq UNIQUE (email), + CONSTRAINT orders_cust_fk FOREIGN KEY (customer_id) + REFERENCES f.customers (id) + ); + ALTER TABLE f.orders + ADD CONSTRAINT orders_qty_big CHECK (qty < 1000) NOT VALID; + `); + const fb = (await extract(src.pool)).factBase; + const files = forLoad(exportSqlFiles(fb)); + const orders = files.find( + (f) => f.name === "schemas/f/tables/orders.sql", + )?.sql; + expect(orders).toBeDefined(); + // all four validated constraints inline, names preserved + expect(orders).toContain(`CONSTRAINT "orders_pk" PRIMARY KEY`); + expect(orders).toContain(`CONSTRAINT "orders_qty_ck" CHECK`); + expect(orders).toContain(`CONSTRAINT "orders_email_uq" UNIQUE`); + expect(orders).toContain(`CONSTRAINT "orders_cust_fk" FOREIGN KEY`); + // the ONLY remaining ADD CONSTRAINT is the NOT VALID one + const addConstraints = orders!.match(/ADD CONSTRAINT/g) ?? []; + expect(addConstraints).toHaveLength(1); + expect(orders).toContain("NOT VALID"); + + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); + + // Satellite-on-extension-member routing must survive grouped/flat regrouping + // (like the .fk.sql guard) AND round-trip: an ACL on a pgcrypto member files + // into cluster/extensions/pgcrypto.sql, never back into schemas/public/…. + test("member ACLs route to the extension file and round-trip (grouped + flat)", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("fid_membacl_src"); + const shadow = await cluster.createDb("fid_membacl_shadow"); + try { + await src.pool.query(` + CREATE EXTENSION pgcrypto; + REVOKE ALL ON FUNCTION public.gen_salt(text) FROM PUBLIC; + `); + const fb = (await extract(src.pool)).factBase; + const files = forLoad( + exportSqlFiles(fb, { + layout: "grouped", + grouping: { flatSchemas: ["public"] }, + }), + ); + const extFile = files.find( + (f) => f.name === "cluster/extensions/pgcrypto.sql", + ); + expect(extFile?.sql).toContain("gen_salt"); + // no member ACL leaks back into a public functions file (the public + // schema's OWN schema.sql — its comment/grants — legitimately remains) + expect(files.some((f) => f.name.includes("functions"))).toBe(false); + expect(files.some((f) => f.sql.includes("gen_salt"))).toBe(true); + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); + + // ADP `FOR ROLE` and `WITH GRANT OPTION` rendering fidelity (Fable checklist + // items 3 + 5). These need a named (non-PUBLIC) role, so the fixture creates + // one; it is cluster-global and dropped in `finally`. by-object layout only — + // this pins ACL/ADP RENDERING, and layout-independence is already covered by + // the cases above. RED risk is a mangled `FOR ROLE` / `WITH GRANT OPTION` + // clause that fails to reload; GREEN = hash-identical round-trip. + test("ADP FOR ROLE + WITH GRANT OPTION round-trip", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("fid_adprole_src"); + const shadow = await cluster.createDb("fid_adprole_shadow"); + try { + await src.pool.query(` + CREATE ROLE fidrole NOLOGIN; + CREATE SCHEMA h; + ALTER DEFAULT PRIVILEGES IN SCHEMA h + GRANT SELECT ON TABLES TO fidrole WITH GRANT OPTION; + CREATE TABLE h.t (id integer); + ALTER DEFAULT PRIVILEGES FOR ROLE fidrole IN SCHEMA h + GRANT INSERT ON TABLES TO PUBLIC; + `); + const fb = (await extract(src.pool)).factBase; + const files = forLoad(exportSqlFiles(fb)); + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await cluster.adminPool + .query(`DROP OWNED BY fidrole CASCADE`) + .catch(() => {}); + await cluster.adminPool + .query(`DROP ROLE IF EXISTS fidrole`) + .catch(() => {}); + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/export-format.test.ts b/packages/pg-delta/tests/export-format.test.ts new file mode 100644 index 000000000..bd8f641eb --- /dev/null +++ b/packages/pg-delta/tests/export-format.test.ts @@ -0,0 +1,112 @@ +/** + * Declarative-export SQL formatting (opt-in, layout-agnostic). + * + * `exportSqlFiles(fb, { format })` runs each file's statements through the + * ported SQL formatter (frontends/sql-format) before joining. It is OFF by + * default (output stays exactly as the renderer emits it) and works with any + * layout. The formatter is a heuristic token reformatter, so the load-bearing + * safeguard is the fidelity gate: load(export(fb, { format })) ≡ fb — formatting + * must never change a statement's meaning or drop one. + * + * Docker required (extracts + reloads against a real database). + */ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cmdSchemaExport } from "../src/cli/commands/schema.ts"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { sharedCluster } from "./containers.ts"; + +const SCHEMA_SQL = ` + CREATE SCHEMA app; + CREATE TABLE app.users (id integer PRIMARY KEY, email text NOT NULL); + CREATE VIEW app.u AS SELECT id FROM app.users; + CREATE FUNCTION app.add(a integer, b integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS 'SELECT a + b'; + COMMENT ON TABLE app.users IS 'x'; +`; + +describe("export: SQL formatting", () => { + test("off by default; --format-options applies keyword casing", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expfmt_case"); + try { + await src.pool.query(SCHEMA_SQL); + const fb = (await extract(src.pool)).factBase; + + const tableOf = (files: { name: string; sql: string }[]) => + files.find((f) => f.name === "schemas/app/tables/users.sql")?.sql ?? ""; + + // default: unformatted — the renderer emits upper-case DDL keywords + const plain = tableOf(exportSqlFiles(fb)); + expect(plain).toContain("CREATE TABLE"); + + // formatted with keywordCase lower: the same statement is lower-cased + const lowered = tableOf( + exportSqlFiles(fb, { format: { keywordCase: "lower" } }), + ); + expect(lowered).toContain("create table"); + expect(lowered).not.toContain("CREATE TABLE"); + } finally { + await src.drop(); + } + }, 60_000); + + // CLI contract: `schema export` is a HUMAN-FACING artifact, so it formats by + // default (lowercase keywords, formatter defaults otherwise). --format-options + // still overrides any knob; --no-format restores the raw renderer output. + test("schema export CLI formats by default (lowercase); --no-format opts out", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expfmt_cli"); + try { + await src.pool.query(SCHEMA_SQL); + + const defaultDir = join(mkdtempSync(join(tmpdir(), "expfmt-")), "d"); + await cmdSchemaExport(["--source", src.uri, "--out-dir", defaultDir]); + const formatted = readFileSync( + join(defaultDir, "schemas/app/tables/users.sql"), + "utf8", + ); + expect(formatted).toContain("create table"); + expect(formatted).not.toContain("CREATE TABLE"); + + const rawDir = join(mkdtempSync(join(tmpdir(), "expfmt-")), "r"); + await cmdSchemaExport([ + "--source", + src.uri, + "--out-dir", + rawDir, + "--no-format", + ]); + const raw = readFileSync( + join(rawDir, "schemas/app/tables/users.sql"), + "utf8", + ); + expect(raw).toContain("CREATE TABLE"); + } finally { + await src.drop(); + } + }, 120_000); + + test("load(export(fb, { format })) is hash-identical (fidelity gate)", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expfmt_fid_src"); + const shadow = await cluster.createDb("expfmt_fid_shadow"); + try { + await src.pool.query(SCHEMA_SQL); + const fb = (await extract(src.pool)).factBase; + + const formatted = exportSqlFiles(fb, { + format: { keywordCase: "upper", maxWidth: 80 }, + }).filter((f) => !f.name.startsWith("cluster/roles")); + + const loaded = await loadSqlFiles(formatted, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/export-grouped.test.ts b/packages/pg-delta/tests/export-grouped.test.ts new file mode 100644 index 000000000..949545f1a --- /dev/null +++ b/packages/pg-delta/tests/export-grouped.test.ts @@ -0,0 +1,181 @@ +/** + * Grouped declarative-export layout (v1 parity, opt-in). + * + * `layout: "grouped"` brings back the old engine's "nice" export: files ordered + * by a fixed semantic category priority (not raw plan order), statements sorted + * within a file for readability, plus opt-in grouping by name pattern, flat + * schemas, and partition-with-parent grouping. The default layouts + * (`by-object`, `ordered`) are unchanged — pinned by export.test.ts / + * export-layout.test.ts. + * + * Fidelity (load(export(fb, "grouped")) ≡ fb) is still the gate: grouped files + * may need the loader's retry rounds, but must reproduce the exact fact base. + * + * Docker required (extracts from a real database). + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { sharedCluster } from "./containers.ts"; + +describe("export: grouped layout (v1 parity)", () => { + test("orders files by semantic category, not dependency/plan order", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expgrp_cat"); + try { + // the view depends on the function, so PLAN order is function-before-view; + // category order is the opposite (views < functions), which is what the + // grouped layout must follow. + await src.pool.query(` + CREATE SCHEMA app; + CREATE FUNCTION app.f() RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT 1'; + CREATE VIEW app.v AS SELECT app.f() AS n; + `); + const fb = (await extract(src.pool)).factBase; + const names = exportSqlFiles(fb, { layout: "grouped" }).map( + (f) => f.name, + ); + + const viewAt = names.indexOf("schemas/app/views/v.sql"); + const fnAt = names.indexOf("schemas/app/functions/f.sql"); + expect(viewAt).toBeGreaterThanOrEqual(0); + expect(fnAt).toBeGreaterThanOrEqual(0); + expect(viewAt).toBeLessThan(fnAt); + } finally { + await src.drop(); + } + }, 60_000); + + test("sorts statements within a file for readability (object before comment)", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expgrp_read"); + try { + await src.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t (id integer PRIMARY KEY); + COMMENT ON TABLE app.t IS 'hello'; + `); + const fb = (await extract(src.pool)).factBase; + const files = exportSqlFiles(fb, { layout: "grouped" }); + const tableFile = files.find( + (f) => f.name === "schemas/app/tables/t.sql", + ); + expect(tableFile).toBeDefined(); + const sql = tableFile?.sql ?? ""; + expect(sql).toContain("CREATE TABLE"); + expect(sql).toContain("COMMENT ON"); + expect(sql.indexOf("CREATE TABLE")).toBeLessThan( + sql.indexOf("COMMENT ON"), + ); + } finally { + await src.drop(); + } + }, 60_000); + + test("groups objects by name pattern into a subdirectory", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expgrp_pat"); + try { + await src.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.auth_users (id integer PRIMARY KEY); + CREATE TABLE app.auth_sessions (id integer PRIMARY KEY); + CREATE TABLE app.billing_invoices (id integer PRIMARY KEY); + `); + const fb = (await extract(src.pool)).factBase; + const names = exportSqlFiles(fb, { + layout: "grouped", + grouping: { + mode: "subdirectory", + groupPatterns: [{ pattern: "^auth_", name: "auth" }], + }, + }).map((f) => f.name); + + // both auth_* tables consolidate under the auth group… + expect(names).toContain("schemas/app/auth/tables.sql"); + expect(names).not.toContain("schemas/app/tables/auth_users.sql"); + expect(names).not.toContain("schemas/app/tables/auth_sessions.sql"); + // …the non-matching table keeps its own per-object file + expect(names).toContain("schemas/app/tables/billing_invoices.sql"); + } finally { + await src.drop(); + } + }, 60_000); + + test("flattens a schema into one file per category", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expgrp_flat"); + try { + await src.pool.query(` + CREATE SCHEMA ext; + CREATE TABLE ext.a (id integer PRIMARY KEY); + CREATE TABLE ext.b (id integer PRIMARY KEY); + `); + const fb = (await extract(src.pool)).factBase; + const files = exportSqlFiles(fb, { + layout: "grouped", + grouping: { flatSchemas: ["ext"] }, + }); + const names = files.map((f) => f.name); + expect(names).toContain("schemas/ext/tables.sql"); + expect(names).not.toContain("schemas/ext/tables/a.sql"); + const flat = files.find((f) => f.name === "schemas/ext/tables.sql"); + expect(flat?.sql).toContain('"ext"."a"'); + expect(flat?.sql).toContain('"ext"."b"'); + } finally { + await src.drop(); + } + }, 60_000); + + test("groups a partition child into its parent's file (default on under grouped)", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expgrp_part"); + try { + await src.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.measurements (id integer, ts date) PARTITION BY RANGE (ts); + CREATE TABLE app.measurements_2024 PARTITION OF app.measurements + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + `); + const fb = (await extract(src.pool)).factBase; + const files = exportSqlFiles(fb, { layout: "grouped" }); + const names = files.map((f) => f.name); + + // the child does NOT get its own file (the by-object contract); it lands + // in the parent's file + expect(names).not.toContain("schemas/app/tables/measurements_2024.sql"); + const parentFile = files.find( + (f) => f.name === "schemas/app/tables/measurements.sql", + ); + expect(parentFile?.sql).toContain("measurements_2024"); + expect(parentFile?.sql).toContain("PARTITION OF"); + } finally { + await src.drop(); + } + }, 60_000); + + test("load(export(fb, grouped)) is hash-identical (fidelity gate)", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expgrp_fid_src"); + const shadow = await cluster.createDb("expgrp_fid_shadow"); + try { + await src.pool.query(` + CREATE SCHEMA app; + CREATE TYPE app.lvl AS ENUM ('low', 'high'); + CREATE TABLE app.users (id integer PRIMARY KEY, lvl app.lvl); + CREATE VIEW app.u AS SELECT id FROM app.users; + CREATE FUNCTION app.f() RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT 1'; + COMMENT ON TABLE app.users IS 'x'; + `); + const fb = (await extract(src.pool)).factBase; + const grouped = exportSqlFiles(fb, { layout: "grouped" }).filter( + (f) => !f.name.startsWith("cluster/roles"), + ); + const loaded = await loadSqlFiles(grouped, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/export-identity-sequence-name.test.ts b/packages/pg-delta/tests/export-identity-sequence-name.test.ts new file mode 100644 index 000000000..ef3a0ea85 --- /dev/null +++ b/packages/pg-delta/tests/export-identity-sequence-name.test.ts @@ -0,0 +1,82 @@ +/** + * Declarative-export fidelity for IDENTITY columns whose implicit backing + * sequence has a NON-DEFAULT name. + * + * An identity column (`GENERATED … AS IDENTITY`) owns an implicit sequence. + * PostgreSQL names it `
__seq` by default, but the name can + * diverge — the sequence was renamed, or created via `SEQUENCE NAME`. pg-delta + * extracts the real name, but if the export renders a bare `AS IDENTITY` with + * no `SEQUENCE NAME` clause, the reload lets PostgreSQL re-derive the default + * name — which no longer matches the source → a spurious `ALTER SEQUENCE … + * RENAME` on the next diff (dogfooded on `public.custom_config_gotrue.id`, + * whose sequence is `auth_config_id_seq`). + * + * The fix emits `SEQUENCE NAME "".""` inside the identity options + * parens when the name differs from the `
__seq` default, and + * keeps default-named columns bare so ordinary exports stay minimal. + * + * Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { sharedCluster } from "./containers.ts"; + +function forLoad(files: { name: string; sql: string }[]) { + // roles are cluster-global and already present in the shared cluster; drop + // the CREATE ROLE file exactly as export-fidelity.test.ts does. + return files.filter((f) => !/cluster[_/]roles/.test(f.name)); +} + +describe("export: identity backing-sequence name fidelity", () => { + test("renamed identity sequence round-trips and emits SEQUENCE NAME", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("idseq_named_src"); + const shadow = await cluster.createDb("idseq_named_shadow"); + try { + await src.pool.query(` + CREATE SCHEMA g; + CREATE TABLE g.t (id integer GENERATED BY DEFAULT AS IDENTITY, x integer); + ALTER SEQUENCE g.t_id_seq RENAME TO renamed_seq; + `); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb)); + const all = files.map((f) => f.sql).join("\n"); + // the renamed sequence name must be reproduced explicitly + expect(all.toLowerCase()).toContain("sequence name"); + expect(all).toContain("renamed_seq"); + + const loaded = await loadSqlFiles(files, shadow.pool); + // rootHash folds the identity payload (incl. the backing sequence + // {schema,name}); equality here == zero residual actions on re-diff. + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); + + test("default-named identity sequence stays bare (no SEQUENCE NAME) and round-trips", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("idseq_default_src"); + const shadow = await cluster.createDb("idseq_default_shadow"); + try { + await src.pool.query(` + CREATE SCHEMA h; + CREATE TABLE h.t2 (id integer GENERATED ALWAYS AS IDENTITY, x integer); + `); + const fb = (await extract(src.pool)).factBase; + + const files = forLoad(exportSqlFiles(fb)); + const all = files.map((f) => f.sql).join("\n"); + // default `
__seq` name → export stays minimal + expect(all.toLowerCase()).not.toContain("sequence name"); + + const loaded = await loadSqlFiles(files, shadow.pool); + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/export-layout.test.ts b/packages/pg-delta/tests/export-layout.test.ts new file mode 100644 index 000000000..9a5a838d7 --- /dev/null +++ b/packages/pg-delta/tests/export-layout.test.ts @@ -0,0 +1,210 @@ +/** + * Export by-object layout parity (Tier 6): the old engine's + * declarative-schema-export suite pinned WHICH file each object's DDL lands in. + * The v2 exporter (frontends/export-sql-files.ts) keeps the parts that mattered + * and deliberately changes a few. This test is the canonical record of the v2 + * by-object layout contract so it cannot silently regress. + * + * Preserved from the old engine (its tests asserted these and still hold): + * - FK constraints render INTO the owning table's file (no foreign_keys/ dir); + * - triggers render INTO the table's file (no triggers/ dir); + * - RLS policies render INTO the table's file (no policies/ dir). + * + * Deliberate v2 invariant (round-trip fidelity, export-fidelity.test.ts): ONLY + * an FK that participates in a cross-table reference CYCLE (mutual FKs) moves to + * a sibling `
.fk.sql` — files apply atomically, so a cyclic pair inline + * would deadlock the loader. Acyclic FKs (the overwhelmingly common case) stay + * inline in their table's file for readability; the loader's bounded retry + * orders them. Still no `foreign_keys/` directory. + * + * Restored old-engine behaviors (readability, by user decision — the earlier + * v2 one-category-per-file rule was reversed): + * - indexes CO-LOCATE with their table / matview file (no indexes/ dir); + * - satellites of a VIEW (INSTEAD OF triggers, policies) file with the view, + * not under tables/ (relation-kind-aware routing); + * - ACL/comment satellites on EXTENSION MEMBERS file with their extension. + * Still deliberate v2 delta: materialized views live under + * materialized_views/ (old: matviews/). + * + * Layout-dependent (second test): a partition child is its OWN tables/.sql + * file in the by-object layout, but the v1-parity "grouped" layout co-locates it + * into the parent table's file (autoGroupPartitions) — matching the old engine's + * "partitioned tables" behavior. That case is ported. + * + * Fidelity (load(export(fb)) ≡ fb) and the ordered-layout zero-round gate are + * covered by export.test.ts; this file only pins the file MAPPING. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { sharedCluster } from "./containers.ts"; + +describe("export: by-object file mapping (v2 contract)", () => { + test("table satellites co-locate; indexes/matviews/partitions get their own paths", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("explayout_src"); + try { + await src.pool.query(` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + name text NOT NULL, + owner_id integer + ); + CREATE INDEX users_name_idx ON test_schema.users (name); + CREATE TABLE test_schema.posts ( + id integer PRIMARY KEY, + user_id integer REFERENCES test_schema.users(id) + ); + ALTER TABLE test_schema.users ENABLE ROW LEVEL SECURITY; + CREATE POLICY user_policy ON test_schema.users + FOR SELECT USING (owner_id = 1); + CREATE FUNCTION test_schema.trigger_fn() RETURNS trigger + AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql; + CREATE TRIGGER users_trigger + BEFORE INSERT ON test_schema.users + FOR EACH ROW EXECUTE FUNCTION test_schema.trigger_fn(); + CREATE TABLE test_schema.measurements (id integer, date date) + PARTITION BY RANGE (date); + CREATE TABLE test_schema.measurements_2024 + PARTITION OF test_schema.measurements + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + CREATE MATERIALIZED VIEW test_schema.user_summary AS + SELECT id FROM test_schema.users; + CREATE INDEX user_summary_idx ON test_schema.user_summary (id); + -- mutual FK pair: the ONLY case whose FKs move to sibling .fk.sql files + CREATE TABLE test_schema.m1 (id integer PRIMARY KEY, m2_id integer); + CREATE TABLE test_schema.m2 (id integer PRIMARY KEY, m1_id integer); + ALTER TABLE test_schema.m1 + ADD CONSTRAINT m1_m2_fk FOREIGN KEY (m2_id) REFERENCES test_schema.m2(id); + ALTER TABLE test_schema.m2 + ADD CONSTRAINT m2_m1_fk FOREIGN KEY (m1_id) REFERENCES test_schema.m1(id); + -- an ACL on an extension MEMBER (satellite-on-member routing) + CREATE EXTENSION pgcrypto; + REVOKE ALL ON FUNCTION public.gen_salt(text) FROM PUBLIC; + -- a VIEW with an INSTEAD OF trigger (relation-kind-aware routing) + CREATE VIEW test_schema.v_users AS SELECT id, name FROM test_schema.users; + CREATE TRIGGER v_users_ins INSTEAD OF INSERT ON test_schema.v_users + FOR EACH ROW EXECUTE FUNCTION test_schema.trigger_fn(); + `); + const fb = (await extract(src.pool)).factBase; + const files = exportSqlFiles(fb); + const byName = new Map(files.map((f) => [f.name, f.sql])); + const has = (frag: string): boolean => + files.some((f) => f.name.includes(frag)); + + // --- preserved co-location (old declarative-schema-export intent) --- + const usersFile = byName.get("schemas/test_schema/tables/users.sql"); + const postsFile = byName.get("schemas/test_schema/tables/posts.sql"); + expect(usersFile).toBeDefined(); + expect(postsFile).toBeDefined(); + // an ACYCLIC FK stays INLINE in the owning table's file (readability); + // no separate foreign_keys/ dir and no sibling .fk.sql for it. + expect(postsFile).toContain("REFERENCES"); + expect(postsFile).toContain("test_schema.users"); + expect(has("posts.fk.sql")).toBe(false); + expect(has("foreign_keys/")).toBe(false); + // a CYCLIC FK pair moves to sibling .fk.sql files (atomic files can't + // hold a reference cycle), and the table files carry no REFERENCES. + expect(byName.get("schemas/test_schema/tables/m1.fk.sql")).toContain( + "m1_m2_fk", + ); + expect(byName.get("schemas/test_schema/tables/m2.fk.sql")).toContain( + "m2_m1_fk", + ); + expect(byName.get("schemas/test_schema/tables/m1.sql")).not.toContain( + "REFERENCES", + ); + + // a satellite whose TARGET is an extension MEMBER (an ACL on a pgcrypto + // function) files into the owning extension's file, next to its CREATE + // EXTENSION — NOT into schemas//functions/ (a real DB with pgTAP would + // otherwise sprout hundreds of REVOKE-only function files). + const pgcryptoFile = byName.get("cluster/extensions/pgcrypto.sql"); + expect(pgcryptoFile).toContain("CREATE EXTENSION"); + expect(pgcryptoFile).toContain("gen_salt"); + expect(has("functions/gen_salt")).toBe(false); + // trigger + RLS policy in the table file, no separate dirs + expect(usersFile).toContain("CREATE TRIGGER"); + expect(usersFile).toContain("users_trigger"); + expect(usersFile).toContain("CREATE POLICY"); + expect(usersFile).toContain("user_policy"); + expect(has("triggers/")).toBe(false); + expect(has("policies/")).toBe(false); + + // indexes CO-LOCATE with their table (readability: the old engine did + // this too; the earlier v2 one-category-per-file rule was reversed by + // user decision) — no indexes/ directory at all. + expect(usersFile).toContain("users_name_idx"); + expect(has("indexes/")).toBe(false); + // matviews under materialized_views/ (old engine used matviews/), and a + // matview's index co-locates with the matview file + expect( + byName.get("schemas/test_schema/materialized_views/user_summary.sql"), + ).toContain("user_summary_idx"); + expect(has("matviews/")).toBe(false); + // a trigger on a VIEW files with the view, not under tables/ + expect(byName.get("schemas/test_schema/views/v_users.sql")).toContain( + "v_users_ins", + ); + expect(has("tables/v_users")).toBe(false); + // a partition child is its OWN table file (old engine grouped it) + expect(has("schemas/test_schema/tables/measurements.sql")).toBe(true); + expect(has("schemas/test_schema/tables/measurements_2024.sql")).toBe( + true, + ); + const parentFile = byName.get( + "schemas/test_schema/tables/measurements.sql", + ); + expect(parentFile).not.toContain("measurements_2024"); + } finally { + await src.drop(); + } + }, 120_000); + + test("grouped layout (v1 parity) co-locates partition children with the parent and indexes with their relation", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("explayout_grouped"); + try { + await src.pool.query(` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.users (id integer PRIMARY KEY, name text NOT NULL); + CREATE INDEX users_name_idx ON test_schema.users (name); + CREATE TABLE test_schema.measurements (id integer, date date) + PARTITION BY RANGE (date); + CREATE TABLE test_schema.measurements_2024 + PARTITION OF test_schema.measurements + FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + CREATE MATERIALIZED VIEW test_schema.user_summary AS + SELECT id FROM test_schema.users; + CREATE INDEX user_summary_idx ON test_schema.user_summary (id); + `); + const fb = (await extract(src.pool)).factBase; + const files = exportSqlFiles(fb, { layout: "grouped" }); + const byName = new Map(files.map((f) => [f.name, f.sql])); + const has = (frag: string): boolean => + files.some((f) => f.name.includes(frag)); + + // v1-parity win: a partition child is grouped INTO its parent table's + // file (autoGroupPartitions), not emitted as a standalone file — this is + // the old engine's "partitioned tables" behavior. + expect( + byName.get("schemas/test_schema/tables/measurements.sql"), + ).toContain("measurements_2024"); + expect(has("tables/measurements_2024.sql")).toBe(false); + + // indexes co-locate with their table / matview file in grouped too + // (old-engine behavior, restored by user decision) — no indexes/ dir. + expect(byName.get("schemas/test_schema/tables/users.sql")).toContain( + "users_name_idx", + ); + expect( + byName.get("schemas/test_schema/materialized_views/user_summary.sql"), + ).toContain("user_summary_idx"); + expect(has("indexes/")).toBe(false); + expect(has("matviews/")).toBe(false); + } finally { + await src.drop(); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/export-ownership.test.ts b/packages/pg-delta/tests/export-ownership.test.ts new file mode 100644 index 000000000..0042d22f2 --- /dev/null +++ b/packages/pg-delta/tests/export-ownership.test.ts @@ -0,0 +1,638 @@ +/** + * `schema export` serializes object ownership as `ALTER … OWNER TO` (an assumed + * role reference, consistent with ACLs) but SUPPRESSES it for the resolved + * DEFAULT owner so exports stay human-readable. The default resolves: + * --default-owner > profile-declared default > database owner (datdba) + * + * The manifest stamps the resolved default owner; `schema apply` fails closed + * (exit 2) when the target connection role differs from a role-name default. + * + * Faithful end-to-end regressions driving the real CLI (subprocess) so exit + * codes and file/manifest output are the production behaviour, not the test's. + * Docker required. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const CLI = join( + new URL("..", import.meta.url).pathname.replace(/\/$/, ""), + "src/cli/main.ts", +); +const PKG_DIR = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); + +interface SpawnResult { + stdout: string; + stderr: string; + exitCode: number; +} +async function runCli(args: string[]): Promise { + const proc = Bun.spawn(["bun", CLI, ...args], { + cwd: PKG_DIR, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; +} + +const readManifest = (dir: string): Record => + JSON.parse(readFileSync(join(dir, ".pgdelta-export.json"), "utf8")); +const readTable = (dir: string, table: string): string => + readFileSync(join(dir, `schemas/public/tables/${table}.sql`), "utf8"); +const readTableIn = (dir: string, schema: string, table: string): string => + readFileSync(join(dir, `schemas/${schema}/tables/${table}.sql`), "utf8"); + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("schema export: default-owner ownership serialization", () => { + // (a) DEFAULT suppression: an object owned by the datdba (the export + // connection role) emits no OWNER TO; an object owned by another role does. + test("(a) suppresses OWNER TO for the datdba default, emits it for others", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expown_a_src"); + dbs.push(src); + await cluster.adminPool + .query(`CREATE ROLE exc_owner_a NOLOGIN`) + .catch(() => {}); + await cluster.adminPool.query(`GRANT exc_owner_a TO test`).catch(() => {}); + await src.pool.query(` + CREATE TABLE public.t_plain (id int); + CREATE TABLE public.t_exc (id int); + ALTER TABLE public.t_exc OWNER TO exc_owner_a; + `); + const datdba = ( + await src.pool.query<{ o: string }>( + `SELECT pg_get_userbyid(datdba) AS o FROM pg_database WHERE datname = current_database()`, + ) + ).rows[0]!.o; + + const dir = mkdtempSync(join(tmpdir(), "expown-a-")); + const res = await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dir, + ]); + expect(res.exitCode).toBe(0); + + expect(readTable(dir, "t_plain")).not.toMatch(/owner to/i); + expect(readTable(dir, "t_exc")).toMatch( + /alter table[\s\S]*owner to "?exc_owner_a"?/i, + ); + expect(readManifest(dir).defaultOwner).toBe(datdba); + }, 120_000); + + // (b) EXPLICIT flag flips which owner is implicit. + test("(b) --default-owner makes that role implicit and emits OWNER TO for the datdba", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expown_b_src"); + dbs.push(src); + await cluster.adminPool + .query(`CREATE ROLE exc_owner_b NOLOGIN`) + .catch(() => {}); + await cluster.adminPool.query(`GRANT exc_owner_b TO test`).catch(() => {}); + await src.pool.query(` + CREATE TABLE public.t_plain (id int); + CREATE TABLE public.t_exc (id int); + ALTER TABLE public.t_exc OWNER TO exc_owner_b; + `); + const datdba = ( + await src.pool.query<{ o: string }>( + `SELECT pg_get_userbyid(datdba) AS o FROM pg_database WHERE datname = current_database()`, + ) + ).rows[0]!.o; + + const dir = mkdtempSync(join(tmpdir(), "expown-b-")); + const res = await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dir, + "--default-owner", + "exc_owner_b", + ]); + expect(res.exitCode).toBe(0); + + expect(readTable(dir, "t_exc")).not.toMatch(/owner to/i); + expect(readTable(dir, "t_plain")).toMatch( + new RegExp(`owner to "?${datdba}"?`, "i"), + ); + expect(readManifest(dir).defaultOwner).toBe("exc_owner_b"); + }, 120_000); + + // (c) VERBOSE: --default-owner none emits every OWNER TO; manifest null. + test("(c) --default-owner none emits OWNER TO for every owned object", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expown_c_src"); + dbs.push(src); + await cluster.adminPool + .query(`CREATE ROLE exc_owner_c NOLOGIN`) + .catch(() => {}); + await cluster.adminPool.query(`GRANT exc_owner_c TO test`).catch(() => {}); + await src.pool.query(` + CREATE TABLE public.t_plain (id int); + CREATE TABLE public.t_exc (id int); + ALTER TABLE public.t_exc OWNER TO exc_owner_c; + `); + + const dir = mkdtempSync(join(tmpdir(), "expown-c-")); + const res = await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dir, + "--default-owner", + "none", + ]); + expect(res.exitCode).toBe(0); + + expect(readTable(dir, "t_plain")).toMatch(/owner to "?test"?/i); + expect(readTable(dir, "t_exc")).toMatch(/owner to "?exc_owner_c"?/i); + expect(readManifest(dir).defaultOwner).toBeNull(); + }, 120_000); +}); + +describe("schema export/apply: two-role ownership round-trip", () => { + // (d) source has a default owner A + one object owned by A and one owned by + // exc_owner; export at database scope; apply to a fresh target connecting as A + // → zero-action drift on a full-scope re-plan and target ownership ≡ source. + test("(d) round-trips ownership, guards a divergent applier, and verbose converges", async () => { + const cluster = await sharedCluster(); + // login roles usable as --target connection roles; superuser so the applier + // can create the co-located shadow and set any owner. + await cluster.adminPool + .query(`CREATE ROLE own_a SUPERUSER LOGIN PASSWORD 'a'`) + .catch(() => {}); + await cluster.adminPool + .query(`CREATE ROLE own_c SUPERUSER LOGIN PASSWORD 'c'`) + .catch(() => {}); + await cluster.adminPool + .query(`CREATE ROLE own_exc NOLOGIN`) + .catch(() => {}); + await cluster.adminPool.query(`GRANT own_exc TO own_a`).catch(() => {}); + await cluster.adminPool.query(`GRANT own_a TO own_c`).catch(() => {}); + await cluster.adminPool.query(`GRANT own_exc TO own_c`).catch(() => {}); + + const src = await cluster.createDb("expown_d_src"); + dbs.push(src); + await cluster.adminPool + .query(`ALTER DATABASE "${src.name}" OWNER TO own_a`) + .catch(() => {}); + await src.pool.query(` + CREATE SCHEMA s AUTHORIZATION own_a; + CREATE TABLE s.t_a (id int); + ALTER TABLE s.t_a OWNER TO own_a; + CREATE TABLE s.t_exc (id int); + ALTER TABLE s.t_exc OWNER TO own_exc; + `); + + const uriAs = (db: TestDb, role: string, pw: string): string => + db.uri.replace("test:test@", `${role}:${pw}@`); + + const dir = mkdtempSync(join(tmpdir(), "expown-d-")); + // export connecting as admin; datdba(src) === own_a → default owner own_a. + expect( + ( + await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dir, + ]) + ).exitCode, + ).toBe(0); + expect(readManifest(dir).defaultOwner).toBe("own_a"); + + // apply to a fresh target owned by own_a, connecting AS own_a → guard passes. + const dst = await cluster.createDb("expown_d_dst"); + dbs.push(dst); + await cluster.adminPool + .query(`ALTER DATABASE "${dst.name}" OWNER TO own_a`) + .catch(() => {}); + const applied = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--target", + uriAs(dst, "own_a", "a"), + "--renames", + "off", + ]); + expect({ code: applied.exitCode, stderr: applied.stderr }).toMatchObject({ + code: 0, + }); + + // ownership round-tripped: full-scope (cluster) re-plan is a no-op. + const [s, d] = await Promise.all([extract(src.pool), extract(dst.pool)]); + const rePlan = plan(s.factBase, d.factBase); + expect(rePlan.actions).toEqual([]); + // direct catalog check for good measure + const owners = async (db: TestDb) => + ( + await db.pool.query<{ rel: string; owner: string }>( + `SELECT relname AS rel, pg_get_userbyid(relowner) AS owner + FROM pg_class WHERE relnamespace = 's'::regnamespace AND relkind = 'r' + ORDER BY relname`, + ) + ).rows; + expect(await owners(dst)).toEqual(await owners(src)); + + // DIVERGE: applying the same (default-owner own_a) export as own_c ≠ own_a + // fails closed with exit 2 and names both roles. + const dst2 = await cluster.createDb("expown_d_dst2"); + dbs.push(dst2); + await cluster.adminPool + .query(`ALTER DATABASE "${dst2.name}" OWNER TO own_c`) + .catch(() => {}); + // baseline the co-located shadow DBs BEFORE the diverged apply so the leak + // assertion is robust to shadows other (concurrent) test files hold. + const shadowNames = async (): Promise => + ( + await cluster.adminPool.query<{ d: string }>( + `SELECT datname AS d FROM pg_database WHERE datname LIKE 'pgdelta_shadow_%'`, + ) + ).rows.map((r) => r.d); + const shadowsBefore = new Set(await shadowNames()); + const diverged = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--target", + uriAs(dst2, "own_c", "c"), + "--renames", + "off", + ]); + expect(diverged.exitCode).toBe(2); + expect(diverged.stderr).toMatch(/own_a/); + expect(diverged.stderr).toMatch(/own_c/); + // NO LEAK: the diverged apply (no --shadow) provisions a co-located shadow + // BEFORE the owner guard fires; the guard's exit(2) must still drop it. + // Before the fix, process.exit skipped the cleanup finally → the shadow DB + // leaked on the target's cluster. + const leaked = (await shadowNames()).filter((d) => !shadowsBefore.has(d)); + expect(leaked).toEqual([]); + + // VERBOSE: re-export --default-owner none; applying as own_c (member of both + // own_a and own_exc) converges despite own_c ≠ own_a (every OWNER TO explicit). + const dirV = mkdtempSync(join(tmpdir(), "expown-dv-")); + expect( + ( + await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dirV, + "--default-owner", + "none", + ]) + ).exitCode, + ).toBe(0); + expect(readManifest(dirV).defaultOwner).toBeNull(); + + const dst3 = await cluster.createDb("expown_d_dst3"); + dbs.push(dst3); + await cluster.adminPool + .query(`ALTER DATABASE "${dst3.name}" OWNER TO own_c`) + .catch(() => {}); + const verbose = await runCli([ + "schema", + "apply", + "--dir", + dirV, + "--target", + uriAs(dst3, "own_c", "c"), + "--renames", + "off", + ]); + expect({ code: verbose.exitCode, stderr: verbose.stderr }).toMatchObject({ + code: 0, + }); + const d3 = await extract(dst3.pool); + expect(plan(s.factBase, d3.factBase).actions).toEqual([]); + }, 300_000); +}); + +// MIDDLEWARE SHAPE (regression for the seeding-path ownership drop): the +// unit/e2e tests above are public-only and extension-free, so `resolveView` +// returns EARLY without reconstructing the fact base. Once an extension (or any +// assumed-schema) is present, `extensionMemberReferenceOnly` is non-empty and +// `resolveView` REBUILDS the base to attach the reference-only marks — a rebuild +// that (before the fix) did not propagate the owner→role `allowDangling` hook and +// silently pruned EVERY retained dangling owner edge, so a database-scope export +// of a DB with extensions emitted ZERO `OWNER TO`. These tests pin that path. +describe("schema export: ownership survives the seeding/member path", () => { + // (e) an EXTENSION is installed (member facts → resolveView reconstructs), plus + // a non-public schema+table owned by a role that is neither the connection role + // nor the default owner. Database-scope export must still emit OWNER TO for it. + test("(e) emits OWNER TO for a non-default owner when an extension is present", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("expown_e_src"); + dbs.push(src); + await cluster.adminPool + .query(`CREATE ROLE exc_owner_e NOLOGIN`) + .catch(() => {}); + await cluster.adminPool.query(`GRANT exc_owner_e TO test`).catch(() => {}); + await src.pool.query(` + CREATE EXTENSION pg_trgm; + CREATE SCHEMA app AUTHORIZATION exc_owner_e; + CREATE TABLE app.t (id int); + ALTER TABLE app.t OWNER TO exc_owner_e; + CREATE TABLE public.t_pub (id int); + `); + const datdba = ( + await src.pool.query<{ o: string }>( + `SELECT pg_get_userbyid(datdba) AS o FROM pg_database WHERE datname = current_database()`, + ) + ).rows[0]!.o; + + const dir = mkdtempSync(join(tmpdir(), "expown-e-")); + const res = await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dir, + ]); + expect(res.exitCode).toBe(0); + expect(readManifest(dir).defaultOwner).toBe(datdba); + // the non-default owner's ownership must survive the reconstruction. + expect(readTableIn(dir, "app", "t")).toMatch( + /alter table[\s\S]*owner to "?exc_owner_e"?/i, + ); + + // VERBOSE: --default-owner none must keep every owner edge, including the + // default-owned public table (also dropped by the un-hooked rebuild). + const dirV = mkdtempSync(join(tmpdir(), "expown-ev-")); + const resV = await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dirV, + "--default-owner", + "none", + ]); + expect(resV.exitCode).toBe(0); + expect(readManifest(dirV).defaultOwner).toBeNull(); + expect(readTableIn(dirV, "app", "t")).toMatch(/owner to "?exc_owner_e"?/i); + expect(readTable(dirV, "t_pub")).toMatch(/owner to "?test"?/i); + }, 120_000); + + // (f) full round-trip through the seeding path: export a DB with an extension + + // a non-default-owned object at database scope, apply into a fresh DB as the + // default owner, re-extract, and assert a full-scope re-plan is a no-op + // (ownership converged). This is the mission assertion; it must exercise the + // reconstruction the member facts force. + test("(f) round-trips ownership through an extension-bearing DB", async () => { + const cluster = await sharedCluster(); + await cluster.adminPool + .query(`CREATE ROLE rt_a SUPERUSER LOGIN PASSWORD 'a'`) + .catch(() => {}); + await cluster.adminPool.query(`CREATE ROLE rt_exc NOLOGIN`).catch(() => {}); + await cluster.adminPool.query(`GRANT rt_exc TO rt_a`).catch(() => {}); + + const src = await cluster.createDb("expown_f_src"); + dbs.push(src); + await cluster.adminPool + .query(`ALTER DATABASE "${src.name}" OWNER TO rt_a`) + .catch(() => {}); + await src.pool.query(` + CREATE EXTENSION pg_trgm; + CREATE SCHEMA app2 AUTHORIZATION rt_a; + CREATE TABLE app2.t_a (id int); + ALTER TABLE app2.t_a OWNER TO rt_a; + CREATE TABLE app2.t_exc (id int); + ALTER TABLE app2.t_exc OWNER TO rt_exc; + `); + + const uriAs = (db: TestDb, role: string, pw: string): string => + db.uri.replace("test:test@", `${role}:${pw}@`); + + const dir = mkdtempSync(join(tmpdir(), "expown-f-")); + expect( + ( + await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dir, + ]) + ).exitCode, + ).toBe(0); + expect(readManifest(dir).defaultOwner).toBe("rt_a"); + // sanity: the non-default owner's ownership is present in the export. + expect(readTableIn(dir, "app2", "t_exc")).toMatch(/owner to "?rt_exc"?/i); + + const dst = await cluster.createDb("expown_f_dst"); + dbs.push(dst); + await cluster.adminPool + .query(`ALTER DATABASE "${dst.name}" OWNER TO rt_a`) + .catch(() => {}); + const applied = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--target", + uriAs(dst, "rt_a", "a"), + "--renames", + "off", + ]); + expect({ code: applied.exitCode, stderr: applied.stderr }).toMatchObject({ + code: 0, + }); + + const [s, d] = await Promise.all([extract(src.pool), extract(dst.pool)]); + expect(plan(s.factBase, d.factBase).actions).toEqual([]); + const owners = async (db: TestDb) => + ( + await db.pool.query<{ rel: string; owner: string }>( + `SELECT relname AS rel, pg_get_userbyid(relowner) AS owner + FROM pg_class WHERE relnamespace = 'app2'::regnamespace AND relkind = 'r' + ORDER BY relname`, + ) + ).rows; + expect(await owners(dst)).toEqual(await owners(src)); + }, 300_000); +}); + +const uriAsRole = (db: TestDb, role: string, pw: string): string => + db.uri.replace("test:test@", `${role}:${pw}@`); + +// FIX ⑤: an explicit --shadow whose connection role differs from the manifest's +// stamped default owner loads the omitted-`OWNER TO` objects as the SHADOW's +// current_user, so the projection (which prunes only edges to the default owner) +// leaves a spurious `ALTER … OWNER TO ` in the plan. The guard must +// fire on the explicit-shadow path too, not just the target connection role. +describe("schema apply: explicit --shadow owner guard", () => { + test("(g) fails closed when an explicit --shadow role differs from the stamped default owner", async () => { + const cluster = await sharedCluster(); + await cluster.adminPool + .query(`CREATE ROLE own5_a SUPERUSER LOGIN PASSWORD 'a'`) + .catch(() => {}); + await cluster.adminPool + .query(`CREATE ROLE own5_b SUPERUSER LOGIN PASSWORD 'b'`) + .catch(() => {}); + + const src = await cluster.createDb("expown5_src"); + dbs.push(src); + await cluster.adminPool + .query(`ALTER DATABASE "${src.name}" OWNER TO own5_a`) + .catch(() => {}); + await src.pool.query(` + CREATE SCHEMA s AUTHORIZATION own5_a; + CREATE TABLE s.t_a (id int); + ALTER TABLE s.t_a OWNER TO own5_a; + `); + + // export as admin; datdba(src) === own5_a → manifest default owner own5_a. + const dir = mkdtempSync(join(tmpdir(), "expown5-")); + expect( + ( + await runCli([ + "schema", + "export", + "--source", + src.uri, + "--out-dir", + dir, + ]) + ).exitCode, + ).toBe(0); + expect(readManifest(dir).defaultOwner).toBe("own5_a"); + + // fresh target owned by own5_a, connecting AS own5_a → target guard passes. + const dst = await cluster.createDb("expown5_dst"); + dbs.push(dst); + await cluster.adminPool + .query(`ALTER DATABASE "${dst.name}" OWNER TO own5_a`) + .catch(() => {}); + // explicit --shadow database connecting as own5_b ≠ own5_a. + const shadowDb = await cluster.createDb("expown5_shadow"); + dbs.push(shadowDb); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--target", + uriAsRole(dst, "own5_a", "a"), + "--shadow", + uriAsRole(shadowDb, "own5_b", "b"), + "--renames", + "off", + ]); + // GREEN: exit 2 BEFORE any shadow load; stderr names both the shadow role + // and the manifest default owner. RED (pre-fix): the omitted-`OWNER TO` + // objects load as own5_b, the plan emits `ALTER … OWNER TO own5_b`, apply + // exits 0 (spurious ownership drift) and s.t_a lands on the target. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 2, + }); + expect(res.stderr).toMatch(/own5_b/); + expect(res.stderr).toMatch(/own5_a/); + expect(res.stderr).toMatch(/shadow/i); + // never loaded/applied: the target stayed empty. + const applied = await dst.pool.query<{ n: string }>( + `SELECT count(*)::text AS n FROM pg_class WHERE relname = 't_a' AND relkind = 'r'`, + ); + expect(applied.rows[0]?.n).toBe("0"); + }, 300_000); +}); + +// FIX ⑥: a directory with NO manifest never opted into default-owner +// suppression, so it must be applied VERBOSE — every explicit `OWNER TO` in the +// files is honored. The old behaviour synthesized a default from the target +// profile/datdba and pruned desired owner edges to it, silently dropping an +// explicit `ALTER … OWNER TO ` when the target object was owned by a +// different role. +describe("schema apply: manifest-absent directory is verbose", () => { + test("(h) honors an explicit OWNER TO in a manifest-less dir instead of pruning to a synthesized default", async () => { + const cluster = await sharedCluster(); + await cluster.adminPool + .query(`CREATE ROLE own6_a SUPERUSER LOGIN PASSWORD 'a'`) + .catch(() => {}); + await cluster.adminPool.query(`CREATE ROLE own6_b NOLOGIN`).catch(() => {}); + await cluster.adminPool.query(`GRANT own6_b TO own6_a`).catch(() => {}); + + // hand-authored dir: NO manifest file; explicit OWNER TO own6_a. + const work = mkdtempSync(join(tmpdir(), "expown6-")); + const dir = join(work, "schema"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "01_t.sql"), + `CREATE TABLE public.t (id int);\nALTER TABLE public.t OWNER TO own6_a;\n`, + "utf8", + ); + + // target owned by own6_a (so a synthesized datdba default WOULD be own6_a), + // with t already existing owned by own6_b (the divergent owner). + const dst = await cluster.createDb("expown6_dst"); + dbs.push(dst); + await cluster.adminPool + .query(`ALTER DATABASE "${dst.name}" OWNER TO own6_a`) + .catch(() => {}); + await dst.pool.query(` + CREATE TABLE public.t (id int); + ALTER TABLE public.t OWNER TO own6_b; + `); + + const res = await runCli([ + "schema", + "apply", + "--dir", + dir, + "--target", + uriAsRole(dst, "own6_a", "a"), + "--renames", + "off", + ]); + // GREEN: verbose NOTE prints, the old datdba WARNING is gone, and the + // explicit OWNER TO own6_a is honored → post-apply owner is own6_a. RED + // (pre-fix): applyDefaultOwner is synthesized to the target datdba (own6_a) + // and the desired owner edge to own6_a is pruned → owner-unlink only, no + // ALTER emitted → t stays owned by own6_b. + expect({ code: res.exitCode, stderr: res.stderr }).toMatchObject({ + code: 0, + }); + expect(res.stderr).toMatch(/NOTE:[\s\S]*no default owner/i); + expect(res.stderr).toMatch(/verbose/i); + expect(res.stderr).not.toMatch( + /WARNING: the export directory records no default owner/i, + ); + const owner = await dst.pool.query<{ o: string }>( + `SELECT pg_get_userbyid(relowner) AS o FROM pg_class + WHERE relname = 't' AND relnamespace = 'public'::regnamespace`, + ); + expect(owner.rows[0]?.o).toBe("own6_a"); + }, 300_000); +}); diff --git a/packages/pg-delta/tests/export-profile-assumed.test.ts b/packages/pg-delta/tests/export-profile-assumed.test.ts new file mode 100644 index 000000000..f08d8d561 --- /dev/null +++ b/packages/pg-delta/tests/export-profile-assumed.test.ts @@ -0,0 +1,54 @@ +/** + * Profile export must preserve the policy's `assumedSchemas`/`assumedRoles` + * (review P1). `exportSqlFiles` re-plans the managed view from a pristine + * baseline. With a profile, that view legitimately keeps actions that consume + * assumed-but-filtered objects — e.g. `CREATE EXTENSION … SCHEMA extensions` + * (the `extensions` schema is filtered out of the managed view) or a + * `GRANT … TO anon`. Without forwarding the assumed sets, the export plan's + * action-graph guard treats those as stranded requirements and throws + * `missing requirement` — even though the DB-to-DB `plan --profile` path + * (which receives `assumedSchemas`/`assumedRoles`) succeeds. + * + * Docker required. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { flattenPolicy, resolveView } from "../src/policy/policy.ts"; +import { supabasePolicy } from "../src/policy/supabase.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("profile export preserves assumed schemas/roles", () => { + test("relocatable extension into a filtered schema exports without 'missing requirement'", async () => { + const cluster = await sharedCluster(); + const db = await cluster.createDb("export_assumed_sch"); + dbs.push(db); + + // `extensions` is a Supabase-managed schema (filtered out of the managed + // view); citext relocated into it consumes `schema:extensions`. + await db.pool.query(` + CREATE SCHEMA extensions; + CREATE EXTENSION citext SCHEMA extensions; + `); + + const state = await extract(db.pool); + const view = resolveView(state.factBase, supabasePolicy); + const flat = flattenPolicy(supabasePolicy); + + // RED before the fix: exportSqlFiles → plan(pristine, view) throws + // missing requirement: action "CREATE EXTENSION "citext" SCHEMA + // "extensions"" consumes schema:extensions … + const files = exportSqlFiles(view, { + assumedSchemas: flat.assumedSchemas, + assumedRoles: flat.assumedRoles, + }); + + const allSql = files.map((f) => f.sql).join("\n"); + expect(allSql).toContain(`CREATE EXTENSION "citext" SCHEMA "extensions"`); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/export-reference-only-parent.test.ts b/packages/pg-delta/tests/export-reference-only-parent.test.ts new file mode 100644 index 000000000..d6ec7f11f --- /dev/null +++ b/packages/pg-delta/tests/export-reference-only-parent.test.ts @@ -0,0 +1,65 @@ +/** + * `schema export --profile supabase` must export a managed object kept under a + * reference-only platform parent — the canonical case being a user trigger on + * `auth.users`. `auth`/`auth.users` are filtered by the policy but kept + * reference-only (assumed schema), so they are neither ours to recreate nor + * present in the from-pristine export baseline. Before the fix, `exportSqlFiles` + * planned from a baseline that lacked them, so the kept trigger's requirement on + * `auth.users` was unsatisfiable (missing requirement) / the assumed table was + * recreated. Seeding reference-only facts into the export baseline resolves both + * (PR #307 review #3501088189). + * + * Docker required. Uses a plain container + the real supabasePolicy (no heavy + * Supabase image needed): a user trigger whose function lives in `public` is + * kept managed by the policy's user-trigger rule. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { flattenPolicy, resolveView } from "../src/policy/policy.ts"; +import { supabasePolicy } from "../src/policy/supabase.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("export: managed child under a reference-only assumed parent", () => { + test("a user trigger on auth.users exports without recreating auth.users", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("export_refonly_src"); + dbs.push(src); + + // auth is a Supabase system schema (filtered + assumed → reference-only). + // The trigger's function lives in public, so the policy keeps the trigger as + // a user-managed object on the reference-only auth.users. + await src.pool.query(` + CREATE SCHEMA auth; + CREATE TABLE auth.users (id uuid PRIMARY KEY, email text); + CREATE FUNCTION public.handle_new_user() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RETURN new; END; $$; + CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + `); + + const { factBase } = await extract(src.pool); + const view = resolveView(factBase, supabasePolicy); + const flat = flattenPolicy(supabasePolicy); + + // RED before the fix: this throws "missing requirement" (the trigger's + // parent auth.users is reference-only and absent from the pristine baseline), + // or the assumed auth.users is recreated. + const files = exportSqlFiles(view, { + assumedSchemas: flat.assumedSchemas, + assumedRoles: flat.assumedRoles, + }); + const sql = files.map((f) => f.sql).join("\n"); + + // the user trigger IS exported ... + expect(sql).toContain("on_auth_user_created"); + // ... and the assumed platform table is NOT recreated. + expect(sql).not.toMatch(/CREATE TABLE[^;]*users/i); + }, 60_000); +}); diff --git a/packages/pg-delta/tests/export.test.ts b/packages/pg-delta/tests/export.test.ts new file mode 100644 index 000000000..a83f894e8 --- /dev/null +++ b/packages/pg-delta/tests/export.test.ts @@ -0,0 +1,88 @@ +/** + * Export round-trip gate (stage 9 deliverable 6): + * loadSqlFiles(exportSqlFiles(fb)) ≡ fb hash-identically, and the + * "ordered" layout loads with zero deferred rounds (single pass). + * Cluster-scoped files (roles) are the environment's in databaseScratch + * mode — the shadow's cluster already has them; the schema files still + * reference them (ownership, grants) and must resolve. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { sharedCluster } from "./containers.ts"; + +const SCHEMA_SQL = ` + CREATE SCHEMA app; + CREATE SCHEMA zlib; + CREATE TYPE app.level AS ENUM ('low', 'high'); + CREATE SEQUENCE app.id_seq START 5; + CREATE TABLE app.users ( + id integer NOT NULL DEFAULT nextval('app.id_seq'), + lvl app.level DEFAULT 'low', + email text NOT NULL, + PRIMARY KEY (id) + ); + CREATE INDEX users_email_idx ON app.users (email); + -- cross-schema reference: zlib sorts AFTER app, so the by-object layout + -- needs a deferred round for this view; the ordered layout must not + CREATE TABLE zlib.notes (user_id integer, body text); + CREATE VIEW app.user_notes AS + SELECT u.id, n.body FROM app.users u JOIN zlib.notes n ON n.user_id = u.id; + COMMENT ON TABLE app.users IS 'exported'; + CREATE FUNCTION app.add(a integer, b integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS 'SELECT a + b'; +`; + +describe("stage 9: declarative export", () => { + test("load(export(fb)) is hash-identical; ordered layout needs zero deferred rounds", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("exp_src"); + const shadowA = await cluster.createDb("exp_shadow_a"); + const shadowB = await cluster.createDb("exp_shadow_b"); + try { + await source.pool.query(SCHEMA_SQL); + const fb = (await extract(source.pool)).factBase; + + const byObject = exportSqlFiles(fb).filter( + (f) => !f.name.startsWith("cluster/roles"), + ); + const ordered = exportSqlFiles(fb, { layout: "ordered" }).filter( + (f) => !f.name.includes("cluster_roles"), + ); + + // human layout: fidelity is the contract (rounds may be > 1) + const loadedA = await loadSqlFiles(byObject, shadowA.pool); + expect(loadedA.factBase.rootHash).toBe(fb.rootHash); + + // ordered layout: fidelity AND single-pass convergence + const loadedB = await loadSqlFiles(ordered, shadowB.pool); + expect(loadedB.factBase.rootHash).toBe(fb.rootHash); + expect(loadedB.rounds).toBe(1); + } finally { + await Promise.all([source.drop(), shadowA.drop(), shadowB.drop()]); + } + }, 120_000); + + test("by-object layout writes the familiar tree", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("exp_tree"); + try { + await source.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t (id integer); + CREATE VIEW app.v AS SELECT id FROM app.t; + CREATE FUNCTION app.f() RETURNS integer LANGUAGE sql AS 'SELECT 1'; + `); + const fb = (await extract(source.pool)).factBase; + const names = exportSqlFiles(fb).map((f) => f.name); + expect(names).toContain("cluster/roles.sql"); + expect(names).toContain("schemas/app/schema.sql"); + expect(names).toContain("schemas/app/tables/t.sql"); + expect(names).toContain("schemas/app/views/v.sql"); + expect(names).toContain("schemas/app/functions/f.sql"); + } finally { + await source.drop(); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/extension-assumed-schema.test.ts b/packages/pg-delta/tests/extension-assumed-schema.test.ts new file mode 100644 index 000000000..a7948bcf7 --- /dev/null +++ b/packages/pg-delta/tests/extension-assumed-schema.test.ts @@ -0,0 +1,80 @@ +/** + * `assumedSchemas` ambient exemption (mirror of `assumedRoles`). + * + * A relocatable extension installed INTO a Supabase-managed schema + * (`CREATE EXTENSION citext SCHEMA extensions`) emits an action that + * `consumes schema:extensions`. The Supabase policy filters `extensions` + * (a `SUPABASE_SYSTEM_SCHEMAS` member) out of the managed view, so the schema + * is absent from the source view even though it physically exists at apply + * time. Without `assumedSchemas`, the action-graph missing-requirement guard + * treats the `consumes schema:extensions` edge as a stranded reference and + * throws — exactly the dbdev core-roundtrip blocker. + * + * Declaring `extensions` (via `SUPABASE_SYSTEM_SCHEMAS`) in + * `supabasePolicy.assumedSchemas` exempts it from the guard like `pg_*`/PUBLIC + * and the assumed roles, so the plan emits `CREATE EXTENSION … SCHEMA + * extensions` and applies cleanly against a database where the schema already + * exists. + * + * Docker required. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { supabasePolicy } from "../src/policy/supabase.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("assumedSchemas: relocatable extension into a managed schema (e2e)", () => { + test("CREATE EXTENSION citext SCHEMA extensions plans + applies under supabasePolicy", async () => { + const cluster = await sharedCluster(); + const source = await cluster.createDb("assumed_sch_src"); + const desired = await cluster.createDb("assumed_sch_dst"); + dbs.push(source, desired); + + // `extensions` exists on BOTH databases (a platform-managed schema present + // at apply time), but the policy projects it out of the managed view. + await source.pool.query(`CREATE SCHEMA extensions`); + await desired.pool.query(` + CREATE SCHEMA extensions; + CREATE EXTENSION citext SCHEMA extensions; + `); + + const [sourceState, desiredState] = await Promise.all([ + extract(source.pool), + extract(desired.pool), + ]); + + // RED before the fix: this throws + // missing requirement: action "CREATE EXTENSION "citext" SCHEMA + // "extensions"" consumes schema:extensions, which neither exists on the + // target nor is produced by this plan + const policyPlan = plan(sourceState.factBase, desiredState.factBase, { + policy: supabasePolicy, + }); + + // the relocatable extension renders WITH its SCHEMA clause + const createsCitext = policyPlan.actions.some((a) => + a.sql.includes(`CREATE EXTENSION "citext" SCHEMA "extensions"`), + ); + expect(createsCitext).toBe(true); + + // applies cleanly against `source` (which already has the extensions schema) + const report = await apply(policyPlan, source.pool, { + fingerprintGate: false, + }); + expect(report.status).toBe("applied"); + + // re-diffing under the policy now converges to zero actions + const reSource = await extract(source.pool); + const rePlan = plan(reSource.factBase, desiredState.factBase, { + policy: supabasePolicy, + }); + expect(rePlan.actions).toEqual([]); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/extension-drop-dataloss.test.ts b/packages/pg-delta/tests/extension-drop-dataloss.test.ts new file mode 100644 index 000000000..e9bd145ed --- /dev/null +++ b/packages/pg-delta/tests/extension-drop-dataloss.test.ts @@ -0,0 +1,63 @@ +/** + * DROP EXTENSION is destructive iff the extension owns a data-bearing persisted + * member (table / materialized view): dropping it cascades to those members and + * destroys their rows, yet the members are projected out of the diff (kept + * reference-only) so nothing else raises the destructive flag. The drop rule + * derives it from the member closure (src/plan/rules/schemas.ts). Docker + * required (two real extracts prove the whole chain: the extractor emits the + * `memberOfExtension` edge → resolveView keeps the member reference-only → the + * drop rule flags data-loss). + * + * An extension whose members are only functions/types (citext) stays + * non-destructive — the companion case that keeps the flag from over-firing. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { createTestDb } from "./containers.ts"; + +describe("DROP EXTENSION data-loss (member closure, live extract)", () => { + test("data-bearing extension member → destructive; functions-only → not", async () => { + const source = await createTestDb("ext_drop_src"); + const desired = await createTestDb("ext_drop_dst"); + try { + // hstore owns a user table with rows (added via ALTER EXTENSION … ADD + // TABLE — the same pg_depend deptype 'e' membership a control file records); + // citext owns only a type + functions. + await source.pool.query("CREATE EXTENSION hstore"); + await source.pool.query("CREATE EXTENSION citext"); + await source.pool.query( + "CREATE TABLE public.ext_data (id integer PRIMARY KEY)", + ); + await source.pool.query("INSERT INTO public.ext_data VALUES (1), (2)"); + await source.pool.query( + "ALTER EXTENSION hstore ADD TABLE public.ext_data", + ); + + const sourceFb = (await extract(source.pool)).factBase; + const desiredFb = (await extract(desired.pool)).factBase; + + // desired has neither extension → the plan drops both. + const thePlan = plan(sourceFb, desiredFb); + + const hstoreDrop = thePlan.actions.find( + (a) => a.verb === "drop" && /DROP EXTENSION "hstore"/.test(a.sql), + ); + const citextDrop = thePlan.actions.find( + (a) => a.verb === "drop" && /DROP EXTENSION "citext"/.test(a.sql), + ); + + expect(hstoreDrop).toBeDefined(); + expect(citextDrop).toBeDefined(); + // owns a data-bearing member table → destructive + expect(hstoreDrop!.dataLoss).toBe("destructive"); + // functions/type only → not destructive + expect(citextDrop!.dataLoss).toBe("none"); + // the aggregate safety report reflects exactly the one destructive drop + expect(thePlan.safetyReport.destructiveActions).toBe(1); + } finally { + await source.drop(); + await desired.drop(); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/extension-intent-cron.test.ts b/packages/pg-delta/tests/extension-intent-cron.test.ts new file mode 100644 index 000000000..ca6b05337 --- /dev/null +++ b/packages/pg-delta/tests/extension-intent-cron.test.ts @@ -0,0 +1,435 @@ +/** + * Extension-intent Deliverable A3, end-to-end against a real pg_cron DB + * (docs/architecture/extension-intent.md §3.2). Drives the public profile seam + * (`resolveProfile`) the same way `profile-e2e-partman.test.ts` does for + * pg_partman: a custom profile carrying ONLY `pgCronHandler` isolates the + * cron-intent mechanism from the rest of `supabaseProfile`'s policy. + * + * pg_cron ships in the `supabase/postgres` image, not plain alpine, so this + * gates behind `runSupabaseBareTests` / `PGDELTA_NEXT_SUPABASE_TESTS` exactly + * like `extension-intent-partman.test.ts` and `profile-e2e-partman.test.ts`. + * + * UNLIKE every other extension-intent integration test, this file cannot use + * `cluster.createDb(...)` isolated databases: pg_cron can only be installed + * and scheduled from a SINGLE database per cluster (`cron.database_name`, + * which defaults to `postgres`). Attempting `CREATE EXTENSION pg_cron` or + * `cron.schedule(...)` on any other database fails with + * "can only create extension in database postgres" / "Jobs must be scheduled + * from the database configured in cron.database_name". So every scenario here + * runs against the cluster's own `postgres` database (`cluster.adminPool`), + * taking two snapshots in TIME (extract → mutate `cron.job` → extract) + * instead of two snapshots in SPACE (two databases). Bun runs a file's tests + * sequentially, so `afterEach` clearing `cron.job` gives each test a clean + * slate despite sharing the one database. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { plan } from "../src/plan/plan.ts"; +import { + type IntegrationProfile, + resolveProfile, +} from "../src/integrations/profile.ts"; +import { pgCronHandler } from "../src/policy/extensions/index.ts"; +import { + SUPABASE_EXTENSION_HANDLERS, + supabaseProfile, +} from "../src/integrations/supabase.ts"; +import { runSupabaseBareTests, supabaseCluster } from "./containers.ts"; + +const cronProfile: IntegrationProfile = { + id: "test-cron", + handlers: [pgCronHandler], +}; + +describe.skipIf(!runSupabaseBareTests)( + "extension-intent: pg_cron jobs (docs/architecture/extension-intent.md §3.2)", + () => { + afterEach(async () => { + const cluster = await supabaseCluster(); + await cluster.adminPool.query(`DELETE FROM cron.job`); + }); + + test("create: a named job in the desired state plans a select cron.schedule_in_database(...) action, and applying it creates the job", async () => { + const cluster = await supabaseCluster(); + const pool = cluster.adminPool; + await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_cron`); + + const ctx = await resolveProfile(pool, cronProfile); + + // SOURCE snapshot: no jobs yet. + const sourceFb = (await ctx.extract(pool)).factBase; + + // DESIRED snapshot: the job exists. + await pool.query( + `SELECT cron.schedule('vac_create', '0 0 * * *', 'VACUUM')`, + ); + const desiredFb = (await ctx.extract(pool)).factBase; + + const thePlan = plan(sourceFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + + const scheduleAction = thePlan.actions.find((a) => + /select cron\.schedule_in_database\('vac_create'/.test(a.sql), + ); + expect(scheduleAction).toBeDefined(); + expect(scheduleAction?.verb).toBe("create"); + + // reset the DB to exactly the SOURCE state before apply, so the + // fingerprint gate (which re-extracts `pool` and compares against + // `thePlan.source.fingerprint`) passes. + await pool.query(`SELECT cron.unschedule('vac_create')`); + + const report = await apply(thePlan, pool, ctx.applyOptions); + expect(report.status).toBe("applied"); + + const { rows } = await pool.query<{ schedule: string }>( + `SELECT schedule FROM cron.job WHERE jobname = 'vac_create'`, + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.schedule).toBe("0 0 * * *"); + }, 180_000); + + test("edit: a schedule change plans unschedule before schedule, and applying it updates the job", async () => { + const cluster = await supabaseCluster(); + const pool = cluster.adminPool; + await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_cron`); + + const ctx = await resolveProfile(pool, cronProfile); + + // SOURCE snapshot: the job with its original schedule. + await pool.query( + `SELECT cron.schedule('vac_edit', '0 0 * * *', 'VACUUM')`, + ); + const sourceFb = (await ctx.extract(pool)).factBase; + + // DESIRED snapshot: the job with a new schedule. + await pool.query(`SELECT cron.unschedule('vac_edit')`); + await pool.query( + `SELECT cron.schedule('vac_edit', '*/5 * * * *', 'VACUUM')`, + ); + const desiredFb = (await ctx.extract(pool)).factBase; + + const thePlan = plan(sourceFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + + const unscheduleIdx = thePlan.actions.findIndex((a) => + /select cron\.unschedule\('vac_edit'\)/.test(a.sql), + ); + const scheduleIdx = thePlan.actions.findIndex((a) => + /select cron\.schedule_in_database\('vac_edit', '\*\/5 \* \* \* \*'/.test( + a.sql, + ), + ); + expect(unscheduleIdx).toBeGreaterThanOrEqual(0); + expect(scheduleIdx).toBeGreaterThanOrEqual(0); + expect(unscheduleIdx).toBeLessThan(scheduleIdx); + + // reset the DB to exactly the SOURCE state before apply. + await pool.query(`SELECT cron.unschedule('vac_edit')`); + await pool.query( + `SELECT cron.schedule('vac_edit', '0 0 * * *', 'VACUUM')`, + ); + + const report = await apply(thePlan, pool, ctx.applyOptions); + expect(report.status).toBe("applied"); + + const { rows } = await pool.query<{ schedule: string }>( + `SELECT schedule FROM cron.job WHERE jobname = 'vac_edit'`, + ); + expect(rows[0]?.schedule).toBe("*/5 * * * *"); + }, 180_000); + + test("remove: a job absent from desired plans a cron.unschedule with no data loss, and applying it removes the job", async () => { + const cluster = await supabaseCluster(); + const pool = cluster.adminPool; + await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_cron`); + + const ctx = await resolveProfile(pool, cronProfile); + + // SOURCE snapshot: the job exists. + await pool.query(`SELECT cron.schedule('vac_rm', '0 0 * * *', 'VACUUM')`); + const sourceFb = (await ctx.extract(pool)).factBase; + + // DESIRED snapshot: the job is gone. + await pool.query(`SELECT cron.unschedule('vac_rm')`); + const desiredFb = (await ctx.extract(pool)).factBase; + + const thePlan = plan(sourceFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + + const dropAction = thePlan.actions.find((a) => + /select cron\.unschedule\('vac_rm'\)/.test(a.sql), + ); + expect(dropAction).toBeDefined(); + expect(dropAction?.verb).toBe("drop"); + expect(dropAction?.dataLoss).toBe("none"); + + // reset the DB to exactly the SOURCE state before apply. + await pool.query(`SELECT cron.schedule('vac_rm', '0 0 * * *', 'VACUUM')`); + + const report = await apply(thePlan, pool, ctx.applyOptions); + expect(report.status).toBe("applied"); + + const { rows } = await pool.query<{ c: number }>( + `SELECT count(*)::int AS c FROM cron.job WHERE jobname = 'vac_rm'`, + ); + expect(rows[0]?.c).toBe(0); + }, 180_000); + + test("convergence: re-extracting the applied DB and re-planning against the same desired state is a no-op", async () => { + const cluster = await supabaseCluster(); + const pool = cluster.adminPool; + await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_cron`); + + const ctx = await resolveProfile(pool, cronProfile); + + // SOURCE snapshot: the job with its original schedule. + await pool.query( + `SELECT cron.schedule('vac_conv', '0 0 * * *', 'VACUUM')`, + ); + const sourceFb = (await ctx.extract(pool)).factBase; + + // DESIRED snapshot: the job with a new schedule. + await pool.query(`SELECT cron.unschedule('vac_conv')`); + await pool.query( + `SELECT cron.schedule('vac_conv', '*/5 * * * *', 'VACUUM')`, + ); + const desiredFb = (await ctx.extract(pool)).factBase; + + const thePlan = plan(sourceFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + + // reset to SOURCE, then apply to reach DESIRED for real. + await pool.query(`SELECT cron.unschedule('vac_conv')`); + await pool.query( + `SELECT cron.schedule('vac_conv', '0 0 * * *', 'VACUUM')`, + ); + const report = await apply(thePlan, pool, ctx.applyOptions); + expect(report.status).toBe("applied"); + + // idempotence: applying the SAME desired state again against the + // now-converged DB must be a no-op plan. + const reappliedFb = (await ctx.extract(pool)).factBase; + const secondPlan = plan(reappliedFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + expect(secondPlan.actions.length).toBe(0); + }, 180_000); + + test("convergence: an inactive job replays via schedule_in_database and stays inactive", async () => { + const cluster = await supabaseCluster(); + const pool = cluster.adminPool; + await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_cron`); + + const ctx = await resolveProfile(pool, cronProfile); + + // SOURCE snapshot: clean, no jobs. + const sourceFb = (await ctx.extract(pool)).factBase; + + // DESIRED snapshot: an INACTIVE job. The 3-arg cron.schedule form cannot + // express this (it always creates the job active), so this is the case + // the schedule_in_database replay exists for. + await pool.query( + `SELECT cron.schedule_in_database('vac_inactive', '0 0 * * *', 'VACUUM', current_database(), 'postgres', false)`, + ); + const desiredFb = (await ctx.extract(pool)).factBase; + + const thePlan = plan(sourceFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + + const scheduleAction = thePlan.actions.find((a) => + /select cron\.schedule_in_database\('vac_inactive'/.test(a.sql), + ); + expect(scheduleAction).toBeDefined(); + expect(scheduleAction?.verb).toBe("create"); + // active=false is replayed as the 6th argument. + expect(scheduleAction?.sql).toContain(", false)"); + + // reset the DB to exactly the SOURCE state before apply. Delete directly + // (not cron.unschedule, which filters by username = current_user) because + // the job is owned by `postgres` while adminPool connects as + // `supabase_admin`. + await pool.query(`DELETE FROM cron.job WHERE jobname = 'vac_inactive'`); + + const report = await apply(thePlan, pool, ctx.applyOptions); + expect(report.status).toBe("applied"); + + // the applied job is inactive AND owned by `postgres` — both the 6-arg + // replay's whole point (the 3-arg form would create it active, owned by + // the executing `supabase_admin`). + const { rows } = await pool.query<{ active: boolean; username: string }>( + `SELECT active, username FROM cron.job WHERE jobname = 'vac_inactive'`, + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.active).toBe(false); + expect(rows[0]?.username).toBe("postgres"); + + // convergence: re-extracting the applied DB and re-planning against the + // same desired state is a no-op (with the old 3-arg form the reapplied + // job would be active=true, so this plan would NOT be empty). + const reappliedFb = (await ctx.extract(pool)).factBase; + const secondPlan = plan(reappliedFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + expect(secondPlan.actions.length).toBe(0); + }, 180_000); + + test("convergence: a job targeting another database replays that database via schedule_in_database", async () => { + const cluster = await supabaseCluster(); + const pool = cluster.adminPool; + await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_cron`); + // A distinct target database the job runs in. pg_cron metadata still lives + // in the cron database (postgres); only the job's `database` column points + // elsewhere — exactly what the 3-arg cron.schedule form cannot reproduce. + await pool.query( + `DROP DATABASE IF EXISTS pgdelta_cron_target WITH (FORCE)`, + ); + await pool.query(`CREATE DATABASE pgdelta_cron_target`); + try { + const ctx = await resolveProfile(pool, cronProfile); + + // SOURCE snapshot: clean, no jobs. + const sourceFb = (await ctx.extract(pool)).factBase; + + // DESIRED snapshot: a job whose command runs in the other database. + await pool.query( + `SELECT cron.schedule_in_database('vac_xdb', '0 0 * * *', 'VACUUM', 'pgdelta_cron_target', 'postgres', true)`, + ); + const desiredFb = (await ctx.extract(pool)).factBase; + + const thePlan = plan(sourceFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + + const scheduleAction = thePlan.actions.find((a) => + /select cron\.schedule_in_database\('vac_xdb'/.test(a.sql), + ); + expect(scheduleAction).toBeDefined(); + expect(scheduleAction?.verb).toBe("create"); + expect(scheduleAction?.sql).toContain("'pgdelta_cron_target'"); + + // reset the DB to exactly the SOURCE state before apply. Delete + // directly (not cron.unschedule, which filters by current_user) since + // the job is owned by `postgres`, not the `supabase_admin` connection. + await pool.query(`DELETE FROM cron.job WHERE jobname = 'vac_xdb'`); + + const report = await apply(thePlan, pool, ctx.applyOptions); + expect(report.status).toBe("applied"); + + // the applied job targets the other database — the whole point. + const { rows } = await pool.query<{ database: string }>( + `SELECT database FROM cron.job WHERE jobname = 'vac_xdb'`, + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.database).toBe("pgdelta_cron_target"); + + // convergence: re-planning against the same desired state is a no-op + // (with the old 3-arg form the job's database would be `postgres`). + const reappliedFb = (await ctx.extract(pool)).factBase; + const secondPlan = plan(reappliedFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + expect(secondPlan.actions.length).toBe(0); + } finally { + // afterEach clears cron.job; drop the extra target database so it does + // not leak across tests (FORCE terminates the cron launcher's backend). + await pool.query( + `DROP DATABASE IF EXISTS pgdelta_cron_target WITH (FORCE)`, + ); + } + }, 180_000); + + test("an unnamed job in the desired state makes plan() throw the unkeyed error", async () => { + const cluster = await supabaseCluster(); + const pool = cluster.adminPool; + await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_cron`); + + const ctx = await resolveProfile(pool, cronProfile); + + // SOURCE snapshot: clean, no jobs. + const sourceFb = (await ctx.extract(pool)).factBase; + + // DESIRED snapshot: an unnamed row inserted directly, simulating the + // legacy 2-arg cron.schedule path (which cron.schedule's 3-arg named + // form can no longer produce). + await pool.query( + `INSERT INTO cron.job (jobname, schedule, command, nodename, nodeport, database, username, active) + VALUES (NULL, '0 0 * * *', 'VACUUM', 'localhost', 5432, current_database(), 'postgres', true)`, + ); + const desiredFb = (await ctx.extract(pool)).factBase; + + expect(() => + plan(sourceFb, desiredFb, { ...ctx.planOptions, renames: "off" }), + ).toThrow(/cannot key|unnamed|no jobname/i); + }, 180_000); + + // ── Supabase database context (review #318): the isolated cronProfile above + // proves the MECHANISM; this proves the same works through the REAL + // `supabaseProfile` — the full managed view (supabase policy + baseline + + // the bundled pgPartmanHandler) must NOT filter out a user cron job's intent + // fact, and the replay must apply cleanly against a Supabase database. ────── + test("supabaseProfile bundles pgCronHandler", () => { + expect(SUPABASE_EXTENSION_HANDLERS).toContain(pgCronHandler); + expect(supabaseProfile.handlers).toContain(pgCronHandler); + }); + + test("supabase context: a user cron job plans + applies as intent through supabaseProfile", async () => { + const cluster = await supabaseCluster(); + const pool = cluster.adminPool; + await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_cron`); + + // Resolve the FULL Supabase profile (handlers + policy + baseline), the + // way the platform would. + const ctx = await resolveProfile(pool, supabaseProfile); + + // SOURCE snapshot: no such job (unique name avoids colliding with any + // job the Supabase image / baseline ships). + const sourceFb = (await ctx.extract(pool)).factBase; + + // DESIRED snapshot: a user job whose command references a user schema, + // like the middleware-db jobs do. + await pool.query( + `SELECT cron.schedule('pgdelta_e2e_supa_prune', '*/15 * * * *', $$DELETE FROM public.audit_log WHERE created_at < now() - interval '30 days'$$)`, + ); + const desiredFb = (await ctx.extract(pool)).factBase; + + const thePlan = plan(sourceFb, desiredFb, { + ...ctx.planOptions, + renames: "off", + }); + + // the cron intent survives the supabase policy + baseline projection. + const scheduleAction = thePlan.actions.find((a) => + /select cron\.schedule_in_database\('pgdelta_e2e_supa_prune'/.test( + a.sql, + ), + ); + expect(scheduleAction).toBeDefined(); + expect(scheduleAction?.verb).toBe("create"); + + // reset to SOURCE, then apply the plan for real through the profile. + await pool.query(`SELECT cron.unschedule('pgdelta_e2e_supa_prune')`); + const report = await apply(thePlan, pool, ctx.applyOptions); + expect(report.status).toBe("applied"); + + const { rows } = await pool.query<{ c: number }>( + `SELECT count(*)::int AS c FROM cron.job WHERE jobname = 'pgdelta_e2e_supa_prune'`, + ); + expect(rows[0]?.c).toBe(1); + }, 180_000); + }, +); diff --git a/packages/pg-delta/tests/extension-intent-partman.test.ts b/packages/pg-delta/tests/extension-intent-partman.test.ts new file mode 100644 index 000000000..3a095634c --- /dev/null +++ b/packages/pg-delta/tests/extension-intent-partman.test.ts @@ -0,0 +1,215 @@ +/** + * Extension-intent Deliverable A, end-to-end against a real pg_partman DB + * (docs/architecture/extension-intent.md §3.3, §4.3; CLI-1555 / CLI-1591). + * + * Reproduces the destructive bug — a declarative diff DROPs the partman child + * partitions — and proves the pg_partman handler + the managed view stop it, + * while leaving the partitioned parent intact. Handlers now run INSIDE the + * extraction transaction (`extract(pool, { handlers })`) and `resolveView` + * (inside `plan`/`prove`) is the single projection point that drops the + * `managedBy` children — no caller-side `excludeManaged`. Uses the Supabase + * image, which ships pg_partman. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import type { ExtensionHandler } from "../src/extract/handler.ts"; +import { diff, type Delta } from "../src/core/diff.ts"; +import { resolveView } from "../src/policy/policy.ts"; +import { pgPartmanHandler } from "../src/policy/extensions/index.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import type { StableId } from "../src/core/stable-id.ts"; +import { sharedCluster, supabaseCluster, type TestDb } from "./containers.ts"; + +const eventsParent: StableId = { + kind: "table", + schema: "public", + name: "events", +}; + +/** a `remove` of a partman child partition (public.events_* but not the parent) */ +function dropsPartmanChild(deltas: Delta[]): boolean { + return deltas.some( + (d) => + d.verb === "remove" && + d.fact.id.kind === "table" && + d.fact.id.schema === "public" && + d.fact.id.name !== "events" && + d.fact.id.name.startsWith("events"), + ); +} + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("extension-intent: pg_partman managed partitions are not dropped (CLI-1555)", () => { + test("handler-aware extraction + resolveView stop the destructive drop, parent survives", async () => { + const cluster = await supabaseCluster(); + + // SOURCE: the live DB — partman creates child partitions at runtime + const source = await cluster.createDb("partman_src"); + dbs.push(source); + await source.pool.query(`CREATE SCHEMA IF NOT EXISTS partman`); + await source.pool.query( + `CREATE EXTENSION IF NOT EXISTS pg_partman WITH SCHEMA partman`, + ); + await source.pool.query( + `CREATE TABLE public.events ( + id bigint GENERATED ALWAYS AS IDENTITY, + created_at timestamptz NOT NULL + ) PARTITION BY RANGE (created_at)`, + ); + await source.pool.query( + `SELECT partman.create_parent( + p_parent_table := 'public.events', + p_control := 'created_at', + p_interval := '1 day' + )`, + ); + + // DESIRED: the declarative source — only the parent is declared + const desired = await cluster.createDb("partman_dst"); + dbs.push(desired); + await desired.pool.query( + `CREATE TABLE public.events ( + id bigint GENERATED ALWAYS AS IDENTITY, + created_at timestamptz NOT NULL + ) PARTITION BY RANGE (created_at)`, + ); + + // CONTROL: a plain diff (no handler) DROPs the partman children + const sourceRaw = await extract(source.pool); + const desiredRaw = await extract(desired.pool); + expect( + dropsPartmanChild(diff(sourceRaw.factBase, desiredRaw.factBase)), + ).toBe(true); + + // FIXED: handler-aware extraction tags children `managedBy` on the core + // snapshot; resolveView (no policy) projects them out of BOTH sides → no + // drop, parent preserved. This is the DEFAULT projection the planner uses. + const sourceManaged = resolveView( + (await extract(source.pool, { handlers: [pgPartmanHandler] })).factBase, + undefined, + ); + const desiredManaged = resolveView( + (await extract(desired.pool, { handlers: [pgPartmanHandler] })).factBase, + undefined, + ); + const fixedDeltas = diff(sourceManaged, desiredManaged); + + expect(dropsPartmanChild(fixedDeltas)).toBe(false); + expect(sourceManaged.has(eventsParent)).toBe(true); + }, 180_000); + + test("the managed plan is proof-clean and preserves child rows (data-preservation)", async () => { + const cluster = await supabaseCluster(); + const handlers = [pgPartmanHandler]; + + // SOURCE: parent + partman children + a seeded row in a child + const source = await cluster.createDb("partman_prove_src"); + dbs.push(source); + await source.pool.query(`CREATE SCHEMA IF NOT EXISTS partman`); + await source.pool.query( + `CREATE EXTENSION IF NOT EXISTS pg_partman WITH SCHEMA partman`, + ); + await source.pool.query( + `CREATE TABLE public.events ( + id bigint GENERATED ALWAYS AS IDENTITY, + created_at timestamptz NOT NULL + ) PARTITION BY RANGE (created_at)`, + ); + await source.pool.query( + `SELECT partman.create_parent( + p_parent_table := 'public.events', + p_control := 'created_at', + p_interval := '1 day' + )`, + ); + await source.pool.query( + `INSERT INTO public.events (created_at) VALUES (now())`, + ); + + // DESIRED: the declarative source declares the extension + the partitioned + // parent and makes a REAL parent change (adds a column), but does NOT run + // create_parent — so it has no runtime children (Phase A). The plan does + // real work (ALTER ADD COLUMN) yet must not touch the managed partitions. + const desiredDb = await cluster.createDb("partman_prove_dst"); + dbs.push(desiredDb); + await desiredDb.pool.query(`CREATE SCHEMA IF NOT EXISTS partman`); + await desiredDb.pool.query( + `CREATE EXTENSION IF NOT EXISTS pg_partman WITH SCHEMA partman`, + ); + await desiredDb.pool.query( + `CREATE TABLE public.events ( + id bigint GENERATED ALWAYS AS IDENTITY, + created_at timestamptz NOT NULL, + note text + ) PARTITION BY RANGE (created_at)`, + ); + + // handler-aware extraction on both sides; plan's resolveView drops the + // managed children, so the plan only carries the parent's column add. + const sourceFb = (await extract(source.pool, { handlers })).factBase; + const desiredFb = (await extract(desiredDb.pool, { handlers })).factBase; + const thePlan = plan(sourceFb, desiredFb, { + renames: "off", + compact: true, + }); + + // prove against a sacrificial clone of the source, re-extracting with the + // SAME handler-aware extractor so the proof projects the SAME managed view. + const clone = await source.clone(); + dbs.push(clone); + const verdict = await provePlan(thePlan, clone.pool, desiredFb, { + reextract: (pool) => extract(pool, { handlers }), + }); + + expect(verdict.applyError).toBeUndefined(); + expect(verdict.driftDeltas).toEqual([]); + expect(verdict.dataViolations).toEqual([]); + expect(verdict.ok).toBe(true); + // the proof reports honest coverage; the seeded child partition is checked. + expect(verdict.coverage.tablesChecked).toBeGreaterThan(0); + + // the seeded child row survived the migration on the clone + const { rows } = await clone.pool.query<{ c: number }>( + `SELECT count(*)::int AS c FROM public.events`, + ); + expect(rows[0]?.c).toBe(1); + }, 180_000); + + test("extension handlers capture on the SAME snapshot as core extraction (coherence)", async () => { + // Proves handlers run inside the core REPEATABLE READ transaction, not on + // a fresh post-COMMIT connection. A committed write issued on a DIFFERENT + // backend DURING capture must be invisible to the handler's snapshot-bound + // query — otherwise handler edges could describe a different moment in DB + // time than the core facts they reference (P1). + const cluster = await sharedCluster(); + const db = await cluster.createDb("handler_snapshot"); + dbs.push(db); + await db.pool.query(`CREATE TABLE public.t (id int)`); + await db.pool.query(`INSERT INTO public.t VALUES (1)`); + + let seen = -1; + const probe: ExtensionHandler = { + extension: "probe", + async capture(ctx) { + const writer = await db.pool.connect(); + try { + await writer.query(`INSERT INTO public.t VALUES (2)`); + } finally { + writer.release(); + } + const rows = await ctx.query(`SELECT count(*)::int AS c FROM public.t`); + seen = Number(rows[0]?.["c"]); + return { facts: [], edges: [] }; + }, + }; + + await extract(db.pool, { handlers: [probe] }); + // frozen snapshot opened before capture → the concurrent insert is unseen. + expect(seen).toBe(1); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/extension-member-acl.test.ts b/packages/pg-delta/tests/extension-member-acl.test.ts new file mode 100644 index 000000000..f612fcc94 --- /dev/null +++ b/packages/pg-delta/tests/extension-member-acl.test.ts @@ -0,0 +1,143 @@ +/** + * Extension-member ACL/comment/seclabel customizations must be diffed and + * reproduced. An extension member (e.g. an hstore function, a pg_net function) + * is created by CREATE EXTENSION, so pg-delta never independently CREATEs/DROPs + * it — but a GRANT / COMMENT / SECURITY LABEL layered on it afterward is USER + * state (Supabase grants net.http_get to anon/authenticated/…), and losing it + * makes the managed view diverge from reality. The member OBJECT stays + * reference-only (never a create/drop/alter action); only its satellite facts + * that differ from the extension's install-time state are diffed. Docker + * required. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("extension-member ACL customizations are diffed", () => { + test("a GRANT on an extension-member function is planned + converges", async () => { + const cluster = await sharedCluster(); + await cluster.adminPool + .query(`CREATE ROLE extacl_grantee NOLOGIN`) + .catch(() => {}); + const src = await cluster.createDb("extacl_src"); + const dst = await cluster.createDb("extacl_dst"); + dbs.push(src, dst); + // both sides install hstore; only dst grants a member function to the role. + await src.pool.query(`CREATE EXTENSION hstore SCHEMA public;`); + await dst.pool.query( + `CREATE EXTENSION hstore SCHEMA public; + GRANT EXECUTE ON FUNCTION hstore(text, text) TO extacl_grantee;`, + ); + const [s, d] = await Promise.all([extract(src.pool), extract(dst.pool)]); + const thePlan = plan(s.factBase, d.factBase); + const sql = thePlan.actions.map((a) => a.sql); + + // The member function is never itself created/dropped (extension-managed). + expect( + sql.some((x) => /CREATE (OR REPLACE )?FUNCTION.*hstore/.test(x)), + ).toBe(false); + expect(sql.some((x) => /DROP FUNCTION.*hstore/.test(x))).toBe(false); + // But the GRANT it carries IS planned. + expect( + sql.some((x) => x.includes("GRANT") && x.includes("extacl_grantee")), + ).toBe(true); + + // …and the plan converges against a real clone of the source. + const clone = await src.clone(); + dbs.push(clone); + const verdict = await provePlan(thePlan, clone.pool, d.factBase); + expect(verdict.applyError).toBeUndefined(); + expect(verdict.driftDeltas).toEqual([]); + expect(verdict.ok).toBe(true); + }, 120_000); + + test("a COMMENT on an extension-member function is planned + converges", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("extcmt_src"); + const dst = await cluster.createDb("extcmt_dst"); + dbs.push(src, dst); + await src.pool.query(`CREATE EXTENSION hstore SCHEMA public;`); + await dst.pool.query( + `CREATE EXTENSION hstore SCHEMA public; + COMMENT ON FUNCTION hstore(text, text) IS 'user note on an extension member';`, + ); + const [s, d] = await Promise.all([extract(src.pool), extract(dst.pool)]); + const thePlan = plan(s.factBase, d.factBase); + const sql = thePlan.actions.map((a) => a.sql); + + expect( + sql.some((x) => /CREATE (OR REPLACE )?FUNCTION.*hstore/.test(x)), + ).toBe(false); + expect( + sql.some( + (x) => x.includes("COMMENT ON FUNCTION") && x.includes("hstore"), + ), + ).toBe(true); + + const clone = await src.clone(); + dbs.push(clone); + const verdict = await provePlan(thePlan, clone.pool, d.factBase); + expect(verdict.applyError).toBeUndefined(); + expect(verdict.driftDeltas).toEqual([]); + expect(verdict.ok).toBe(true); + }, 120_000); + + // A REVOKE of an extension member's INSTALL-TIME grant (here PUBLIC EXECUTE, + // which acldefault gives every function) is a customization BELOW the + // as-installed state — the init-privs delta must emit it, and the reverse + // (dropping the customization) must RESTORE the install grant, not blindly + // REVOKE ALL. Both directions are invisible to the corpus proof loop because + // extraction is symmetrically blind, so they are asserted on plan shape. + test("a REVOKE ... FROM PUBLIC on a member function is planned + restored on drop", async () => { + const cluster = await sharedCluster(); + const plain = await cluster.createDb("extrev_plain"); + const revoked = await cluster.createDb("extrev_revoked"); + dbs.push(plain, revoked); + await plain.pool.query(`CREATE EXTENSION hstore SCHEMA public;`); + await revoked.pool.query( + `CREATE EXTENSION hstore SCHEMA public; + REVOKE EXECUTE ON FUNCTION hstore(text, text) FROM PUBLIC;`, + ); + const [plainState, revokedState] = await Promise.all([ + extract(plain.pool), + extract(revoked.pool), + ]); + + // forward (plain -> revoked): the REVOKE must appear (RED today: both sides + // extract no PUBLIC acl fact, so the diff is empty). + const forward = plan(plainState.factBase, revokedState.factBase); + const fwdSql = forward.actions.map((a) => a.sql); + expect(fwdSql.some((s) => /REVOKE .*hstore.* FROM PUBLIC/.test(s))).toBe( + true, + ); + const fclone = await plain.clone(); + dbs.push(fclone); + const fwdVerdict = await provePlan( + forward, + fclone.pool, + revokedState.factBase, + ); + expect(fwdVerdict.ok).toBe(true); + + // reverse (revoked -> plain): dropping the customization RESTORES the + // install grant (GRANT ... TO PUBLIC), not a bare REVOKE ALL. + const reverse = plan(revokedState.factBase, plainState.factBase); + const revSql = reverse.actions.map((a) => a.sql); + expect(revSql.some((s) => /GRANT .*hstore.* TO PUBLIC/.test(s))).toBe(true); + const rclone = await revoked.clone(); + dbs.push(rclone); + const revVerdict = await provePlan( + reverse, + rclone.pool, + plainState.factBase, + ); + expect(revVerdict.ok).toBe(true); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/extension-member-ordering.test.ts b/packages/pg-delta/tests/extension-member-ordering.test.ts new file mode 100644 index 000000000..e496fe4f7 --- /dev/null +++ b/packages/pg-delta/tests/extension-member-ordering.test.ts @@ -0,0 +1,76 @@ +/** + * Dependency-resolution decision for the provenance flip (4b Stage 3). + * Docker required (the Supabase image, which ships citext). + * + * DECISION: keep the pg_depend resolver's "collapse member references to the + * extension" behaviour (src/extract/extract.ts, the pg_proc / pg_type / … CASE + * branches), now that members are also observed as facts (Stage 2). + * + * Why not re-point references to the member fact? A user object that depends on + * an extension member (a table column of an extension type, a default calling + * an extension function) records a pg_depend edge to the MEMBER. The resolver + * collapses that to a depends edge on the EXTENSION. The member is now kept + * REFERENCE-ONLY (not hard-pruned), but it is never a create action of its own, + * so a depends edge on the member would give the user object no "create the + * extension first" constraint. The collapsed edge points at the extension + * (which CREATE EXTENSION produces), so the ordering holds. + * + * This test pins that: a user table using an extension type is ordered AFTER the + * extension and proves clean. (Member-as-fact observation + projection is + * covered by extension-member-parity and extension-intent-partman.) + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { supabaseCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("extension-member ordering (4b Stage 3): resolver collapse preserves ordering", () => { + test("a table using an extension type is ordered after the extension and proves clean", async () => { + const cluster = await supabaseCluster(); + + // DESIRED: a user table whose column uses citext, an extension-owned type + const desired = await cluster.createDb("ext_order_desired"); + dbs.push(desired); + await desired.pool.query(`CREATE EXTENSION IF NOT EXISTS citext`); + await desired.pool.query( + `CREATE TABLE public.contacts (id integer PRIMARY KEY, email citext NOT NULL)`, + ); + + // SOURCE: empty — the plan must build the extension AND the table + const source = await cluster.createDb("ext_order_source"); + dbs.push(source); + + const desiredState = await extract(desired.pool); + const sourceState = await extract(source.pool); + const thePlan = plan(sourceState.factBase, desiredState.factBase); + + // the table's CREATE must come after the extension's CREATE (identified by + // the StableId each action produces — not by SQL text) + const produces = (kind: string, name: string) => + thePlan.actions.findIndex((a) => + a.produces.some( + (id) => id.kind === kind && (id as { name?: string }).name === name, + ), + ); + const extIdx = produces("extension", "citext"); + const tableIdx = produces("table", "contacts"); + expect(extIdx).toBeGreaterThanOrEqual(0); + expect(tableIdx).toBeGreaterThan(extIdx); + + // end-to-end: applying the plan to an empty clone converges and preserves + // data — this is the real ordering check (a table built before its + // extension would fail to apply, so proof would not be ok) + const clone = await source.clone(); + dbs.push(clone); + const verdict = await provePlan(thePlan, clone.pool, desiredState.factBase); + expect(verdict.applyError).toBeUndefined(); + expect(verdict.driftDeltas).toEqual([]); + expect(verdict.ok).toBe(true); + }, 180_000); +}); diff --git a/packages/pg-delta/tests/extension-member-parity.test.ts b/packages/pg-delta/tests/extension-member-parity.test.ts new file mode 100644 index 000000000..1362d9745 --- /dev/null +++ b/packages/pg-delta/tests/extension-member-parity.test.ts @@ -0,0 +1,251 @@ +/** + * Parity / inventory harness for the provenance flip (4b Stage 1). + * Docker required (the Supabase image, which ships rich extensions). + * + * This is an INDEPENDENT oracle: it asks pg_depend directly which objects are + * extension members (deptype 'e') of a modeled kind — the set the extractor + * historically removed with `notExtensionMember` — and checks the flipped + * extractor against it, family by family: + * + * - soundness (enforced for every flipped family, always): every observed + * fact that the catalog says is an extension member MUST carry an outgoing + * `memberOfExtension` edge. A family flipped WITHOUT emitting the edge would + * leak an untagged member past the default projection — this catches it. + * - completeness (per kind in FLIPPED_KINDS): every catalog member of a + * flipped kind MUST be observed as a fact. This is what goes RED before a + * family is flipped and GREEN after — the per-family migration signal. + * + * FLIPPED_KINDS grows in Stage 2 as each extractor family is flipped. Before a + * kind is flipped its members are absent (filtered), so completeness is not yet + * asserted for it; soundness is asserted unconditionally (absent members are + * vacuously sound). + */ +import { afterAll, describe, expect, test } from "bun:test"; +import type { Pool } from "pg"; +import type { FactBase } from "../src/core/fact.ts"; +import { encodeId, type StableId } from "../src/core/stable-id.ts"; +import { extract } from "../src/extract/extract.ts"; +import { supabaseCluster, type TestDb } from "./containers.ts"; + +/** Member kinds whose extractor family has been flipped (Stage 2). The + * member-ROOT families are flipped; sub-entity families (columns, constraints, + * indexes, triggers, policies, rules) and rare member-root kinds (fdw, server, + * foreignTable, eventTrigger, publication) keep their anti-joins for now — a + * documented, regression-free limitation (COVERAGE.md). */ +const FLIPPED_KINDS: ReadonlySet = new Set([ + "schema", + "table", + "sequence", + "view", + "materializedView", + "function", // routines family: functions ('f') + "procedure", // routines family: procedures ('p') + "aggregate", + "domain", + "type", // enum, composite, range + "collation", +]); + +/** Independent oracle: every extension member (deptype 'e') of a modeled kind, + * resolved to (kind, identity) WITHOUT collapsing to the extension. Mirrors the + * resolver's fallback branches but is deliberately a separate query so it can + * disagree with the code under test. */ +async function catalogMembers( + pool: Pool, +): Promise<{ id: StableId; extension: string }[]> { + // mirror the extractor's scope: user schemas only, and plpgsql is not a + // tracked extension (memberExtensionExpr / the extensions extractor skip it) + const userNs = (col: string) => + `${col} NOT IN ('pg_catalog', 'information_schema') + AND ${col} NOT LIKE 'pg\\_toast%' AND ${col} NOT LIKE 'pg\\_temp%'`; + // mirror the extractor's canonical session too: extraction runs under + // `search_path = pg_catalog`, so format_type schema-qualifies user types + // (`public.hstore`, not `hstore`). This oracle must format identity args the + // same way or its join keys never match the extracted fact ids. + const client = await pool.connect(); + let rows: { ident: Record; ext: string }[]; + try { + await client.query(`BEGIN`); + await client.query(`SET LOCAL search_path TO 'pg_catalog'`); + ({ rows } = await client.query<{ + ident: Record; + ext: string; + }>(` + SELECT ident, ext FROM ( + -- relations: tables, views, matviews, sequences (indexes 'i','I' skipped: + -- not standalone facts in the member sense for parity) + SELECT json_build_object( + 'kind', CASE c.relkind + WHEN 'r' THEN 'table' WHEN 'p' THEN 'table' + WHEN 'v' THEN 'view' WHEN 'm' THEN 'materializedView' + WHEN 'S' THEN 'sequence' END, + 'schema', n.nspname, 'name', c.relname) AS ident, + e.extname AS ext + FROM pg_depend d + JOIN pg_extension e ON e.oid = d.refobjid + JOIN pg_class c ON c.oid = d.objid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE d.deptype = 'e' AND d.classid = 'pg_class'::regclass + AND d.refclassid = 'pg_extension'::regclass + AND c.relkind IN ('r','p','v','m','S') + AND e.extname <> 'plpgsql' AND ${userNs("n.nspname")} + UNION ALL + -- routines (functions → 'function', procedures → 'procedure', + -- aggregates → 'aggregate'), minus those that are an internal dependency + -- (type I/O etc.): the extractor excludes deptype 'i', so they are not + -- standalone facts + SELECT json_build_object( + 'kind', CASE p.prokind WHEN 'a' THEN 'aggregate' WHEN 'p' THEN 'procedure' ELSE 'function' END, + 'schema', n.nspname, 'name', p.proname, + 'args', ARRAY(SELECT format_type(t.t, NULL) + FROM unnest(p.proargtypes) WITH ORDINALITY AS t(t, ord) + ORDER BY t.ord)::text[]) AS ident, + e.extname AS ext + FROM pg_depend d + JOIN pg_extension e ON e.oid = d.refobjid + JOIN pg_proc p ON p.oid = d.objid + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE d.deptype = 'e' AND d.classid = 'pg_proc'::regclass + AND d.refclassid = 'pg_extension'::regclass + AND p.prokind IN ('f','p','a') + AND e.extname <> 'plpgsql' AND ${userNs("n.nspname")} + AND NOT EXISTS ( + SELECT 1 FROM pg_depend idep + WHERE idep.classid = 'pg_proc'::regclass AND idep.objid = p.oid + AND idep.deptype = 'i') + UNION ALL + -- types and domains + SELECT json_build_object( + 'kind', CASE t.typtype WHEN 'd' THEN 'domain' ELSE 'type' END, + 'schema', n.nspname, 'name', t.typname) AS ident, + e.extname AS ext + FROM pg_depend d + JOIN pg_extension e ON e.oid = d.refobjid + JOIN pg_type t ON t.oid = d.objid + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE d.deptype = 'e' AND d.classid = 'pg_type'::regclass + AND d.refclassid = 'pg_extension'::regclass + AND t.typtype IN ('d','e','c','r') + -- skip the implicit array types extensions drag in (they are not facts) + AND t.typname NOT LIKE '\\_%' + -- skip TABLE rowtypes: a member table's rowtype is typtype 'c' but is + -- not a standalone composite-type fact (the extractor requires the + -- backing relation to be relkind 'c'); the table itself is the fact + AND (t.typtype <> 'c' OR EXISTS ( + SELECT 1 FROM pg_class tc + WHERE tc.oid = t.typrelid AND tc.relkind = 'c')) + AND e.extname <> 'plpgsql' AND ${userNs("n.nspname")} + UNION ALL + -- schemas owned by an extension + SELECT json_build_object('kind', 'schema', 'name', n.nspname) AS ident, + e.extname AS ext + FROM pg_depend d + JOIN pg_extension e ON e.oid = d.refobjid + JOIN pg_namespace n ON n.oid = d.objid + WHERE d.deptype = 'e' AND d.classid = 'pg_namespace'::regclass + AND d.refclassid = 'pg_extension'::regclass + AND e.extname <> 'plpgsql' AND ${userNs("n.nspname")} + UNION ALL + -- collations + SELECT json_build_object('kind', 'collation', 'schema', n.nspname, + 'name', cl.collname) AS ident, + e.extname AS ext + FROM pg_depend d + JOIN pg_extension e ON e.oid = d.refobjid + JOIN pg_collation cl ON cl.oid = d.objid + JOIN pg_namespace n ON n.oid = cl.collnamespace + WHERE d.deptype = 'e' AND d.classid = 'pg_collation'::regclass + AND d.refclassid = 'pg_extension'::regclass + AND e.extname <> 'plpgsql' AND ${userNs("n.nspname")} + ) m + ORDER BY ident::text`)); + await client.query(`COMMIT`); + } finally { + client.release(); + } + return rows.map((r) => ({ id: identToStableId(r.ident), extension: r.ext })); +} + +function identToStableId(o: Record): StableId { + const kind = String(o["kind"]); + switch (kind) { + case "schema": + return { kind: "schema", name: String(o["name"]) }; + case "table": + case "view": + case "materializedView": + case "sequence": + case "type": + case "domain": + case "collation": + return { + kind, + schema: String(o["schema"]), + name: String(o["name"]), + }; + case "function": + case "procedure": + case "aggregate": + return { + kind, + schema: String(o["schema"]), + name: String(o["name"]), + args: (o["args"] as string[]).map(String), + }; + default: + throw new Error(`parity oracle: unmapped kind ${kind}`); + } +} + +/** every fact id that carries an outgoing memberOfExtension edge */ +function taggedMemberIds(fb: FactBase): Set { + const tagged = new Set(); + for (const e of fb.edges) { + if (e.kind === "memberOfExtension") tagged.add(encodeId(e.from)); + } + return tagged; +} + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("extension-member parity (4b Stage 1/2)", () => { + test("every observed extension member is tagged; flipped kinds are fully observed", async () => { + const cluster = await supabaseCluster(); + const db = await cluster.createDb("member_parity"); + dbs.push(db); + + // install extensions that own members across several families: + // - pg_partman: functions (procedure) + config tables (table) + // - hstore / citext: a type each, plus functions and operators + await db.pool.query(`CREATE SCHEMA IF NOT EXISTS partman`); + await db.pool.query( + `CREATE EXTENSION IF NOT EXISTS pg_partman WITH SCHEMA partman`, + ); + await db.pool.query(`CREATE EXTENSION IF NOT EXISTS hstore`); + await db.pool.query(`CREATE EXTENSION IF NOT EXISTS citext`); + + const members = await catalogMembers(db.pool); + const fb = (await extract(db.pool)).factBase; + + const present = new Set(fb.facts().map((f) => encodeId(f.id))); + const tagged = taggedMemberIds(fb); + + // soundness: any catalog member that IS observed must be tagged + const untagged = members.filter( + (m) => present.has(encodeId(m.id)) && !tagged.has(encodeId(m.id)), + ); + expect(untagged.map((m) => encodeId(m.id))).toEqual([]); + + // completeness: for each flipped kind, every catalog member is observed + const missing = members.filter( + (m) => FLIPPED_KINDS.has(m.id.kind) && !present.has(encodeId(m.id)), + ); + expect(missing.map((m) => encodeId(m.id))).toEqual([]); + + // sanity: the oracle actually found members (guards a silent empty oracle) + expect(members.length).toBeGreaterThan(0); + }, 180_000); +}); diff --git a/packages/pg-delta/tests/extension-member-projection.test.ts b/packages/pg-delta/tests/extension-member-projection.test.ts new file mode 100644 index 000000000..6a6110e6b --- /dev/null +++ b/packages/pg-delta/tests/extension-member-projection.test.ts @@ -0,0 +1,87 @@ +/** + * provePlan() applies the default extension-member projection to the proven + * re-extract (4b Stage 0). Docker required (a sacrificial clone pool). + * + * The proof re-extracts the clone after applying and diffs it against the + * (projected) target. Post-flip, that re-extract will observe extension members + * — they must be projected OUT of `proven` too, or every extension's internals + * would read as drift. This test injects a member through the `reextract` hook + * (the designed extension point, the same shape the flipped extractor will + * produce) so the prove-side wiring is pinned independently of Stage 2. + */ +import { describe, expect, test } from "bun:test"; +import type { Pool } from "pg"; +import { + buildFactBase, + type DependencyEdge, + type Fact, + type FactBase, +} from "../src/core/fact.ts"; +import type { StableId } from "../src/core/stable-id.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { createTestDb } from "./containers.ts"; + +const extSynth: StableId = { kind: "extension", name: "synth_ext" }; +const memberTable: StableId = { + kind: "table", + schema: "public", + name: "synth_member", +}; +const schemaPublic: StableId = { kind: "schema", name: "public" }; + +describe("provePlan — default extension-member projection (4b Stage 0)", () => { + test("extension members in the proven re-extract are not reported as drift", async () => { + const db = await createTestDb("prove_member"); + try { + await db.pool.query("CREATE TABLE public.keep (id integer PRIMARY KEY)"); + const state = await extract(db.pool); + + // empty plan: source == desired, so applying it to the clone is a no-op + const emptyPlan = plan(state.factBase, state.factBase); + expect(emptyPlan.actions).toHaveLength(0); + + // the comparison target: real state + a synthetic extension fact. The + // extension is on BOTH sides so ONLY the member differs — isolating the + // member-exclusion behaviour from anything else. + const extFact: Fact = { id: extSynth, payload: {} }; + const desired = buildFactBase( + [...state.factBase.facts(), extFact], + [...state.factBase.edges], + state.factBase.source, + ); + + // reextract simulates the POST-FLIP extractor: the same real state, plus + // the extension fact and an extension-OWNED member tagged memberOfExtension + const reextract = async (pool: Pool): Promise<{ factBase: FactBase }> => { + const real = await extract(pool); + const facts: Fact[] = [ + ...real.factBase.facts(), + extFact, + { + id: memberTable, + parent: schemaPublic, + payload: { persistence: "p" }, + }, + ]; + const edges: DependencyEdge[] = [ + ...real.factBase.edges, + { from: memberTable, to: extSynth, kind: "memberOfExtension" }, + ]; + return { factBase: buildFactBase(facts, edges, real.factBase.source) }; + }; + + const verdict = await provePlan(emptyPlan, db.pool, desired, { + reextract, + }); + + // RED before wiring: the member reads as drift (remove synth_member). + // GREEN after wiring: members are projected out of `proven` → no drift. + expect(verdict.driftDeltas).toHaveLength(0); + expect(verdict.ok).toBe(true); + } finally { + await db.drop(); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/extension-relocatable.test.ts b/packages/pg-delta/tests/extension-relocatable.test.ts new file mode 100644 index 000000000..335e4bc0c --- /dev/null +++ b/packages/pg-delta/tests/extension-relocatable.test.ts @@ -0,0 +1,92 @@ +/** + * The `CREATE EXTENSION … SCHEMA` clause is derived from the extension's + * `relocatable` fact (pg_extension.extrelocatable), not a `skipSchema` serialize + * param (docs/architecture/managed-view-architecture.md, move 2). Two real-database proofs: + * + * A. a relocatable extension (hstore, stock alpine) extracts relocatable=true + * and roundtrips WITH a SCHEMA clause. + * B. a non-relocatable self-schema extension (pgmq, Supabase image) extracts + * relocatable=false and roundtrips with a BARE CREATE — proving the + * skipSchema hack removal is safe: the plan applies to a clone that has no + * pgmq schema beforehand, so the create needs no schema dependency. + * + * Docker required. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { sharedCluster, supabaseCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +function relocatableOf( + state: Awaited>, + name: string, +): unknown { + const ext = state.factBase + .facts() + .find((f) => f.id.kind === "extension" && f.id.name === name); + return ext?.payload["relocatable"]; +} + +describe("extension SCHEMA clause derived from relocatable (e2e)", () => { + test("relocatable extension extracts relocatable=true and roundtrips with SCHEMA", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("ext_reloc_src"); + const dst = await cluster.createDb("ext_reloc_dst"); + dbs.push(src, dst); + await dst.pool.query("CREATE EXTENSION hstore"); + + const srcState = await extract(src.pool); + const dstState = await extract(dst.pool); + + // extraction is catalog-true + const { rows } = await dst.pool.query( + "SELECT extrelocatable FROM pg_extension WHERE extname = 'hstore'", + ); + expect(relocatableOf(dstState, "hstore")).toBe(rows[0].extrelocatable); + expect(relocatableOf(dstState, "hstore")).toBe(true); + + const thePlan = plan(srcState.factBase, dstState.factBase); + const clone = await src.clone(); + dbs.push(clone); + const verdict = await provePlan(thePlan, clone.pool, dstState.factBase); + expect(verdict.applyError).toBeUndefined(); + expect(verdict.driftDeltas).toEqual([]); + expect(verdict.ok).toBe(true); + }, 120_000); + + test("non-relocatable self-schema extension (pgmq) roundtrips with a BARE create — no skipSchema", async () => { + const cluster = await supabaseCluster(); + const src = await cluster.createDb("ext_pgmq_src"); + const dst = await cluster.createDb("ext_pgmq_dst"); + dbs.push(src, dst); + await dst.pool.query("CREATE EXTENSION pgmq"); + + const srcState = await extract(src.pool); + const dstState = await extract(dst.pool); + + // pgmq pins its own schema → non-relocatable; extraction is catalog-true + const { rows } = await dst.pool.query( + "SELECT extrelocatable FROM pg_extension WHERE extname = 'pgmq'", + ); + expect(rows[0].extrelocatable).toBe(false); + expect(relocatableOf(dstState, "pgmq")).toBe(false); + + // The plan applies to a clone of `src` that has NO pgmq schema beforehand. + // If the create still emitted `SCHEMA pgmq` + consumed that schema, the + // missing-requirement guard would throw at plan time; that it plans and + // proves clean is the proof of the bare path. + const thePlan = plan(srcState.factBase, dstState.factBase); + const clone = await src.clone(); + dbs.push(clone); + const verdict = await provePlan(thePlan, clone.pool, dstState.factBase); + expect(verdict.applyError).toBeUndefined(); + expect(verdict.driftDeltas).toEqual([]); + expect(verdict.ok).toBe(true); + }, 240_000); +}); diff --git a/packages/pg-delta/tests/extract-statement-timeout.test.ts b/packages/pg-delta/tests/extract-statement-timeout.test.ts new file mode 100644 index 000000000..59a0da87c --- /dev/null +++ b/packages/pg-delta/tests/extract-statement-timeout.test.ts @@ -0,0 +1,59 @@ +/** + * Milestone A — extraction statement-timeout budget. A runaway catalog query on + * a pathological schema must fail with an actionable diagnostic that NAMES the + * offending query, not an opaque `canceling statement due to statement timeout` + * (or an indefinite hang). A tiny budget against a populated catalog is a + * deterministic way to drive the path. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { extract, ExtractionTimeoutError } from "../src/extract/extract.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +/** Enough objects that catalog queries (esp. the pg_depend resolver) take well + * over a 1ms server-side budget. */ +function fixtureSql(): string { + const parts: string[] = ["CREATE SCHEMA app;"]; + for (let i = 0; i < 25; i++) { + parts.push( + `CREATE TABLE app.t${i} (id integer PRIMARY KEY, v text DEFAULT 'x');`, + `CREATE INDEX t${i}_v_idx ON app.t${i} (v);`, + `CREATE VIEW app.vt${i} AS SELECT id, v FROM app.t${i};`, + ); + } + return parts.join("\n"); +} + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb("extract-timeout"); + await db.pool.query(fixtureSql()); +}, 120_000); + +afterAll(async () => { + await db.drop(); +}); + +describe("extract: statement-timeout budget", () => { + test("a 1ms budget fails with an actionable, query-naming error", async () => { + let err: unknown; + try { + await extract(db.pool, { statementTimeoutMs: 1 }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(ExtractionTimeoutError); + const timeout = err as ExtractionTimeoutError; + expect(timeout.timeoutMs).toBe(1); + // names which query blew the budget — actionable, not opaque + expect(timeout.queryLabel.length).toBeGreaterThan(0); + expect(timeout.diagnostic.code).toBe("extraction_timeout"); + expect(timeout.diagnostic.severity).toBe("error"); + expect(timeout.message).toContain(timeout.queryLabel); + }, 60_000); + + test("no budget (default) extracts normally", async () => { + const result = await extract(db.pool); + expect(result.factBase.facts().length).toBeGreaterThan(0); + }, 60_000); +}); diff --git a/packages/pg-delta/tests/extract.test.ts b/packages/pg-delta/tests/extract.test.ts new file mode 100644 index 000000000..4bd3cbde3 --- /dev/null +++ b/packages/pg-delta/tests/extract.test.ts @@ -0,0 +1,297 @@ +/** + * Stage-2 extractor fixture ring: known DDL in, specific facts out + * (target-architecture §4.3 "independent extractor ring"). + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { diff } from "../src/core/diff.ts"; +import { encodeId } from "../src/core/stable-id.ts"; +import { + deserializeSnapshot, + serializeSnapshot, +} from "../src/core/snapshot.ts"; +import { extract, type ExtractResult } from "../src/extract/extract.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +const FIXTURE_DDL = /* sql */ ` + CREATE SCHEMA app; + CREATE SEQUENCE app.order_seq START 100 INCREMENT 5; + CREATE TABLE app.users ( + id integer GENERATED ALWAYS AS IDENTITY, + email text NOT NULL, + score numeric(10,2) DEFAULT 0.0, + CONSTRAINT users_pkey PRIMARY KEY (id), + CONSTRAINT score_positive CHECK (score >= 0) + ); + CREATE TABLE app.orders ( + id bigint DEFAULT nextval('app.order_seq') PRIMARY KEY, + user_id integer NOT NULL, + CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES app.users (id) + ); + CREATE INDEX orders_user_idx ON app.orders (user_id); + CREATE VIEW app.user_emails AS SELECT id, email FROM app.users; + CREATE FUNCTION app.add(a integer, b integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS 'SELECT a + b'; + COMMENT ON TABLE app.users IS 'user accounts'; + COMMENT ON COLUMN app.users.email IS 'login email'; + ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; + CREATE POLICY users_self ON app.users FOR SELECT USING (true); + CREATE ROLE app_reader_xyz NOLOGIN; + GRANT SELECT ON app.users TO app_reader_xyz; + CREATE TYPE app.addr AS (street text, city varchar(90)); +`; + +let db: TestDb; +let result: ExtractResult; +let pgMajor: number; + +beforeAll(async () => { + db = await createTestDb("extract"); + await db.pool.query(FIXTURE_DDL); + pgMajor = await db.cluster.pgMajor(); + // publication column lists are PG15+; on PG14 publish the whole table so the + // rest of the fixture still exercises publication facts. + await db.pool.query( + pgMajor >= 15 + ? `CREATE PUBLICATION app_pub FOR TABLE app.users (id, email)` + : `CREATE PUBLICATION app_pub FOR TABLE app.users`, + ); + result = await extract(db.pool); +}, 120_000); + +afterAll(async () => { + await db.pool.query(`DROP ROLE IF EXISTS app_reader_xyz`).catch(() => {}); + await db.drop(); +}); + +describe("extract: fixture ring", () => { + const fb = () => result.factBase; + + test("schema, table, and column facts exist with normalized payloads", () => { + // ownership is an `owner` EDGE (object --owner--> role), not a payload field + // (managed-view move 2); the schema "app" is owned by the connection role. + expect(fb().has({ kind: "schema", name: "app" })).toBe(true); + const schemaOwnerEdge = fb().edges.find( + (e) => e.kind === "owner" && encodeId(e.from) === "schema:app", + ); + expect(schemaOwnerEdge?.to).toEqual({ kind: "role", name: "test" }); + const table = fb().get({ kind: "table", schema: "app", name: "users" }); + expect(table?.payload).toMatchObject({ + persistence: "p", + rowSecurity: true, + }); + const email = fb().get({ + kind: "column", + schema: "app", + table: "users", + name: "email", + }); + expect(email?.payload).toMatchObject({ + type: "text", + notNull: true, + identity: null, + }); + const id = fb().get({ + kind: "column", + schema: "app", + table: "users", + name: "id", + }); + expect(id?.payload).toMatchObject({ + type: "integer", + identity: { generation: "a" }, + }); + const score = fb().get({ + kind: "column", + schema: "app", + table: "users", + name: "score", + }); + expect(score?.payload).toMatchObject({ type: "numeric(10,2)" }); + }); + + test("defaults are their own facts (pg_attrdef model)", () => { + const def = fb().get({ + kind: "default", + schema: "app", + table: "users", + name: "score", + }); + expect(def?.payload["expr"]).toBe("0.0"); + const orderDefault = fb().get({ + kind: "default", + schema: "app", + table: "orders", + name: "id", + }); + expect(orderDefault?.payload["expr"] as string).toContain("nextval"); + }); + + test("constraints carry canonical pg_get_constraintdef", () => { + const pk = fb().get({ + kind: "constraint", + schema: "app", + table: "users", + name: "users_pkey", + }); + expect(pk?.payload["def"]).toBe("PRIMARY KEY (id)"); + const fk = fb().get({ + kind: "constraint", + schema: "app", + table: "orders", + name: "orders_user_fk", + }); + expect(fk?.payload["def"]).toBe( + "FOREIGN KEY (user_id) REFERENCES app.users(id)", + ); + expect(fk?.payload["type"]).toBe("f"); + }); + + test("non-constraint index extracted with canonical def; pkey index is not", () => { + const idx = fb().get({ + kind: "index", + schema: "app", + name: "orders_user_idx", + }); + expect(idx?.payload["def"] as string).toContain( + "CREATE INDEX orders_user_idx", + ); + expect(fb().has({ kind: "index", schema: "app", name: "users_pkey" })).toBe( + false, + ); + }); + + test("identity-column backing sequence is excluded; user sequence is present", () => { + const seq = fb().get({ + kind: "sequence", + schema: "app", + name: "order_seq", + }); + expect(seq?.payload).toMatchObject({ start: "100", increment: "5" }); + const internal = fb() + .facts() + .filter( + (f) => f.id.kind === "sequence" && encodeId(f.id).includes("users_id"), + ); + expect(internal).toHaveLength(0); + }); + + test("view, function, policy, trigger-less fixture facts", () => { + const view = fb().get({ kind: "view", schema: "app", name: "user_emails" }); + expect(view?.payload["def"] as string).toContain("FROM app.users"); + const fn = fb().get({ + kind: "function", + schema: "app", + name: "add", + args: ["integer", "integer"], + }); + expect(fn?.payload["def"] as string).toContain("SELECT a + b"); + const policy = fb().get({ + kind: "policy", + schema: "app", + table: "users", + name: "users_self", + }); + expect(policy?.payload).toMatchObject({ cmd: "r", usingExpr: "true" }); + }); + + test("composite-type attributes are their own facts (granularity is one)", () => { + const type = fb().get({ kind: "type", schema: "app", name: "addr" }); + expect(type?.payload).toMatchObject({ variant: "composite" }); + // the attributes are NOT in the type payload — they are sub-facts + expect(type?.payload["attributes"]).toBeUndefined(); + const street = fb().get({ + kind: "typeAttribute", + schema: "app", + type: "addr", + name: "street", + }); + expect(street?.payload).toMatchObject({ type: "text" }); + expect(street?.parent).toEqual({ + kind: "type", + schema: "app", + name: "addr", + }); + const city = fb().get({ + kind: "typeAttribute", + schema: "app", + type: "addr", + name: "city", + }); + expect(city?.payload["type"]).toBe("character varying(90)"); + }); + + test("published tables are their own facts, parented to the publication", () => { + const pub = fb().get({ kind: "publication", name: "app_pub" }); + expect(pub?.payload["tables"]).toBeUndefined(); + const rel = fb().get({ + kind: "publicationRel", + publication: "app_pub", + schema: "app", + table: "users", + }); + expect(rel?.parent).toEqual({ kind: "publication", name: "app_pub" }); + // column lists are PG15+; on PG14 the publication has no column list + expect(rel?.payload["columns"]).toEqual( + pgMajor >= 15 ? ["email", "id"] : null, + ); + }); + + test("comments and ACLs are satellite facts parented to their target", () => { + const tableId = { kind: "table", schema: "app", name: "users" } as const; + const comment = fb().get({ kind: "comment", target: tableId }); + expect(comment?.payload["text"]).toBe("user accounts"); + const colComment = fb().get({ + kind: "comment", + target: { kind: "column", schema: "app", table: "users", name: "email" }, + }); + expect(colComment?.payload["text"]).toBe("login email"); + const acl = fb().get({ + kind: "acl", + target: tableId, + grantee: "app_reader_xyz", + }); + expect(acl?.payload["privileges"]).toEqual(["SELECT"]); + }); + + test("pg_depend edges arrive at column grain (one granularity, §3.1)", () => { + const edgeSet = new Set( + fb().edges.map((e) => `${encodeId(e.from)}->${encodeId(e.to)}`), + ); + // view references are per-column in pg_depend — exactly our fact grain + expect(edgeSet.has("view:app.user_emails->column:app.users.email")).toBe( + true, + ); + expect(edgeSet.has("default:app.orders.id->sequence:app.order_seq")).toBe( + true, + ); + // FK constraints reference the target table's columns + expect( + edgeSet.has("constraint:app.orders.orders_user_fk->column:app.users.id"), + ).toBe(true); + }); + + test("extraction is deterministic: re-extract is hash-identical", async () => { + const again = await extract(db.pool); + expect(again.factBase.rootHash).toBe(result.factBase.rootHash); + }, 30_000); + + test("snapshot round-trips a real extraction hash-identically", () => { + const json = serializeSnapshot(result.factBase, { + pgVersion: result.pgVersion, + }); + const restored = deserializeSnapshot(json); + expect(restored.factBase.rootHash).toBe(result.factBase.rootHash); + expect(diff(result.factBase, restored.factBase)).toEqual([]); + }); + + test("clone fidelity: TEMPLATE clone extracts hash-identical", async () => { + const clone = await db.clone(); + try { + const cloned = await extract(clone.pool); + expect(cloned.factBase.rootHash).toBe(result.factBase.rootHash); + expect(diff(result.factBase, cloned.factBase)).toEqual([]); + } finally { + await clone.drop(); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/fixture-validity.test.ts b/packages/pg-delta/tests/fixture-validity.test.ts new file mode 100644 index 000000000..ebc7341f1 --- /dev/null +++ b/packages/pg-delta/tests/fixture-validity.test.ts @@ -0,0 +1,52 @@ +/** + * Fixture-validity layer (stage 0): every scenario's DDL applies cleanly. + * Green independently of the engine — engine reds can only ever mean + * "engine missing/broken", never "fixture broken". + */ +import { describe, expect, test } from "bun:test"; +import { loadCorpus } from "./corpus.ts"; +import { + isolatedClusterPair, + sharedCluster, + type Cluster, +} from "./containers.ts"; + +describe("corpus fixture validity", () => { + for (const scenario of loadCorpus()) { + test( + scenario.name, + async () => { + let clusterA: Cluster; + let clusterB: Cluster; + if (scenario.meta.isolatedCluster) { + [clusterA, clusterB] = await isolatedClusterPair(); + } else { + clusterA = clusterB = await sharedCluster(); + } + if (scenario.meta.minVersion !== undefined) { + if ((await clusterA.pgMajor()) < scenario.meta.minVersion) return; + } + const [baseA, baseB] = scenario.meta.isolatedCluster + ? await Promise.all([clusterA.listRoles(), clusterB.listRoles()]) + : [null, null]; + const dbA = await clusterA.createDb("fv_a"); + const dbB = await clusterB.createDb("fv_b"); + try { + await dbA.pool.query(scenario.a); + await dbB.pool.query(scenario.b); + if (scenario.seed) await dbA.pool.query(scenario.seed); + expect(true).toBe(true); + } finally { + await Promise.all([dbA.drop(), dbB.drop()]); + if (baseA && baseB) { + await Promise.all([ + clusterA.dropRolesExcept(baseA), + clusterB.dropRolesExcept(baseB), + ]); + } + } + }, + 120_000, + ); + } +}); diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141357_extensions.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141357_extensions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141357_extensions.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141357_extensions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141359_app_schema.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141359_app_schema.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141359_app_schema.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141359_app_schema.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141507_semver.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141507_semver.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141507_semver.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141507_semver.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141645_valid_name_type.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141645_valid_name_type.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141645_valid_name_type.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141645_valid_name_type.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141942_email_address_type.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141942_email_address_type.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117141942_email_address_type.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117141942_email_address_type.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142104_account_and_org_tables.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142104_account_and_org_tables.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142104_account_and_org_tables.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142104_account_and_org_tables.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142137_package_tables.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142137_package_tables.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142137_package_tables.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142137_package_tables.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142138_developer_tools.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142138_developer_tools.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142138_developer_tools.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142138_developer_tools.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142141_security_utilities.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142141_security_utilities.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142141_security_utilities.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142141_security_utilities.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142142_security_definitions.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142142_security_definitions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117142142_security_definitions.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117142142_security_definitions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117155720_views.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117155720_views.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117155720_views.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117155720_views.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117155820_rpc.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117155820_rpc.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20220117155820_rpc.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20220117155820_rpc.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230323180034_reserved_user_accts.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230323180034_reserved_user_accts.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230323180034_reserved_user_accts.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230323180034_reserved_user_accts.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230328185043_olirice_asciiplot.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230328185043_olirice_asciiplot.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230328185043_olirice_asciiplot.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230328185043_olirice_asciiplot.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230330155137_supabase_dbdev.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230330155137_supabase_dbdev.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230330155137_supabase_dbdev.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230330155137_supabase_dbdev.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331145934_burggraf-pg_headerkit.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230331145934_burggraf-pg_headerkit.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331145934_burggraf-pg_headerkit.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230331145934_burggraf-pg_headerkit.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331163908_olirice-index_advisor.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230331163908_olirice-index_advisor.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331163908_olirice-index_advisor.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230331163908_olirice-index_advisor.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331163909_olirice-read_once.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230331163909_olirice-read_once.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230331163909_olirice-read_once.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230331163909_olirice-read_once.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230404162614_michelp-adminpack.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230404162614_michelp-adminpack.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230404162614_michelp-adminpack.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230404162614_michelp-adminpack.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405083103_fix_auth_schema_values.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230405083103_fix_auth_schema_values.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405083103_fix_auth_schema_values.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230405083103_fix_auth_schema_values.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405085810_fix_avatars_handle.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230405085810_fix_avatars_handle.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405085810_fix_avatars_handle.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230405085810_fix_avatars_handle.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405163940_download_metrics.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230405163940_download_metrics.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230405163940_download_metrics.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230405163940_download_metrics.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411104448_download_metrics_computed_relation.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230411104448_download_metrics_computed_relation.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411104448_download_metrics_computed_relation.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230411104448_download_metrics_computed_relation.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411175952_langchain-embedding_search.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230411175952_langchain-embedding_search.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411175952_langchain-embedding_search.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230411175952_langchain-embedding_search.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411175953_langchain-hybrid_search.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230411175953_langchain-hybrid_search.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230411175953_langchain-hybrid_search.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230411175953_langchain-hybrid_search.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230413130634_popular_packages_function.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230413130634_popular_packages_function.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230413130634_popular_packages_function.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230413130634_popular_packages_function.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230413140356_update_profile_function.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230413140356_update_profile_function.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230413140356_update_profile_function.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230413140356_update_profile_function.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230417141004_dbdev_short_desc_typo.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230417141004_dbdev_short_desc_typo.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230417141004_dbdev_short_desc_typo.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230417141004_dbdev_short_desc_typo.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508165641_packages_order_version.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230508165641_packages_order_version.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508165641_packages_order_version.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230508165641_packages_order_version.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508175952_langchain-embedding_search-1_1_0.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230508175952_langchain-embedding_search-1_1_0.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508175952_langchain-embedding_search-1_1_0.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230508175952_langchain-embedding_search-1_1_0.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230508175953_langchain-hybrid_search-1_1_0.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230622212339_langchain_headerkit_config_dump.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230622212339_langchain_headerkit_config_dump.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230622212339_langchain_headerkit_config_dump.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230622212339_langchain_headerkit_config_dump.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230623181432_dbdev_supports_multiple_versions.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230623181432_dbdev_supports_multiple_versions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230623181432_dbdev_supports_multiple_versions.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230623181432_dbdev_supports_multiple_versions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230829125510_fix_view_permissions.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230829125510_fix_view_permissions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230829125510_fix_view_permissions.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230829125510_fix_view_permissions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230830083255_olirice-index_advisor-0_2_0.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230830083255_olirice-index_advisor-0_2_0.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230830083255_olirice-index_advisor-0_2_0.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230830083255_olirice-index_advisor-0_2_0.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230831172915_allow_anon_access_to_package_views.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230831172915_allow_anon_access_to_package_views.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230831172915_allow_anon_access_to_package_views.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230831172915_allow_anon_access_to_package_views.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230906110845_access_token.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230906110845_access_token.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230906110845_access_token.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230906110845_access_token.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230906111353_publish_package.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230906111353_publish_package.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20230906111353_publish_package.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20230906111353_publish_package.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231110061036_allow_publishing_relocatable_and_requires.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231205051816_add_default_version.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231205051816_add_default_version.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231205051816_add_default_version.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231205051816_add_default_version.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231205101809_dbdev_supports_default_version.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231205101809_dbdev_supports_default_version.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231205101809_dbdev_supports_default_version.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231205101809_dbdev_supports_default_version.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207071422_new_package_name.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207071422_new_package_name.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207071422_new_package_name.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207071422_new_package_name.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207073048_dbdev_supports_new_package_names.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207073048_dbdev_supports_new_package_names.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207073048_dbdev_supports_new_package_names.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207073048_dbdev_supports_new_package_names.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207111703_langchain@embedding_search-1.1.1.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207111703_langchain@embedding_search-1.1.1.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207111703_langchain@embedding_search-1.1.1.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207111703_langchain@embedding_search-1.1.1.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207112129_langchain@hybrid_search-1.1.1.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207112942_michelp@adminpack-0.0.2.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207112942_michelp@adminpack-0.0.2.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207112942_michelp@adminpack-0.0.2.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207112942_michelp@adminpack-0.0.2.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207113329_olirice@index_advisor-0.2.1.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207113329_olirice@index_advisor-0.2.1.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207113329_olirice@index_advisor-0.2.1.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207113329_olirice@index_advisor-0.2.1.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207113857_olirice@read_once-0.3.2.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207113857_olirice@read_once-0.3.2.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20231207113857_olirice@read_once-0.3.2.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20231207113857_olirice@read_once-0.3.2.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240108072747_update_provider_id.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20240108072747_update_provider_id.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240108072747_update_provider_id.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20240108072747_update_provider_id.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240605122023_fix_view_permissions.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20240605122023_fix_view_permissions.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240605122023_fix_view_permissions.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20240605122023_fix_view_permissions.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240705083738_remove_contact_email.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20240705083738_remove_contact_email.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20240705083738_remove_contact_email.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20240705083738_remove_contact_email.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250106073735_jwt_secret_from_vault.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20250106073735_jwt_secret_from_vault.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250106073735_jwt_secret_from_vault.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20250106073735_jwt_secret_from_vault.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250217100252_restrict_accounts_and_orgs.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20250217100252_restrict_accounts_and_orgs.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250217100252_restrict_accounts_and_orgs.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20250217100252_restrict_accounts_and_orgs.sql diff --git a/packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250804111152_remove_dbdev_from_popular_packages.sql b/packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20250804111152_remove_dbdev_from_popular_packages.sql similarity index 100% rename from packages/pg-delta/tests/integration/fixtures/dbdev-migrations/migrations/20250804111152_remove_dbdev_from_popular_packages.sql rename to packages/pg-delta/tests/fixtures/dbdev-migrations/migrations/20250804111152_remove_dbdev_from_popular_packages.sql diff --git a/packages/pg-delta/tests/fixtures/supabase-base-init/17.sql b/packages/pg-delta/tests/fixtures/supabase-base-init/17.sql new file mode 100644 index 000000000..088220461 --- /dev/null +++ b/packages/pg-delta/tests/fixtures/supabase-base-init/17.sql @@ -0,0 +1,3597 @@ +-- Supabase baseline: delta from bare supabase/postgres:17.6.1.135 to `supabase start`. +-- Generated by scripts/sync-supabase-base-images.ts — DO NOT EDIT BY HAND. +-- Regenerate with: bun run sync-base-images + +SET check_function_bodies = off; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON SEQUENCES FROM "anon"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON SEQUENCES FROM "authenticated"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON SEQUENCES FROM "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON FUNCTIONS FROM "anon"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON FUNCTIONS FROM "authenticated"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON FUNCTIONS FROM "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON TABLES FROM "anon"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON TABLES FROM "authenticated"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON TABLES FROM "service_role"; + +DROP INDEX "auth"."refresh_tokens_token_idx"; + +DROP INDEX "auth"."users_instance_id_email_idx"; + +ALTER TABLE "auth"."users" DROP CONSTRAINT "users_email_key"; + +ALTER TABLE "auth"."users" DROP COLUMN "confirmed_at"; + +ALTER TABLE "auth"."users" DROP COLUMN "email_change_token"; + +CREATE ROLE "supabase_functions_admin" WITH NOSUPERUSER NOINHERIT CREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS; + +ALTER ROLE "supabase_functions_admin" SET "search_path" TO 'supabase_functions'; + +CREATE ROLE "supabase_realtime_admin" WITH NOSUPERUSER NOINHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS; + +GRANT "supabase_functions_admin" TO "postgres"; + +GRANT "supabase_realtime_admin" TO "postgres"; + +CREATE SCHEMA "_realtime" AUTHORIZATION "postgres"; + +CREATE SCHEMA "supabase_functions" AUTHORIZATION "supabase_admin"; + +CREATE EXTENSION "pg_net" SCHEMA "extensions"; + +CREATE SEQUENCE "supabase_functions"."hooks_id_seq" AS bigint INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1 NO CYCLE; + +ALTER SEQUENCE "supabase_functions"."hooks_id_seq" OWNER TO "supabase_functions_admin"; + +CREATE TABLE "_realtime"."extensions" ("id" uuid NOT NULL, "inserted_at" timestamp(0) without time zone NOT NULL, "settings" jsonb, "tenant_external_id" text, "type" text, "updated_at" timestamp(0) without time zone NOT NULL); + +ALTER TABLE "_realtime"."extensions" OWNER TO "supabase_admin"; + +CREATE TABLE "_realtime"."feature_flags" ("enabled" boolean NOT NULL DEFAULT false, "id" uuid NOT NULL, "inserted_at" timestamp(0) without time zone NOT NULL, "name" character varying(255) NOT NULL, "updated_at" timestamp(0) without time zone NOT NULL); + +ALTER TABLE "_realtime"."feature_flags" OWNER TO "supabase_admin"; + +CREATE TABLE "_realtime"."schema_migrations" ("inserted_at" timestamp(0) without time zone, "version" bigint NOT NULL); + +ALTER TABLE "_realtime"."schema_migrations" OWNER TO "supabase_admin"; + +CREATE TABLE "_realtime"."tenants" ("broadcast_adapter" character varying(255) DEFAULT 'gen_rpc'::character varying, "client_presence_window_ms" integer, "external_id" text, "feature_flags" jsonb NOT NULL DEFAULT '{}'::jsonb, "id" uuid NOT NULL, "inserted_at" timestamp(0) without time zone NOT NULL, "jwt_jwks" jsonb, "jwt_secret" text, "max_bytes_per_second" integer NOT NULL DEFAULT 100000, "max_channels_per_client" integer NOT NULL DEFAULT 100, "max_client_presence_events_per_window" integer, "max_concurrent_users" integer NOT NULL DEFAULT 200, "max_events_per_second" integer NOT NULL DEFAULT 100, "max_joins_per_second" integer NOT NULL DEFAULT 500, "max_payload_size_in_kb" integer DEFAULT 3000, "max_presence_events_per_second" integer DEFAULT 1000, "migrations_ran" integer DEFAULT 0, "name" text, "notify_private_alpha" boolean DEFAULT false, "postgres_cdc_default" text DEFAULT 'postgres_cdc_rls'::text, "presence_enabled" boolean NOT NULL DEFAULT false, "private_only" boolean NOT NULL DEFAULT false, "suspend" boolean DEFAULT false, "updated_at" timestamp(0) without time zone NOT NULL); + +ALTER TABLE "_realtime"."tenants" OWNER TO "supabase_admin"; + +ALTER TABLE "auth"."audit_log_entries" ENABLE ROW LEVEL SECURITY; + +CREATE TABLE "auth"."custom_oauth_providers" ("acceptable_client_ids" text[] NOT NULL DEFAULT '{}'::text[], "attribute_mapping" jsonb NOT NULL DEFAULT '{}'::jsonb, "authorization_params" jsonb NOT NULL DEFAULT '{}'::jsonb, "authorization_url" text, "cached_discovery" jsonb, "client_id" text NOT NULL, "client_secret" text NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "discovery_cached_at" timestamp with time zone, "discovery_url" text, "email_optional" boolean NOT NULL DEFAULT false, "enabled" boolean NOT NULL DEFAULT true, "identifier" text NOT NULL, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "issuer" text, "jwks_uri" text, "name" text NOT NULL, "pkce_enabled" boolean NOT NULL DEFAULT true, "provider_type" text NOT NULL, "scopes" text[] NOT NULL DEFAULT '{}'::text[], "skip_nonce_check" boolean NOT NULL DEFAULT false, "token_url" text, "updated_at" timestamp with time zone NOT NULL DEFAULT now(), "userinfo_url" text); + +ALTER TABLE "auth"."custom_oauth_providers" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."flow_state" ("auth_code_issued_at" timestamp with time zone, "auth_code" text, "authentication_method" text NOT NULL, "code_challenge" text, "created_at" timestamp with time zone, "email_optional" boolean NOT NULL DEFAULT false, "id" uuid NOT NULL, "invite_token" text, "linking_target_id" uuid, "oauth_client_state_id" uuid, "provider_access_token" text, "provider_refresh_token" text, "provider_type" text NOT NULL, "referrer" text, "updated_at" timestamp with time zone, "user_id" uuid); + +ALTER TABLE "auth"."flow_state" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."flow_state" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."identities" ("created_at" timestamp with time zone, "identity_data" jsonb NOT NULL, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "last_sign_in_at" timestamp with time zone, "provider_id" text NOT NULL, "provider" text NOT NULL, "updated_at" timestamp with time zone, "user_id" uuid NOT NULL); + +ALTER TABLE "auth"."identities" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."identities" OWNER TO "supabase_auth_admin"; + +ALTER TABLE "auth"."instances" ENABLE ROW LEVEL SECURITY; + +CREATE TABLE "auth"."mfa_amr_claims" ("authentication_method" text NOT NULL, "created_at" timestamp with time zone NOT NULL, "id" uuid NOT NULL, "session_id" uuid NOT NULL, "updated_at" timestamp with time zone NOT NULL); + +ALTER TABLE "auth"."mfa_amr_claims" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."mfa_amr_claims" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."mfa_challenges" ("created_at" timestamp with time zone NOT NULL, "factor_id" uuid NOT NULL, "id" uuid NOT NULL, "ip_address" inet NOT NULL, "otp_code" text, "verified_at" timestamp with time zone, "web_authn_session_data" jsonb); + +ALTER TABLE "auth"."mfa_challenges" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."mfa_challenges" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."mfa_factors" ("created_at" timestamp with time zone NOT NULL, "friendly_name" text, "id" uuid NOT NULL, "last_challenged_at" timestamp with time zone, "last_webauthn_challenge_data" jsonb, "phone" text, "secret" text, "updated_at" timestamp with time zone NOT NULL, "user_id" uuid NOT NULL, "web_authn_aaguid" uuid, "web_authn_credential" jsonb); + +ALTER TABLE "auth"."mfa_factors" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."mfa_factors" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."oauth_authorizations" ("approved_at" timestamp with time zone, "authorization_code" text, "authorization_id" text NOT NULL, "client_id" uuid NOT NULL, "code_challenge" text, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "expires_at" timestamp with time zone NOT NULL DEFAULT (now() + '00:03:00'::interval), "id" uuid NOT NULL, "nonce" text, "redirect_uri" text NOT NULL, "resource" text, "scope" text NOT NULL, "state" text, "user_id" uuid); + +ALTER TABLE "auth"."oauth_authorizations" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."oauth_client_states" ("code_verifier" text, "created_at" timestamp with time zone NOT NULL, "id" uuid NOT NULL, "provider_type" text NOT NULL); + +ALTER TABLE "auth"."oauth_client_states" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."oauth_clients" ("client_name" text, "client_secret_hash" text, "client_uri" text, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "deleted_at" timestamp with time zone, "grant_types" text NOT NULL, "id" uuid NOT NULL, "logo_uri" text, "redirect_uris" text NOT NULL, "token_endpoint_auth_method" text NOT NULL, "updated_at" timestamp with time zone NOT NULL DEFAULT now()); + +ALTER TABLE "auth"."oauth_clients" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."oauth_consents" ("client_id" uuid NOT NULL, "granted_at" timestamp with time zone NOT NULL DEFAULT now(), "id" uuid NOT NULL, "revoked_at" timestamp with time zone, "scopes" text NOT NULL, "user_id" uuid NOT NULL); + +ALTER TABLE "auth"."oauth_consents" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."one_time_tokens" ("created_at" timestamp without time zone NOT NULL DEFAULT now(), "id" uuid NOT NULL, "relates_to" text NOT NULL, "token_hash" text NOT NULL, "updated_at" timestamp without time zone NOT NULL DEFAULT now(), "user_id" uuid NOT NULL); + +ALTER TABLE "auth"."one_time_tokens" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."one_time_tokens" OWNER TO "supabase_auth_admin"; + +ALTER TABLE "auth"."refresh_tokens" ENABLE ROW LEVEL SECURITY; + +CREATE TABLE "auth"."saml_providers" ("attribute_mapping" jsonb, "created_at" timestamp with time zone, "entity_id" text NOT NULL, "id" uuid NOT NULL, "metadata_url" text, "metadata_xml" text NOT NULL, "name_id_format" text, "sso_provider_id" uuid NOT NULL, "updated_at" timestamp with time zone); + +ALTER TABLE "auth"."saml_providers" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."saml_providers" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."saml_relay_states" ("created_at" timestamp with time zone, "flow_state_id" uuid, "for_email" text, "id" uuid NOT NULL, "redirect_to" text, "request_id" text NOT NULL, "sso_provider_id" uuid NOT NULL, "updated_at" timestamp with time zone); + +ALTER TABLE "auth"."saml_relay_states" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."saml_relay_states" OWNER TO "supabase_auth_admin"; + +ALTER TABLE "auth"."schema_migrations" ENABLE ROW LEVEL SECURITY; + +CREATE TABLE "auth"."sessions" ("created_at" timestamp with time zone, "factor_id" uuid, "id" uuid NOT NULL, "ip" inet, "not_after" timestamp with time zone, "oauth_client_id" uuid, "refresh_token_counter" bigint, "refresh_token_hmac_key" text, "refreshed_at" timestamp without time zone, "scopes" text, "tag" text, "updated_at" timestamp with time zone, "user_agent" text, "user_id" uuid NOT NULL); + +ALTER TABLE "auth"."sessions" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."sessions" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."sso_domains" ("created_at" timestamp with time zone, "domain" text NOT NULL, "id" uuid NOT NULL, "sso_provider_id" uuid NOT NULL, "updated_at" timestamp with time zone); + +ALTER TABLE "auth"."sso_domains" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."sso_domains" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."sso_providers" ("created_at" timestamp with time zone, "disabled" boolean, "id" uuid NOT NULL, "resource_id" text, "updated_at" timestamp with time zone); + +ALTER TABLE "auth"."sso_providers" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "auth"."sso_providers" OWNER TO "supabase_auth_admin"; + +ALTER TABLE "auth"."users" ENABLE ROW LEVEL SECURITY; + +CREATE TABLE "auth"."webauthn_challenges" ("challenge_type" text NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "expires_at" timestamp with time zone NOT NULL, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "session_data" jsonb NOT NULL, "user_id" uuid); + +ALTER TABLE "auth"."webauthn_challenges" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "auth"."webauthn_credentials" ("aaguid" uuid, "attestation_type" text NOT NULL DEFAULT ''::text, "backed_up" boolean NOT NULL DEFAULT false, "backup_eligible" boolean NOT NULL DEFAULT false, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "credential_id" bytea NOT NULL, "friendly_name" text NOT NULL DEFAULT ''::text, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "last_used_at" timestamp with time zone, "public_key" bytea NOT NULL, "sign_count" bigint NOT NULL DEFAULT 0, "transports" jsonb NOT NULL DEFAULT '[]'::jsonb, "updated_at" timestamp with time zone NOT NULL DEFAULT now(), "user_id" uuid NOT NULL); + +ALTER TABLE "auth"."webauthn_credentials" OWNER TO "supabase_auth_admin"; + +CREATE TABLE "realtime"."messages" ("binary_payload" bytea, "event" text, "extension" text NOT NULL, "id" uuid NOT NULL, "inserted_at" timestamp without time zone NOT NULL, "payload" jsonb, "private" boolean, "topic" text NOT NULL, "updated_at" timestamp without time zone NOT NULL) PARTITION BY RANGE (inserted_at); + +CREATE TABLE "realtime"."messages_2026_07_08" PARTITION OF "realtime"."messages" FOR VALUES FROM ('2026-07-08 00:00:00') TO ('2026-07-09 00:00:00'); + +ALTER TABLE "realtime"."messages_2026_07_08" OWNER TO "supabase_realtime_admin"; + +CREATE TABLE "realtime"."messages_2026_07_09" PARTITION OF "realtime"."messages" FOR VALUES FROM ('2026-07-09 00:00:00') TO ('2026-07-10 00:00:00'); + +ALTER TABLE "realtime"."messages_2026_07_09" OWNER TO "supabase_realtime_admin"; + +CREATE TABLE "realtime"."messages_2026_07_10" PARTITION OF "realtime"."messages" FOR VALUES FROM ('2026-07-10 00:00:00') TO ('2026-07-11 00:00:00'); + +ALTER TABLE "realtime"."messages_2026_07_10" OWNER TO "supabase_realtime_admin"; + +CREATE TABLE "realtime"."messages_2026_07_11" PARTITION OF "realtime"."messages" FOR VALUES FROM ('2026-07-11 00:00:00') TO ('2026-07-12 00:00:00'); + +ALTER TABLE "realtime"."messages_2026_07_11" OWNER TO "supabase_realtime_admin"; + +CREATE TABLE "realtime"."messages_2026_07_12" PARTITION OF "realtime"."messages" FOR VALUES FROM ('2026-07-12 00:00:00') TO ('2026-07-13 00:00:00'); + +ALTER TABLE "realtime"."messages_2026_07_12" OWNER TO "supabase_realtime_admin"; + +ALTER TABLE "realtime"."messages" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "realtime"."messages" OWNER TO "supabase_realtime_admin"; + +CREATE TABLE "realtime"."schema_migrations" ("inserted_at" timestamp(0) without time zone, "version" bigint NOT NULL); + +ALTER TABLE "realtime"."schema_migrations" OWNER TO "supabase_admin"; + +CREATE TABLE "realtime"."subscription" ("action_filter" text DEFAULT '*'::text, "claims" jsonb NOT NULL, "created_at" timestamp without time zone NOT NULL DEFAULT timezone('utc'::text, now()), "entity" regclass NOT NULL, "id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL, "selected_columns" text[], "subscription_id" uuid NOT NULL); + +ALTER TABLE "realtime"."subscription" OWNER TO "supabase_admin"; + +CREATE TABLE "storage"."buckets_analytics" ("created_at" timestamp with time zone NOT NULL DEFAULT now(), "deleted_at" timestamp with time zone, "format" text NOT NULL DEFAULT 'ICEBERG'::text, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "name" text NOT NULL, "updated_at" timestamp with time zone NOT NULL DEFAULT now()); + +ALTER TABLE "storage"."buckets_analytics" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."buckets_analytics" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."buckets_vectors" ("created_at" timestamp with time zone NOT NULL DEFAULT now(), "id" text NOT NULL, "updated_at" timestamp with time zone NOT NULL DEFAULT now()); + +ALTER TABLE "storage"."buckets_vectors" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."buckets_vectors" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."buckets" ("allowed_mime_types" text[], "avif_autodetection" boolean DEFAULT false, "created_at" timestamp with time zone DEFAULT now(), "file_size_limit" bigint, "id" text NOT NULL, "name" text NOT NULL, "owner_id" text, "owner" uuid, "public" boolean DEFAULT false, "updated_at" timestamp with time zone DEFAULT now()); + +ALTER TABLE "storage"."buckets" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."buckets" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."iceberg_namespaces" ("bucket_name" text NOT NULL, "catalog_id" uuid NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "id" uuid NOT NULL DEFAULT gen_random_uuid(), "metadata" jsonb NOT NULL DEFAULT '{}'::jsonb, "name" text COLLATE pg_catalog."C" NOT NULL, "updated_at" timestamp with time zone NOT NULL DEFAULT now()); + +ALTER TABLE "storage"."iceberg_namespaces" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."iceberg_namespaces" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."iceberg_tables" ("bucket_name" text NOT NULL, "catalog_id" uuid NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "id" uuid NOT NULL DEFAULT gen_random_uuid(), "location" text NOT NULL, "namespace_id" uuid NOT NULL, "name" text COLLATE pg_catalog."C" NOT NULL, "remote_table_id" text, "shard_id" text, "shard_key" text, "updated_at" timestamp with time zone NOT NULL DEFAULT now()); + +ALTER TABLE "storage"."iceberg_tables" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."iceberg_tables" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."migrations" ("executed_at" timestamp without time zone DEFAULT CURRENT_TIMESTAMP, "hash" character varying(40) NOT NULL, "id" integer NOT NULL, "name" character varying(100) NOT NULL); + +ALTER TABLE "storage"."migrations" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."migrations" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."objects" ("bucket_id" text, "created_at" timestamp with time zone DEFAULT now(), "id" uuid NOT NULL DEFAULT gen_random_uuid(), "last_accessed_at" timestamp with time zone DEFAULT now(), "metadata" jsonb, "name" text, "owner_id" text, "owner" uuid, "updated_at" timestamp with time zone DEFAULT now(), "user_metadata" jsonb, "version" text); + +ALTER TABLE "storage"."objects" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."objects" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."s3_multipart_uploads_parts" ("bucket_id" text NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "etag" text NOT NULL, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "key" text COLLATE pg_catalog."C" NOT NULL, "owner_id" text, "part_number" integer NOT NULL, "size" bigint NOT NULL DEFAULT 0, "upload_id" text NOT NULL, "version" text NOT NULL); + +ALTER TABLE "storage"."s3_multipart_uploads_parts" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."s3_multipart_uploads_parts" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."s3_multipart_uploads" ("bucket_id" text NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "id" text NOT NULL, "in_progress_size" bigint NOT NULL DEFAULT 0, "key" text COLLATE pg_catalog."C" NOT NULL, "metadata" jsonb, "owner_id" text, "upload_signature" text NOT NULL, "user_metadata" jsonb, "version" text NOT NULL); + +ALTER TABLE "storage"."s3_multipart_uploads" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."s3_multipart_uploads" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "storage"."vector_indexes" ("bucket_id" text NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now(), "data_type" text NOT NULL, "dimension" integer NOT NULL, "distance_metric" text NOT NULL, "id" text NOT NULL DEFAULT gen_random_uuid(), "metadata_configuration" jsonb, "name" text COLLATE pg_catalog."C" NOT NULL, "updated_at" timestamp with time zone NOT NULL DEFAULT now()); + +ALTER TABLE "storage"."vector_indexes" ENABLE ROW LEVEL SECURITY; + +ALTER TABLE "storage"."vector_indexes" OWNER TO "supabase_storage_admin"; + +CREATE TABLE "supabase_functions"."hooks" ("created_at" timestamp with time zone NOT NULL DEFAULT now(), "hook_name" text NOT NULL, "hook_table_id" integer NOT NULL, "id" bigint NOT NULL DEFAULT nextval('supabase_functions.hooks_id_seq'::regclass), "request_id" bigint); + +ALTER TABLE "supabase_functions"."hooks" OWNER TO "supabase_functions_admin"; + +CREATE TABLE "supabase_functions"."migrations" ("inserted_at" timestamp with time zone NOT NULL DEFAULT now(), "version" text NOT NULL); + +ALTER TABLE "supabase_functions"."migrations" OWNER TO "supabase_functions_admin"; + +ALTER TABLE "auth"."audit_log_entries" ADD COLUMN "ip_address" character varying(64) NOT NULL DEFAULT ''::character varying; + +ALTER TABLE "auth"."identities" ADD COLUMN "email" text GENERATED ALWAYS AS (lower((identity_data ->> 'email'::text))) STORED; + +ALTER TABLE "auth"."refresh_tokens" ADD COLUMN "parent" character varying(255); + +ALTER TABLE "auth"."refresh_tokens" ADD COLUMN "session_id" uuid; + +ALTER TABLE "auth"."users" ADD COLUMN "banned_until" timestamp with time zone; + +ALTER TABLE "auth"."users" ADD COLUMN "deleted_at" timestamp with time zone; + +ALTER TABLE "auth"."users" ADD COLUMN "email_change_confirm_status" smallint DEFAULT 0; + +ALTER TABLE "auth"."users" ADD COLUMN "email_change_token_current" character varying(255) DEFAULT ''::character varying; + +ALTER TABLE "auth"."users" ADD COLUMN "email_change_token_new" character varying(255); + +ALTER TABLE "auth"."users" ADD COLUMN "email_confirmed_at" timestamp with time zone; + +ALTER TABLE "auth"."users" ADD COLUMN "is_anonymous" boolean NOT NULL DEFAULT false; + +ALTER TABLE "auth"."users" ADD COLUMN "is_sso_user" boolean NOT NULL DEFAULT false; + +ALTER TABLE "auth"."users" ADD COLUMN "phone_change_sent_at" timestamp with time zone; + +ALTER TABLE "auth"."users" ADD COLUMN "phone_change_token" character varying(255) DEFAULT ''::character varying; + +ALTER TABLE "auth"."users" ADD COLUMN "phone_change" text DEFAULT ''::character varying; + +ALTER TABLE "auth"."users" ADD COLUMN "phone_confirmed_at" timestamp with time zone; + +ALTER TABLE "auth"."users" ADD COLUMN "confirmed_at" timestamp with time zone GENERATED ALWAYS AS (LEAST(email_confirmed_at, phone_confirmed_at)) STORED; + +ALTER TABLE "auth"."users" ADD COLUMN "phone" text DEFAULT NULL::character varying; + +ALTER TABLE "auth"."users" ADD COLUMN "reauthentication_sent_at" timestamp with time zone; + +ALTER TABLE "auth"."users" ADD COLUMN "reauthentication_token" character varying(255) DEFAULT ''::character varying; + +ALTER TABLE "storage"."objects" ADD COLUMN "path_tokens" text[] GENERATED ALWAYS AS (string_to_array(name, '/'::text)) STORED; + +ALTER SEQUENCE "supabase_functions"."hooks_id_seq" OWNED BY "supabase_functions"."hooks"."id"; + +ALTER TABLE "realtime"."messages" ALTER COLUMN "id" SET DEFAULT gen_random_uuid(); + +ALTER TABLE "realtime"."messages" ALTER COLUMN "inserted_at" SET DEFAULT now(); + +ALTER TABLE "realtime"."messages" ALTER COLUMN "private" SET DEFAULT false; + +ALTER TABLE "realtime"."messages" ALTER COLUMN "updated_at" SET DEFAULT now(); + +CREATE TYPE "auth"."aal_level" AS ENUM ('aal1', 'aal2', 'aal3'); + +ALTER TABLE "auth"."sessions" ADD COLUMN "aal" aal_level; + +ALTER TYPE "auth"."aal_level" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "auth"."code_challenge_method" AS ENUM ('s256', 'plain'); + +ALTER TABLE "auth"."flow_state" ADD COLUMN "code_challenge_method" code_challenge_method; + +ALTER TABLE "auth"."oauth_authorizations" ADD COLUMN "code_challenge_method" code_challenge_method; + +ALTER TYPE "auth"."code_challenge_method" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "auth"."factor_status" AS ENUM ('unverified', 'verified'); + +ALTER TABLE "auth"."mfa_factors" ADD COLUMN "status" factor_status NOT NULL; + +ALTER TYPE "auth"."factor_status" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "auth"."factor_type" AS ENUM ('totp', 'webauthn', 'phone'); + +ALTER TABLE "auth"."mfa_factors" ADD COLUMN "factor_type" factor_type NOT NULL; + +ALTER TYPE "auth"."factor_type" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "auth"."oauth_authorization_status" AS ENUM ('pending', 'approved', 'denied', 'expired'); + +ALTER TABLE "auth"."oauth_authorizations" ADD COLUMN "status" oauth_authorization_status NOT NULL DEFAULT 'pending'::oauth_authorization_status; + +ALTER TYPE "auth"."oauth_authorization_status" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "auth"."oauth_client_type" AS ENUM ('public', 'confidential'); + +ALTER TABLE "auth"."oauth_clients" ADD COLUMN "client_type" oauth_client_type NOT NULL DEFAULT 'confidential'::oauth_client_type; + +ALTER TYPE "auth"."oauth_client_type" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "auth"."oauth_registration_type" AS ENUM ('dynamic', 'manual'); + +ALTER TABLE "auth"."oauth_clients" ADD COLUMN "registration_type" oauth_registration_type NOT NULL; + +ALTER TYPE "auth"."oauth_registration_type" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "auth"."oauth_response_type" AS ENUM ('code'); + +ALTER TABLE "auth"."oauth_authorizations" ADD COLUMN "response_type" oauth_response_type NOT NULL DEFAULT 'code'::oauth_response_type; + +ALTER TYPE "auth"."oauth_response_type" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "auth"."one_time_token_type" AS ENUM ('confirmation_token', 'reauthentication_token', 'recovery_token', 'email_change_token_new', 'email_change_token_current', 'phone_change_token'); + +ALTER TABLE "auth"."one_time_tokens" ADD COLUMN "token_type" one_time_token_type NOT NULL; + +ALTER TYPE "auth"."one_time_token_type" OWNER TO "supabase_auth_admin"; + +CREATE TYPE "realtime"."action" AS ENUM ('INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR'); + +ALTER TYPE "realtime"."action" OWNER TO "supabase_admin"; + +CREATE TYPE "realtime"."equality_op" AS ENUM ('eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in'); + +ALTER TYPE "realtime"."equality_op" OWNER TO "supabase_admin"; + +CREATE TYPE "realtime"."user_defined_filter" AS ("column_name" text, "op" realtime.equality_op, "value" text); + +ALTER TABLE "realtime"."subscription" ADD COLUMN "filters" realtime.user_defined_filter[] NOT NULL DEFAULT '{}'::realtime.user_defined_filter[]; + +ALTER TYPE "realtime"."user_defined_filter" OWNER TO "supabase_admin"; + +CREATE TYPE "realtime"."wal_column" AS ("name" text, "type_name" text, "type_oid" oid, "value" jsonb, "is_pkey" boolean, "is_selectable" boolean); + +ALTER TYPE "realtime"."wal_column" OWNER TO "supabase_admin"; + +CREATE TYPE "realtime"."wal_rls" AS ("wal" jsonb, "is_rls_enabled" boolean, "subscription_ids" uuid[], "errors" text[]); + +ALTER TYPE "realtime"."wal_rls" OWNER TO "supabase_admin"; + +CREATE TYPE "storage"."buckettype" AS ENUM ('STANDARD', 'ANALYTICS', 'VECTOR'); + +ALTER TABLE "storage"."buckets" ADD COLUMN "type" storage.buckettype NOT NULL DEFAULT 'STANDARD'::storage.buckettype; + +ALTER TABLE "storage"."buckets_analytics" ADD COLUMN "type" storage.buckettype NOT NULL DEFAULT 'ANALYTICS'::storage.buckettype; + +ALTER TABLE "storage"."buckets_vectors" ADD COLUMN "type" storage.buckettype NOT NULL DEFAULT 'VECTOR'::storage.buckettype; + +ALTER TYPE "storage"."buckettype" OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION auth.email() + RETURNS text + LANGUAGE sql + STABLE +AS $function$ + select + coalesce( + nullif(current_setting('request.jwt.claim.email', true), ''), + (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') + )::text +$function$; + +CREATE OR REPLACE FUNCTION auth.jwt() + RETURNS jsonb + LANGUAGE sql + STABLE +AS $function$ + select + coalesce( + nullif(current_setting('request.jwt.claim', true), ''), + nullif(current_setting('request.jwt.claims', true), '') + )::jsonb +$function$; + +ALTER FUNCTION "auth"."jwt"() OWNER TO "supabase_auth_admin"; + +CREATE OR REPLACE FUNCTION auth.role() + RETURNS text + LANGUAGE sql + STABLE +AS $function$ + select + coalesce( + nullif(current_setting('request.jwt.claim.role', true), ''), + (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role') + )::text +$function$; + +CREATE OR REPLACE FUNCTION auth.uid() + RETURNS uuid + LANGUAGE sql + STABLE +AS $function$ + select + coalesce( + nullif(current_setting('request.jwt.claim.sub', true), ''), + (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') + )::uuid +$function$; + +CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql +AS $function$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; +END; +$function$; + +CREATE OR REPLACE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) + RETURNS SETOF realtime.wal_rls + LANGUAGE plpgsql +AS $function$ +declare + -- Regclass of the table e.g. public.notes + entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass; + + -- I, U, D, T: insert, update ... + action realtime.action = ( + case wal ->> 'action' + when 'I' then 'INSERT' + when 'U' then 'UPDATE' + when 'D' then 'DELETE' + else 'ERROR' + end + ); + + -- Is row level security enabled for the table + is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_; + + subscriptions realtime.subscription[] = array_agg(subs) + from + realtime.subscription subs + where + subs.entity = entity_ + -- Filter by action early - only get subscriptions interested in this action + -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE' + and (subs.action_filter = '*' or subs.action_filter = action::text); + + -- Subscription vars + working_role regrole; + working_selected_columns text[]; + claimed_role regrole; + claims jsonb; + + subscription_id uuid; + subscription_has_access bool; + visible_to_subscription_ids uuid[] = '{}'; + + -- structured info for wal's columns + columns realtime.wal_column[]; + -- previous identity values for update/delete + old_columns realtime.wal_column[]; + + error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes; + + -- Primary jsonb output for record + output jsonb; + + -- Loop record for iterating unique roles (outer loop) + role_record record; + -- Loop record for iterating unique selected_columns within a role (inner loop) + cols_record record; + -- Subscription ids visible at the role level (before fanning out by selected_columns) + visible_role_sub_ids uuid[] = '{}'; + +begin + perform set_config('role', null, true); + + columns = + array_agg( + ( + x->>'name', + x->>'type', + x->>'typeoid', + realtime.cast( + (x->'value') #>> '{}', + coalesce( + (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 + (x->>'type')::regtype + ) + ), + (pks ->> 'name') is not null, + true + )::realtime.wal_column + ) + from + jsonb_array_elements(wal -> 'columns') x + left join jsonb_array_elements(wal -> 'pk') pks + on (x ->> 'name') = (pks ->> 'name'); + + old_columns = + array_agg( + ( + x->>'name', + x->>'type', + x->>'typeoid', + realtime.cast( + (x->'value') #>> '{}', + coalesce( + (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 + (x->>'type')::regtype + ) + ), + (pks ->> 'name') is not null, + true + )::realtime.wal_column + ) + from + jsonb_array_elements(wal -> 'identity') x + left join jsonb_array_elements(wal -> 'pk') pks + on (x ->> 'name') = (pks ->> 'name'); + + for role_record in + select claims_role + from (select distinct claims_role from unnest(subscriptions)) t + order by claims_role::text + loop + working_role := role_record.claims_role; + + -- Update `is_selectable` for columns and old_columns (once per role) + columns = + array_agg( + ( + c.name, + c.type_name, + c.type_oid, + c.value, + c.is_pkey, + pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') + )::realtime.wal_column + ) + from + unnest(columns) c; + + old_columns = + array_agg( + ( + c.name, + c.type_name, + c.type_oid, + c.value, + c.is_pkey, + pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') + )::realtime.wal_column + ) + from + unnest(old_columns) c; + + if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then + -- Fan out 400 error per distinct selected_columns for this role + for cols_record in + select selected_columns + from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t + order by coalesce(array_to_string(selected_columns, ','), '') + loop + working_selected_columns := cols_record.selected_columns; + return next ( + jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action + ), + is_rls_enabled, + (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)), + array['Error 400: Bad Request, no primary key'] + )::realtime.wal_rls; + end loop; + + -- The claims role does not have SELECT permission to the primary key of entity + elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then + -- Fan out 401 error per distinct selected_columns for this role + for cols_record in + select selected_columns + from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t + order by coalesce(array_to_string(selected_columns, ','), '') + loop + working_selected_columns := cols_record.selected_columns; + return next ( + jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action + ), + is_rls_enabled, + (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)), + array['Error 401: Unauthorized'] + )::realtime.wal_rls; + end loop; + + else + -- Create the prepared statement (once per role) + if is_rls_enabled and action <> 'DELETE' then + if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then + deallocate walrus_rls_stmt; + end if; + execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns); + end if; + + -- Collect all visible subscription IDs for this role (filter check + RLS check) + visible_role_sub_ids = '{}'; + + for subscription_id, claims in ( + select + subs.subscription_id, + subs.claims + from + unnest(subscriptions) subs + where + subs.entity = entity_ + and subs.claims_role = working_role + and ( + realtime.is_visible_through_filters(columns, subs.filters) + or ( + action = 'DELETE' + and realtime.is_visible_through_filters(old_columns, subs.filters) + ) + ) + ) loop + + if not is_rls_enabled or action = 'DELETE' then + visible_role_sub_ids = visible_role_sub_ids || subscription_id; + else + -- Check if RLS allows the role to see the record + perform + -- Trim leading and trailing quotes from working_role because set_config + -- doesn't recognize the role as valid if they are included + set_config('role', trim(both '"' from working_role::text), true), + set_config('request.jwt.claims', claims::text, true); + + execute 'execute walrus_rls_stmt' into subscription_has_access; + + if subscription_has_access then + visible_role_sub_ids = visible_role_sub_ids || subscription_id; + end if; + end if; + end loop; + + perform set_config('role', null, true); + + -- Inner loop: per distinct selected_columns for this role + for cols_record in + select selected_columns + from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t + order by coalesce(array_to_string(selected_columns, ','), '') + loop + working_selected_columns := cols_record.selected_columns; + + output = jsonb_build_object( + 'schema', wal ->> 'schema', + 'table', wal ->> 'table', + 'type', action, + 'commit_timestamp', to_char( + ((wal ->> 'timestamp')::timestamptz at time zone 'utc'), + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'columns', ( + select + jsonb_agg( + jsonb_build_object( + 'name', pa.attname, + 'type', pt.typname + ) + order by pa.attnum asc + ) + from + pg_attribute pa + join pg_type pt + on pa.atttypid = pt.oid + left join ( + select unnest(conkey) as pkey_attnum + from pg_constraint + where conrelid = entity_ and contype = 'p' + ) pk on pk.pkey_attnum = pa.attnum + where + attrelid = entity_ + and attnum > 0 + and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT') + and (working_selected_columns is null or pa.attname = any(working_selected_columns) or pk.pkey_attnum is not null) + ) + ) + -- Add "record" key for insert and update + || case + when action in ('INSERT', 'UPDATE') then + jsonb_build_object( + 'record', + ( + select + jsonb_object_agg( + -- if unchanged toast, get column name and value from old record + coalesce((c).name, (oc).name), + case + when (c).name is null then (oc).value + else (c).value + end + ) + from + unnest(columns) c + full outer join unnest(old_columns) oc + on (c).name = (oc).name + where + coalesce((c).is_selectable, (oc).is_selectable) + and (working_selected_columns is null or coalesce((c).name, (oc).name) = any(working_selected_columns) or coalesce((c).is_pkey, (oc).is_pkey)) + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + ) + ) + else '{}'::jsonb + end + -- Add "old_record" key for update and delete + || case + when action = 'UPDATE' then + jsonb_build_object( + 'old_record', + ( + select jsonb_object_agg((c).name, (c).value) + from unnest(old_columns) c + where + (c).is_selectable + and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey) + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + ) + ) + when action = 'DELETE' then + jsonb_build_object( + 'old_record', + ( + select jsonb_object_agg((c).name, (c).value) + from unnest(old_columns) c + where + (c).is_selectable + and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey) + and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) + and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey + ) + ) + else '{}'::jsonb + end; + + -- Filter visible_role_sub_ids to those matching the current selected_columns group + visible_to_subscription_ids = coalesce( + ( + select array_agg(s.subscription_id) + from unnest(subscriptions) s + where s.claims_role = working_role + and (s.selected_columns is not distinct from working_selected_columns) + and s.subscription_id = any(visible_role_sub_ids) + ), + '{}'::uuid[] + ); + + return next ( + output, + is_rls_enabled, + visible_to_subscription_ids, + case + when error_record_exceeds_max_size then array['Error 413: Payload Too Large'] + else '{}' + end + )::realtime.wal_rls; + end loop; + + end if; + end loop; + + perform set_config('role', null, true); +end; +$function$; + +ALTER FUNCTION "realtime"."apply_rls"(jsonb, integer) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.broadcast_changes(topic_name text, event_name text, operation text, table_name text, table_schema text, new record, old record, level text DEFAULT 'ROW'::text) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + -- Declare a variable to hold the JSONB representation of the row + row_data jsonb := '{}'::jsonb; +BEGIN + IF level = 'STATEMENT' THEN + RAISE EXCEPTION 'function can only be triggered for each row, not for each statement'; + END IF; + -- Check the operation type and handle accordingly + IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN + row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema); + PERFORM realtime.send (row_data, event_name, topic_name); + ELSE + RAISE EXCEPTION 'Unexpected operation type: %', operation; + END IF; +EXCEPTION + WHEN OTHERS THEN + RAISE EXCEPTION 'Failed to process the row: %', SQLERRM; +END; + +$function$; + +ALTER FUNCTION "realtime"."broadcast_changes"(text, text, text, text, text, record, record, text) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[]) + RETURNS text + LANGUAGE sql +AS $function$ + /* + Builds a sql string that, if executed, creates a prepared statement to + tests retrive a row from *entity* by its primary key columns. + Example + select realtime.build_prepared_statement_sql('public.notes', '{"id"}'::text[], '{"bigint"}'::text[]) + */ + select + 'prepare ' || prepared_statement_name || ' as + select + exists( + select + 1 + from + ' || entity || ' + where + ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || ' + )' + from + unnest(columns) pkc + where + pkc.is_pkey + group by + entity + $function$; + +ALTER FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime."cast"(val text, type_ regtype) + RETURNS jsonb + LANGUAGE plpgsql + IMMUTABLE +AS $function$ +declare + res jsonb; +begin + if type_::text = 'bytea' then + return to_jsonb(val); + end if; + execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res; + return res; +end +$function$; + +ALTER FUNCTION "realtime"."cast"(text, regtype) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text) + RETURNS boolean + LANGUAGE plpgsql + IMMUTABLE +AS $function$ + /* + Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness + */ + declare + op_symbol text = ( + case + when op = 'eq' then '=' + when op = 'neq' then '!=' + when op = 'lt' then '<' + when op = 'lte' then '<=' + when op = 'gt' then '>' + when op = 'gte' then '>=' + when op = 'in' then '= any' + else 'UNKNOWN OP' + end + ); + res boolean; + begin + execute format( + 'select %L::'|| type_::text || ' ' || op_symbol + || ' ( %L::' + || ( + case + when op = 'in' then type_::text || '[]' + else type_::text end + ) + || ')', val_1, val_2) into res; + return res; + end; + $function$; + +ALTER FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +AS $function$ + /* + Should the record be visible (true) or filtered out (false) after *filters* are applied + */ + select + -- Default to allowed when no filters present + $2 is null -- no filters. this should not happen because subscriptions has a default + or array_length($2, 1) is null -- array length of an empty array is null + or bool_and( + coalesce( + realtime.check_equality_op( + op:=f.op, + type_:=coalesce( + col.type_oid::regtype, -- null when wal2json version <= 2.4 + col.type_name::regtype + ), + -- cast jsonb to text + val_1:=col.value #>> '{}', + val_2:=f.value + ), + false -- if null, filter does not match + ) + ) + from + unnest(filters) f + join unnest(columns) col + on f.column_name = col.name; + $function$; + +ALTER FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.list_changes(publication name, slot_name name, max_changes integer, max_record_bytes integer) + RETURNS TABLE(wal jsonb, is_rls_enabled boolean, subscription_ids uuid[], errors text[], slot_changes_count bigint) + LANGUAGE sql + SET log_min_messages TO 'fatal' +AS $function$ + WITH pub AS ( + SELECT + concat_ws( + ',', + CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END, + CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END, + CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END + ) AS w2j_actions, + coalesce( + string_agg( + realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass), + ',' + ) filter (WHERE ppt.tablename IS NOT NULL), + '' + ) AS w2j_add_tables + FROM pg_publication pp + LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname + WHERE pp.pubname = publication + GROUP BY pp.pubname + LIMIT 1 + ), + -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once + w2j AS MATERIALIZED ( + SELECT x.*, pub.w2j_add_tables + FROM pub, + pg_logical_slot_get_changes( + slot_name, null, max_changes, + 'include-pk', 'true', + 'include-transaction', 'false', + 'include-timestamp', 'true', + 'include-type-oids', 'true', + 'format-version', '2', + 'actions', pub.w2j_actions, + 'add-tables', pub.w2j_add_tables + ) x + ), + slot_count AS ( + SELECT count(*)::bigint AS cnt + FROM w2j + WHERE w2j.w2j_add_tables <> '' + ), + rls_filtered AS ( + SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors + FROM w2j, + realtime.apply_rls( + wal := w2j.data::jsonb, + max_record_bytes := max_record_bytes + ) xyz(wal, is_rls_enabled, subscription_ids, errors) + WHERE w2j.w2j_add_tables <> '' + AND xyz.subscription_ids[1] IS NOT NULL + ) + SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt + FROM rls_filtered rf, slot_count sc + + UNION ALL + + SELECT null, null, null, null, sc.cnt + FROM slot_count sc + WHERE NOT EXISTS (SELECT 1 FROM rls_filtered) +$function$; + +ALTER FUNCTION "realtime"."list_changes"(name, name, integer, integer) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.quote_wal2json(entity regclass) + RETURNS text + LANGUAGE sql + IMMUTABLE STRICT +AS $function$ + SELECT + realtime.wal2json_escape_identifier(nsp.nspname::text) + || '.' + || realtime.wal2json_escape_identifier(pc.relname::text) + FROM pg_class pc + JOIN pg_namespace nsp ON pc.relnamespace = nsp.oid + WHERE pc.oid = entity +$function$; + +ALTER FUNCTION "realtime"."quote_wal2json"(regclass) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + generated_id uuid; + final_payload jsonb; +BEGIN + BEGIN + generated_id := gen_random_uuid(); + + -- Check if payload has an 'id' key, if not, add the generated UUID + IF payload ? 'id' THEN + final_payload := payload; + ELSE + final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id)); + END IF; + + -- Set the topic configuration + EXECUTE format('SET LOCAL realtime.topic TO %L', topic); + + INSERT INTO realtime.messages (id, payload, event, topic, private, extension) + VALUES (generated_id, final_payload, event, topic, private, 'broadcast'); + EXCEPTION + WHEN OTHERS THEN + RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM; + END; +END; +$function$; + +ALTER FUNCTION "realtime"."send"(jsonb, text, text, boolean) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.send_binary(payload bytea, event text, topic text, private boolean DEFAULT true) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + generated_id uuid; +BEGIN + BEGIN + generated_id := gen_random_uuid(); + + EXECUTE format('SET LOCAL realtime.topic TO %L', topic); + + INSERT INTO realtime.messages (id, binary_payload, event, topic, private, extension) + VALUES (generated_id, payload, event, topic, private, 'broadcast'); + EXCEPTION + WHEN OTHERS THEN + RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM; + END; +END; +$function$; + +ALTER FUNCTION "realtime"."send_binary"(bytea, text, text, boolean) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.subscription_check_filters() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +declare + col_names text[] = coalesce( + array_agg(a.attname order by a.attnum), + '{}'::text[] + ) + from + pg_catalog.pg_attribute a + where + a.attrelid = new.entity + and a.attnum > 0 + and not a.attisdropped + and pg_catalog.has_column_privilege( + (new.claims ->> 'role'), + a.attrelid, + a.attnum, + 'SELECT' + ); + filter realtime.user_defined_filter; + col_type regtype; + in_val jsonb; + selected_col text; +begin + for filter in select * from unnest(new.filters) loop + if not filter.column_name = any(col_names) then + raise exception 'invalid column for filter %', filter.column_name; + end if; + + col_type = ( + select atttypid::regtype + from pg_catalog.pg_attribute + where attrelid = new.entity + and attname = filter.column_name + ); + if col_type is null then + raise exception 'failed to lookup type for column %', filter.column_name; + end if; + + if filter.op = 'in'::realtime.equality_op then + in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype); + if coalesce(jsonb_array_length(in_val), 0) > 100 then + raise exception 'too many values for `in` filter. Maximum 100'; + end if; + else + perform realtime.cast(filter.value, col_type); + end if; + end loop; + + if new.selected_columns is not null then + for selected_col in select * from unnest(new.selected_columns) loop + if not selected_col = any(col_names) then + raise exception 'invalid column for select %', selected_col; + end if; + end loop; + end if; + + new.filters = coalesce( + array_agg(f order by f.column_name, f.op, f.value), + '{}' + ) from unnest(new.filters) f; + + new.selected_columns = ( + select array_agg(c order by c) + from unnest(new.selected_columns) c + ); + + return new; +end; +$function$; + +ALTER FUNCTION "realtime"."subscription_check_filters"() OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.to_regrole(role_name text) + RETURNS regrole + LANGUAGE sql + IMMUTABLE +AS $function$ select role_name::regrole $function$; + +ALTER TABLE "realtime"."subscription" ADD COLUMN "claims_role" regrole GENERATED ALWAYS AS (realtime.to_regrole((claims ->> 'role'::text))) STORED NOT NULL; + +ALTER FUNCTION "realtime"."to_regrole"(text) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION realtime.topic() + RETURNS text + LANGUAGE sql + STABLE +AS $function$ +select nullif(current_setting('realtime.topic', true), '')::text; +$function$; + +ALTER FUNCTION "realtime"."topic"() OWNER TO "supabase_realtime_admin"; + +CREATE OR REPLACE FUNCTION realtime.wal2json_escape_identifier(name text) + RETURNS text + LANGUAGE sql + IMMUTABLE STRICT +AS $function$ + -- Prefix `\`, `,`, `.`, and any whitespace with `\` + SELECT regexp_replace(name, '([\\,.[:space:]])', '\\\1', 'g') +$function$; + +ALTER FUNCTION "realtime"."wal2json_escape_identifier"(text) OWNER TO "supabase_admin"; + +CREATE OR REPLACE FUNCTION storage.allow_any_operation(expected_operations text[]) + RETURNS boolean + LANGUAGE sql + STABLE +AS $function$ + WITH current_operation AS ( + SELECT storage.operation() AS raw_operation + ), + normalized AS ( + SELECT CASE + WHEN raw_operation LIKE 'storage.%' THEN substr(raw_operation, 9) + ELSE raw_operation + END AS current_operation + FROM current_operation + ) + SELECT EXISTS ( + SELECT 1 + FROM normalized n + CROSS JOIN LATERAL unnest(expected_operations) AS expected_operation + WHERE expected_operation IS NOT NULL + AND expected_operation <> '' + AND n.current_operation = CASE + WHEN expected_operation LIKE 'storage.%' THEN substr(expected_operation, 9) + ELSE expected_operation + END + ); +$function$; + +ALTER FUNCTION "storage"."allow_any_operation"(text[]) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.allow_only_operation(expected_operation text) + RETURNS boolean + LANGUAGE sql + STABLE +AS $function$ + WITH current_operation AS ( + SELECT storage.operation() AS raw_operation + ), + normalized AS ( + SELECT + CASE + WHEN raw_operation LIKE 'storage.%' THEN substr(raw_operation, 9) + ELSE raw_operation + END AS current_operation, + CASE + WHEN expected_operation LIKE 'storage.%' THEN substr(expected_operation, 9) + ELSE expected_operation + END AS requested_operation + FROM current_operation + ) + SELECT CASE + WHEN requested_operation IS NULL OR requested_operation = '' THEN FALSE + ELSE COALESCE(current_operation = requested_operation, FALSE) + END + FROM normalized; +$function$; + +ALTER FUNCTION "storage"."allow_only_operation"(text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) + RETURNS void + LANGUAGE plpgsql +AS $function$ +BEGIN + INSERT INTO "storage"."objects" ("bucket_id", "name", "owner", "metadata") VALUES (bucketid, name, owner, metadata); + -- hack to rollback the successful insert + RAISE sqlstate 'PT200' using + message = 'ROLLBACK', + detail = 'rollback successful insert'; +END +$function$; + +ALTER FUNCTION "storage"."can_insert_object"(text, text, uuid, jsonb) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.enforce_bucket_name_length() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +begin + if length(new.name) > 100 then + raise exception 'bucket name "%" is too long (% characters). Max is 100.', new.name, length(new.name); + end if; + return new; +end; +$function$; + +ALTER FUNCTION "storage"."enforce_bucket_name_length"() OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.extension(name text) + RETURNS text + LANGUAGE plpgsql + IMMUTABLE +AS $function$ +DECLARE + _parts text[]; + _filename text; +BEGIN + -- Split on "/" to get path segments + SELECT string_to_array(name, '/') INTO _parts; + -- Get the last path segment (the actual filename) + SELECT _parts[array_length(_parts, 1)] INTO _filename; + -- Extract extension: reverse, split on '.', then reverse again + RETURN reverse(split_part(reverse(_filename), '.', 1)); +END +$function$; + +ALTER FUNCTION "storage"."extension"(text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.filename(name text) + RETURNS text + LANGUAGE plpgsql +AS $function$ +DECLARE +_parts text[]; +BEGIN + select string_to_array(name, '/') into _parts; + return _parts[array_length(_parts,1)]; +END +$function$; + +ALTER FUNCTION "storage"."filename"(text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.foldername(name text) + RETURNS text[] + LANGUAGE plpgsql + IMMUTABLE +AS $function$ +DECLARE + _parts text[]; +BEGIN + -- Split on "/" to get path segments + SELECT string_to_array(name, '/') INTO _parts; + -- Return everything except the last segment + RETURN _parts[1 : array_length(_parts,1) - 1]; +END +$function$; + +ALTER FUNCTION "storage"."foldername"(text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.get_common_prefix(p_key text, p_prefix text, p_delimiter text) + RETURNS text + LANGUAGE sql + IMMUTABLE +AS $function$ +SELECT CASE + WHEN position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1)) > 0 + THEN left(p_key, length(p_prefix) + position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1))) + ELSE NULL +END; +$function$; + +ALTER FUNCTION "storage"."get_common_prefix"(text, text, text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.get_size_by_bucket() + RETURNS TABLE(size bigint, bucket_id text) + LANGUAGE plpgsql + STABLE +AS $function$ +BEGIN + return query + select sum((metadata->>'size')::bigint)::bigint as size, obj.bucket_id + from "storage".objects as obj + group by obj.bucket_id; +END +$function$; + +ALTER FUNCTION "storage"."get_size_by_bucket"() OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.list_multipart_uploads_with_delimiter(bucket_id text, prefix_param text, delimiter_param text, max_keys integer DEFAULT 100, next_key_token text DEFAULT ''::text, next_upload_token text DEFAULT ''::text) + RETURNS TABLE(key text, id text, created_at timestamp with time zone) + LANGUAGE plpgsql +AS $function$ +BEGIN + RETURN QUERY EXECUTE + 'SELECT DISTINCT ON(key COLLATE "C") * from ( + SELECT + CASE + WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN + substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) + ELSE + key + END AS key, id, created_at + FROM + storage.s3_multipart_uploads + WHERE + bucket_id = $5 AND + key ILIKE $1 || ''%'' AND + CASE + WHEN $4 != '''' AND $6 = '''' THEN + CASE + WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN + substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) COLLATE "C" > $4 + ELSE + key COLLATE "C" > $4 + END + ELSE + true + END AND + CASE + WHEN $6 != '''' THEN + id COLLATE "C" > $6 + ELSE + true + END + ORDER BY + key COLLATE "C" ASC, created_at ASC) as e order by key COLLATE "C" LIMIT $3' + USING prefix_param, delimiter_param, max_keys, next_key_token, bucket_id, next_upload_token; +END; +$function$; + +ALTER FUNCTION "storage"."list_multipart_uploads_with_delimiter"(text, text, text, integer, text, text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.list_objects_with_delimiter(_bucket_id text, prefix_param text, delimiter_param text, max_keys integer DEFAULT 100, start_after text DEFAULT ''::text, next_token text DEFAULT ''::text, sort_order text DEFAULT 'asc'::text) + RETURNS TABLE(name text, id uuid, metadata jsonb, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone) + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_peek_name TEXT; + v_current RECORD; + v_common_prefix TEXT; + + -- Configuration + v_is_asc BOOLEAN; + v_prefix TEXT; + v_start TEXT; + v_upper_bound TEXT; + v_file_batch_size INT; + + -- Seek state + v_next_seek TEXT; + v_count INT := 0; + + -- Dynamic SQL for batch query only + v_batch_query TEXT; + +BEGIN + -- ======================================================================== + -- INITIALIZATION + -- ======================================================================== + v_is_asc := lower(coalesce(sort_order, 'asc')) = 'asc'; + v_prefix := coalesce(prefix_param, ''); + v_start := CASE WHEN coalesce(next_token, '') <> '' THEN next_token ELSE coalesce(start_after, '') END; + v_file_batch_size := LEAST(GREATEST(max_keys * 2, 100), 1000); + + -- Calculate upper bound for prefix filtering (bytewise, using COLLATE "C") + IF v_prefix = '' THEN + v_upper_bound := NULL; + ELSIF right(v_prefix, 1) = delimiter_param THEN + v_upper_bound := left(v_prefix, -1) || chr(ascii(delimiter_param) + 1); + ELSE + v_upper_bound := left(v_prefix, -1) || chr(ascii(right(v_prefix, 1)) + 1); + END IF; + + -- Build batch query (dynamic SQL - called infrequently, amortized over many rows) + IF v_is_asc THEN + IF v_upper_bound IS NOT NULL THEN + v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || + 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' || + 'AND o.name COLLATE "C" < $3 ORDER BY o.name COLLATE "C" ASC LIMIT $4'; + ELSE + v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || + 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' || + 'ORDER BY o.name COLLATE "C" ASC LIMIT $4'; + END IF; + ELSE + IF v_upper_bound IS NOT NULL THEN + v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || + 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' || + 'AND o.name COLLATE "C" >= $3 ORDER BY o.name COLLATE "C" DESC LIMIT $4'; + ELSE + v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || + 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' || + 'ORDER BY o.name COLLATE "C" DESC LIMIT $4'; + END IF; + END IF; + + -- ======================================================================== + -- SEEK INITIALIZATION: Determine starting position + -- ======================================================================== + IF v_start = '' THEN + IF v_is_asc THEN + v_next_seek := v_prefix; + ELSE + -- DESC without cursor: find the last item in range + IF v_upper_bound IS NOT NULL THEN + SELECT o.name INTO v_next_seek FROM storage.objects o + WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix AND o.name COLLATE "C" < v_upper_bound + ORDER BY o.name COLLATE "C" DESC LIMIT 1; + ELSIF v_prefix <> '' THEN + SELECT o.name INTO v_next_seek FROM storage.objects o + WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix + ORDER BY o.name COLLATE "C" DESC LIMIT 1; + ELSE + SELECT o.name INTO v_next_seek FROM storage.objects o + WHERE o.bucket_id = _bucket_id + ORDER BY o.name COLLATE "C" DESC LIMIT 1; + END IF; + + IF v_next_seek IS NOT NULL THEN + v_next_seek := v_next_seek || delimiter_param; + ELSE + RETURN; + END IF; + END IF; + ELSE + -- Cursor provided: determine if it refers to a folder or leaf + IF EXISTS ( + SELECT 1 FROM storage.objects o + WHERE o.bucket_id = _bucket_id + AND o.name COLLATE "C" LIKE v_start || delimiter_param || '%' + LIMIT 1 + ) THEN + -- Cursor refers to a folder + IF v_is_asc THEN + v_next_seek := v_start || chr(ascii(delimiter_param) + 1); + ELSE + v_next_seek := v_start || delimiter_param; + END IF; + ELSE + -- Cursor refers to a leaf object + IF v_is_asc THEN + v_next_seek := v_start || delimiter_param; + ELSE + v_next_seek := v_start; + END IF; + END IF; + END IF; + + -- ======================================================================== + -- MAIN LOOP: Hybrid peek-then-batch algorithm + -- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch + -- ======================================================================== + LOOP + EXIT WHEN v_count >= max_keys; + + -- STEP 1: PEEK using STATIC SQL (plan cached, very fast) + IF v_is_asc THEN + IF v_upper_bound IS NOT NULL THEN + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek AND o.name COLLATE "C" < v_upper_bound + ORDER BY o.name COLLATE "C" ASC LIMIT 1; + ELSE + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek + ORDER BY o.name COLLATE "C" ASC LIMIT 1; + END IF; + ELSE + IF v_upper_bound IS NOT NULL THEN + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix + ORDER BY o.name COLLATE "C" DESC LIMIT 1; + ELSIF v_prefix <> '' THEN + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix + ORDER BY o.name COLLATE "C" DESC LIMIT 1; + ELSE + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek + ORDER BY o.name COLLATE "C" DESC LIMIT 1; + END IF; + END IF; + + EXIT WHEN v_peek_name IS NULL; + + -- STEP 2: Check if this is a FOLDER or FILE + v_common_prefix := storage.get_common_prefix(v_peek_name, v_prefix, delimiter_param); + + IF v_common_prefix IS NOT NULL THEN + -- FOLDER: Emit and skip to next folder (no heap access needed) + name := rtrim(v_common_prefix, delimiter_param); + id := NULL; + updated_at := NULL; + created_at := NULL; + last_accessed_at := NULL; + metadata := NULL; + RETURN NEXT; + v_count := v_count + 1; + + -- Advance seek past the folder range + IF v_is_asc THEN + v_next_seek := left(v_common_prefix, -1) || chr(ascii(delimiter_param) + 1); + ELSE + v_next_seek := v_common_prefix; + END IF; + ELSE + -- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows) + -- For ASC: upper_bound is the exclusive upper limit (< condition) + -- For DESC: prefix is the inclusive lower limit (>= condition) + FOR v_current IN EXECUTE v_batch_query USING _bucket_id, v_next_seek, + CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix) ELSE v_prefix END, v_file_batch_size + LOOP + v_common_prefix := storage.get_common_prefix(v_current.name, v_prefix, delimiter_param); + + IF v_common_prefix IS NOT NULL THEN + -- Hit a folder: exit batch, let peek handle it + v_next_seek := v_current.name; + EXIT; + END IF; + + -- Emit file + name := v_current.name; + id := v_current.id; + updated_at := v_current.updated_at; + created_at := v_current.created_at; + last_accessed_at := v_current.last_accessed_at; + metadata := v_current.metadata; + RETURN NEXT; + v_count := v_count + 1; + + -- Advance seek past this file + IF v_is_asc THEN + v_next_seek := v_current.name || delimiter_param; + ELSE + v_next_seek := v_current.name; + END IF; + + EXIT WHEN v_count >= max_keys; + END LOOP; + END IF; + END LOOP; +END; +$function$; + +ALTER FUNCTION "storage"."list_objects_with_delimiter"(text, text, text, integer, text, text, text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.operation() + RETURNS text + LANGUAGE plpgsql + STABLE +AS $function$ +BEGIN + RETURN current_setting('storage.operation', true); +END; +$function$; + +ALTER FUNCTION "storage"."operation"() OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.protect_delete() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +BEGIN + -- Check if storage.allow_delete_query is set to 'true' + IF COALESCE(current_setting('storage.allow_delete_query', true), 'false') != 'true' THEN + RAISE EXCEPTION 'Direct deletion from storage tables is not allowed. Use the Storage API instead.' + USING HINT = 'This prevents accidental data loss from orphaned objects.', + ERRCODE = '42501'; + END IF; + RETURN NULL; +END; +$function$; + +ALTER FUNCTION "storage"."protect_delete"() OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.search(prefix text, bucketname text, limits integer DEFAULT 100, levels integer DEFAULT 1, offsets integer DEFAULT 0, search text DEFAULT ''::text, sortcolumn text DEFAULT 'name'::text, sortorder text DEFAULT 'asc'::text) + RETURNS TABLE(name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb) + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_peek_name TEXT; + v_current RECORD; + v_common_prefix TEXT; + v_delimiter CONSTANT TEXT := '/'; + + -- Configuration + v_limit INT; + v_prefix TEXT; + v_prefix_lower TEXT; + v_is_asc BOOLEAN; + v_order_by TEXT; + v_sort_order TEXT; + v_upper_bound TEXT; + v_file_batch_size INT; + + -- Dynamic SQL for batch query only + v_batch_query TEXT; + + -- Seek state + v_next_seek TEXT; + v_count INT := 0; + v_skipped INT := 0; +BEGIN + -- ======================================================================== + -- INITIALIZATION + -- ======================================================================== + v_limit := LEAST(coalesce(limits, 100), 1500); + v_prefix := coalesce(prefix, '') || coalesce(search, ''); + v_prefix_lower := lower(v_prefix); + v_is_asc := lower(coalesce(sortorder, 'asc')) = 'asc'; + v_file_batch_size := LEAST(GREATEST(v_limit * 2, 100), 1000); + + -- Validate sort column + CASE lower(coalesce(sortcolumn, 'name')) + WHEN 'name' THEN v_order_by := 'name'; + WHEN 'updated_at' THEN v_order_by := 'updated_at'; + WHEN 'created_at' THEN v_order_by := 'created_at'; + WHEN 'last_accessed_at' THEN v_order_by := 'last_accessed_at'; + ELSE v_order_by := 'name'; + END CASE; + + v_sort_order := CASE WHEN v_is_asc THEN 'asc' ELSE 'desc' END; + + -- ======================================================================== + -- NON-NAME SORTING: Use path_tokens approach (unchanged) + -- ======================================================================== + IF v_order_by != 'name' THEN + RETURN QUERY EXECUTE format( + $sql$ + WITH folders AS ( + SELECT path_tokens[$1] AS folder + FROM storage.objects + WHERE objects.name ILIKE $2 || '%%' + AND bucket_id = $3 + AND array_length(objects.path_tokens, 1) <> $1 + GROUP BY folder + ORDER BY folder %s + ) + (SELECT folder AS "name", + NULL::uuid AS id, + NULL::timestamptz AS updated_at, + NULL::timestamptz AS created_at, + NULL::timestamptz AS last_accessed_at, + NULL::jsonb AS metadata FROM folders) + UNION ALL + (SELECT path_tokens[$1] AS "name", + id, updated_at, created_at, last_accessed_at, metadata + FROM storage.objects + WHERE objects.name ILIKE $2 || '%%' + AND bucket_id = $3 + AND array_length(objects.path_tokens, 1) = $1 + ORDER BY %I %s) + LIMIT $4 OFFSET $5 + $sql$, v_sort_order, v_order_by, v_sort_order + ) USING levels, v_prefix, bucketname, v_limit, offsets; + RETURN; + END IF; + + -- ======================================================================== + -- NAME SORTING: Hybrid skip-scan with batch optimization + -- ======================================================================== + + -- Calculate upper bound for prefix filtering + IF v_prefix_lower = '' THEN + v_upper_bound := NULL; + ELSIF right(v_prefix_lower, 1) = v_delimiter THEN + v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(v_delimiter) + 1); + ELSE + v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(right(v_prefix_lower, 1)) + 1); + END IF; + + -- Build batch query (dynamic SQL - called infrequently, amortized over many rows) + IF v_is_asc THEN + IF v_upper_bound IS NOT NULL THEN + v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || + 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' || + 'AND lower(o.name) COLLATE "C" < $3 ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4'; + ELSE + v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || + 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' || + 'ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4'; + END IF; + ELSE + IF v_upper_bound IS NOT NULL THEN + v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || + 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' || + 'AND lower(o.name) COLLATE "C" >= $3 ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4'; + ELSE + v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || + 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' || + 'ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4'; + END IF; + END IF; + + -- Initialize seek position + IF v_is_asc THEN + v_next_seek := v_prefix_lower; + ELSE + -- DESC: find the last item in range first (static SQL) + IF v_upper_bound IS NOT NULL THEN + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower AND lower(o.name) COLLATE "C" < v_upper_bound + ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; + ELSIF v_prefix_lower <> '' THEN + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower + ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; + ELSE + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = bucketname + ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; + END IF; + + IF v_peek_name IS NOT NULL THEN + v_next_seek := lower(v_peek_name) || v_delimiter; + ELSE + RETURN; + END IF; + END IF; + + -- ======================================================================== + -- MAIN LOOP: Hybrid peek-then-batch algorithm + -- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch + -- ======================================================================== + LOOP + EXIT WHEN v_count >= v_limit; + + -- STEP 1: PEEK using STATIC SQL (plan cached, very fast) + IF v_is_asc THEN + IF v_upper_bound IS NOT NULL THEN + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek AND lower(o.name) COLLATE "C" < v_upper_bound + ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1; + ELSE + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek + ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1; + END IF; + ELSE + IF v_upper_bound IS NOT NULL THEN + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower + ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; + ELSIF v_prefix_lower <> '' THEN + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower + ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; + ELSE + SELECT o.name INTO v_peek_name FROM storage.objects o + WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek + ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; + END IF; + END IF; + + EXIT WHEN v_peek_name IS NULL; + + -- STEP 2: Check if this is a FOLDER or FILE + v_common_prefix := storage.get_common_prefix(lower(v_peek_name), v_prefix_lower, v_delimiter); + + IF v_common_prefix IS NOT NULL THEN + -- FOLDER: Handle offset, emit if needed, skip to next folder + IF v_skipped < offsets THEN + v_skipped := v_skipped + 1; + ELSE + name := split_part(rtrim(storage.get_common_prefix(v_peek_name, v_prefix, v_delimiter), v_delimiter), v_delimiter, levels); + id := NULL; + updated_at := NULL; + created_at := NULL; + last_accessed_at := NULL; + metadata := NULL; + RETURN NEXT; + v_count := v_count + 1; + END IF; + + -- Advance seek past the folder range + IF v_is_asc THEN + v_next_seek := lower(left(v_common_prefix, -1)) || chr(ascii(v_delimiter) + 1); + ELSE + v_next_seek := lower(v_common_prefix); + END IF; + ELSE + -- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows) + -- For ASC: upper_bound is the exclusive upper limit (< condition) + -- For DESC: prefix_lower is the inclusive lower limit (>= condition) + FOR v_current IN EXECUTE v_batch_query + USING bucketname, v_next_seek, + CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix_lower) ELSE v_prefix_lower END, v_file_batch_size + LOOP + v_common_prefix := storage.get_common_prefix(lower(v_current.name), v_prefix_lower, v_delimiter); + + IF v_common_prefix IS NOT NULL THEN + -- Hit a folder: exit batch, let peek handle it + v_next_seek := lower(v_current.name); + EXIT; + END IF; + + -- Handle offset skipping + IF v_skipped < offsets THEN + v_skipped := v_skipped + 1; + ELSE + -- Emit file + name := split_part(v_current.name, v_delimiter, levels); + id := v_current.id; + updated_at := v_current.updated_at; + created_at := v_current.created_at; + last_accessed_at := v_current.last_accessed_at; + metadata := v_current.metadata; + RETURN NEXT; + v_count := v_count + 1; + END IF; + + -- Advance seek past this file + IF v_is_asc THEN + v_next_seek := lower(v_current.name) || v_delimiter; + ELSE + v_next_seek := lower(v_current.name); + END IF; + + EXIT WHEN v_count >= v_limit; + END LOOP; + END IF; + END LOOP; +END; +$function$; + +ALTER FUNCTION "storage"."search"(text, text, integer, integer, integer, text, text, text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.search_by_timestamp(p_prefix text, p_bucket_id text, p_limit integer, p_level integer, p_start_after text, p_sort_order text, p_sort_column text, p_sort_column_after text) + RETURNS TABLE(key text, name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb) + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_cursor_op text; + v_query text; + v_prefix text; +BEGIN + v_prefix := coalesce(p_prefix, ''); + + IF p_sort_order = 'asc' THEN + v_cursor_op := '>'; + ELSE + v_cursor_op := '<'; + END IF; + + v_query := format($sql$ + WITH raw_objects AS ( + SELECT + o.name AS obj_name, + o.id AS obj_id, + o.updated_at AS obj_updated_at, + o.created_at AS obj_created_at, + o.last_accessed_at AS obj_last_accessed_at, + o.metadata AS obj_metadata, + storage.get_common_prefix(o.name, $1, '/') AS common_prefix + FROM storage.objects o + WHERE o.bucket_id = $2 + AND o.name COLLATE "C" LIKE $1 || '%%' + ), + -- Aggregate common prefixes (folders) + -- Both created_at and updated_at use MIN(obj_created_at) to match the old prefixes table behavior + aggregated_prefixes AS ( + SELECT + rtrim(common_prefix, '/') AS name, + NULL::uuid AS id, + MIN(obj_created_at) AS updated_at, + MIN(obj_created_at) AS created_at, + NULL::timestamptz AS last_accessed_at, + NULL::jsonb AS metadata, + TRUE AS is_prefix + FROM raw_objects + WHERE common_prefix IS NOT NULL + GROUP BY common_prefix + ), + leaf_objects AS ( + SELECT + obj_name AS name, + obj_id AS id, + obj_updated_at AS updated_at, + obj_created_at AS created_at, + obj_last_accessed_at AS last_accessed_at, + obj_metadata AS metadata, + FALSE AS is_prefix + FROM raw_objects + WHERE common_prefix IS NULL + ), + combined AS ( + SELECT * FROM aggregated_prefixes + UNION ALL + SELECT * FROM leaf_objects + ), + filtered AS ( + SELECT * + FROM combined + WHERE ( + $5 = '' + OR ROW( + date_trunc('milliseconds', %I), + name COLLATE "C" + ) %s ROW( + COALESCE(NULLIF($6, '')::timestamptz, 'epoch'::timestamptz), + $5 + ) + ) + ) + SELECT + split_part(name, '/', $3) AS key, + name, + id, + updated_at, + created_at, + last_accessed_at, + metadata + FROM filtered + ORDER BY + COALESCE(date_trunc('milliseconds', %I), 'epoch'::timestamptz) %s, + name COLLATE "C" %s + LIMIT $4 + $sql$, + p_sort_column, + v_cursor_op, + p_sort_column, + p_sort_order, + p_sort_order + ); + + RETURN QUERY EXECUTE v_query + USING v_prefix, p_bucket_id, p_level, p_limit, p_start_after, p_sort_column_after; +END; +$function$; + +ALTER FUNCTION "storage"."search_by_timestamp"(text, text, integer, integer, text, text, text, text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.search_v2(prefix text, bucket_name text, limits integer DEFAULT 100, levels integer DEFAULT 1, start_after text DEFAULT ''::text, sort_order text DEFAULT 'asc'::text, sort_column text DEFAULT 'name'::text, sort_column_after text DEFAULT ''::text) + RETURNS TABLE(key text, name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb) + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_sort_col text; + v_sort_ord text; + v_limit int; +BEGIN + -- Cap limit to maximum of 1500 records + v_limit := LEAST(coalesce(limits, 100), 1500); + + -- Validate and normalize sort_order + v_sort_ord := lower(coalesce(sort_order, 'asc')); + IF v_sort_ord NOT IN ('asc', 'desc') THEN + v_sort_ord := 'asc'; + END IF; + + -- Validate and normalize sort_column + v_sort_col := lower(coalesce(sort_column, 'name')); + IF v_sort_col NOT IN ('name', 'updated_at', 'created_at') THEN + v_sort_col := 'name'; + END IF; + + -- Route to appropriate implementation + IF v_sort_col = 'name' THEN + -- Use list_objects_with_delimiter for name sorting (most efficient: O(k * log n)) + RETURN QUERY + SELECT + split_part(l.name, '/', levels) AS key, + l.name AS name, + l.id, + l.updated_at, + l.created_at, + l.last_accessed_at, + l.metadata + FROM storage.list_objects_with_delimiter( + bucket_name, + coalesce(prefix, ''), + '/', + v_limit, + start_after, + '', + v_sort_ord + ) l; + ELSE + -- Use aggregation approach for timestamp sorting + -- Not efficient for large datasets but supports correct pagination + RETURN QUERY SELECT * FROM storage.search_by_timestamp( + prefix, bucket_name, v_limit, levels, start_after, + v_sort_ord, v_sort_col, sort_column_after + ); + END IF; +END; +$function$; + +ALTER FUNCTION "storage"."search_v2"(text, text, integer, integer, text, text, text, text) OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION storage.update_updated_at_column() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$function$; + +ALTER FUNCTION "storage"."update_updated_at_column"() OWNER TO "supabase_storage_admin"; + +CREATE OR REPLACE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'supabase_functions' +AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END +$function$; + +ALTER FUNCTION "supabase_functions"."http_request"() OWNER TO "supabase_functions_admin"; + +ALTER TABLE "_realtime"."extensions" ADD CONSTRAINT "extensions_pkey" PRIMARY KEY (id); + +ALTER TABLE "_realtime"."feature_flags" ADD CONSTRAINT "feature_flags_pkey" PRIMARY KEY (id); + +ALTER TABLE "_realtime"."schema_migrations" ADD CONSTRAINT "schema_migrations_pkey" PRIMARY KEY (version); + +ALTER TABLE "_realtime"."tenants" ADD CONSTRAINT "jwt_secret_or_jwt_jwks_required" CHECK (((jwt_secret IS NOT NULL) OR (jwt_jwks IS NOT NULL))); + +ALTER TABLE "_realtime"."tenants" ADD CONSTRAINT "tenants_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_authorization_url_https" CHECK (((authorization_url IS NULL) OR (authorization_url ~~ 'https://%'::text))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_authorization_url_length" CHECK (((authorization_url IS NULL) OR (char_length(authorization_url) <= 2048))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_client_id_length" CHECK (((char_length(client_id) >= 1) AND (char_length(client_id) <= 512))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_discovery_url_length" CHECK (((discovery_url IS NULL) OR (char_length(discovery_url) <= 2048))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_identifier_format" CHECK ((identifier ~ '^[a-z0-9][a-z0-9:-]{0,48}[a-z0-9]$'::text)); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_identifier_key" UNIQUE (identifier); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_issuer_length" CHECK (((issuer IS NULL) OR ((char_length(issuer) >= 1) AND (char_length(issuer) <= 2048)))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_jwks_uri_https" CHECK (((jwks_uri IS NULL) OR (jwks_uri ~~ 'https://%'::text))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_jwks_uri_length" CHECK (((jwks_uri IS NULL) OR (char_length(jwks_uri) <= 2048))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_name_length" CHECK (((char_length(name) >= 1) AND (char_length(name) <= 100))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_oauth2_requires_endpoints" CHECK (((provider_type <> 'oauth2'::text) OR ((authorization_url IS NOT NULL) AND (token_url IS NOT NULL) AND (userinfo_url IS NOT NULL)))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_oidc_discovery_url_https" CHECK (((provider_type <> 'oidc'::text) OR (discovery_url IS NULL) OR (discovery_url ~~ 'https://%'::text))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_oidc_issuer_https" CHECK (((provider_type <> 'oidc'::text) OR (issuer IS NULL) OR (issuer ~~ 'https://%'::text))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_oidc_requires_issuer" CHECK (((provider_type <> 'oidc'::text) OR (issuer IS NOT NULL))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_provider_type_check" CHECK ((provider_type = ANY (ARRAY['oauth2'::text, 'oidc'::text]))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_token_url_https" CHECK (((token_url IS NULL) OR (token_url ~~ 'https://%'::text))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_token_url_length" CHECK (((token_url IS NULL) OR (char_length(token_url) <= 2048))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_userinfo_url_https" CHECK (((userinfo_url IS NULL) OR (userinfo_url ~~ 'https://%'::text))); + +ALTER TABLE "auth"."custom_oauth_providers" ADD CONSTRAINT "custom_oauth_providers_userinfo_url_length" CHECK (((userinfo_url IS NULL) OR (char_length(userinfo_url) <= 2048))); + +ALTER TABLE "auth"."flow_state" ADD CONSTRAINT "flow_state_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."identities" ADD CONSTRAINT "identities_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."identities" ADD CONSTRAINT "identities_provider_id_provider_unique" UNIQUE (provider_id, provider); + +ALTER TABLE "auth"."identities" ADD CONSTRAINT "identities_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."mfa_amr_claims" ADD CONSTRAINT "amr_id_pk" PRIMARY KEY (id); + +ALTER TABLE "auth"."mfa_amr_claims" ADD CONSTRAINT "mfa_amr_claims_session_id_authentication_method_pkey" UNIQUE (session_id, authentication_method); + +ALTER TABLE "auth"."mfa_challenges" ADD CONSTRAINT "mfa_challenges_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."mfa_factors" ADD CONSTRAINT "mfa_factors_last_challenged_at_key" UNIQUE (last_challenged_at); + +ALTER TABLE "auth"."mfa_factors" ADD CONSTRAINT "mfa_factors_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."mfa_challenges" ADD CONSTRAINT "mfa_challenges_auth_factor_id_fkey" FOREIGN KEY (factor_id) REFERENCES mfa_factors(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."mfa_factors" ADD CONSTRAINT "mfa_factors_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_authorization_code_key" UNIQUE (authorization_code); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_authorization_code_length" CHECK ((char_length(authorization_code) <= 255)); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_authorization_id_key" UNIQUE (authorization_id); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_code_challenge_length" CHECK ((char_length(code_challenge) <= 128)); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_expires_at_future" CHECK ((expires_at > created_at)); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_nonce_length" CHECK ((char_length(nonce) <= 255)); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_redirect_uri_length" CHECK ((char_length(redirect_uri) <= 2048)); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_resource_length" CHECK ((char_length(resource) <= 2048)); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_scope_length" CHECK ((char_length(scope) <= 4096)); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_state_length" CHECK ((char_length(state) <= 4096)); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."oauth_client_states" ADD CONSTRAINT "oauth_client_states_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."oauth_clients" ADD CONSTRAINT "oauth_clients_client_name_length" CHECK ((char_length(client_name) <= 1024)); + +ALTER TABLE "auth"."oauth_clients" ADD CONSTRAINT "oauth_clients_client_uri_length" CHECK ((char_length(client_uri) <= 2048)); + +ALTER TABLE "auth"."oauth_clients" ADD CONSTRAINT "oauth_clients_logo_uri_length" CHECK ((char_length(logo_uri) <= 2048)); + +ALTER TABLE "auth"."oauth_clients" ADD CONSTRAINT "oauth_clients_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."oauth_authorizations" ADD CONSTRAINT "oauth_authorizations_client_id_fkey" FOREIGN KEY (client_id) REFERENCES oauth_clients(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."oauth_clients" ADD CONSTRAINT "oauth_clients_token_endpoint_auth_method_check" CHECK ((token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, 'client_secret_post'::text, 'none'::text]))); + +ALTER TABLE "auth"."oauth_consents" ADD CONSTRAINT "oauth_consents_client_id_fkey" FOREIGN KEY (client_id) REFERENCES oauth_clients(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."oauth_consents" ADD CONSTRAINT "oauth_consents_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."oauth_consents" ADD CONSTRAINT "oauth_consents_revoked_after_granted" CHECK (((revoked_at IS NULL) OR (revoked_at >= granted_at))); + +ALTER TABLE "auth"."oauth_consents" ADD CONSTRAINT "oauth_consents_scopes_length" CHECK ((char_length(scopes) <= 2048)); + +ALTER TABLE "auth"."oauth_consents" ADD CONSTRAINT "oauth_consents_scopes_not_empty" CHECK ((char_length(TRIM(BOTH FROM scopes)) > 0)); + +ALTER TABLE "auth"."oauth_consents" ADD CONSTRAINT "oauth_consents_user_client_unique" UNIQUE (user_id, client_id); + +ALTER TABLE "auth"."oauth_consents" ADD CONSTRAINT "oauth_consents_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."one_time_tokens" ADD CONSTRAINT "one_time_tokens_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."one_time_tokens" ADD CONSTRAINT "one_time_tokens_token_hash_check" CHECK ((char_length(token_hash) > 0)); + +ALTER TABLE "auth"."one_time_tokens" ADD CONSTRAINT "one_time_tokens_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."refresh_tokens" ADD CONSTRAINT "refresh_tokens_token_unique" UNIQUE (token); + +ALTER TABLE "auth"."saml_providers" ADD CONSTRAINT "entity_id not empty" CHECK ((char_length(entity_id) > 0)); + +ALTER TABLE "auth"."saml_providers" ADD CONSTRAINT "metadata_url not empty" CHECK (((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))); + +ALTER TABLE "auth"."saml_providers" ADD CONSTRAINT "metadata_xml not empty" CHECK ((char_length(metadata_xml) > 0)); + +ALTER TABLE "auth"."saml_providers" ADD CONSTRAINT "saml_providers_entity_id_key" UNIQUE (entity_id); + +ALTER TABLE "auth"."saml_providers" ADD CONSTRAINT "saml_providers_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."saml_relay_states" ADD CONSTRAINT "request_id not empty" CHECK ((char_length(request_id) > 0)); + +ALTER TABLE "auth"."saml_relay_states" ADD CONSTRAINT "saml_relay_states_flow_state_id_fkey" FOREIGN KEY (flow_state_id) REFERENCES flow_state(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."saml_relay_states" ADD CONSTRAINT "saml_relay_states_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_oauth_client_id_fkey" FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."mfa_amr_claims" ADD CONSTRAINT "mfa_amr_claims_session_id_fkey" FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."refresh_tokens" ADD CONSTRAINT "refresh_tokens_session_id_fkey" FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_scopes_length" CHECK ((char_length(scopes) <= 4096)); + +ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."sso_domains" ADD CONSTRAINT "domain not empty" CHECK ((char_length(domain) > 0)); + +ALTER TABLE "auth"."sso_domains" ADD CONSTRAINT "sso_domains_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."sso_providers" ADD CONSTRAINT "resource_id not empty" CHECK (((resource_id = NULL::text) OR (char_length(resource_id) > 0))); + +ALTER TABLE "auth"."sso_providers" ADD CONSTRAINT "sso_providers_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."saml_providers" ADD CONSTRAINT "saml_providers_sso_provider_id_fkey" FOREIGN KEY (sso_provider_id) REFERENCES sso_providers(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."saml_relay_states" ADD CONSTRAINT "saml_relay_states_sso_provider_id_fkey" FOREIGN KEY (sso_provider_id) REFERENCES sso_providers(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."sso_domains" ADD CONSTRAINT "sso_domains_sso_provider_id_fkey" FOREIGN KEY (sso_provider_id) REFERENCES sso_providers(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."users" ADD CONSTRAINT "users_email_change_confirm_status_check" CHECK (((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2))); + +ALTER TABLE "auth"."users" ADD CONSTRAINT "users_phone_key" UNIQUE (phone); + +ALTER TABLE "auth"."webauthn_challenges" ADD CONSTRAINT "webauthn_challenges_challenge_type_check" CHECK ((challenge_type = ANY (ARRAY['signup'::text, 'registration'::text, 'authentication'::text]))); + +ALTER TABLE "auth"."webauthn_challenges" ADD CONSTRAINT "webauthn_challenges_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."webauthn_challenges" ADD CONSTRAINT "webauthn_challenges_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE "auth"."webauthn_credentials" ADD CONSTRAINT "webauthn_credentials_pkey" PRIMARY KEY (id); + +ALTER TABLE "auth"."webauthn_credentials" ADD CONSTRAINT "webauthn_credentials_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE "realtime"."messages" ADD CONSTRAINT "messages_payload_exclusive" CHECK (((payload IS NULL) OR (binary_payload IS NULL))) NOT VALID; + +ALTER TABLE "realtime"."messages" ADD CONSTRAINT "messages_pkey" PRIMARY KEY (id, inserted_at); + +ALTER TABLE "realtime"."schema_migrations" ADD CONSTRAINT "schema_migrations_pkey" PRIMARY KEY (version); + +ALTER TABLE "realtime"."subscription" ADD CONSTRAINT "pk_subscription" PRIMARY KEY (id); + +ALTER TABLE "realtime"."subscription" ADD CONSTRAINT "subscription_action_filter_check" CHECK ((action_filter = ANY (ARRAY['*'::text, 'INSERT'::text, 'UPDATE'::text, 'DELETE'::text]))); + +ALTER TABLE "storage"."buckets" ADD CONSTRAINT "buckets_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."buckets_analytics" ADD CONSTRAINT "buckets_analytics_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."buckets_vectors" ADD CONSTRAINT "buckets_vectors_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."iceberg_namespaces" ADD CONSTRAINT "iceberg_namespaces_catalog_id_fkey" FOREIGN KEY (catalog_id) REFERENCES storage.buckets_analytics(id) ON DELETE CASCADE; + +ALTER TABLE "storage"."iceberg_namespaces" ADD CONSTRAINT "iceberg_namespaces_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."iceberg_tables" ADD CONSTRAINT "iceberg_tables_catalog_id_fkey" FOREIGN KEY (catalog_id) REFERENCES storage.buckets_analytics(id) ON DELETE CASCADE; + +ALTER TABLE "storage"."iceberg_tables" ADD CONSTRAINT "iceberg_tables_namespace_id_fkey" FOREIGN KEY (namespace_id) REFERENCES storage.iceberg_namespaces(id) ON DELETE CASCADE; + +ALTER TABLE "storage"."iceberg_tables" ADD CONSTRAINT "iceberg_tables_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."migrations" ADD CONSTRAINT "migrations_name_key" UNIQUE (name); + +ALTER TABLE "storage"."migrations" ADD CONSTRAINT "migrations_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."objects" ADD CONSTRAINT "objects_bucketId_fkey" FOREIGN KEY (bucket_id) REFERENCES storage.buckets(id); + +ALTER TABLE "storage"."objects" ADD CONSTRAINT "objects_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."s3_multipart_uploads" ADD CONSTRAINT "s3_multipart_uploads_bucket_id_fkey" FOREIGN KEY (bucket_id) REFERENCES storage.buckets(id); + +ALTER TABLE "storage"."s3_multipart_uploads" ADD CONSTRAINT "s3_multipart_uploads_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."s3_multipart_uploads_parts" ADD CONSTRAINT "s3_multipart_uploads_parts_bucket_id_fkey" FOREIGN KEY (bucket_id) REFERENCES storage.buckets(id); + +ALTER TABLE "storage"."s3_multipart_uploads_parts" ADD CONSTRAINT "s3_multipart_uploads_parts_pkey" PRIMARY KEY (id); + +ALTER TABLE "storage"."s3_multipart_uploads_parts" ADD CONSTRAINT "s3_multipart_uploads_parts_upload_id_fkey" FOREIGN KEY (upload_id) REFERENCES storage.s3_multipart_uploads(id) ON DELETE CASCADE; + +ALTER TABLE "storage"."vector_indexes" ADD CONSTRAINT "vector_indexes_bucket_id_fkey" FOREIGN KEY (bucket_id) REFERENCES storage.buckets_vectors(id); + +ALTER TABLE "storage"."vector_indexes" ADD CONSTRAINT "vector_indexes_pkey" PRIMARY KEY (id); + +ALTER TABLE "supabase_functions"."hooks" ADD CONSTRAINT "hooks_pkey" PRIMARY KEY (id); + +ALTER TABLE "supabase_functions"."migrations" ADD CONSTRAINT "migrations_pkey" PRIMARY KEY (version); + +CREATE INDEX extensions_tenant_external_id_index ON _realtime.extensions USING btree (tenant_external_id); + +CREATE UNIQUE INDEX extensions_tenant_external_id_type_index ON _realtime.extensions USING btree (tenant_external_id, type); + +CREATE UNIQUE INDEX feature_flags_name_index ON _realtime.feature_flags USING btree (name); + +CREATE UNIQUE INDEX tenants_external_id_index ON _realtime.tenants USING btree (external_id); + +ALTER TABLE "_realtime"."extensions" ADD CONSTRAINT "extensions_tenant_external_id_fkey" FOREIGN KEY (tenant_external_id) REFERENCES _realtime.tenants(external_id) ON DELETE CASCADE; + +CREATE UNIQUE INDEX confirmation_token_idx ON auth.users USING btree (confirmation_token) WHERE ((confirmation_token)::text !~ '^[0-9 ]*$'::text); + +CREATE INDEX custom_oauth_providers_created_at_idx ON auth.custom_oauth_providers USING btree (created_at); + +CREATE INDEX custom_oauth_providers_enabled_idx ON auth.custom_oauth_providers USING btree (enabled); + +CREATE INDEX custom_oauth_providers_identifier_idx ON auth.custom_oauth_providers USING btree (identifier); + +CREATE INDEX custom_oauth_providers_provider_type_idx ON auth.custom_oauth_providers USING btree (provider_type); + +CREATE UNIQUE INDEX email_change_token_current_idx ON auth.users USING btree (email_change_token_current) WHERE ((email_change_token_current)::text !~ '^[0-9 ]*$'::text); + +CREATE UNIQUE INDEX email_change_token_new_idx ON auth.users USING btree (email_change_token_new) WHERE ((email_change_token_new)::text !~ '^[0-9 ]*$'::text); + +CREATE INDEX factor_id_created_at_idx ON auth.mfa_factors USING btree (user_id, created_at); + +CREATE INDEX flow_state_created_at_idx ON auth.flow_state USING btree (created_at DESC); + +CREATE INDEX identities_email_idx ON auth.identities USING btree (email text_pattern_ops); + +CREATE INDEX identities_user_id_idx ON auth.identities USING btree (user_id); + +CREATE INDEX idx_auth_code ON auth.flow_state USING btree (auth_code); + +CREATE INDEX idx_oauth_client_states_created_at ON auth.oauth_client_states USING btree (created_at); + +CREATE INDEX idx_user_id_auth_method ON auth.flow_state USING btree (user_id, authentication_method); + +CREATE INDEX mfa_challenge_created_at_idx ON auth.mfa_challenges USING btree (created_at DESC); + +CREATE UNIQUE INDEX mfa_factors_user_friendly_name_unique ON auth.mfa_factors USING btree (friendly_name, user_id) WHERE (TRIM(BOTH FROM friendly_name) <> ''::text); + +CREATE INDEX mfa_factors_user_id_idx ON auth.mfa_factors USING btree (user_id); + +CREATE INDEX oauth_auth_pending_exp_idx ON auth.oauth_authorizations USING btree (expires_at) WHERE (status = 'pending'::oauth_authorization_status); + +CREATE INDEX oauth_clients_deleted_at_idx ON auth.oauth_clients USING btree (deleted_at); + +CREATE INDEX oauth_consents_active_client_idx ON auth.oauth_consents USING btree (client_id) WHERE (revoked_at IS NULL); + +CREATE INDEX oauth_consents_active_user_client_idx ON auth.oauth_consents USING btree (user_id, client_id) WHERE (revoked_at IS NULL); + +CREATE INDEX oauth_consents_user_order_idx ON auth.oauth_consents USING btree (user_id, granted_at DESC); + +CREATE INDEX one_time_tokens_relates_to_hash_idx ON auth.one_time_tokens USING hash (relates_to); + +CREATE INDEX one_time_tokens_token_hash_hash_idx ON auth.one_time_tokens USING hash (token_hash); + +CREATE UNIQUE INDEX one_time_tokens_user_id_token_type_key ON auth.one_time_tokens USING btree (user_id, token_type); + +CREATE UNIQUE INDEX reauthentication_token_idx ON auth.users USING btree (reauthentication_token) WHERE ((reauthentication_token)::text !~ '^[0-9 ]*$'::text); + +CREATE UNIQUE INDEX recovery_token_idx ON auth.users USING btree (recovery_token) WHERE ((recovery_token)::text !~ '^[0-9 ]*$'::text); + +CREATE INDEX refresh_tokens_parent_idx ON auth.refresh_tokens USING btree (parent); + +CREATE INDEX refresh_tokens_session_id_revoked_idx ON auth.refresh_tokens USING btree (session_id, revoked); + +CREATE INDEX refresh_tokens_updated_at_idx ON auth.refresh_tokens USING btree (updated_at DESC); + +CREATE INDEX saml_providers_sso_provider_id_idx ON auth.saml_providers USING btree (sso_provider_id); + +CREATE INDEX saml_relay_states_created_at_idx ON auth.saml_relay_states USING btree (created_at DESC); + +CREATE INDEX saml_relay_states_for_email_idx ON auth.saml_relay_states USING btree (for_email); + +CREATE INDEX saml_relay_states_sso_provider_id_idx ON auth.saml_relay_states USING btree (sso_provider_id); + +CREATE INDEX sessions_not_after_idx ON auth.sessions USING btree (not_after DESC); + +CREATE INDEX sessions_oauth_client_id_idx ON auth.sessions USING btree (oauth_client_id); + +CREATE INDEX sessions_user_id_idx ON auth.sessions USING btree (user_id); + +CREATE UNIQUE INDEX sso_domains_domain_idx ON auth.sso_domains USING btree (lower(domain)); + +CREATE INDEX sso_domains_sso_provider_id_idx ON auth.sso_domains USING btree (sso_provider_id); + +CREATE UNIQUE INDEX sso_providers_resource_id_idx ON auth.sso_providers USING btree (lower(resource_id)); + +CREATE INDEX sso_providers_resource_id_pattern_idx ON auth.sso_providers USING btree (resource_id text_pattern_ops); + +CREATE UNIQUE INDEX unique_phone_factor_per_user ON auth.mfa_factors USING btree (user_id, phone); + +CREATE INDEX user_id_created_at_idx ON auth.sessions USING btree (user_id, created_at); + +CREATE UNIQUE INDEX users_email_partial_key ON auth.users USING btree (email) WHERE (is_sso_user = false); + +CREATE INDEX users_instance_id_email_idx ON auth.users USING btree (instance_id, lower((email)::text)); + +CREATE INDEX users_is_anonymous_idx ON auth.users USING btree (is_anonymous); + +CREATE INDEX webauthn_challenges_expires_at_idx ON auth.webauthn_challenges USING btree (expires_at); + +CREATE INDEX webauthn_challenges_user_id_idx ON auth.webauthn_challenges USING btree (user_id); + +CREATE UNIQUE INDEX webauthn_credentials_credential_id_key ON auth.webauthn_credentials USING btree (credential_id); + +CREATE INDEX webauthn_credentials_user_id_idx ON auth.webauthn_credentials USING btree (user_id); + +CREATE INDEX ix_realtime_subscription_entity ON realtime.subscription USING btree (entity); + +CREATE INDEX messages_inserted_at_topic_index ON ONLY realtime.messages USING btree (inserted_at DESC, topic) WHERE ((extension = 'broadcast'::text) AND (private IS TRUE)); + +CREATE UNIQUE INDEX subscription_subscription_id_entity_filters_action_filter_selec ON realtime.subscription USING btree (subscription_id, entity, filters, action_filter, COALESCE(selected_columns, '{}'::text[])); + +CREATE UNIQUE INDEX bname ON storage.buckets USING btree (name); + +CREATE UNIQUE INDEX bucketid_objname ON storage.objects USING btree (bucket_id, name); + +CREATE UNIQUE INDEX buckets_analytics_unique_name_idx ON storage.buckets_analytics USING btree (name) WHERE (deleted_at IS NULL); + +CREATE UNIQUE INDEX idx_iceberg_namespaces_bucket_id ON storage.iceberg_namespaces USING btree (catalog_id, name); + +CREATE UNIQUE INDEX idx_iceberg_tables_location ON storage.iceberg_tables USING btree (location); + +CREATE UNIQUE INDEX idx_iceberg_tables_namespace_id ON storage.iceberg_tables USING btree (catalog_id, namespace_id, name); + +CREATE INDEX idx_multipart_uploads_list ON storage.s3_multipart_uploads USING btree (bucket_id, key, created_at); + +CREATE INDEX idx_objects_bucket_id_name_lower ON storage.objects USING btree (bucket_id, lower(name) COLLATE "C"); + +CREATE INDEX idx_objects_bucket_id_name ON storage.objects USING btree (bucket_id, name COLLATE "C"); + +CREATE INDEX name_prefix_search ON storage.objects USING btree (name text_pattern_ops); + +CREATE UNIQUE INDEX vector_indexes_name_bucket_id_idx ON storage.vector_indexes USING btree (name, bucket_id); + +CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + +CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + +CREATE TRIGGER tr_check_filters BEFORE INSERT OR UPDATE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION realtime.subscription_check_filters(); + +CREATE TRIGGER enforce_bucket_name_length_trigger BEFORE INSERT OR UPDATE OF name ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.enforce_bucket_name_length(); + +CREATE TRIGGER protect_buckets_delete BEFORE DELETE ON storage.buckets FOR EACH STATEMENT EXECUTE FUNCTION storage.protect_delete(); + +CREATE TRIGGER protect_objects_delete BEFORE DELETE ON storage.objects FOR EACH STATEMENT EXECUTE FUNCTION storage.protect_delete(); + +CREATE TRIGGER update_objects_updated_at BEFORE UPDATE ON storage.objects FOR EACH ROW EXECUTE FUNCTION storage.update_updated_at_column(); + +COMMENT ON COLUMN "auth"."identities"."email" IS 'Auth: Email is a generated column that references the optional email property in the identity_data'; + +COMMENT ON COLUMN "auth"."mfa_factors"."last_webauthn_challenge_data" IS 'Stores the latest WebAuthn challenge data including attestation/assertion for customer verification'; + +COMMENT ON COLUMN "auth"."sessions"."not_after" IS 'Auth: Not after is a nullable column that contains a timestamp after which the session should be regarded as expired.'; + +COMMENT ON COLUMN "auth"."sessions"."refresh_token_counter" IS 'Holds the ID (counter) of the last issued refresh token.'; + +COMMENT ON COLUMN "auth"."sessions"."refresh_token_hmac_key" IS 'Holds a HMAC-SHA256 key used to sign refresh tokens for this session.'; + +COMMENT ON COLUMN "auth"."sso_providers"."resource_id" IS 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.'; + +COMMENT ON COLUMN "auth"."users"."is_sso_user" IS 'Auth: Set this column to true when the account comes from SSO. These accounts can have duplicate emails.'; + +COMMENT ON COLUMN "storage"."buckets"."owner" IS 'Field is deprecated, use owner_id instead'; + +COMMENT ON COLUMN "storage"."objects"."owner" IS 'Field is deprecated, use owner_id instead'; + +COMMENT ON EXTENSION "pg_net" IS 'Async HTTP'; + +COMMENT ON FUNCTION "auth"."email"() IS 'Deprecated. Use auth.jwt() -> ''email'' instead.'; + +COMMENT ON FUNCTION "auth"."role"() IS 'Deprecated. Use auth.jwt() -> ''role'' instead.'; + +COMMENT ON FUNCTION "auth"."uid"() IS 'Deprecated. Use auth.jwt() -> ''sub'' instead.'; + +COMMENT ON FUNCTION "net"."check_worker_is_up"() IS 'raises an exception if the pg_net background worker is not up, otherwise it doesn''t return anything'; + +COMMENT ON FUNCTION "net"."wait_until_running"() IS 'waits until the worker is running'; + +COMMENT ON INDEX "auth"."identities_email_idx" IS 'Auth: Ensures indexed queries on the email column'; + +COMMENT ON INDEX "auth"."users_email_partial_key" IS 'Auth: A partial unique index that applies only when is_sso_user is false'; + +COMMENT ON TABLE "auth"."flow_state" IS 'Stores metadata for all OAuth/SSO login flows'; + +COMMENT ON TABLE "auth"."identities" IS 'Auth: Stores identities associated to a user.'; + +COMMENT ON TABLE "auth"."mfa_amr_claims" IS 'auth: stores authenticator method reference claims for multi factor authentication'; + +COMMENT ON TABLE "auth"."mfa_challenges" IS 'auth: stores metadata about challenge requests made'; + +COMMENT ON TABLE "auth"."mfa_factors" IS 'auth: stores metadata about factors'; + +COMMENT ON TABLE "auth"."oauth_client_states" IS 'Stores OAuth states for third-party provider authentication flows where Supabase acts as the OAuth client.'; + +COMMENT ON TABLE "auth"."saml_providers" IS 'Auth: Manages SAML Identity Provider connections.'; + +COMMENT ON TABLE "auth"."saml_relay_states" IS 'Auth: Contains SAML Relay State information for each Service Provider initiated login.'; + +COMMENT ON TABLE "auth"."sessions" IS 'Auth: Stores session data associated to a user.'; + +COMMENT ON TABLE "auth"."sso_domains" IS 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.'; + +COMMENT ON TABLE "auth"."sso_providers" IS 'Auth: Manages SSO identity provider information; see saml_providers for SAML.'; + +COMMENT ON TABLE "supabase_functions"."hooks" IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + +GRANT EXECUTE ON FUNCTION "auth"."jwt"() TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "auth"."jwt"() TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "auth"."jwt"() TO "postgres"; + +REVOKE ALL ON FUNCTION "auth"."jwt"() FROM "supabase_auth_admin"; + +GRANT EXECUTE ON FUNCTION "auth"."jwt"() TO "supabase_auth_admin"; + +REVOKE ALL ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) FROM PUBLIC; + +REVOKE ALL ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) FROM "anon"; + +GRANT EXECUTE ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) TO "anon"; + +REVOKE ALL ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) FROM "authenticated"; + +GRANT EXECUTE ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) TO "authenticated"; + +REVOKE ALL ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) FROM "postgres"; + +GRANT EXECUTE ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) TO "postgres"; + +REVOKE ALL ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) FROM "service_role"; + +GRANT EXECUTE ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) TO "service_role"; + +REVOKE ALL ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) FROM "supabase_functions_admin"; + +GRANT EXECUTE ON FUNCTION "net"."http_get"(text, jsonb, jsonb, integer) TO "supabase_functions_admin"; + +REVOKE ALL ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) FROM PUBLIC; + +REVOKE ALL ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) FROM "anon"; + +GRANT EXECUTE ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) TO "anon"; + +REVOKE ALL ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) FROM "authenticated"; + +GRANT EXECUTE ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) TO "authenticated"; + +REVOKE ALL ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) FROM "postgres"; + +GRANT EXECUTE ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) TO "postgres"; + +REVOKE ALL ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) FROM "service_role"; + +GRANT EXECUTE ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) TO "service_role"; + +REVOKE ALL ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) FROM "supabase_functions_admin"; + +GRANT EXECUTE ON FUNCTION "net"."http_post"(text, jsonb, jsonb, jsonb, integer) TO "supabase_functions_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."apply_rls"(jsonb, integer) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."apply_rls"(jsonb, integer) TO "anon"; + +GRANT EXECUTE ON FUNCTION "realtime"."apply_rls"(jsonb, integer) TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "realtime"."apply_rls"(jsonb, integer) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."apply_rls"(jsonb, integer) TO "postgres"; + +GRANT EXECUTE ON FUNCTION "realtime"."apply_rls"(jsonb, integer) TO "service_role"; + +REVOKE ALL ON FUNCTION "realtime"."apply_rls"(jsonb, integer) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."apply_rls"(jsonb, integer) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."apply_rls"(jsonb, integer) TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."broadcast_changes"(text, text, text, text, text, record, record, text) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."broadcast_changes"(text, text, text, text, text, record, record, text) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."broadcast_changes"(text, text, text, text, text, record, record, text) TO "postgres"; + +REVOKE ALL ON FUNCTION "realtime"."broadcast_changes"(text, text, text, text, text, record, record, text) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."broadcast_changes"(text, text, text, text, text, record, record, text) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) TO "anon"; + +GRANT EXECUTE ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) TO "postgres"; + +GRANT EXECUTE ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) TO "service_role"; + +REVOKE ALL ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."build_prepared_statement_sql"(text, regclass, realtime.wal_column[]) TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."cast"(text, regtype) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."cast"(text, regtype) TO "anon"; + +GRANT EXECUTE ON FUNCTION "realtime"."cast"(text, regtype) TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "realtime"."cast"(text, regtype) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."cast"(text, regtype) TO "postgres"; + +GRANT EXECUTE ON FUNCTION "realtime"."cast"(text, regtype) TO "service_role"; + +REVOKE ALL ON FUNCTION "realtime"."cast"(text, regtype) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."cast"(text, regtype) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."cast"(text, regtype) TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) TO "anon"; + +GRANT EXECUTE ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) TO "postgres"; + +GRANT EXECUTE ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) TO "service_role"; + +REVOKE ALL ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."check_equality_op"(realtime.equality_op, regtype, text, text) TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) TO "anon"; + +GRANT EXECUTE ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) TO "postgres"; + +GRANT EXECUTE ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) TO "service_role"; + +REVOKE ALL ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."is_visible_through_filters"(realtime.wal_column[], realtime.user_defined_filter[]) TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."list_changes"(name, name, integer, integer) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."list_changes"(name, name, integer, integer) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."list_changes"(name, name, integer, integer) TO "postgres"; + +REVOKE ALL ON FUNCTION "realtime"."list_changes"(name, name, integer, integer) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."list_changes"(name, name, integer, integer) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."quote_wal2json"(regclass) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."quote_wal2json"(regclass) TO "anon"; + +GRANT EXECUTE ON FUNCTION "realtime"."quote_wal2json"(regclass) TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "realtime"."quote_wal2json"(regclass) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."quote_wal2json"(regclass) TO "postgres"; + +GRANT EXECUTE ON FUNCTION "realtime"."quote_wal2json"(regclass) TO "service_role"; + +REVOKE ALL ON FUNCTION "realtime"."quote_wal2json"(regclass) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."quote_wal2json"(regclass) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."quote_wal2json"(regclass) TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."send"(jsonb, text, text, boolean) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."send"(jsonb, text, text, boolean) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."send"(jsonb, text, text, boolean) TO "postgres"; + +REVOKE ALL ON FUNCTION "realtime"."send"(jsonb, text, text, boolean) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."send"(jsonb, text, text, boolean) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."send_binary"(bytea, text, text, boolean) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."send_binary"(bytea, text, text, boolean) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."send_binary"(bytea, text, text, boolean) TO "postgres"; + +REVOKE ALL ON FUNCTION "realtime"."send_binary"(bytea, text, text, boolean) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."send_binary"(bytea, text, text, boolean) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."subscription_check_filters"() TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."subscription_check_filters"() TO "anon"; + +GRANT EXECUTE ON FUNCTION "realtime"."subscription_check_filters"() TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "realtime"."subscription_check_filters"() TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."subscription_check_filters"() TO "postgres"; + +GRANT EXECUTE ON FUNCTION "realtime"."subscription_check_filters"() TO "service_role"; + +REVOKE ALL ON FUNCTION "realtime"."subscription_check_filters"() FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."subscription_check_filters"() TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."subscription_check_filters"() TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."to_regrole"(text) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."to_regrole"(text) TO "anon"; + +GRANT EXECUTE ON FUNCTION "realtime"."to_regrole"(text) TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "realtime"."to_regrole"(text) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."to_regrole"(text) TO "postgres"; + +GRANT EXECUTE ON FUNCTION "realtime"."to_regrole"(text) TO "service_role"; + +REVOKE ALL ON FUNCTION "realtime"."to_regrole"(text) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."to_regrole"(text) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."to_regrole"(text) TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."topic"() TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."topic"() TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."topic"() TO "postgres"; + +REVOKE ALL ON FUNCTION "realtime"."topic"() FROM "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."topic"() TO "supabase_realtime_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."wal2json_escape_identifier"(text) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION "realtime"."wal2json_escape_identifier"(text) TO "dashboard_user"; + +GRANT EXECUTE ON FUNCTION "realtime"."wal2json_escape_identifier"(text) TO "postgres"; + +REVOKE ALL ON FUNCTION "realtime"."wal2json_escape_identifier"(text) FROM "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "realtime"."wal2json_escape_identifier"(text) TO "supabase_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."allow_any_operation"(text[]) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."allow_any_operation"(text[]) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."allow_any_operation"(text[]) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."allow_only_operation"(text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."allow_only_operation"(text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."allow_only_operation"(text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."can_insert_object"(text, text, uuid, jsonb) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."can_insert_object"(text, text, uuid, jsonb) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."can_insert_object"(text, text, uuid, jsonb) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."enforce_bucket_name_length"() TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."enforce_bucket_name_length"() FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."enforce_bucket_name_length"() TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."extension"(text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."extension"(text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."extension"(text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."filename"(text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."filename"(text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."filename"(text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."foldername"(text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."foldername"(text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."foldername"(text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."get_common_prefix"(text, text, text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."get_common_prefix"(text, text, text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."get_common_prefix"(text, text, text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."get_size_by_bucket"() TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."get_size_by_bucket"() FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."get_size_by_bucket"() TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."list_multipart_uploads_with_delimiter"(text, text, text, integer, text, text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."list_multipart_uploads_with_delimiter"(text, text, text, integer, text, text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."list_multipart_uploads_with_delimiter"(text, text, text, integer, text, text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."list_objects_with_delimiter"(text, text, text, integer, text, text, text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."list_objects_with_delimiter"(text, text, text, integer, text, text, text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."list_objects_with_delimiter"(text, text, text, integer, text, text, text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."operation"() TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."operation"() FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."operation"() TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."protect_delete"() TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."protect_delete"() FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."protect_delete"() TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."search"(text, text, integer, integer, integer, text, text, text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."search"(text, text, integer, integer, integer, text, text, text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."search"(text, text, integer, integer, integer, text, text, text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."search_by_timestamp"(text, text, integer, integer, text, text, text, text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."search_by_timestamp"(text, text, integer, integer, text, text, text, text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."search_by_timestamp"(text, text, integer, integer, text, text, text, text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."search_v2"(text, text, integer, integer, text, text, text, text) TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."search_v2"(text, text, integer, integer, text, text, text, text) FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."search_v2"(text, text, integer, integer, text, text, text, text) TO "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."update_updated_at_column"() TO PUBLIC; + +REVOKE ALL ON FUNCTION "storage"."update_updated_at_column"() FROM "supabase_storage_admin"; + +GRANT EXECUTE ON FUNCTION "storage"."update_updated_at_column"() TO "supabase_storage_admin"; + +REVOKE ALL ON FUNCTION "supabase_functions"."http_request"() FROM PUBLIC; + +GRANT EXECUTE ON FUNCTION "supabase_functions"."http_request"() TO "anon"; + +GRANT EXECUTE ON FUNCTION "supabase_functions"."http_request"() TO "authenticated"; + +GRANT EXECUTE ON FUNCTION "supabase_functions"."http_request"() TO "postgres"; + +GRANT EXECUTE ON FUNCTION "supabase_functions"."http_request"() TO "service_role"; + +REVOKE ALL ON FUNCTION "supabase_functions"."http_request"() FROM "supabase_functions_admin"; + +GRANT EXECUTE ON FUNCTION "supabase_functions"."http_request"() TO "supabase_functions_admin"; + +REVOKE ALL ON SCHEMA "net" FROM "anon"; + +GRANT USAGE ON SCHEMA "net" TO "anon"; + +REVOKE ALL ON SCHEMA "net" FROM "authenticated"; + +GRANT USAGE ON SCHEMA "net" TO "authenticated"; + +REVOKE ALL ON SCHEMA "net" FROM "postgres"; + +GRANT USAGE ON SCHEMA "net" TO "postgres"; + +REVOKE ALL ON SCHEMA "net" FROM "service_role"; + +GRANT USAGE ON SCHEMA "net" TO "service_role"; + +REVOKE ALL ON SCHEMA "net" FROM "supabase_functions_admin"; + +GRANT USAGE ON SCHEMA "net" TO "supabase_functions_admin"; + +REVOKE ALL ON SCHEMA "realtime" FROM "anon"; + +GRANT USAGE ON SCHEMA "realtime" TO "anon"; + +REVOKE ALL ON SCHEMA "realtime" FROM "authenticated"; + +GRANT USAGE ON SCHEMA "realtime" TO "authenticated"; + +REVOKE ALL ON SCHEMA "realtime" FROM "service_role"; + +GRANT USAGE ON SCHEMA "realtime" TO "service_role"; + +REVOKE ALL ON SCHEMA "realtime" FROM "supabase_realtime_admin"; + +GRANT CREATE, USAGE ON SCHEMA "realtime" TO "supabase_realtime_admin"; + +GRANT USAGE ON SCHEMA "supabase_functions" TO "anon"; + +GRANT USAGE ON SCHEMA "supabase_functions" TO "authenticated"; + +GRANT USAGE ON SCHEMA "supabase_functions" TO "postgres"; + +GRANT USAGE ON SCHEMA "supabase_functions" TO "service_role"; + +GRANT CREATE, USAGE ON SCHEMA "supabase_functions" TO "supabase_functions_admin"; + +GRANT SELECT, UPDATE, USAGE ON SEQUENCE "supabase_functions"."hooks_id_seq" TO "anon"; + +GRANT SELECT, UPDATE, USAGE ON SEQUENCE "supabase_functions"."hooks_id_seq" TO "authenticated"; + +GRANT SELECT, UPDATE, USAGE ON SEQUENCE "supabase_functions"."hooks_id_seq" TO "postgres"; + +GRANT SELECT, UPDATE, USAGE ON SEQUENCE "supabase_functions"."hooks_id_seq" TO "service_role"; + +REVOKE ALL ON SEQUENCE "supabase_functions"."hooks_id_seq" FROM "supabase_functions_admin"; + +GRANT SELECT, UPDATE, USAGE ON SEQUENCE "supabase_functions"."hooks_id_seq" TO "supabase_functions_admin"; + +REVOKE ALL ON TABLE "auth"."audit_log_entries" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."audit_log_entries" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."audit_log_entries" TO "postgres" WITH GRANT OPTION; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."custom_oauth_providers" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."custom_oauth_providers" TO "postgres"; + +REVOKE ALL ON TABLE "auth"."custom_oauth_providers" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."custom_oauth_providers" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."flow_state" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."flow_state" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."flow_state" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."flow_state" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."flow_state" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."flow_state" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."identities" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."identities" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."identities" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."identities" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."identities" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."identities" TO "supabase_auth_admin"; + +REVOKE ALL ON TABLE "auth"."instances" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."instances" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."instances" TO "postgres" WITH GRANT OPTION; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_amr_claims" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."mfa_amr_claims" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_amr_claims" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."mfa_amr_claims" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."mfa_amr_claims" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_amr_claims" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_challenges" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."mfa_challenges" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_challenges" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."mfa_challenges" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."mfa_challenges" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_challenges" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_factors" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."mfa_factors" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_factors" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."mfa_factors" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."mfa_factors" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."mfa_factors" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_authorizations" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_authorizations" TO "postgres"; + +REVOKE ALL ON TABLE "auth"."oauth_authorizations" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_authorizations" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_client_states" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_client_states" TO "postgres"; + +REVOKE ALL ON TABLE "auth"."oauth_client_states" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_client_states" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_clients" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_clients" TO "postgres"; + +REVOKE ALL ON TABLE "auth"."oauth_clients" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_clients" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_consents" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_consents" TO "postgres"; + +REVOKE ALL ON TABLE "auth"."oauth_consents" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."oauth_consents" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."one_time_tokens" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."one_time_tokens" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."one_time_tokens" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."one_time_tokens" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."one_time_tokens" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."one_time_tokens" TO "supabase_auth_admin"; + +REVOKE ALL ON TABLE "auth"."refresh_tokens" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."refresh_tokens" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."refresh_tokens" TO "postgres" WITH GRANT OPTION; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."saml_providers" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."saml_providers" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."saml_providers" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."saml_providers" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."saml_providers" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."saml_providers" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."saml_relay_states" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."saml_relay_states" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."saml_relay_states" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."saml_relay_states" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."saml_relay_states" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."saml_relay_states" TO "supabase_auth_admin"; + +REVOKE ALL ON TABLE "auth"."schema_migrations" FROM "postgres"; + +GRANT SELECT ON TABLE "auth"."schema_migrations" TO "postgres" WITH GRANT OPTION; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sessions" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."sessions" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sessions" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."sessions" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."sessions" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sessions" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sso_domains" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."sso_domains" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sso_domains" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."sso_domains" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."sso_domains" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sso_domains" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sso_providers" TO "dashboard_user"; + +REVOKE ALL ON TABLE "auth"."sso_providers" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sso_providers" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."sso_providers" TO "postgres" WITH GRANT OPTION; + +REVOKE ALL ON TABLE "auth"."sso_providers" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."sso_providers" TO "supabase_auth_admin"; + +REVOKE ALL ON TABLE "auth"."users" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."users" TO "postgres"; + +GRANT SELECT ON TABLE "auth"."users" TO "postgres" WITH GRANT OPTION; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."webauthn_challenges" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."webauthn_challenges" TO "postgres"; + +REVOKE ALL ON TABLE "auth"."webauthn_challenges" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."webauthn_challenges" TO "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."webauthn_credentials" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."webauthn_credentials" TO "postgres"; + +REVOKE ALL ON TABLE "auth"."webauthn_credentials" FROM "supabase_auth_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "auth"."webauthn_credentials" TO "supabase_auth_admin"; + +GRANT INSERT, SELECT, UPDATE ON TABLE "realtime"."messages" TO "anon"; + +GRANT INSERT, SELECT, UPDATE ON TABLE "realtime"."messages" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages" TO "postgres"; + +GRANT INSERT, SELECT, UPDATE ON TABLE "realtime"."messages" TO "service_role"; + +REVOKE ALL ON TABLE "realtime"."messages" FROM "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages" TO "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_08" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_08" TO "postgres"; + +REVOKE ALL ON TABLE "realtime"."messages_2026_07_08" FROM "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_08" TO "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_09" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_09" TO "postgres"; + +REVOKE ALL ON TABLE "realtime"."messages_2026_07_09" FROM "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_09" TO "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_10" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_10" TO "postgres"; + +REVOKE ALL ON TABLE "realtime"."messages_2026_07_10" FROM "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_10" TO "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_11" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_11" TO "postgres"; + +REVOKE ALL ON TABLE "realtime"."messages_2026_07_11" FROM "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_11" TO "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_12" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_12" TO "postgres"; + +REVOKE ALL ON TABLE "realtime"."messages_2026_07_12" FROM "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."messages_2026_07_12" TO "supabase_realtime_admin"; + +GRANT SELECT ON TABLE "realtime"."schema_migrations" TO "anon"; + +GRANT SELECT ON TABLE "realtime"."schema_migrations" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."schema_migrations" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."schema_migrations" TO "postgres"; + +GRANT SELECT ON TABLE "realtime"."schema_migrations" TO "service_role"; + +REVOKE ALL ON TABLE "realtime"."schema_migrations" FROM "supabase_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."schema_migrations" TO "supabase_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."schema_migrations" TO "supabase_realtime_admin"; + +GRANT SELECT ON TABLE "realtime"."subscription" TO "anon"; + +GRANT SELECT ON TABLE "realtime"."subscription" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."subscription" TO "dashboard_user"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."subscription" TO "postgres"; + +GRANT SELECT ON TABLE "realtime"."subscription" TO "service_role"; + +REVOKE ALL ON TABLE "realtime"."subscription" FROM "supabase_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."subscription" TO "supabase_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "realtime"."subscription" TO "supabase_realtime_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets" TO "anon"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets" TO "authenticated"; + +REVOKE ALL ON TABLE "storage"."buckets" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets" TO "postgres" WITH GRANT OPTION; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."buckets" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets" TO "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets_analytics" TO "anon"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets_analytics" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets_analytics" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."buckets_analytics" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets_analytics" TO "supabase_storage_admin"; + +REVOKE ALL ON TABLE "storage"."buckets_vectors" FROM "anon"; + +GRANT SELECT ON TABLE "storage"."buckets_vectors" TO "anon"; + +REVOKE ALL ON TABLE "storage"."buckets_vectors" FROM "authenticated"; + +GRANT SELECT ON TABLE "storage"."buckets_vectors" TO "authenticated"; + +REVOKE ALL ON TABLE "storage"."buckets_vectors" FROM "service_role"; + +GRANT SELECT ON TABLE "storage"."buckets_vectors" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."buckets_vectors" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."buckets_vectors" TO "supabase_storage_admin"; + +REVOKE ALL ON TABLE "storage"."iceberg_namespaces" FROM "anon"; + +GRANT SELECT ON TABLE "storage"."iceberg_namespaces" TO "anon"; + +REVOKE ALL ON TABLE "storage"."iceberg_namespaces" FROM "authenticated"; + +GRANT SELECT ON TABLE "storage"."iceberg_namespaces" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."iceberg_namespaces" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."iceberg_namespaces" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."iceberg_namespaces" TO "supabase_storage_admin"; + +REVOKE ALL ON TABLE "storage"."iceberg_tables" FROM "anon"; + +GRANT SELECT ON TABLE "storage"."iceberg_tables" TO "anon"; + +REVOKE ALL ON TABLE "storage"."iceberg_tables" FROM "authenticated"; + +GRANT SELECT ON TABLE "storage"."iceberg_tables" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."iceberg_tables" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."iceberg_tables" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."iceberg_tables" TO "supabase_storage_admin"; + +REVOKE ALL ON TABLE "storage"."migrations" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."migrations" TO "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."objects" TO "anon"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."objects" TO "authenticated"; + +REVOKE ALL ON TABLE "storage"."objects" FROM "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."objects" TO "postgres" WITH GRANT OPTION; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."objects" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."objects" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."objects" TO "supabase_storage_admin"; + +REVOKE ALL ON TABLE "storage"."s3_multipart_uploads" FROM "anon"; + +GRANT SELECT ON TABLE "storage"."s3_multipart_uploads" TO "anon"; + +REVOKE ALL ON TABLE "storage"."s3_multipart_uploads" FROM "authenticated"; + +GRANT SELECT ON TABLE "storage"."s3_multipart_uploads" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."s3_multipart_uploads" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."s3_multipart_uploads" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."s3_multipart_uploads" TO "supabase_storage_admin"; + +REVOKE ALL ON TABLE "storage"."s3_multipart_uploads_parts" FROM "anon"; + +GRANT SELECT ON TABLE "storage"."s3_multipart_uploads_parts" TO "anon"; + +REVOKE ALL ON TABLE "storage"."s3_multipart_uploads_parts" FROM "authenticated"; + +GRANT SELECT ON TABLE "storage"."s3_multipart_uploads_parts" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."s3_multipart_uploads_parts" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."s3_multipart_uploads_parts" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."s3_multipart_uploads_parts" TO "supabase_storage_admin"; + +REVOKE ALL ON TABLE "storage"."vector_indexes" FROM "anon"; + +GRANT SELECT ON TABLE "storage"."vector_indexes" TO "anon"; + +REVOKE ALL ON TABLE "storage"."vector_indexes" FROM "authenticated"; + +GRANT SELECT ON TABLE "storage"."vector_indexes" TO "authenticated"; + +REVOKE ALL ON TABLE "storage"."vector_indexes" FROM "service_role"; + +GRANT SELECT ON TABLE "storage"."vector_indexes" TO "service_role"; + +REVOKE ALL ON TABLE "storage"."vector_indexes" FROM "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "storage"."vector_indexes" TO "supabase_storage_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."hooks" TO "anon"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."hooks" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."hooks" TO "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."hooks" TO "service_role"; + +REVOKE ALL ON TABLE "supabase_functions"."hooks" FROM "supabase_functions_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."hooks" TO "supabase_functions_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."migrations" TO "anon"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."migrations" TO "authenticated"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."migrations" TO "postgres"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."migrations" TO "service_role"; + +REVOKE ALL ON TABLE "supabase_functions"."migrations" FROM "supabase_functions_admin"; + +GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "supabase_functions"."migrations" TO "supabase_functions_admin"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT UPDATE ON SEQUENCES TO "anon"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT UPDATE ON SEQUENCES TO "authenticated"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT UPDATE ON SEQUENCES TO "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT MAINTAIN, REFERENCES, TRIGGER, TRUNCATE ON TABLES TO "anon"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT MAINTAIN, REFERENCES, TRIGGER, TRUNCATE ON TABLES TO "authenticated"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT MAINTAIN, REFERENCES, TRIGGER, TRUNCATE ON TABLES TO "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT SELECT, UPDATE, USAGE ON SEQUENCES TO "anon"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT SELECT, UPDATE, USAGE ON SEQUENCES TO "authenticated"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT SELECT, UPDATE, USAGE ON SEQUENCES TO "postgres"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT SELECT, UPDATE, USAGE ON SEQUENCES TO "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" REVOKE ALL ON SEQUENCES FROM "supabase_admin"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" REVOKE ALL ON FUNCTIONS FROM PUBLIC; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT EXECUTE ON FUNCTIONS TO "anon"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT EXECUTE ON FUNCTIONS TO "authenticated"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT EXECUTE ON FUNCTIONS TO "postgres"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT EXECUTE ON FUNCTIONS TO "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" REVOKE ALL ON FUNCTIONS FROM "supabase_admin"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLES TO "anon"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLES TO "authenticated"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLES TO "postgres"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" GRANT DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLES TO "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA "supabase_functions" REVOKE ALL ON TABLES FROM "supabase_admin"; diff --git a/packages/pg-delta/tests/foreign-extension-user-mapping.test.ts b/packages/pg-delta/tests/foreign-extension-user-mapping.test.ts new file mode 100644 index 000000000..07bfc3472 --- /dev/null +++ b/packages/pg-delta/tests/foreign-extension-user-mapping.test.ts @@ -0,0 +1,41 @@ +/** + * Item 15 (issue #333): a user mapping whose SERVER was added to an extension + * (`ALTER EXTENSION … ADD SERVER …`) orphans `buildFactBase` — the server + * itself is correctly excluded as an extension member (foreign.ts's + * `notExtensionMember("pg_foreign_server", "s.oid")` on the server query), but + * the user-mapping query has no matching anti-join, so it still emits a + * `userMapping` fact parented to a server id that was never extracted. This is + * a superuser-only scenario (extension ownership, not a role-privilege gap). + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("item 15: user mapping on an extension-owned server", () => { + test("extract() succeeds and omits the orphaned mapping (no missing-parent throw)", async () => { + const cluster = await sharedCluster(); + const db = await cluster.createDb("ext_server_mapping"); + dbs.push(db); + await db.pool.query(` + CREATE EXTENSION citext; + CREATE FOREIGN DATA WRAPPER dummy_fdw; + CREATE SERVER s1 FOREIGN DATA WRAPPER dummy_fdw; + ALTER EXTENSION citext ADD SERVER s1; + CREATE USER MAPPING FOR CURRENT_USER SERVER s1; + `); + + // GREEN: resolves, and the mapping (parented to the extension-excluded + // server) is absent from the fact base. RED (missing anti-join): rejects + // with `FactBase: fact userMapping:… references missing parent server:s1`. + const { factBase } = await extract(db.pool); + expect(factBase.facts().some((f) => f.id.kind === "userMapping")).toBe( + false, + ); + expect(factBase.facts().some((f) => f.id.kind === "server")).toBe(false); + }, 60_000); +}); diff --git a/packages/pg-delta/tests/generated-column-shadow.test.ts b/packages/pg-delta/tests/generated-column-shadow.test.ts new file mode 100644 index 000000000..f3c358a04 --- /dev/null +++ b/packages/pg-delta/tests/generated-column-shadow.test.ts @@ -0,0 +1,88 @@ +/** + * Shadowed dependency edges for column defaults (PR #307 review #3500428568). + * + * pg records a column's `pg_attrdef` dependencies under a `default` id. An + * ORDINARY default is its own fact (which carries the dep and is alsoProduced by + * the column CREATE), so the column needs NO shadow edge — adding one would make + * buildActionGraph reject a still-valid plan when a policy filters the default. + * A GENERATED column has NO default fact, so the dep is shadowed onto the column + * (the only carrier) to drive ordering. + * + * Docker required. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { encodeId } from "../src/core/stable-id.ts"; +import { extract } from "../src/extract/extract.ts"; +import { sharedCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("column default shadow edges", () => { + test("ordinary default: dep on the `default` fact, NOT a column shadow", async () => { + const cluster = await sharedCluster(); + const db = await cluster.createDb("gencol_ordinary"); + dbs.push(db); + await db.pool.query(` + CREATE SCHEMA app; + CREATE SEQUENCE app.s; + CREATE TABLE app.t (id integer DEFAULT nextval('app.s')); + `); + const { factBase } = await extract(db.pool); + const seq = encodeId({ kind: "sequence", schema: "app", name: "s" }); + const col = encodeId({ + kind: "column", + schema: "app", + table: "t", + name: "id", + }); + const dep = encodeId({ + kind: "default", + schema: "app", + table: "t", + name: "id", + }); + const edgeKeys = factBase.edges.map( + (e) => `${encodeId(e.from)} -> ${encodeId(e.to)}`, + ); + // the default fact carries the dep … + expect(edgeKeys).toContain(`${dep} -> ${seq}`); + // … and the column does NOT also shadow it + expect(edgeKeys).not.toContain(`${col} -> ${seq}`); + }); + + test("generated column: dep is shadowed onto the column (no default fact)", async () => { + const cluster = await sharedCluster(); + const db = await cluster.createDb("gencol_generated"); + dbs.push(db); + await db.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.t ( + a integer, + g integer GENERATED ALWAYS AS (a + 1) STORED + ); + `); + const { factBase } = await extract(db.pool); + const colG = encodeId({ + kind: "column", + schema: "app", + table: "t", + name: "g", + }); + const colA = encodeId({ + kind: "column", + schema: "app", + table: "t", + name: "a", + }); + const edgeKeys = factBase.edges.map( + (e) => `${encodeId(e.from)} -> ${encodeId(e.to)}`, + ); + // no `default` fact for a generated column … + expect(factBase.facts().some((f) => f.id.kind === "default")).toBe(false); + // … so the base-column dep is shadowed onto the generated column + expect(edgeKeys).toContain(`${colG} -> ${colA}`); + }); +}); diff --git a/packages/pg-delta/tests/generative.test.ts b/packages/pg-delta/tests/generative.test.ts new file mode 100644 index 000000000..84fb467c2 --- /dev/null +++ b/packages/pg-delta/tests/generative.test.ts @@ -0,0 +1,95 @@ +/** + * The generative soak (stage 3 / stage 10): seeded random schema pairs + * through the FULL proof loop, both directions. The default batch keeps CI + * honest; PGDELTA_NEXT_SOAK= scales the run (a sustained soak is the + * stage-10 parity-bar item). Every failure prints its seed — the seed IS + * the repro case. + * + * A generated script that fails to LOAD is skipped (the generator emits + * SQL mutations without elaborating dependencies — Postgres adjudicates); + * a loaded pair that fails to PROVE is a real engine bug and fails the + * suite. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { sharedCluster } from "./containers.ts"; +import { generatePair, KIND_COVERAGE } from "./generative/generator.ts"; + +const BATCH = Number(process.env["PGDELTA_NEXT_SOAK"] ?? 12); +const SEED_BASE = Number(process.env["PGDELTA_NEXT_SOAK_SEED"] ?? 1000); + +describe("generative soak", () => { + test("kind-coverage checklist has no silent gaps", () => { + for (const [kind, covered] of Object.entries(KIND_COVERAGE)) { + // every entry is either covered or carries a written reason + if (typeof covered === "string") { + expect(covered.length).toBeGreaterThan(10); + } else { + expect(covered).toBe(true); + } + expect(kind.length).toBeGreaterThan(0); + } + }); + + test( + `${BATCH} seeded roundtrips prove in both directions`, + async () => { + const cluster = await sharedCluster(); + let proven = 0; + let skippedUnloadable = 0; + const failures: string[] = []; + + for (let i = 0; i < BATCH; i++) { + const seed = SEED_BASE + i; + const pair = generatePair(seed); + // the proof MUTATES the source db: each direction gets fresh dbs + for (const dir of ["forward", "reverse"] as const) { + const [fromSql, toSql] = + dir === "forward" ? [pair.a, pair.b] : [pair.b, pair.a]; + const source = await cluster.createDb(`gen_${dir}_src_${seed}`); + const desired = await cluster.createDb(`gen_${dir}_dst_${seed}`); + try { + try { + await source.pool.query(fromSql); + await desired.pool.query(toSql); + } catch { + skippedUnloadable++; + break; // unloadable script — not an engine problem + } + const [s, d] = [ + await extract(source.pool), + await extract(desired.pool), + ]; + const thePlan = plan(s.factBase, d.factBase); + const verdict = await provePlan(thePlan, source.pool, d.factBase); + if (!verdict.ok) { + failures.push( + `seed ${seed} (${dir}): ${ + verdict.applyError + ? `apply failed: ${verdict.applyError.message} at "${verdict.applyError.sql}"` + : `drift=${verdict.driftDeltas.length} data=${verdict.dataViolations.length}` + }`, + ); + break; + } + proven++; + } finally { + await Promise.all([source.drop(), desired.drop()]); + } + } + } + + if (failures.length > 0) { + throw new Error( + `generative soak failures (seeds are repro cases):\n ${failures.join("\n ")}`, + ); + } + // the batch must do real work — an all-skip run proves nothing + expect(proven).toBeGreaterThanOrEqual(BATCH); + expect(skippedUnloadable).toBeLessThan(BATCH / 2); + }, + 20 * 60_000, + ); +}); diff --git a/packages/pg-delta/tests/generative/generator.ts b/packages/pg-delta/tests/generative/generator.ts new file mode 100644 index 000000000..59a9efc05 --- /dev/null +++ b/packages/pg-delta/tests/generative/generator.ts @@ -0,0 +1,286 @@ +/** + * The generative engine (stage 3/stage 5 deliverable 9): seeded random + * schema pairs for the proof-loop soak. The generator emits SQL (Postgres + * elaborates it — P1 holds even here) and mutates a copy to produce the + * desired state, so every generated pair exercises diff/plan/prove in + * both directions. + * + * KIND_COVERAGE is the stage-10 checklist: a soak only counts if the + * generator emits every supported kind. Cluster-scoped kinds are excluded + * deliberately (they leak across the shared cluster); the corpus's + * isolated-cluster scenarios cover them. + */ + +export const KIND_COVERAGE: Record = { + schema: true, + table: true, + column: true, + default: true, + constraint: true, // pk, fk, check, unique + index: true, + sequence: true, + view: true, + materializedView: true, + function: true, + procedure: + "covered by corpus procedure-operations scenarios; the soak generator emits only CREATE FUNCTION", + aggregate: true, + trigger: true, + policy: true, + rule: true, + domain: true, + type: true, // enum + composite + range + collation: true, + comment: true, + acl: "implicit — acldefault facts ride on every object", + eventTrigger: "excluded: database-wide firing leaks across parallel tests", + extension: "excluded: image-dependent availability", + role: "excluded: cluster-scoped (corpus isolated-cluster scenarios cover)", + membership: "excluded: cluster-scoped", + defaultPrivilege: "excluded: cluster-scoped", + publication: "excluded: interacts with concurrent generative drops", + subscription: "excluded: cluster-scoped (replication slots)", + fdw: "excluded: needs extension", + server: "excluded: needs extension", + userMapping: "excluded: needs extension", +}; + +/** mulberry32 — tiny deterministic PRNG; the seed IS the repro case. */ +export function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a += 0x6d2b79f5; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +interface Gen { + random: () => number; + statements: string[]; + tables: Array<{ schema: string; name: string; columns: string[] }>; + enums: Array<{ schema: string; name: string; values: string[] }>; + sequences: Array<{ schema: string; name: string }>; + functions: Array<{ schema: string; name: string }>; +} + +const pick = (g: Gen, items: T[]): T => + items[Math.floor(g.random() * items.length)] as T; +const chance = (g: Gen, p: number): boolean => g.random() < p; + +const COLUMN_TYPES = [ + "integer", + "bigint", + "text", + "numeric(12,3)", + "boolean", + "timestamptz", + "date", + "jsonb", + "uuid", +]; + +function genSchema(g: Gen, name: string): void { + g.statements.push(`CREATE SCHEMA ${name};`); + + if (chance(g, 0.7)) { + const enumName = `${name}_status`; + const values = ["draft", "active", "done", "archived"].slice( + 0, + 2 + Math.floor(g.random() * 3), + ); + g.statements.push( + `CREATE TYPE ${name}.${enumName} AS ENUM (${values.map((v) => `'${v}'`).join(", ")});`, + ); + g.enums.push({ schema: name, name: enumName, values }); + } + if (chance(g, 0.3)) { + g.statements.push(`CREATE TYPE ${name}.pair AS (x integer, y integer);`); + } + if (chance(g, 0.25)) { + g.statements.push(`CREATE TYPE ${name}.span AS RANGE (SUBTYPE = numeric);`); + } + if (chance(g, 0.4)) { + g.statements.push( + `CREATE DOMAIN ${name}.positive AS integer CHECK (VALUE > 0);`, + ); + } + if (chance(g, 0.2)) { + g.statements.push( + `CREATE COLLATION ${name}.c_icu (PROVIDER = icu, LOCALE = 'en-US');`, + ); + } + if (chance(g, 0.5)) { + const seq = `${name}_seq`; + g.statements.push( + `CREATE SEQUENCE ${name}.${seq} START ${1 + Math.floor(g.random() * 100)};`, + ); + g.sequences.push({ schema: name, name: seq }); + } + + const tableCount = 1 + Math.floor(g.random() * 3); + for (let t = 0; t < tableCount; t++) { + const tableName = `t${t}`; + const columns: string[] = ["id integer NOT NULL"]; + const colNames = ["id"]; + const colCount = 1 + Math.floor(g.random() * 4); + for (let c = 0; c < colCount; c++) { + const colName = `c${c}`; + let type = pick(g, COLUMN_TYPES); + if (chance(g, 0.2) && g.enums.some((e) => e.schema === name)) { + const e = pick( + g, + g.enums.filter((x) => x.schema === name), + ); + type = `${e.schema}.${e.name}`; + columns.push( + `${colName} ${type}${chance(g, 0.5) ? ` DEFAULT '${e.values[0]}'` : ""}`, + ); + } else { + const withDefault = + chance(g, 0.3) && type === "integer" ? " DEFAULT 0" : ""; + const notNull = chance(g, 0.2) && withDefault ? " NOT NULL" : ""; + columns.push(`${colName} ${type}${withDefault}${notNull}`); + } + colNames.push(colName); + } + columns.push(`PRIMARY KEY (id)`); + g.statements.push( + `CREATE TABLE ${name}.${tableName} (${columns.join(", ")});`, + ); + g.tables.push({ schema: name, name: tableName, columns: colNames }); + + if (chance(g, 0.5) && colNames.length > 1) { + g.statements.push( + `CREATE INDEX ${tableName}_idx_${t} ON ${name}.${tableName} (${colNames[1]});`, + ); + } + if (chance(g, 0.35) && g.tables.length > 1) { + const target = pick(g, g.tables.slice(0, -1)); + g.statements.push( + `ALTER TABLE ${name}.${tableName} ADD CONSTRAINT ${tableName}_fk_${t} FOREIGN KEY (id) REFERENCES ${target.schema}.${target.name}(id);`, + ); + } + if (chance(g, 0.3)) { + g.statements.push( + `ALTER TABLE ${name}.${tableName} ADD CONSTRAINT ${tableName}_ck CHECK (id >= 0);`, + ); + } + if (chance(g, 0.3)) { + g.statements.push( + `COMMENT ON TABLE ${name}.${tableName} IS 'generated ${tableName}';`, + ); + } + if (chance(g, 0.25)) { + g.statements.push( + `ALTER TABLE ${name}.${tableName} ENABLE ROW LEVEL SECURITY;`, + `CREATE POLICY p_${tableName} ON ${name}.${tableName} FOR SELECT USING (id > 0);`, + ); + } + if (chance(g, 0.25)) { + g.statements.push( + `CREATE RULE r_${tableName} AS ON DELETE TO ${name}.${tableName} DO ALSO NOTHING;`, + ); + } + } + + if (chance(g, 0.6) && g.tables.some((t) => t.schema === name)) { + const t = pick( + g, + g.tables.filter((x) => x.schema === name), + ); + g.statements.push( + `CREATE VIEW ${name}.v_${t.name} AS SELECT id FROM ${t.schema}.${t.name} WHERE id > 0;`, + ); + } + if (chance(g, 0.3) && g.tables.some((t) => t.schema === name)) { + const t = pick( + g, + g.tables.filter((x) => x.schema === name), + ); + g.statements.push( + `CREATE MATERIALIZED VIEW ${name}.mv_${t.name} AS SELECT id FROM ${t.schema}.${t.name};`, + ); + } + if (chance(g, 0.6)) { + const fn = `f_${name}`; + g.statements.push( + `CREATE FUNCTION ${name}.${fn}(a integer) RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT a * 2';`, + ); + g.functions.push({ schema: name, name: fn }); + } + if (chance(g, 0.3)) { + g.statements.push( + `CREATE AGGREGATE ${name}.agg_sum (integer) (SFUNC = int4pl, STYPE = integer, INITCOND = '0');`, + ); + } + if (chance(g, 0.35) && g.tables.some((t) => t.schema === name)) { + const t = pick( + g, + g.tables.filter((x) => x.schema === name), + ); + g.statements.push( + `CREATE FUNCTION ${name}.tg_${t.name}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;`, + `CREATE TRIGGER trg_${t.name} BEFORE INSERT ON ${t.schema}.${t.name} FOR EACH ROW EXECUTE FUNCTION ${name}.tg_${t.name}();`, + ); + } +} + +/** Mutations applied to the BASE statements to derive the desired state. */ +function mutate(g: Gen, base: string[]): string[] { + const out = [...base]; + // drop a random non-schema statement (dependents may break the script — + // mutations operate on the SQL, Postgres adjudicates; a script that + // fails to load is regenerated by the caller) + if (chance(g, 0.5) && out.length > 3) { + const idx = 1 + Math.floor(g.random() * (out.length - 1)); + const victim = out[idx] as string; + if (!victim.startsWith("CREATE SCHEMA")) out.splice(idx, 1); + } + // append additions + const schemaMatch = /CREATE SCHEMA (\w+);/.exec(out[0] ?? ""); + const schema = schemaMatch?.[1] ?? "gen0"; + if (chance(g, 0.8)) { + out.push( + `CREATE TABLE ${schema}.added_t (id integer PRIMARY KEY, note text DEFAULT 'x');`, + ); + } + if (chance(g, 0.5)) { + out.push(`CREATE SEQUENCE ${schema}.added_seq START 9;`); + } + if (chance(g, 0.4)) { + out.push( + `CREATE FUNCTION ${schema}.added_f() RETURNS integer LANGUAGE sql AS 'SELECT 41';`, + ); + } + // in-place alters expressed as desired-state differences + if (chance(g, 0.5)) { + out.push(`COMMENT ON SCHEMA ${schema} IS 'mutated';`); + } + return out; +} + +export interface GeneratedPair { + seed: number; + a: string; + b: string; +} + +export function generatePair(seed: number): GeneratedPair { + const random = rng(seed); + const g: Gen = { + random, + statements: [], + tables: [], + enums: [], + sequences: [], + functions: [], + }; + const schemaCount = 1 + Math.floor(random() * 2); + for (let i = 0; i < schemaCount; i++) genSchema(g, `gen${i}`); + const a = g.statements.join("\n"); + const b = mutate(g, g.statements).join("\n"); + return { seed, a, b }; +} diff --git a/packages/pg-delta/tests/global-setup.ts b/packages/pg-delta/tests/global-setup.ts deleted file mode 100644 index 4bee84a37..000000000 --- a/packages/pg-delta/tests/global-setup.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getContainerRuntimeClient, ImageName } from "testcontainers"; -import { - POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG, - POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG, - POSTGRES_VERSIONS, - SUPABASE_POSTGRES_VERSIONS, -} from "./constants.ts"; -import { containerManager } from "./container-manager.ts"; - -const containerRuntimeClient = await getContainerRuntimeClient(); -// pull all the images before running the tests -const imagesSupabasePostgres = SUPABASE_POSTGRES_VERSIONS.map( - (postgresVersion) => - `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[postgresVersion]}`, -); -const imagesAlpinePostgres = POSTGRES_VERSIONS.map( - (postgresVersion) => - `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[postgresVersion]}`, -); - -await Promise.all([ - ...imagesSupabasePostgres.map((image) => - containerRuntimeClient.image.pull(ImageName.fromString(image)), - ), - ...imagesAlpinePostgres.map((image) => - containerRuntimeClient.image.pull(ImageName.fromString(image)), - ), -]); - -// Pre-create shared containers so tests don't pay lazy-init cost. -// Essential for concurrent execution — prevents races to initialize the same container. -await containerManager.initialize(POSTGRES_VERSIONS); diff --git a/packages/pg-delta/tests/index-invalid-repair.test.ts b/packages/pg-delta/tests/index-invalid-repair.test.ts new file mode 100644 index 000000000..8bc8b3dc2 --- /dev/null +++ b/packages/pg-delta/tests/index-invalid-repair.test.ts @@ -0,0 +1,81 @@ +/** + * An INVALID index (pg_index.indisvalid = false — the residue of a failed or + * cancelled `CREATE INDEX CONCURRENTLY`) must not converge against a desired + * VALID index. `pg_get_indexdef` renders the same text for both, so before + * `indisvalid` was captured the unusable index hashed EQUAL to the valid one and + * the plan was empty (0 actions) — the drift went unrepaired and the proof + * passed vacuously. + * + * The fix captures `indisvalid` as a semantic payload field, so invalid ≠ valid, + * and routes the transition through the index "replace" strategy (DROP + CREATE) + * — the standard way to repair an invalid index. + * + * We fabricate the invalid state with `UPDATE pg_index SET indisvalid = false` + * (a superuser DML on the catalog — no allow_system_table_mods needed) rather + * than racing a real concurrent build. + * + * Stock alpine image; Docker required. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let source: TestDb; +let desired: TestDb; +let clone: TestDb; + +beforeAll(async () => { + source = await createTestDb("index-invalid-src"); + desired = await createTestDb("index-invalid-dst"); + // source: a valid index, then forced invalid to simulate a failed + // CREATE INDEX CONCURRENTLY. + await source.pool.query(` + CREATE TABLE public.t (id integer PRIMARY KEY, v integer); + CREATE INDEX idx_t_v ON public.t (v); + UPDATE pg_index SET indisvalid = false + WHERE indexrelid = 'public.idx_t_v'::regclass; + `); + // desired: the same index, valid (a fresh CREATE INDEX is always valid). + await desired.pool.query(` + CREATE TABLE public.t (id integer PRIMARY KEY, v integer); + CREATE INDEX idx_t_v ON public.t (v); + `); +}, 120_000); + +afterAll(async () => { + await Promise.all([ + source.drop().catch(() => {}), + desired.drop().catch(() => {}), + clone?.drop().catch(() => {}), + ]); +}); + +describe("invalid index repair (replace)", () => { + test("an invalid index is dropped and recreated, and converges to a valid index", async () => { + const sourceState = await extract(source.pool); + const desiredState = await extract(desired.pool); + const thePlan = plan(sourceState.factBase, desiredState.factBase); + const indexActions = thePlan.actions + .map((a) => a.sql) + .filter( + (s) => /INDEX "?public"?\."?idx_t_v"?/i.test(s) || /idx_t_v/.test(s), + ); + // RED before the fix: indexActions is empty (invalid hashed equal to valid). + expect(indexActions.some((s) => s.startsWith("DROP INDEX"))).toBe(true); + expect(indexActions.some((s) => s.startsWith("CREATE INDEX"))).toBe(true); + + clone = await source.clone(); + const verdict = await provePlan(thePlan, clone.pool, desiredState.factBase); + expect(verdict.applyError).toBeUndefined(); + expect(verdict.driftDeltas).toEqual([]); + expect(verdict.ok).toBe(true); + + // the repaired index is valid again. + const res = await clone.pool.query( + `SELECT indisvalid FROM pg_index WHERE indexrelid = 'public.idx_t_v'::regclass`, + ); + expect((res.rows[0] as { indisvalid: boolean }).indisvalid).toBe(true); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/inheritance-parent-determinism.test.ts b/packages/pg-delta/tests/inheritance-parent-determinism.test.ts new file mode 100644 index 000000000..22f5bc461 --- /dev/null +++ b/packages/pg-delta/tests/inheritance-parent-determinism.test.ts @@ -0,0 +1,46 @@ +/** + * The single-`parentTable` payload captured for an inheriting table must be + * DETERMINISTIC when a table has multiple inheritance parents. The extractor + * captures one parent (multi-parent support is tracked separately), and the + * `pg_inherits` subquery must `ORDER BY inhseqno` so the FIRST-declared parent + * is always the one captured — otherwise the chosen parent flaps across + * extractions and drives spurious table replaces. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { extract, type ExtractResult } from "../src/extract/extract.ts"; +import { createTestDb, type TestDb } from "./containers.ts"; + +let db: TestDb; +let result: ExtractResult; + +beforeAll(async () => { + db = await createTestDb("inh-parent"); + // The child inherits p_zeta FIRST (inhseqno = 1) then p_alpha (inhseqno = 2); + // the declared order is intentionally the reverse of alphabetical so a naive + // unordered LIMIT 1 has a chance to disagree with the first-declared parent. + await db.pool.query(` + CREATE SCHEMA app; + CREATE TABLE app.p_zeta (z int); + CREATE TABLE app.p_alpha (a int); + CREATE TABLE app.child () INHERITS (app.p_zeta, app.p_alpha); + `); + result = await extract(db.pool); +}, 120_000); + +afterAll(async () => { + await db.drop(); +}); + +describe("inheritance parent capture (multiple parents)", () => { + test("captures the first-declared parent (inhseqno = 1) deterministically", () => { + const child = result.factBase.get({ + kind: "table", + schema: "app", + name: "child", + }); + expect(child?.payload["parentTable"]).toEqual({ + schema: "app", + name: "p_zeta", + }); + }); +}); diff --git a/packages/pg-delta/tests/integration/aggregate-operations.test.ts b/packages/pg-delta/tests/integration/aggregate-operations.test.ts deleted file mode 100644 index 02d874c5a..000000000 --- a/packages/pg-delta/tests/integration/aggregate-operations.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Integration tests for PostgreSQL aggregate operations. - */ - -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import type { Change } from "../../src/core/change.types.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`aggregate operations (pg${pgVersion})`, () => { - test( - "aggregate creation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE AGGREGATE test_schema.collect_text(text) - ( - SFUNC = pg_catalog.array_append, - STYPE = text[], - INITCOND = '{}' - ); - `, - }); - }), - ); - - test( - "aggregate owner change", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE AGGREGATE test_schema.collect_text(text) - ( - SFUNC = array_append, - STYPE = text[], - INITCOND = '{}' - ); - CREATE ROLE aggregate_owner; - `, - testSql: dedent` - ALTER AGGREGATE test_schema.collect_text(text) OWNER TO aggregate_owner; - `, - }); - }), - ); - - test( - "aggregate drop", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE AGGREGATE test_schema.collect_text(text) - ( - SFUNC = array_append, - STYPE = text[], - INITCOND = '{}' - ); - `, - testSql: dedent` - DROP AGGREGATE test_schema.collect_text(text); - `, - }); - }), - ); - - test( - "aggregate comment creation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE AGGREGATE test_schema.collect_text_comment(text) - ( - SFUNC = pg_catalog.array_append, - STYPE = text[], - INITCOND = '{}' - ); - `, - testSql: dedent` - COMMENT ON AGGREGATE test_schema.collect_text_comment(text) IS 'aggregate comment'; - `, - }); - }), - ); - - test( - "aggregate comment removal", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE AGGREGATE test_schema.collect_text_comment_drop(text) - ( - SFUNC = pg_catalog.array_append, - STYPE = text[], - INITCOND = '{}' - ); - COMMENT ON AGGREGATE test_schema.collect_text_comment_drop(text) IS 'aggregate comment'; - `, - testSql: dedent` - COMMENT ON AGGREGATE test_schema.collect_text_comment_drop(text) IS NULL; - `, - }); - }), - ); - - test( - "aggregate comment creation depends on aggregate create order", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE AGGREGATE test_schema.collect_text_dependency(text) - ( - SFUNC = pg_catalog.array_append, - STYPE = text[], - INITCOND = '{}' - ); - - COMMENT ON AGGREGATE test_schema.collect_text_dependency(text) IS 'dependency check'; - `, - sortChangesCallback: (a, b) => { - // force comment create ahead of aggregate create to ensure dependency sorting fixes the order - const priority = (change: Change) => { - if ( - change.objectType === "aggregate" && - change.scope === "comment" && - change.operation === "create" - ) { - return 0; - } - if ( - change.objectType === "aggregate" && - change.scope === "object" && - change.operation === "create" - ) { - return 1; - } - return 2; - }; - - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "aggregate grant privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE AGGREGATE test_schema.collect_text_priv(text) - ( - SFUNC = pg_catalog.array_append, - STYPE = text[], - INITCOND = '{}' - ); - CREATE ROLE aggregate_executor; - `, - testSql: dedent` - GRANT EXECUTE ON FUNCTION test_schema.collect_text_priv(text) TO aggregate_executor; - `, - }); - }), - ); - - test( - "aggregate revoke privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE AGGREGATE test_schema.collect_text_priv_revoke(text) - ( - SFUNC = pg_catalog.array_append, - STYPE = text[], - INITCOND = '{}' - ); - CREATE ROLE aggregate_executor; - GRANT EXECUTE ON FUNCTION test_schema.collect_text_priv_revoke(text) TO aggregate_executor; - `, - testSql: dedent` - REVOKE EXECUTE ON FUNCTION test_schema.collect_text_priv_revoke(text) FROM aggregate_executor; - `, - }); - }), - ); - - // Regression for CLI-1471: when an aggregate exists in branch but not - // main, pg-delta must emit `CREATE AGGREGATE` alongside any GRANT on - // the aggregate. Emitting the GRANT alone produced - // `WARNING (01007): no privileges were granted for ...` at apply time - // because the GRANT referenced an aggregate the planner had not - // enumerated. The roundtripFidelityTest re-applies the generated - // migration against main, which fails immediately if pg-delta produces - // an orphan GRANT without the matching CREATE AGGREGATE. - test( - "aggregate create + grant roundtrips without orphan grant", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE ROLE aggregate_executor;", - testSql: dedent` - CREATE FUNCTION public.last_sfunc(state anyelement, value anyelement) - RETURNS anyelement LANGUAGE sql IMMUTABLE AS $$ SELECT value $$; - CREATE AGGREGATE public.last(anyelement) - ( - SFUNC = public.last_sfunc, - STYPE = anyelement - ); - GRANT ALL ON FUNCTION public.last(anyelement) TO aggregate_executor; - `, - }); - }), - ); - - // The wild report on CLI-1471 cited a GRANT with signature - // `public.last(anyelement, any)`, which is the shape an - // ordered-set / hypothetical-set aggregate produces in `proargtypes` - // (the procedure path would emit that exact format). Aggregates with - // `aggkind = 'o' | 'h'` go through the same enumeration code path as - // plain aggregates (`prokind = 'a'`), and the procedure extractor's - // `lanname not in ('c', 'internal')` filter excludes them, so the - // ACL must be carried by the aggregate path. Lock that in. - test( - "ordered-set aggregate create + grant roundtrips without orphan grant", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE ROLE aggregate_executor;", - testSql: dedent` - CREATE FUNCTION public.os_last_sfunc(state anyelement, value anyelement) - RETURNS anyelement LANGUAGE sql IMMUTABLE AS $$ SELECT value $$; - CREATE AGGREGATE public.os_last(anyelement ORDER BY anyelement) - ( - SFUNC = public.os_last_sfunc, - STYPE = anyelement - ); - GRANT ALL ON FUNCTION public.os_last(anyelement, anyelement) TO aggregate_executor; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/alter-table-operations.test.ts b/packages/pg-delta/tests/integration/alter-table-operations.test.ts deleted file mode 100644 index d8fdf7496..000000000 --- a/packages/pg-delta/tests/integration/alter-table-operations.test.ts +++ /dev/null @@ -1,647 +0,0 @@ -/** - * Integration tests for PostgreSQL ALTER TABLE operations. - */ - -import { describe, expect, test } from "bun:test"; -import type { Change } from "../../src/core/change.types.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - // TODO: Fix ALTER TABLE operations dependency detection issues - describe(`alter table operations (pg${pgVersion})`, () => { - test( - "add column then create unique index on it", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.idx_users ( - id integer NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.idx_users ADD COLUMN email character varying(255); - ALTER TABLE test_schema.idx_users ADD CONSTRAINT users_email_key UNIQUE (email); - `, - // Force AlterTableAddConstraint to be after AlterTableAddColumn - sortChangesCallback: (a, b) => { - const priority = (change: Change) => { - if ( - change.objectType === "table" && - change.operation === "alter" - ) { - return change.constructor.name === "AlterTableAddColumn" - ? 0 - : 1; - } - return 2; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - test( - "add column to existing table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.users ADD COLUMN email character varying(255) NOT NULL DEFAULT 'user@example.com'; - `, - }); - }), - ); - - test( - "drop column from existing table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.products ( - id integer NOT NULL, - name text NOT NULL, - old_field text, - description text - ); - `, - testSql: ` - ALTER TABLE test_schema.products DROP COLUMN old_field; - `, - }); - }), - ); - - test( - "change column type", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.conversions ( - id integer NOT NULL, - price numeric(8,2), - status_code smallint - ); - `, - testSql: ` - ALTER TABLE test_schema.conversions ALTER COLUMN price TYPE numeric(12,4); - `, - }); - }), - ); - - test( - "change column type after dropping dependent view", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.alter_column_type_view_dependent_users ( - id integer PRIMARY KEY, - age numeric - ); - - CREATE VIEW public.alter_column_type_view_dependent_user_ages AS - SELECT id, age - FROM public.alter_column_type_view_dependent_users - WHERE age > 0; - `, - testSql: ` - DROP VIEW public.alter_column_type_view_dependent_user_ages; - - ALTER TABLE public.alter_column_type_view_dependent_users - ALTER COLUMN age TYPE integer USING age::integer; - - CREATE VIEW public.alter_column_type_view_dependent_user_ages AS - SELECT id, age - FROM public.alter_column_type_view_dependent_users - WHERE age > 0; - `, - assertSqlStatements: (sqlStatements) => { - expect(sqlStatements).toHaveLength(3); - expect(sqlStatements[0]).toBe( - "DROP VIEW public.alter_column_type_view_dependent_user_ages", - ); - expect(sqlStatements[1]).toBe( - "ALTER TABLE public.alter_column_type_view_dependent_users ALTER COLUMN age TYPE integer USING age::integer", - ); - expect(sqlStatements[2]).toMatch( - /^CREATE VIEW public\.alter_column_type_view_dependent_user_ages AS SELECT /, - ); - expect(sqlStatements[2]).toContain( - "FROM alter_column_type_view_dependent_users", - ); - expect(sqlStatements[2]).toContain("age > 0"); - }, - }); - }), - ); - - test( - "change column type after dropping dependent view preserves metadata", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE ROLE alter_column_type_view_metadata_reader; - - CREATE TABLE public.alter_column_type_view_metadata_users ( - id integer PRIMARY KEY, - age numeric - ); - - CREATE VIEW public.alter_column_type_view_metadata_user_ages AS - SELECT id, age - FROM public.alter_column_type_view_metadata_users - WHERE age > 0; - - COMMENT ON VIEW public.alter_column_type_view_metadata_user_ages - IS 'dependent view metadata'; - - GRANT SELECT ON public.alter_column_type_view_metadata_user_ages - TO alter_column_type_view_metadata_reader; - `, - testSql: ` - DROP VIEW public.alter_column_type_view_metadata_user_ages; - - ALTER TABLE public.alter_column_type_view_metadata_users - ALTER COLUMN age TYPE integer USING age::integer; - - CREATE VIEW public.alter_column_type_view_metadata_user_ages AS - SELECT id, age - FROM public.alter_column_type_view_metadata_users - WHERE age > 0; - - COMMENT ON VIEW public.alter_column_type_view_metadata_user_ages - IS 'dependent view metadata'; - - GRANT SELECT ON public.alter_column_type_view_metadata_user_ages - TO alter_column_type_view_metadata_reader; - `, - assertSqlStatements: (sqlStatements) => { - expect(sqlStatements).toHaveLength(5); - expect(sqlStatements[0]).toBe( - "DROP VIEW public.alter_column_type_view_metadata_user_ages", - ); - expect(sqlStatements[1]).toBe( - "ALTER TABLE public.alter_column_type_view_metadata_users ALTER COLUMN age TYPE integer USING age::integer", - ); - expect(sqlStatements[2]).toMatch( - /^CREATE VIEW public\.alter_column_type_view_metadata_user_ages AS SELECT /, - ); - expect(sqlStatements[2]).toContain( - "FROM alter_column_type_view_metadata_users", - ); - expect(sqlStatements[2]).toContain("age > 0"); - expect(sqlStatements[3]).toBe( - "COMMENT ON VIEW public.alter_column_type_view_metadata_user_ages IS 'dependent view metadata'", - ); - expect(sqlStatements[4]).toBe( - "GRANT SELECT ON public.alter_column_type_view_metadata_user_ages TO alter_column_type_view_metadata_reader", - ); - }, - }); - }), - ); - - test( - "change column type to enum with default", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TYPE test_schema.status AS ENUM ('active', 'inactive', 'archived'); - CREATE TABLE test_schema.items ( - id integer NOT NULL, - state text NOT NULL DEFAULT 'active' - ); - INSERT INTO test_schema.items (id, state) VALUES (1, 'active'); - `, - testSql: ` - ALTER TABLE test_schema.items - ALTER COLUMN state DROP DEFAULT; - ALTER TABLE test_schema.items - ALTER COLUMN state TYPE test_schema.status USING state::test_schema.status; - ALTER TABLE test_schema.items - ALTER COLUMN state SET DEFAULT 'active'::test_schema.status; - `, - }); - }), - ); - - test( - "change varchar column type to integer with using cast", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.orders ( - id integer NOT NULL, - amount varchar(10) - ); - INSERT INTO test_schema.orders (id, amount) VALUES (1, '42'); - `, - testSql: ` - ALTER TABLE test_schema.orders - ALTER COLUMN amount TYPE integer USING amount::integer; - `, - }); - }), - ); - - test( - "set column default", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.settings ( - id integer NOT NULL, - enabled boolean, - created_at timestamp - ); - `, - testSql: ` - ALTER TABLE test_schema.settings ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP; - `, - }); - }), - ); - - test( - "drop column default", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.configs ( - id integer NOT NULL, - status text DEFAULT 'pending', - value text - ); - `, - testSql: ` - ALTER TABLE test_schema.configs ALTER COLUMN status DROP DEFAULT; - `, - }); - }), - ); - - test( - "set column not null", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - name text - ); - INSERT INTO test_schema.users (id, name) VALUES (1, 'Test User'); - `, - testSql: ` - ALTER TABLE test_schema.users ALTER COLUMN name SET NOT NULL; - `, - }); - }), - ); - - test( - "drop column not null", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.profiles ( - id integer NOT NULL, - email text NOT NULL, - phone text - ); - `, - testSql: ` - ALTER TABLE test_schema.profiles ALTER COLUMN email DROP NOT NULL; - `, - }); - }), - ); - - test( - "multiple alter operations - state-based diffing", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.evolution ( - id integer NOT NULL, - old_name varchar(50), - status text DEFAULT 'pending' - ); - `, - testSql: ` - ALTER TABLE test_schema.evolution ADD COLUMN email character varying(255); - ALTER TABLE test_schema.evolution ALTER COLUMN old_name TYPE text; - ALTER TABLE test_schema.evolution ALTER COLUMN status DROP DEFAULT; - ALTER TABLE test_schema.evolution DROP COLUMN status; - `, - }); - }), - ); - - test( - "complex column changes", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.complex_changes ( - id integer NOT NULL, - email text, - status varchar(20) DEFAULT 'active', - created_at timestamp - ); - `, - testSql: ` - ALTER TABLE test_schema.complex_changes ALTER COLUMN email TYPE character varying(255); - ALTER TABLE test_schema.complex_changes ALTER COLUMN email SET NOT NULL; - ALTER TABLE test_schema.complex_changes ALTER COLUMN email SET DEFAULT 'user@example.com'; - ALTER TABLE test_schema.complex_changes ALTER COLUMN status DROP DEFAULT; - ALTER TABLE test_schema.complex_changes ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP; - `, - }); - }), - ); - - test( - "generated column operations", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - first_name text NOT NULL, - last_name text NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.users ADD COLUMN full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED; - ALTER TABLE test_schema.users ADD COLUMN email character varying(255) DEFAULT 'user@example.com'; - `, - }); - }), - ); - - test( - "drop generated column", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.products ( - id integer NOT NULL, - price numeric(10,2) NOT NULL, - tax_rate numeric(5,4) DEFAULT 0.0875, - total_price numeric(10,2) GENERATED ALWAYS AS (price * (1 + tax_rate)) STORED - ); - `, - testSql: ` - ALTER TABLE test_schema.products DROP COLUMN total_price; - `, - }); - }), - ); - - test( - "alter generated column expression", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.calculations ( - id integer NOT NULL, - value_a numeric NOT NULL, - value_b numeric NOT NULL, - computed numeric GENERATED ALWAYS AS (value_a + value_b) STORED - ); - `, - testSql: ` - ALTER TABLE test_schema.calculations DROP COLUMN computed; - ALTER TABLE test_schema.calculations ADD COLUMN computed numeric GENERATED ALWAYS AS (value_a * value_b) STORED; - `, - // Force ADD COLUMN to be before DROP COLUMN to test that we track the dependency column -> generated column - sortChangesCallback: (a, b) => { - const priority = (change: Change) => { - if ( - change.objectType === "table" && - change.operation === "alter" - ) { - return change.constructor.name === "AlterTableAddColumn" - ? 0 - : 1; - } - return 2; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "table and column comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.events ( - id integer, - created_at timestamp - ); - `, - testSql: ` - COMMENT ON TABLE test_schema.events IS 'events table'; - COMMENT ON COLUMN test_schema.events.created_at IS 'created_at column'; - `, - }); - }), - ); - - // Regression coverage for CLI-754: widening the type of a column that - // already has a default on main must preserve the default. - test( - "widen column type preserves pre-existing default", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.priced ( - id integer NOT NULL, - price numeric(8,2) DEFAULT 0.00 - ); - INSERT INTO test_schema.priced (id) VALUES (1); - `, - testSql: ` - ALTER TABLE test_schema.priced ALTER COLUMN price TYPE numeric(12,4); - `, - }); - }), - ); - - test( - "change column type from enum to text preserves default", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TYPE test_schema.status AS ENUM ('active', 'inactive'); - CREATE TABLE test_schema.items ( - id integer NOT NULL, - state test_schema.status DEFAULT 'active' - ); - INSERT INTO test_schema.items (id) VALUES (1); - `, - testSql: ` - ALTER TABLE test_schema.items - ALTER COLUMN state DROP DEFAULT, - ALTER COLUMN state TYPE text USING state::text, - ALTER COLUMN state SET DEFAULT 'active'; - DROP TYPE test_schema.status; - `, - }); - }), - ); - - test( - "set replica identity using index on existing table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.replicated ( - id integer NOT NULL, - tenant_id integer NOT NULL, - payload text - ); - CREATE UNIQUE INDEX replicated_tenant_id_key - ON test_schema.replicated (tenant_id); - `, - testSql: ` - ALTER TABLE test_schema.replicated - REPLICA IDENTITY USING INDEX replicated_tenant_id_key; - `, - }); - }), - ); - - test( - "create table with replica identity using index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - CREATE TABLE test_schema.replicated ( - id integer NOT NULL, - tenant_id integer NOT NULL, - payload text - ); - CREATE UNIQUE INDEX replicated_tenant_id_key - ON test_schema.replicated (tenant_id); - ALTER TABLE test_schema.replicated - REPLICA IDENTITY USING INDEX replicated_tenant_id_key; - `, - }); - }), - ); - - test( - "redefine replica identity index without changing the table's replica identity setting", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - // Both sides start with the index and the REPLICA IDENTITY pointing - // at it, so table.replica_identity / replica_identity_index match - // between main and branch and table.diff sees no change. - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.replicated ( - id integer NOT NULL, - tenant_id integer NOT NULL, - payload text - ); - CREATE UNIQUE INDEX replicated_tenant_id_key - ON test_schema.replicated (tenant_id); - ALTER TABLE test_schema.replicated - REPLICA IDENTITY USING INDEX replicated_tenant_id_key; - `, - // Branch widens the index key. The index diff emits DROP + CREATE - // because the definition changed; PostgreSQL silently flips the - // table to REPLICA IDENTITY DEFAULT on the DROP, and CREATE INDEX - // alone cannot restore the marker. The post-diff pass must inject - // the table's ALTER TABLE ... REPLICA IDENTITY USING INDEX after - // the recreated index for the roundtrip to converge. - testSql: ` - DROP INDEX test_schema.replicated_tenant_id_key; - CREATE UNIQUE INDEX replicated_tenant_id_key - ON test_schema.replicated (tenant_id, id); - ALTER TABLE test_schema.replicated - REPLICA IDENTITY USING INDEX replicated_tenant_id_key; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/apply-plan.test.ts b/packages/pg-delta/tests/integration/apply-plan.test.ts deleted file mode 100644 index b5795bbe4..000000000 --- a/packages/pg-delta/tests/integration/apply-plan.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { applyPlan } from "../../src/core/plan/apply.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`applyPlan (pg${pgVersion})`, () => { - test( - "returns invalid_plan when the plan has no units", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE TABLE public.test_table (id integer)"); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - const plan = result.plan; - - plan.units = []; - - const applied = await applyPlan(plan, db.main, db.branch); - expect(applied.status).toBe("invalid_plan"); - }), - ); - - test( - "returns already_applied when source fingerprint matches target", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE TABLE public.test_table (id integer)"); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - const plan = result.plan; - - plan.target.fingerprint = plan.source.fingerprint; - - const applied = await applyPlan(plan, db.main, db.branch); - expect(applied.status).toBe("already_applied"); - }), - ); - - test( - "returns fingerprint_mismatch when source database changed", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE TABLE public.test_table (id integer)"); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - const plan = result.plan; - - await db.main.query("CREATE TABLE public.extra_table (x integer)"); - - const applied = await applyPlan(plan, db.main, db.branch); - expect(applied.status).toBe("fingerprint_mismatch"); - expect(applied).toHaveProperty("current"); - expect(applied).toHaveProperty("expected"); - }), - ); - - test( - "returns failed when SQL execution errors", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE TABLE public.test_table (id integer)"); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - const plan = result.plan; - - plan.units = [ - { - transactionMode: "transactional", - reason: "default", - statements: ["INVALID SQL SYNTAX"], - }, - ]; - - const applied = await applyPlan(plan, db.main, db.branch); - expect(applied.status).toBe("failed"); - if (applied.status !== "failed") throw new Error("expected failed"); - expect(applied).toHaveProperty("error"); - expect(applied).toHaveProperty("script"); - expect(applied.failedUnitIndex).toBe(0); - expect(applied.completedUnits).toBe(0); - }), - ); - }); - - describe(`createPlan (pg${pgVersion})`, () => { - test( - "filter DSL restricts plan to matching schema", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE SCHEMA custom_schema"); - await db.branch.query("CREATE TABLE public.pub_table (id integer)"); - await db.branch.query( - "CREATE TABLE custom_schema.priv_table (id integer)", - ); - - const result = await createPlan(db.main, db.branch, { - filter: { "*/schema": "public" }, - }); - - expect(result).not.toBeNull(); - expect(flattenPlanStatements(result!.plan)).toMatchInlineSnapshot(` - [ - "CREATE TABLE public.pub_table (id integer)", - ] - `); - }), - ); - - test( - "source null produces plan from empty catalog baseline", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE TABLE public.from_scratch (id integer)"); - - const result = await createPlan(null, db.branch); - - expect(result).not.toBeNull(); - expect(flattenPlanStatements(result!.plan)).toMatchInlineSnapshot(` - [ - "CREATE TABLE public.from_scratch (id integer)", - ] - `); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/catalog-diff.test.ts b/packages/pg-delta/tests/integration/catalog-diff.test.ts deleted file mode 100644 index de5c0cc05..000000000 --- a/packages/pg-delta/tests/integration/catalog-diff.test.ts +++ /dev/null @@ -1,1297 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`catalog diff (pg${pgVersion})`, () => { - test( - "create schema then composite type", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create type test_schema.address as ( - street varchar, - city varchar, - state varchar - ); - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - // Expect the changes to be: - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "composite_type", - scope: "object", - compositeType: expect.objectContaining({ - name: "address", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - ]), - ); - expect(changes).toHaveLength(2); - }), - ); - - test( - "create table with columns and constraints", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) unique not null, - email varchar(255) unique not null, - created_at timestamp default now() - ); - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - // Remove the two index expectations - unique constraints are handled as table constraints - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - ownedBy: expect.objectContaining({ - schema: "test_schema", - table: "users", - column: "id", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_pkey", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_username_key", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_email_key", - }), - }), - ]), - ); - expect(changes).toHaveLength(7); - }), - ); - - test( - "create view", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) not null - ); - create view test_schema.active_users as - select id, username from test_schema.users where id > 0; - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_pkey", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "view", - scope: "object", - view: expect.objectContaining({ - name: "active_users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - ownedBy: expect.objectContaining({ - schema: "test_schema", - table: "users", - column: "id", - }), - }), - ]), - ); - expect(changes).toHaveLength(6); - }), - ); - - test( - "create sequence", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create sequence test_schema.user_id_seq - start with 1000 - increment by 1 - minvalue 1000 - maxvalue 999999 - cache 1; - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "user_id_seq", - schema: "test_schema", - }), - }), - ]), - ); - expect(changes).toHaveLength(2); - }), - ); - - test( - "create enum type", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create type test_schema.user_status as enum ('active', 'inactive', 'pending'); - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "enum", - scope: "object", - enum: expect.objectContaining({ - name: "user_status", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - ]), - ); - expect(changes).toHaveLength(2); - }), - ); - - test( - "create domain", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create domain test_schema.email_address as varchar(255) - constraint email_check check (value ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$'); - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "domain", - scope: "object", - domain: expect.objectContaining({ - name: "email_address", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - ]), - ); - expect(changes).toHaveLength(2); - }), - ); - - test( - "create procedure", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create or replace procedure test_schema.create_user( - p_username varchar(50), - p_email varchar(255) - ) - language plpgsql - as $$ - begin - insert into test_schema.users (username, email) values (p_username, p_email); - end; - $$; - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "procedure", - scope: "object", - procedure: expect.objectContaining({ - name: "create_user", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - ]), - ); - expect(changes).toHaveLength(2); - }), - ); - - test( - "create materialized view", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) not null, - created_at timestamp default now() - ); - create materialized view test_schema.user_stats as - select - count(*) as total_users, - date_trunc('day', created_at) as day - from test_schema.users - group by date_trunc('day', created_at); - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "materialized_view", - scope: "object", - materializedView: expect.objectContaining({ - name: "user_stats", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_pkey", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - ownedBy: expect.objectContaining({ - schema: "test_schema", - table: "users", - column: "id", - }), - }), - ]), - ); - expect(changes).toHaveLength(6); - }), - ); - - test( - "create trigger", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) not null, - updated_at timestamp default now() - ); - create or replace function test_schema.update_updated_at() - returns trigger as $$ - begin - new.updated_at = now(); - return new; - end; - $$ language plpgsql; - - create trigger users_updated_at_trigger - before update on test_schema.users - for each row - execute function test_schema.update_updated_at(); - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "procedure", - scope: "object", - procedure: expect.objectContaining({ - name: "update_updated_at", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_pkey", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "trigger", - scope: "object", - trigger: expect.objectContaining({ - name: "users_updated_at_trigger", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - ownedBy: expect.objectContaining({ - schema: "test_schema", - table: "users", - column: "id", - }), - }), - ]), - ); - expect(changes).toHaveLength(7); - }), - ); - - test( - "create RLS policy", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) not null, - tenant_id integer not null - ); - - alter table test_schema.users enable row level security; - - create policy tenant_isolation_policy on test_schema.users - for all - using (tenant_id = current_setting('app.tenant_id')::integer); - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "rls_policy", - scope: "object", - policy: expect.objectContaining({ - name: "tenant_isolation_policy", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_pkey", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "users_id_seq", - schema: "test_schema", - }), - ownedBy: expect.objectContaining({ - schema: "test_schema", - table: "users", - column: "id", - }), - }), - ]), - ); - expect(changes).toHaveLength(7); - }), - ); - - test( - "complex scenario with multiple entity creations", - withDb(pgVersion, async (db) => { - await db.branch.query(` - create schema test_schema; - - -- Create enum - create type test_schema.user_role as enum ('admin', 'user', 'moderator'); - - -- Create domain - create domain test_schema.positive_integer as integer - constraint positive_check check (value > 0); - - -- Create sequence - create sequence test_schema.global_id_seq start 10000; - - -- Create table - create table test_schema.users ( - id test_schema.positive_integer primary key default nextval('test_schema.global_id_seq'), - username varchar(50) unique not null, - role test_schema.user_role default 'user', - created_at timestamp default now() - ); - - -- Create view - create view test_schema.admin_users as - select * from test_schema.users where role = 'admin'; - - -- Create procedure - create or replace procedure test_schema.create_admin_user( - p_username varchar(50) - ) - language plpgsql - as $$ - begin - insert into test_schema.users (username, role) values (p_username, 'admin'); - end; - $$; - `); - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "create", - objectType: "domain", - scope: "object", - domain: expect.objectContaining({ - name: "positive_integer", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "enum", - scope: "object", - enum: expect.objectContaining({ - name: "user_role", - schema: "test_schema", - }), - }), - // Remove the index expectation - unique constraints are handled as table constraints - expect.objectContaining({ - operation: "create", - objectType: "procedure", - scope: "object", - procedure: expect.objectContaining({ - name: "create_admin_user", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "global_id_seq", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_pkey", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "users_username_key", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "view", - scope: "object", - view: expect.objectContaining({ - name: "admin_users", - schema: "test_schema", - }), - }), - ]), - ); - expect(changes).toHaveLength(9); - }), - ); - - test( - "complex scenario with multiple entity drops", - withDb(pgVersion, async (db) => { - // Create entities in main database - await db.main.query(` - create schema test_schema; - - -- Create enum - create type test_schema.user_role as enum ('admin', 'user', 'moderator'); - - -- Create domain - create domain test_schema.positive_integer as integer - constraint positive_check check (value > 0); - - -- Create sequence - create sequence test_schema.global_id_seq start 10000; - - -- Create table - create table test_schema.users ( - id test_schema.positive_integer primary key default nextval('test_schema.global_id_seq'), - username varchar(50) unique not null, - role test_schema.user_role default 'user', - created_at timestamp default now() - ); - - -- Create view - create view test_schema.admin_users as - select * from test_schema.users where role = 'admin'; - - -- Create procedure - create or replace procedure test_schema.create_admin_user( - p_username varchar(50) - ) - language plpgsql - as $$ - begin - insert into test_schema.users (username, role) values (p_username, 'admin'); - end; - $$; - `); - - // Don't create any entities in branch database (they should be dropped) - await db.branch.query(` - -- Branch database is empty, all entities from main should be dropped - `); - - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "drop", - objectType: "domain", - scope: "object", - domain: expect.objectContaining({ - name: "positive_integer", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "drop", - objectType: "enum", - scope: "object", - enum: expect.objectContaining({ - name: "user_role", - schema: "test_schema", - }), - }), - - expect.objectContaining({ - operation: "drop", - objectType: "procedure", - scope: "object", - procedure: expect.objectContaining({ - name: "create_admin_user", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "drop", - objectType: "schema", - scope: "object", - schema: expect.objectContaining({ - name: "test_schema", - }), - }), - expect.objectContaining({ - operation: "drop", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "global_id_seq", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "drop", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "drop", - objectType: "view", - scope: "object", - view: expect.objectContaining({ - name: "admin_users", - schema: "test_schema", - }), - }), - ]), - ); - expect(changes).toHaveLength(7); - }), - ); - - test( - "complex scenario with multiple entity alter", - withDb(pgVersion, async (db) => { - // Create entities in main database - await db.main.query(` - create schema test_schema; - - -- Create enum with fewer values - create type test_schema.user_role as enum ('admin', 'user'); - - -- Create domain without constraint - create domain test_schema.positive_integer as integer; - - -- Create sequence with different start value - create sequence test_schema.global_id_seq start 1; - - -- Create table with fewer columns - create table test_schema.users ( - id integer primary key, - username varchar(50) not null - ); - - -- Create view with simpler definition - create view test_schema.admin_users as - select id, username from test_schema.users where id > 0; - - -- Create procedure with different body - create or replace procedure test_schema.create_admin_user( - p_username varchar(50) - ) - language plpgsql - as $$ - begin - -- Simple insert - insert into test_schema.users (username) values (p_username); - end; - $$; - `); - - // Create modified entities in branch database - await db.branch.query(` - create schema test_schema; - - -- Create enum with more values - create type test_schema.user_role as enum ('admin', 'user', 'moderator'); - - -- Create domain with constraint - create domain test_schema.positive_integer as integer - constraint positive_check check (value > 0); - - -- Create sequence with different start value - create sequence test_schema.global_id_seq start 10000; - - -- Create table with more columns - create table test_schema.users ( - id integer primary key, - username varchar(50) not null, - email varchar(255), - created_at timestamp default now() - ); - - -- Create view with more complex definition - create view test_schema.admin_users as - select id, username, email, created_at from test_schema.users where id > 0; - - -- Create procedure with different body - create or replace procedure test_schema.create_admin_user( - p_username varchar(50) - ) - language plpgsql - as $$ - begin - -- More complex insert with email - insert into test_schema.users (username, email) values (p_username, p_username || '@example.com'); - end; - $$; - `); - - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - // We expect 7 alter operations (1 for enum, 1 for domain, 1 for sequence, 2 for table columns, 1 for view, 1 for procedure) - - // Check that we have alter operations for different entity types - const alterChanges = changes.filter( - (change) => change.operation === "alter", - ); - expect(alterChanges.length).toBeGreaterThan(0); - - // Verify specific alter operations - expect(changes).toEqual([ - expect.objectContaining({ - operation: "alter", - objectType: "domain", - scope: "object", - domain: expect.objectContaining({ - name: "positive_integer", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "positive_check", - check_expression: "(VALUE > 0)", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "enum", - scope: "object", - enum: expect.objectContaining({ - name: "user_role", - schema: "test_schema", - }), - newValue: "moderator", - position: { after: "user" }, - }), - expect.objectContaining({ - operation: "create", - objectType: "procedure", - scope: "object", - orReplace: true, - procedure: expect.objectContaining({ - name: "create_admin_user", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "sequence", - scope: "object", - sequence: expect.objectContaining({ - name: "global_id_seq", - schema: "test_schema", - }), - options: ["START WITH", "10000"], - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - column: expect.objectContaining({ - name: "email", - }), - }), - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - column: expect.objectContaining({ - name: "created_at", - }), - }), - expect.objectContaining({ - operation: "drop", - objectType: "view", - scope: "object", - view: expect.objectContaining({ - name: "admin_users", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "view", - scope: "object", - view: expect.objectContaining({ - name: "admin_users", - schema: "test_schema", - }), - }), - ]); - expect(changes).toHaveLength(8); - }), - ); - - test( - "test enum modification - add new value", - withDb(pgVersion, async (db) => { - // Create initial state in main - await db.main.query(` - create schema test_schema; - create type test_schema.status as enum ('active', 'inactive'); - `); - - // Add new value in branch - await db.branch.query(` - create schema test_schema; - create type test_schema.status as enum ('active', 'inactive', 'pending'); - `); - - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual([ - expect.objectContaining({ - operation: "alter", - objectType: "enum", - scope: "object", - newValue: "pending", - position: { after: "inactive" }, - }), - ]); - expect(changes).toHaveLength(1); - }), - ); - - test( - "test domain modification - add constraint", - withDb(pgVersion, async (db) => { - // Create initial state in main - await db.main.query(` - create schema test_schema; - create domain test_schema.age as integer; - `); - - // Add constraint in branch - await db.branch.query(` - create schema test_schema; - create domain test_schema.age as integer - constraint age_check check (value >= 0 and value <= 150); - `); - - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual([ - expect.objectContaining({ - operation: "alter", - objectType: "domain", - scope: "object", - domain: expect.objectContaining({ - name: "age", - schema: "test_schema", - }), - constraint: expect.objectContaining({ - name: "age_check", - check_expression: "((VALUE >= 0) AND (VALUE <= 150))", - }), - }), - ]); - expect(changes).toHaveLength(1); - }), - ); - - test( - "test table modification - add column", - withDb(pgVersion, async (db) => { - // Create initial state in main - await db.main.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) not null - ); - `); - - // Add column in branch - await db.branch.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) not null, - email varchar(255) - ); - `); - - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual([ - expect.objectContaining({ - operation: "alter", - objectType: "table", - scope: "object", - table: expect.objectContaining({ - name: "users", - schema: "test_schema", - }), - column: expect.objectContaining({ - name: "email", - }), - }), - ]); - expect(changes).toHaveLength(1); - }), - ); - - test( - "test view modification - change definition", - withDb(pgVersion, async (db) => { - // Create initial state in main - await db.main.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) not null, - role varchar(20) default 'user' - ); - create view test_schema.user_list as - select id, username from test_schema.users; - `); - - // Change view definition in branch - await db.branch.query(` - create schema test_schema; - create table test_schema.users ( - id serial primary key, - username varchar(50) not null, - role varchar(20) default 'user' - ); - create view test_schema.user_list as - select id, username, role from test_schema.users; - `); - - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const changes = diffCatalogs(mainCatalog, branchCatalog); - - expect(changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "drop", - objectType: "view", - scope: "object", - view: expect.objectContaining({ - name: "user_list", - schema: "test_schema", - }), - }), - expect.objectContaining({ - operation: "create", - objectType: "view", - scope: "object", - view: expect.objectContaining({ - name: "user_list", - schema: "test_schema", - definition: - pgVersion === 15 - ? " SELECT users.id,\n users.username,\n users.role\n FROM test_schema.users" - : " SELECT id,\n username,\n role\n FROM test_schema.users", - }), - }), - ]), - ); - expect(changes).toHaveLength(2); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/catalog-export-filter.test.ts b/packages/pg-delta/tests/integration/catalog-export-filter.test.ts deleted file mode 100644 index 601718a72..000000000 --- a/packages/pg-delta/tests/integration/catalog-export-filter.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { filterCatalog } from "../../src/core/catalog.filter.ts"; -import { type Catalog, extractCatalog } from "../../src/core/catalog.model.ts"; -import { - deserializeCatalog, - serializeCatalog, - stringifyCatalogSnapshot, -} from "../../src/core/catalog.snapshot.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`catalog-export --filter (pg${pgVersion})`, () => { - test( - "filterCatalog keeps only objects matching the filter", - withDb(pgVersion, async (db) => { - await db.branch.query(` - CREATE SCHEMA app; - CREATE TABLE app.users (id serial PRIMARY KEY, name text NOT NULL); - CREATE TABLE app.posts (id serial PRIMARY KEY, title text NOT NULL); - CREATE SCHEMA other; - CREATE TABLE other.config (key text PRIMARY KEY); - `); - - const full = await extractCatalog(db.branch); - const scoped = await filterCatalog(full, { "*/schema": "app" }); - - expect(Object.keys(scoped.schemas).sort()).toEqual(["schema:app"]); - expect(Object.keys(scoped.tables).sort()).toEqual([ - "table:app.posts", - "table:app.users", - ]); - expect(Object.keys(scoped.tables)).not.toContain("table:other.config"); - expect(Object.keys(scoped.schemas)).not.toContain("schema:other"); - expect(Object.keys(scoped.schemas)).not.toContain("schema:public"); - }), - ); - - test( - "filterCatalog drops pg_depend edges that touch pruned objects", - withDb(pgVersion, async (db) => { - await db.branch.query(` - CREATE SCHEMA app; - CREATE TABLE app.users (id serial PRIMARY KEY); - CREATE SCHEMA other; - CREATE TABLE other.t (id serial PRIMARY KEY); - `); - - const full = await extractCatalog(db.branch); - const scoped = await filterCatalog(full, { "*/schema": "app" }); - - for (const dep of scoped.depends) { - expect(dep.dependent_stable_id).not.toContain("other"); - expect(dep.referenced_stable_id).not.toContain("other"); - } - }), - ); - - test( - "round-trip: filtered snapshot diffs to zero against live source with same filter", - withDb(pgVersion, async (db) => { - await db.branch.query(` - CREATE SCHEMA app; - CREATE TABLE app.users (id serial PRIMARY KEY, name text NOT NULL); - CREATE SCHEMA other; - CREATE TABLE other.config (key text PRIMARY KEY); - `); - - const full = await extractCatalog(db.branch); - const filter = { "*/schema": "app" }; - const scoped = await filterCatalog(full, filter); - - // Reconstruct via the snapshot serializer to prove the prune survives - // a real save→load cycle (which is what catalog-export does). - const roundTripped = deserializeCatalog( - JSON.parse(stringifyCatalogSnapshot(serializeCatalog(scoped))), - ); - - const plan = await createPlan(db.branch, roundTripped, { filter }); - expect(plan).toBeNull(); - }), - ); - - test( - "schema filter keeps schema even when its owner role is filtered out", - withDb(pgVersion, async (db) => { - // Reproduces a class of bug surfaced by Supabase images: the kept - // schema's CREATE change `requires` an owner role; if filterCatalog - // ran cascadeExclusions, the filter would drop the role change, - // cascade would propagate to the schema, and the snapshot would - // come out empty. The filter must keep the schema (and its objects) - // even when out-of-scope owners exist in the live catalog. - await db.branch.query(` - CREATE ROLE app_owner; - CREATE SCHEMA realtime AUTHORIZATION app_owner; - CREATE TABLE realtime.subscription (id serial PRIMARY KEY); - `); - - const full = await extractCatalog(db.branch); - const scoped = await filterCatalog(full, { "*/schema": "realtime" }); - - expect(Object.keys(scoped.schemas)).toContain("schema:realtime"); - expect(Object.keys(scoped.tables)).toContain( - "table:realtime.subscription", - ); - // The owner role itself is filtered out (no `role/schema` to match). - expect(Object.keys(scoped.roles)).not.toContain("role:app_owner"); - }), - ); - - test( - "round-trip matches realtime usage: schema filter survives plan", - withDb(pgVersion, async (db) => { - // Mirrors the Realtime baseline workflow: snapshot a 'kitchen sink' - // database scoped to one schema, then drift-check tenant against it - // using the same filter at plan time. - await db.branch.query(` - CREATE SCHEMA realtime; - CREATE TABLE realtime.schema_migrations ( - version bigint PRIMARY KEY, - inserted_at timestamp - ); - CREATE TABLE realtime.subscription ( - id bigserial PRIMARY KEY, - entity regclass NOT NULL, - filters jsonb DEFAULT '[]'::jsonb - ); - CREATE SCHEMA auth; - CREATE TABLE auth.users (id uuid PRIMARY KEY); - CREATE TABLE auth.sessions (id uuid PRIMARY KEY); - `); - - const full = await extractCatalog(db.branch); - const filter = { "*/schema": "realtime" }; - const scoped = await filterCatalog(full, filter); - - expect(Object.keys(scoped.schemas)).toContain("schema:realtime"); - expect(Object.keys(scoped.schemas)).not.toContain("schema:auth"); - expect(Object.keys(scoped.tables)).toContain( - "table:realtime.schema_migrations", - ); - expect(Object.keys(scoped.tables)).not.toContain("table:auth.users"); - - const snapshot = deserializeCatalog( - JSON.parse(stringifyCatalogSnapshot(serializeCatalog(scoped))), - ); - const plan = await createPlan(db.branch, snapshot, { filter }); - expect(plan).toBeNull(); - }), - ); - }); -} - -describe("catalog-export --filter (version-independent)", () => { - test("filterCatalog rejects cascade: true with an explanatory error", async () => { - await expect( - filterCatalog({} as Catalog, { "*/schema": "app", cascade: true }), - ).rejects.toThrow(/cascade: true` is not supported by catalog-export/); - }); -}); diff --git a/packages/pg-delta/tests/integration/catalog-model.test.ts b/packages/pg-delta/tests/integration/catalog-model.test.ts deleted file mode 100644 index 20516c92a..000000000 --- a/packages/pg-delta/tests/integration/catalog-model.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { POSTGRES_VERSIONS, SUPABASE_POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated, withDbSupabaseIsolated } from "../utils.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`catalog extraction (pg${pgVersion})`, () => { - test( - "extract schemas and basic tables", - withDb(pgVersion, async (db) => { - // Create schemas and tables - await db.main.query(` - CREATE SCHEMA test_schema; - CREATE SCHEMA schema_a; - CREATE SCHEMA schema_b; - CREATE TABLE test_schema.users ( - id serial PRIMARY KEY, - name text NOT NULL, - email text - ); - CREATE TABLE schema_a.table_a (id int); - CREATE TABLE schema_b.table_b (id int); - `); - - const catalog = await extractCatalog(db.main); - - // Check schemas - expect(catalog.schemas["schema:public"]).toBeDefined(); - expect(catalog.schemas["schema:test_schema"]).toBeDefined(); - expect(catalog.schemas["schema:schema_a"]).toBeDefined(); - expect(catalog.schemas["schema:schema_b"]).toBeDefined(); - - // Check tables - const usersTable = catalog.tables["table:test_schema.users"]; - const tableA = catalog.tables["table:schema_a.table_a"]; - const tableB = catalog.tables["table:schema_b.table_b"]; - - expect(usersTable).toBeDefined(); - expect(usersTable.name).toBe("users"); - expect(usersTable.schema).toBe("test_schema"); - expect(usersTable.persistence).toBe("p"); - expect(usersTable.columns).toHaveLength(3); - - expect(tableA).toBeDefined(); - expect(tableB).toBeDefined(); - - // Check column details - expect(usersTable.columns).toStrictEqual([ - expect.objectContaining({ - name: "id", - data_type_str: "integer", - not_null: true, - position: 1, - }), - expect.objectContaining({ - name: "name", - data_type_str: "text", - not_null: true, - position: 2, - }), - expect.objectContaining({ - name: "email", - data_type_str: "text", - not_null: false, - position: 3, - }), - ]); - }), - ); - - test( - "extract table structure and constraints", - withDb(pgVersion, async (db) => { - // Create tables with various types, constraints, and ordering - await db.main.query(` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.type_test ( - col_int integer, - col_bigint bigint, - col_text text, - col_varchar varchar(50), - col_boolean boolean, - col_timestamp timestamp, - col_numeric numeric(10,2), - col_uuid uuid - ); - CREATE TABLE test_schema.constrained_table ( - id serial PRIMARY KEY, - name text NOT NULL, - email text, - age integer CHECK (age > 0) - ); - CREATE TABLE test_schema.ordered_table ( - third_col text, - first_col integer, - second_col boolean - ); - `); - - const catalog = await extractCatalog(db.main); - - // Test type resolution - // biome-ignore lint/style/noNonNullAssertion: seeded data - const typeTable = catalog.tables["table:test_schema.type_test"]!; - expect(typeTable.columns).toHaveLength(8); - - const typeNames = Object.fromEntries( - typeTable.columns.map((col) => [col.name, col.data_type_str]), - ); - expect(typeNames.col_int).toBe("integer"); - expect(typeNames.col_bigint).toBe("bigint"); - expect(typeNames.col_text).toBe("text"); - expect(typeNames.col_varchar).toBe("character varying(50)"); - expect(typeNames.col_boolean).toBe("boolean"); - expect(typeNames.col_timestamp).toBe("timestamp without time zone"); - expect(typeNames.col_numeric).toBe("numeric(10,2)"); - expect(typeNames.col_uuid).toBe("uuid"); - - // Test constraints - const constrainedTable = - // biome-ignore lint/style/noNonNullAssertion: seeded data - catalog.tables["table:test_schema.constrained_table"]!; - // biome-ignore lint/style/noNonNullAssertion: seeded data - const idCol = constrainedTable.columns.find( - (col) => col.name === "id", - )!; - // biome-ignore lint/style/noNonNullAssertion: seeded data - const nameCol = constrainedTable.columns.find( - (col) => col.name === "name", - )!; - // biome-ignore lint/style/noNonNullAssertion: seeded data - const emailCol = constrainedTable.columns.find( - (col) => col.name === "email", - )!; - // biome-ignore lint/style/noNonNullAssertion: seeded data - const ageCol = constrainedTable.columns.find( - (col) => col.name === "age", - )!; - - expect(idCol.not_null).toBe(true); - expect(nameCol.not_null).toBe(true); - expect(emailCol.not_null).toBe(false); - expect(ageCol.not_null).toBe(false); - expect( - constrainedTable.constraints.map( - (constraint) => constraint.constraint_type, - ), - ).toEqual(["c", "p"]); - - // Test column ordering - // biome-ignore lint/style/noNonNullAssertion: seeded data - const orderedTable = catalog.tables["table:test_schema.ordered_table"]!; - expect(orderedTable.columns).toHaveLength(3); - expect(orderedTable.columns[0].name).toBe("third_col"); - expect(orderedTable.columns[0].position).toBe(1); - expect(orderedTable.columns[1].name).toBe("first_col"); - expect(orderedTable.columns[1].position).toBe(2); - expect(orderedTable.columns[2].name).toBe("second_col"); - expect(orderedTable.columns[2].position).toBe(3); - }), - ); - - test( - "extract view system", - withDb(pgVersion, async (db) => { - // Create views and materialized views - await db.main.query(` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users (id int, name text); - CREATE VIEW test_schema.users_view AS SELECT id, name FROM test_schema.users; - CREATE MATERIALIZED VIEW test_schema.users_mv AS SELECT id, name FROM test_schema.users; - `); - - const catalog = await extractCatalog(db.main); - - // Test regular views - expect(Object.keys(catalog.views)).toHaveLength(1); - const view = catalog.views["view:test_schema.users_view"]; - expect(view.name).toBe("users_view"); - expect(view.schema).toBe("test_schema"); - expect(view.definition).toBeDefined(); - - // Test materialized views - expect(Object.keys(catalog.materializedViews)).toHaveLength(1); - const mv = - catalog.materializedViews["materializedView:test_schema.users_mv"]; - expect(mv.name).toBe("users_mv"); - expect(mv.schema).toBe("test_schema"); - }), - ); - - test( - "extract database objects", - withDb(pgVersion, async (db) => { - // Create sequences, indexes, triggers, and procedures - await db.main.query(` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users (id int, name text); - CREATE SEQUENCE test_schema.test_seq START 1 INCREMENT 1; - CREATE INDEX users_name_idx ON test_schema.users (name); - CREATE OR REPLACE FUNCTION test_schema.log_changes() - RETURNS TRIGGER AS $$ - BEGIN - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - CREATE TRIGGER users_audit_trigger - AFTER INSERT OR UPDATE ON test_schema.users - FOR EACH ROW EXECUTE FUNCTION test_schema.log_changes(); - CREATE OR REPLACE PROCEDURE test_schema.test_proc(param1 int) - LANGUAGE plpgsql - AS $$ - BEGIN - -- procedure body - END; - $$; - `); - - const catalog = await extractCatalog(db.main); - - // Test sequences - expect(Object.keys(catalog.sequences).length).toBeGreaterThan(0); - // biome-ignore lint/style/noNonNullAssertion: seeded data - const sequence = catalog.sequences["sequence:test_schema.test_seq"]!; - expect(sequence.name).toBe("test_seq"); - expect(sequence.schema).toBe("test_schema"); - - // Test indexes - expect(Object.keys(catalog.indexes).length).toBeGreaterThan(0); - const index = - // biome-ignore lint/style/noNonNullAssertion: seeded data - catalog.indexes["index:test_schema.users.users_name_idx"]!; - expect(index.name).toBe("users_name_idx"); - expect(index.schema).toBe("test_schema"); - expect(index.table_name).toBe("users"); - - // Test triggers - expect(Object.keys(catalog.triggers)).toHaveLength(1); - const trigger = - catalog.triggers["trigger:test_schema.users.users_audit_trigger"]; - expect(trigger.name).toBe("users_audit_trigger"); - expect(trigger.schema).toBe("test_schema"); - expect(trigger.table_name).toBe("users"); - - // Test procedures - expect(Object.keys(catalog.procedures).length).toBeGreaterThan(0); - - const procedure = - // biome-ignore lint/style/noNonNullAssertion: seeded data - catalog.procedures["procedure:test_schema.test_proc(integer)"]!; - expect(procedure.name).toBe("test_proc"); - expect(procedure.schema).toBe("test_schema"); - }), - ); - - test( - "extract event triggers", - withDb(pgVersion, async (db) => { - await db.main.query(` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.log_ddl() - RETURNS event_trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RAISE NOTICE 'DDL event %', TG_TAG; - END; - $$; - CREATE EVENT TRIGGER ddl_logger - ON ddl_command_start - WHEN TAG IN ('CREATE TABLE') - EXECUTE FUNCTION test_schema.log_ddl(); - `); - - const catalog = await extractCatalog(db.main); - - expect(Object.keys(catalog.eventTriggers)).toHaveLength(1); - const eventTrigger = catalog.eventTriggers["eventTrigger:ddl_logger"]; - expect(eventTrigger).toBeDefined(); - expect(eventTrigger?.event).toBe("ddl_command_start"); - expect(eventTrigger?.function_schema).toBe("test_schema"); - expect(eventTrigger?.function_name).toBe("log_ddl"); - expect(eventTrigger?.tags).toEqual(["CREATE TABLE"]); - }), - ); - - test( - "extract advanced features", - withDb(pgVersion, async (db) => { - // Create domains, extensions, collations, and RLS policies - await db.main.query(` - CREATE SCHEMA test_schema; - CREATE DOMAIN test_schema.email_address AS varchar(255) CHECK (value LIKE '%@%'); - CREATE COLLATION test_schema.test_collation (locale = 'en_US.utf8'); - CREATE TABLE test_schema.users (id int, name text); - ALTER TABLE test_schema.users ENABLE ROW LEVEL SECURITY; - CREATE POLICY users_select_policy ON test_schema.users - FOR SELECT USING (true); - `); - - const catalog = await extractCatalog(db.main); - - // Test domains - expect(Object.keys(catalog.domains)).toHaveLength(1); - const domain = catalog.domains["domain:test_schema.email_address"]; - expect(domain.name).toBe("email_address"); - expect(domain.schema).toBe("test_schema"); - expect(domain.base_type).toBe("varchar"); - - // Test collations - expect(Object.keys(catalog.collations)).toHaveLength(1); - const collation = - catalog.collations["collation:test_schema.test_collation"]; - expect(collation.name).toBe("test_collation"); - expect(collation.schema).toBe("test_schema"); - - // Test RLS policies - expect(Object.keys(catalog.rlsPolicies)).toHaveLength(1); - const policy = - catalog.rlsPolicies[ - "rlsPolicy:test_schema.users.users_select_policy" - ]; - expect(policy.name).toBe("users_select_policy"); - expect(policy.schema).toBe("test_schema"); - expect(policy.table_name).toBe("users"); - }), - ); - - if (pgVersion === 18) { - test( - "extract temporal table constraints", - withDbIsolated(pgVersion, async (db) => { - await db.main.query(` - CREATE EXTENSION IF NOT EXISTS btree_gist; - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.bookings ( - room_id integer NOT NULL, - booking_period tstzrange NOT NULL, - CONSTRAINT bookings_pkey PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS) - ); - CREATE TABLE test_schema.booking_audit ( - room_id integer NOT NULL, - booking_period tstzrange NOT NULL, - CONSTRAINT booking_audit_fkey FOREIGN KEY (room_id, PERIOD booking_period) - REFERENCES test_schema.bookings (room_id, PERIOD booking_period) - ); - `); - - const catalog = await extractCatalog(db.main); - const bookings = - // biome-ignore lint/style/noNonNullAssertion: seeded data - catalog.tables["table:test_schema.bookings"]!; - const bookingAudit = - // biome-ignore lint/style/noNonNullAssertion: seeded data - catalog.tables["table:test_schema.booking_audit"]!; - - expect(bookings.constraints).toContainEqual( - expect.objectContaining({ - name: "bookings_pkey", - constraint_type: "p", - is_temporal: true, - key_columns: ["room_id", "booking_period"], - definition: - "PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS)", - }), - ); - expect(bookingAudit.constraints).toContainEqual( - expect.objectContaining({ - name: "booking_audit_fkey", - constraint_type: "f", - is_temporal: true, - key_columns: ["room_id", "booking_period"], - foreign_key_columns: ["room_id", "booking_period"], - definition: - "FOREIGN KEY (room_id, PERIOD booking_period) REFERENCES test_schema.bookings(room_id, PERIOD booking_period)", - }), - ); - }), - ); - } - }); -} - -for (const pgVersion of SUPABASE_POSTGRES_VERSIONS) { - describe(`catalog extraction with supabase features (pg${pgVersion})`, () => { - test( - "extract type system and dependencies", - withDbSupabaseIsolated(pgVersion, async (db) => { - // Create types and check dependencies - await db.main.query(` - CREATE SCHEMA test_schema; - CREATE TYPE test_schema.address AS ( - street varchar, - city varchar, - state varchar - ); - CREATE TYPE test_schema.status AS ENUM ('active', 'inactive', 'pending'); - CREATE TABLE test_schema.users ( - id serial PRIMARY KEY, - name text - ); - `); - - const catalog = await extractCatalog(db.main); - - // Test composite types - const compositeType = Object.values(catalog.compositeTypes).find( - (type) => type.schema === "test_schema" && type.name === "address", - ); - expect(compositeType).toBeDefined(); - expect(compositeType?.owner).toBe("supabase_admin"); - - // Test enum types - const enumType = Object.values(catalog.enums).find( - (type) => type.schema === "test_schema" && type.name === "status", - ); - expect(enumType).toBeDefined(); - expect(enumType?.labels.map((l) => l.label)).toEqual([ - "active", - "inactive", - "pending", - ]); - - // Test dependencies - expect(catalog.depends.length).toBeGreaterThan(0); - for (const dep of catalog.depends) { - expect(dep.dependent_stable_id).toBeDefined(); - expect(dep.referenced_stable_id).toBeDefined(); - expect(["n", "a", "i"]).toContain(dep.deptype); - } - }), - 60_000, - ); - - test( - "extract system objects and filtering", - withDbSupabaseIsolated(pgVersion, async (db) => { - // Test system schema filtering and role extraction - await db.main.query("CREATE TABLE public.test_table (id int)"); - - const catalog = await extractCatalog(db.main); - - // Test system schema filtering - const schemaNames = Object.keys(catalog.schemas).map( - (key) => catalog.schemas[key].name, - ); - const systemSchemas = ["information_schema", "pg_catalog", "pg_toast"]; - - for (const systemSchema of systemSchemas) { - expect(schemaNames).not.toContain(systemSchema); - } - expect(schemaNames).toContain("public"); - - // Test role extraction - expect(Object.keys(catalog.roles).length).toBeGreaterThan(0); - const adminRole = catalog.roles["role:supabase_admin"]; - if (adminRole) { - expect(adminRole.name).toBe("supabase_admin"); - } - - // Test extension extraction - const extension = catalog.extensions["extension:uuid-ossp"]; - if (extension) { - expect(extension.name).toBe("uuid-ossp"); - expect(extension.schema).toBe("extensions"); - } - }), - 60_000, - ); - }); -} diff --git a/packages/pg-delta/tests/integration/check-constraint-ordering.test.ts b/packages/pg-delta/tests/integration/check-constraint-ordering.test.ts deleted file mode 100644 index 975ade996..000000000 --- a/packages/pg-delta/tests/integration/check-constraint-ordering.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Integration tests to validate CHECK constraint ordering theory. - * - * This test validates the theory about CHECK constraints that reference - * non-existent objects (functions, types, etc.) and whether the dependency - * system properly handles the ordering. - */ - -import { describe, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`CHECK constraint ordering validation (pg${pgVersion})`, () => { - test( - "CHECK constraint referencing function created later", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create a table with CHECK constraint that references a function - -- that will be created later - CREATE TABLE test_schema.products ( - id integer PRIMARY KEY, - name text NOT NULL, - price decimal NOT NULL, - status text NOT NULL - ); - - -- Create the function that the CHECK constraint references - CREATE OR REPLACE FUNCTION test_schema.validate_price(price decimal) - RETURNS boolean AS $$ - BEGIN - RETURN price > 0 AND price < 1000000; - END; - $$ LANGUAGE plpgsql; - - CREATE OR REPLACE FUNCTION test_schema.validate_status(status text) - RETURNS boolean AS $$ - BEGIN - RETURN status IN ('active', 'inactive', 'pending', 'archived'); - END; - $$ LANGUAGE plpgsql; - - -- Add CHECK constraints that reference the functions - ALTER TABLE test_schema.products - ADD CONSTRAINT products_price_valid - CHECK (test_schema.validate_price(price)); - - ALTER TABLE test_schema.products - ADD CONSTRAINT products_status_valid - CHECK (test_schema.validate_status(status)); - `, - }); - }), - ); - - test( - "CHECK constraint referencing custom type created later", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create a table that will reference a custom type - CREATE TABLE test_schema.orders ( - id integer PRIMARY KEY, - status text NOT NULL, - priority text NOT NULL - ); - - -- Create custom types - CREATE TYPE test_schema.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled'); - CREATE TYPE test_schema.priority_level AS ENUM ('low', 'medium', 'high', 'urgent'); - - -- Add CHECK constraints that reference the custom types - ALTER TABLE test_schema.orders - ADD CONSTRAINT orders_status_valid - CHECK (status::test_schema.order_status IS NOT NULL); - - ALTER TABLE test_schema.orders - ADD CONSTRAINT orders_priority_valid - CHECK (priority::test_schema.priority_level IS NOT NULL); - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/collation-operations.test.ts b/packages/pg-delta/tests/integration/collation-operations.test.ts deleted file mode 100644 index d3dc77d35..000000000 --- a/packages/pg-delta/tests/integration/collation-operations.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Integration tests for collation create, alter, drop, and comment. - */ - -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`collation operations (pg${pgVersion})`, () => { - test( - "create collation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA coll_schema;", - testSql: dedent` - CREATE COLLATION coll_schema.c1 (LC_COLLATE = 'C', LC_CTYPE = 'C'); - `, - }); - }), - ); - - test( - "comment on collation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA coll_schema; - CREATE COLLATION coll_schema.c2 (LC_COLLATE = 'C', LC_CTYPE = 'C'); - `, - testSql: dedent` - COMMENT ON COLLATION coll_schema.c2 IS 'Test collation comment'; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/complex-dependency-ordering.test.ts b/packages/pg-delta/tests/integration/complex-dependency-ordering.test.ts deleted file mode 100644 index cf73ed742..000000000 --- a/packages/pg-delta/tests/integration/complex-dependency-ordering.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Integration tests to validate complex multi-dependency ordering theory. - * - * This test validates the theory about complex scenarios where multiple - * changes depend on multiple other changes, testing the overall dependency - * resolution system. - */ - -import { describe, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`complex dependency ordering validation (pg${pgVersion})`, () => { - test( - "complete e-commerce scenario with all dependency types", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA ecommerce; - `, - testSql: ` - -- Create roles - CREATE ROLE ecommerce_admin WITH LOGIN; - CREATE ROLE ecommerce_user WITH LOGIN; - CREATE ROLE analytics_user WITH LOGIN; - - -- Create custom types - CREATE TYPE ecommerce.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled'); - CREATE TYPE ecommerce.priority_level AS ENUM ('low', 'medium', 'high', 'urgent'); - - -- Create functions - CREATE OR REPLACE FUNCTION ecommerce.validate_amount(amount decimal) - RETURNS boolean AS $$ - BEGIN - RETURN amount > 0 AND amount < 1000000; - END; - $$ LANGUAGE plpgsql; - - CREATE OR REPLACE FUNCTION ecommerce.calculate_tax(amount decimal) - RETURNS decimal AS $$ - BEGIN - RETURN amount * 0.08; -- 8% tax - END; - $$ LANGUAGE plpgsql; - - -- Create base tables - CREATE TABLE ecommerce.customers ( - id integer PRIMARY KEY, - name text NOT NULL, - email text UNIQUE NOT NULL, - created_at timestamp DEFAULT now() - ); - - CREATE TABLE ecommerce.products ( - id integer PRIMARY KEY, - name text NOT NULL, - price decimal NOT NULL, - category_id integer, - status text NOT NULL - ); - - CREATE TABLE ecommerce.categories ( - id integer PRIMARY KEY, - name text NOT NULL, - parent_id integer - ); - - CREATE TABLE ecommerce.orders ( - id integer PRIMARY KEY, - customer_id integer NOT NULL, - order_date date NOT NULL, - status text NOT NULL, - priority text NOT NULL, - total_amount decimal NOT NULL - ); - - CREATE TABLE ecommerce.order_items ( - id integer PRIMARY KEY, - order_id integer NOT NULL, - product_id integer NOT NULL, - quantity integer NOT NULL, - unit_price decimal NOT NULL - ); - - -- Add foreign key constraints - ALTER TABLE ecommerce.products - ADD CONSTRAINT products_category_fkey - FOREIGN KEY (category_id) REFERENCES ecommerce.categories(id); - - ALTER TABLE ecommerce.categories - ADD CONSTRAINT categories_parent_fkey - FOREIGN KEY (parent_id) REFERENCES ecommerce.categories(id); - - ALTER TABLE ecommerce.orders - ADD CONSTRAINT orders_customer_fkey - FOREIGN KEY (customer_id) REFERENCES ecommerce.customers(id); - - ALTER TABLE ecommerce.order_items - ADD CONSTRAINT order_items_order_fkey - FOREIGN KEY (order_id) REFERENCES ecommerce.orders(id); - - ALTER TABLE ecommerce.order_items - ADD CONSTRAINT order_items_product_fkey - FOREIGN KEY (product_id) REFERENCES ecommerce.products(id); - - -- Add CHECK constraints - ALTER TABLE ecommerce.orders - ADD CONSTRAINT orders_status_valid - CHECK (status::ecommerce.order_status IS NOT NULL); - - ALTER TABLE ecommerce.orders - ADD CONSTRAINT orders_priority_valid - CHECK (priority::ecommerce.priority_level IS NOT NULL); - - ALTER TABLE ecommerce.orders - ADD CONSTRAINT orders_amount_valid - CHECK (ecommerce.validate_amount(total_amount)); - - ALTER TABLE ecommerce.order_items - ADD CONSTRAINT order_items_quantity_valid - CHECK (quantity > 0); - - ALTER TABLE ecommerce.order_items - ADD CONSTRAINT order_items_price_valid - CHECK (ecommerce.validate_amount(unit_price)); - - -- Create views - CREATE VIEW ecommerce.customer_orders AS - SELECT - c.id as customer_id, - c.name as customer_name, - c.email, - COUNT(o.id) as order_count, - SUM(o.total_amount) as total_spent - FROM ecommerce.customers c - LEFT JOIN ecommerce.orders o ON c.id = o.customer_id - GROUP BY c.id, c.name, c.email; - - CREATE VIEW ecommerce.product_sales AS - SELECT - p.id as product_id, - p.name as product_name, - SUM(oi.quantity) as total_sold, - SUM(oi.quantity * oi.unit_price) as total_revenue - FROM ecommerce.products p - LEFT JOIN ecommerce.order_items oi ON p.id = oi.product_id - GROUP BY p.id, p.name; - - -- Create materialized view - CREATE MATERIALIZED VIEW ecommerce.daily_sales AS - SELECT - order_date, - COUNT(*) as order_count, - SUM(total_amount) as total_revenue, - AVG(total_amount) as avg_order_value - FROM ecommerce.orders - GROUP BY order_date; - - -- Create indexes - CREATE INDEX idx_orders_customer_date ON ecommerce.orders(customer_id, order_date); - CREATE INDEX idx_orders_status ON ecommerce.orders(status); - CREATE INDEX idx_order_items_order ON ecommerce.order_items(order_id); - CREATE INDEX idx_products_category ON ecommerce.products(category_id); - CREATE INDEX idx_categories_parent ON ecommerce.categories(parent_id); - - -- Change owners - ALTER TABLE ecommerce.customers OWNER TO ecommerce_admin; - ALTER TABLE ecommerce.products OWNER TO ecommerce_admin; - ALTER TABLE ecommerce.categories OWNER TO ecommerce_admin; - ALTER TABLE ecommerce.orders OWNER TO ecommerce_user; - ALTER TABLE ecommerce.order_items OWNER TO ecommerce_user; - ALTER VIEW ecommerce.customer_orders OWNER TO analytics_user; - ALTER VIEW ecommerce.product_sales OWNER TO analytics_user; - ALTER MATERIALIZED VIEW ecommerce.daily_sales OWNER TO analytics_user; - `, - }); - }), - ); - - test( - "circular dependency scenario - should fail gracefully", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create tables that will have circular dependencies - CREATE TABLE test_schema.table_a ( - id integer PRIMARY KEY, - b_id integer - ); - - CREATE TABLE test_schema.table_b ( - id integer PRIMARY KEY, - a_id integer - ); - - -- Add foreign key constraints that create a circular dependency - ALTER TABLE test_schema.table_a - ADD CONSTRAINT table_a_b_fkey - FOREIGN KEY (b_id) REFERENCES test_schema.table_b(id); - - ALTER TABLE test_schema.table_b - ADD CONSTRAINT table_b_a_fkey - FOREIGN KEY (a_id) REFERENCES test_schema.table_a(id); - `, - }); - }), - ); - - test( - "mixed operation types with complex dependencies", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - - -- Create initial state - CREATE TABLE test_schema.base_table ( - id integer PRIMARY KEY, - name text NOT NULL - ); - - CREATE ROLE existing_role WITH LOGIN; - `, - testSql: ` - -- Create new role - CREATE ROLE new_role WITH LOGIN; - - -- Create new type - CREATE TYPE test_schema.status_enum AS ENUM ('active', 'inactive'); - - -- Create new function - CREATE OR REPLACE FUNCTION test_schema.validate_name(name text) - RETURNS boolean AS $$ - BEGIN - RETURN length(name) > 0 AND length(name) <= 100; - END; - $$ LANGUAGE plpgsql; - - -- Create new table - CREATE TABLE test_schema.new_table ( - id integer PRIMARY KEY, - base_id integer NOT NULL, - name text NOT NULL, - status text NOT NULL - ); - - -- Add foreign key constraint - ALTER TABLE test_schema.new_table - ADD CONSTRAINT new_table_base_fkey - FOREIGN KEY (base_id) REFERENCES test_schema.base_table(id); - - -- Add CHECK constraints - ALTER TABLE test_schema.new_table - ADD CONSTRAINT new_table_name_valid - CHECK (test_schema.validate_name(name)); - - ALTER TABLE test_schema.new_table - ADD CONSTRAINT new_table_status_valid - CHECK (status::test_schema.status_enum IS NOT NULL); - - -- Create view - CREATE VIEW test_schema.combined_view AS - SELECT - bt.id as base_id, - bt.name as base_name, - nt.id as new_id, - nt.name as new_name, - nt.status - FROM test_schema.base_table bt - JOIN test_schema.new_table nt ON bt.id = nt.base_id; - - -- Change owners - ALTER TABLE test_schema.base_table OWNER TO existing_role; - ALTER TABLE test_schema.new_table OWNER TO new_role; - ALTER VIEW test_schema.combined_view OWNER TO new_role; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/constraint-operations.test.ts b/packages/pg-delta/tests/integration/constraint-operations.test.ts deleted file mode 100644 index f75815bad..000000000 --- a/packages/pg-delta/tests/integration/constraint-operations.test.ts +++ /dev/null @@ -1,507 +0,0 @@ -/** - * Integration tests for PostgreSQL constraint operations. - */ - -import { describe, expect, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - // TODO: Fix constraint dependency detection issues - many complex dependencies - describe(`constraint operations (pg${pgVersion})`, () => { - test( - "add primary key constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - email character varying(255) NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); - `, - }); - }), - ); - - test( - "add unique constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - email character varying(255) NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.users ADD CONSTRAINT users_email_key UNIQUE (email); - `, - }); - }), - ); - - test( - "add check constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.products ( - id integer NOT NULL, - price numeric(10,2) NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.products ADD CONSTRAINT products_price_check CHECK (price > 0); - `, - }); - }), - ); - - test( - "add CHECK (FALSE) NO INHERIT constraint on inheritance parent", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - CREATE TABLE test_schema.parent_base ( - id uuid PRIMARY KEY, - name text NOT NULL, - CONSTRAINT no_direct_insert CHECK (FALSE) NO INHERIT - ); - `, - assertSqlStatements: (sqlStatements) => { - expect( - sqlStatements.some((stmt) => - stmt.includes( - "ADD CONSTRAINT no_direct_insert CHECK (false) NO INHERIT", - ), - ), - ).toBe(true); - }, - }); - }), - ); - - test( - "add CHECK (FALSE) NO INHERIT on parent with INHERITS child", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - CREATE TABLE test_schema.parent_base ( - id uuid PRIMARY KEY, - name text NOT NULL, - CONSTRAINT no_direct_insert CHECK (FALSE) NO INHERIT - ); - - CREATE TABLE test_schema.child ( - CONSTRAINT child_pkey PRIMARY KEY (id) - ) INHERITS (test_schema.parent_base); - `, - assertSqlStatements: (sqlStatements) => { - expect( - sqlStatements.some((stmt) => - stmt.includes( - "ADD CONSTRAINT no_direct_insert CHECK (false) NO INHERIT", - ), - ), - ).toBe(true); - expect( - sqlStatements.some((stmt) => - stmt.includes("INHERITS (test_schema.parent_base)"), - ), - ).toBe(true); - }, - }); - }), - ); - - test( - "drop primary key constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - email character varying(255) NOT NULL, - CONSTRAINT users_pkey PRIMARY KEY (id) - ); - `, - testSql: ` - ALTER TABLE test_schema.users DROP CONSTRAINT users_pkey; - `, - }); - }), - ); - - test( - "add foreign key constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - email character varying(255) NOT NULL, - CONSTRAINT users_pkey PRIMARY KEY (id) - ); - CREATE TABLE test_schema.orders ( - id integer NOT NULL, - user_id integer NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.orders ADD CONSTRAINT orders_user_id_fkey - FOREIGN KEY (user_id) REFERENCES test_schema.users (id) ON DELETE CASCADE; - `, - }); - }), - ); - - test( - "modify composite foreign key preserves referenced column order", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.parent ( - x int NOT NULL, - y int NOT NULL, - UNIQUE (y, x) - ); - CREATE TABLE test_schema.child ( - b int NOT NULL, - a int NOT NULL, - CONSTRAINT fk_child_parent - FOREIGN KEY (b, a) REFERENCES test_schema.parent (y, x) - ); - `, - testSql: ` - ALTER TABLE test_schema.child DROP CONSTRAINT fk_child_parent; - ALTER TABLE test_schema.child - ADD CONSTRAINT fk_child_parent - FOREIGN KEY (b, a) REFERENCES test_schema.parent (y, x) - ON DELETE CASCADE; - `, - }); - }), - ); - - test( - "drop unique constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - email character varying(255) NOT NULL, - CONSTRAINT users_email_key UNIQUE (email) - ); - `, - testSql: ` - ALTER TABLE test_schema.users DROP CONSTRAINT users_email_key; - `, - }); - }), - ); - - test( - "drop check constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.products ( - id integer NOT NULL, - price numeric(10,2) NOT NULL, - CONSTRAINT products_price_check CHECK (price > 0) - ); - `, - testSql: ` - ALTER TABLE test_schema.products DROP CONSTRAINT products_price_check; - `, - }); - }), - ); - - test( - "drop foreign key constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - CONSTRAINT users_pkey PRIMARY KEY (id) - ); - CREATE TABLE test_schema.orders ( - id integer NOT NULL, - user_id integer NOT NULL, - CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_schema.users (id) - ); - `, - testSql: ` - ALTER TABLE test_schema.orders DROP CONSTRAINT orders_user_id_fkey; - `, - }); - }), - ); - - test( - "add multiple constraints to same table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer NOT NULL, - email character varying(255) NOT NULL, - age integer - ); - `, - testSql: ` - ALTER TABLE test_schema.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); - ALTER TABLE test_schema.users ADD CONSTRAINT users_email_key UNIQUE (email); - ALTER TABLE test_schema.users ADD CONSTRAINT users_age_check CHECK (age >= 0); - `, - }); - }), - ); - - test( - "constraint with special characters in names", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA "my-schema"; - CREATE TABLE "my-schema"."my-table" ( - id integer NOT NULL, - "my-field" text - ); - `, - testSql: ` - ALTER TABLE "my-schema"."my-table" ADD CONSTRAINT "my-table_check$constraint" - CHECK ("my-field" IS NOT NULL); - `, - }); - }), - ); - - test( - "constraint comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.events ( - id integer PRIMARY KEY, - created_at timestamp - ); - ALTER TABLE test_schema.events ADD CONSTRAINT events_created_at_not_null CHECK (created_at IS NOT NULL); - `, - testSql: ` - COMMENT ON CONSTRAINT events_created_at_not_null ON test_schema.events IS 'created_at must be set'; - `, - }); - }), - ); - - test( - "add exclude constraint", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE EXTENSION IF NOT EXISTS btree_gist; - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.reservations ( - id integer PRIMARY KEY, - room_id integer NOT NULL, - during tstzrange NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.reservations - ADD CONSTRAINT no_overlap - EXCLUDE USING gist (room_id WITH =, during WITH &&); - `, - expectedSqlTerms: [ - "ALTER TABLE test_schema.reservations ADD CONSTRAINT no_overlap EXCLUDE USING gist (room_id WITH =, during WITH &&)", - ], - }); - }), - 120_000, - ); - - test( - "extract exclude constraint defined over an expression", - withDbIsolated(pgVersion, async (db) => { - // Regression: an EXCLUDE constraint whose key is an expression stores - // attnum=0 in pg_constraint.conkey, which never matches pg_attribute. - // The previous extractor's inner json_agg returned SQL NULL in that - // case, which tripped tablePropsSchema with "expected array, received - // null" at constraints[*].key_columns. Roundtrip must succeed. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE EXTENSION IF NOT EXISTS btree_gist; - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.expr_excl ( - a integer NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.expr_excl - ADD CONSTRAINT expr_excl_check - EXCLUDE USING gist ((a + 0) WITH =); - `, - }); - }), - 120_000, - ); - - if (pgVersion === 18) { - test( - "convert primary key to temporal primary key", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE EXTENSION IF NOT EXISTS btree_gist; - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.bookings ( - room_id integer NOT NULL, - booking_period tstzrange NOT NULL, - CONSTRAINT bookings_pkey PRIMARY KEY (room_id, booking_period) - ); - `, - testSql: ` - ALTER TABLE test_schema.bookings DROP CONSTRAINT bookings_pkey; - ALTER TABLE test_schema.bookings - ADD CONSTRAINT bookings_pkey - PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS); - `, - }); - }), - 120_000, - ); - - test( - "add temporal foreign key constraint", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE EXTENSION IF NOT EXISTS btree_gist; - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.bookings ( - room_id integer NOT NULL, - booking_period tstzrange NOT NULL, - CONSTRAINT bookings_pkey PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS) - ); - CREATE TABLE test_schema.booking_audit ( - room_id integer NOT NULL, - booking_period tstzrange NOT NULL - ); - `, - testSql: ` - ALTER TABLE test_schema.booking_audit - ADD CONSTRAINT booking_audit_room_id_booking_period_fkey - FOREIGN KEY (room_id, PERIOD booking_period) - REFERENCES test_schema.bookings (room_id, PERIOD booking_period); - `, - }); - }), - 120_000, - ); - - // Silent-downgrade scenario from #182: two related tables whose - // non-temporal PK + FK are dropped and re-added together to introduce - // WITHOUT OVERLAPS on the PK and PERIOD on the FK columns. - test( - "convert related PK and FK to temporal together", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE EXTENSION IF NOT EXISTS btree_gist; - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.contacts ( - contact_id integer NOT NULL, - valid_period tstzrange NOT NULL, - CONSTRAINT contacts_pkey PRIMARY KEY (contact_id, valid_period) - ); - CREATE TABLE test_schema.conversations ( - conversation_id integer NOT NULL, - contact_id integer NOT NULL, - valid_period tstzrange NOT NULL, - CONSTRAINT conversations_pkey PRIMARY KEY (conversation_id), - CONSTRAINT conversations_contact_fkey - FOREIGN KEY (contact_id, valid_period) - REFERENCES test_schema.contacts (contact_id, valid_period) - ); - `, - testSql: ` - ALTER TABLE test_schema.conversations DROP CONSTRAINT conversations_contact_fkey; - ALTER TABLE test_schema.contacts DROP CONSTRAINT contacts_pkey; - ALTER TABLE test_schema.contacts - ADD CONSTRAINT contacts_pkey - PRIMARY KEY (contact_id, valid_period WITHOUT OVERLAPS); - ALTER TABLE test_schema.conversations - ADD CONSTRAINT conversations_contact_fkey - FOREIGN KEY (contact_id, PERIOD valid_period) - REFERENCES test_schema.contacts (contact_id, PERIOD valid_period); - `, - }); - }), - 120_000, - ); - } - }); -} diff --git a/packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts b/packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts deleted file mode 100644 index 1f9630514..000000000 --- a/packages/pg-delta/tests/integration/dbdev-roundtrip.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Integration test: dbdev declarative schema roundtrip with Supabase integration. - * - * Reproduces two bugs in the declarative export CLI command that cause a - * roundtrip verification to fail on the real dbdev Supabase project: - * - * Bug 1 (Missing GRANT SELECT): - * The CLI connects as `postgres`, which is the same role that ran - * `ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO authenticated, anon`. - * When createPlan computes the default privilege state, it subtracts these - * defaults from the explicit GRANTs, so GRANT SELECT is never emitted. On - * re-apply, those GRANTs are missing because ALTER DEFAULT PRIVILEGES runs - * after CREATE TABLE (no explicit ordering in pg-topo). - * - * Bug 2 (Missing RLS policies with auth.uid()): - * The CLI pre-compiles the supabase filter DSL to a function before passing - * it to createPlan. When a function filter is used, cascading is enabled by - * default. The supabase filter excludes the `auth` schema, and the cascade - * logic removes all changes that depend on excluded auth objects via pg_depend. - * RLS policies with `auth.uid()` expressions have a pg_depend on auth.uid(), - * so they get cascade-excluded and never appear in the export. - */ - -import { describe, expect, test } from "bun:test"; -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import type { Pool } from "pg"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { applyDeclarativeSchema } from "../../src/core/declarative-apply/index.ts"; -import { exportDeclarativeSchema } from "../../src/core/export/index.ts"; -import { compileFilterDSL } from "../../src/core/integrations/filter/dsl.ts"; -import { compileSerializeDSL } from "../../src/core/integrations/serialize/dsl.ts"; -import { supabase as supabaseIntegration } from "../../src/core/integrations/supabase.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { createPool, endPool } from "../../src/core/postgres-config.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { - POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG, - type PostgresVersion, -} from "../constants.ts"; -import { SupabasePostgreSqlContainer } from "../supabase-postgres.js"; -import { applySupabaseBaseInit, waitForPool } from "../utils.ts"; - -const MIGRATIONS_DIR = path.join( - import.meta.dir, - "fixtures/dbdev-migrations/migrations", -); - -/** - * Load the core schema migrations that are sufficient to reproduce both bugs. - * - * We only apply the initial 20220117* migrations, which create all the - * tables, types, functions, GRANTs, and RLS policies needed. Later migrations - * are data-only inserts that reference columns that changed across Supabase image - * versions (e.g. storage.buckets.public, auth.users.email_confirmed_at) and are - * not required to demonstrate the export bugs. - */ -async function loadMigrations(): Promise<{ filename: string; sql: string }[]> { - const files = await readdir(MIGRATIONS_DIR); - // Only the foundational 20220117 schema migrations -- sufficient to reproduce - // both Bug 1 (GRANT SELECT subtraction) and Bug 2 (auth.uid() cascade). - const sqlFiles = files - .filter((f) => f.endsWith(".sql") && f.startsWith("20220117")) - .sort(); - return Promise.all( - sqlFiles.map(async (f) => ({ - filename: f, - sql: await readFile(path.join(MIGRATIONS_DIR, f), "utf-8"), - })), - ); -} - -function suppressShutdownError(err: Error & { code?: string }) { - if (err.code === "57P01" || err.code === "53100") return; - console.error("Pool error:", err); -} - -/** - * Create a pool that connects as supabase_admin but immediately sets - * the role to postgres on each connection. This makes currentUser = postgres - * in catalog extractions, matching the real production scenario where the - * CLI runs as the postgres superuser (reproduces Bug 1). - */ -function createPostgresRolePool(connectionUri: string): Pool { - return createPool(connectionUri, { - onError: suppressShutdownError, - onConnect: async (client) => { - await client.query("SET ROLE postgres"); - }, - }); -} - -// dbdev targets PG15 -- only run this test against that version. -const pgVersion: PostgresVersion = 15; - -describe(`dbdev declarative roundtrip (pg${pgVersion})`, () => { - test( - "exported schema roundtrips to 0 remaining changes with supabase integration", - async () => { - const image = `supabase/postgres:${POSTGRES_VERSION_TO_SUPABASE_POSTGRES_TAG[pgVersion]}`; - - // Start two fresh Supabase containers: - // containerMain = clean baseline (no user migrations) - // containerBranch = desired state (all dbdev migrations applied) - const [containerMain, containerBranch] = await Promise.all([ - new SupabasePostgreSqlContainer(image).start(), - new SupabasePostgreSqlContainer(image).start(), - ]); - - // Pools connect via supabase_admin but operate as postgres role so that: - // - Tables are owned by postgres (not a SUPABASE_SYSTEM_ROLE, so not filtered out) - // - ALTER DEFAULT PRIVILEGES is set for postgres - // - catalog.currentUser = postgres (triggering Bug 1 in the unfixed CLI path) - // - // Use plain `supabase_admin` pools only for the shared base-init replay: - // `applySupabaseBaseInit(...)` models the normal test/runtime bootstrap - // path before we intentionally switch into the bug-repro connection shape. - const setupMainPool = createPool(containerMain.getConnectionUri(), { - onError: suppressShutdownError, - }); - const setupBranchPool = createPool(containerBranch.getConnectionUri(), { - onError: suppressShutdownError, - }); - // These are the pools the actual roundtrip uses. Every connection issues - // `SET ROLE postgres` so `createPlan(...)` sees the same effective user as - // the real CLI path that originally triggered the regressions above. - const mainPool = createPostgresRolePool(containerMain.getConnectionUri()); - const branchPool = createPostgresRolePool( - containerBranch.getConnectionUri(), - ); - - try { - // First bring both databases up to the shared generated Supabase - // baseline. Only after that do we apply dbdev-specific migrations to the - // branch side and ask pg-delta to export the difference. - await Promise.all([ - waitForPool(setupMainPool), - waitForPool(setupBranchPool), - ]); - await Promise.all([ - applySupabaseBaseInit(setupMainPool, pgVersion), - applySupabaseBaseInit(setupBranchPool, pgVersion), - ]); - - // Now layer dbdev on top of that shared baseline: `main` stays at - // "Supabase base-init only", while `branch` becomes the desired state we - // expect declarative export/apply to reproduce. - const migrations = await loadMigrations(); - for (const { filename, sql } of migrations) { - await branchPool.query(sql).catch((err) => { - throw new Error(`Migration ${filename} failed: ${err}`, { - cause: err, - }); - }); - } - - // ── Use the fixed CLI code path ───────────────────────────────────── - // - // Pass raw DSL (not compiled functions) to createPlan and enable - // skipDefaultPrivilegeSubtraction. This matches the fixed - // declarative-export.ts behavior: - // - Raw DSL → createPlan correctly disables cascading (Bug 2 fix) - // - skipDefaultPrivilegeSubtraction → all GRANTs emitted explicitly (Bug 1 fix) - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - const compiledFilter = compileFilterDSL(supabaseIntegration.filter); - const compiledSerialize = compileSerializeDSL( - supabaseIntegration.serialize, - ); - - const planResult = await createPlan(mainPool, branchPool, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - skipDefaultPrivilegeSubtraction: true, - }); - - if (!planResult) { - throw new Error( - "createPlan returned null -- no changes detected between baseline and branch", - ); - } - - // Export from "Supabase base-init only" -> "Supabase base-init + dbdev". - // This mirrors real usage where project schemas sit on top of - // service-managed Supabase objects that already exist. - const output = exportDeclarativeSchema(planResult, { - integration: { serialize: compiledSerialize }, - }); - - // Apply the exported declarative schema files to the clean main DB. - // Disable final function body validation: functions reference auth.uid() - // and other auth schema objects that exist in Supabase but aren't created - // by the declarative export itself (they're system objects). - const applyResult = await applyDeclarativeSchema({ - content: output.files.map((f) => ({ filePath: f.path, sql: f.sql })), - pool: mainPool, - disableCheckFunctionBodies: true, - validateFunctionBodies: false, - }); - - if (applyResult.apply.status !== "success") { - const stuckSql = applyResult.apply.stuckStatements - ?.map((s) => `[${s.code}] ${s.message}\n SQL: ${s.statement.sql}`) - .join("\n"); - const errorSql = applyResult.apply.errors - ?.map((s) => `[${s.code}] ${s.message}\n SQL: ${s.statement.sql}`) - .join("\n"); - throw new Error( - `Declarative apply failed (${applyResult.apply.status}):\n${stuckSql ?? errorSql ?? "(no detail)"}`, - { cause: applyResult }, - ); - } - - // Final assertion: after applying the exported declarative schema to the - // clean baseline, the Supabase-filtered diff should be empty. - const mainCatalog = await extractCatalog(mainPool); - const branchCatalog = await extractCatalog(branchPool); - const allChanges = diffCatalogs(mainCatalog, branchCatalog); - const remainingChanges = allChanges.filter(compiledFilter); - - if (remainingChanges.length > 0) { - const sorted = sortChanges( - { mainCatalog, branchCatalog }, - remainingChanges, - ); - const remainingSql = sorted.map((c) => c.serialize()).join(";\n"); - console.error( - `[dbdev-roundtrip] ${remainingChanges.length} remaining change(s) after roundtrip:\n${remainingSql}`, - ); - } - - expect(remainingChanges).toHaveLength(0); - } finally { - await Promise.all([ - endPool(setupMainPool), - endPool(setupBranchPool), - endPool(mainPool), - endPool(branchPool), - ]); - await Promise.all([containerMain.stop(), containerBranch.stop()]); - } - }, - 5 * 60 * 1000, // 5 min -- two Supabase containers + 54 migrations - ); -}); diff --git a/packages/pg-delta/tests/integration/declarative-apply.test.ts b/packages/pg-delta/tests/integration/declarative-apply.test.ts deleted file mode 100644 index 91ad3097b..000000000 --- a/packages/pg-delta/tests/integration/declarative-apply.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Integration tests for the declarative-apply command. - * - * Exports a declarative schema from a "branch" database, writes it to temp - * SQL files, then applies it to a fresh "main" database using the round-based - * engine. Verifies the resulting schema matches the original. - */ - -import { describe, expect, test } from "bun:test"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { loadDeclarativeSchema } from "../../src/core/declarative-apply/discover-sql.ts"; -import { applyDeclarativeSchema } from "../../src/core/declarative-apply/index.ts"; -import { exportDeclarativeSchema } from "../../src/core/export/index.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; - -/** - * Helper: export declarative schema from branch DB, write to temp dir, - * then apply to main DB using loadDeclarativeSchema + applyDeclarativeSchema (content-based). - */ -async function testDeclarativeApply(options: { - mainSession: import("pg").Pool; - branchSession: import("pg").Pool; - initialSetup?: string; - testSql: string; -}) { - const { mainSession, branchSession, initialSetup, testSql } = options; - - // 1. Set up initial schema in branch (and optionally main) - const sessionConfig = ["SET LOCAL client_min_messages = error"]; - if (initialSetup) { - await mainSession.query([...sessionConfig, initialSetup].join(";\n\n")); - await branchSession.query([...sessionConfig, initialSetup].join(";\n\n")); - } - - // Execute the test SQL in branch only - await branchSession.query([...sessionConfig, testSql].join(";\n\n")); - - // 2. Export declarative schema from (main → branch) - const planResult = await createPlan(mainSession, branchSession); - if (!planResult) { - throw new Error("No changes detected - cannot test declarative apply"); - } - - const output = exportDeclarativeSchema(planResult); - - // 3. Write SQL files to a temp directory - const tempDir = path.join( - tmpdir(), - `pgdelta-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(tempDir, { recursive: true }); - - try { - for (const file of output.files) { - const filePath = path.join(tempDir, file.path); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, file.sql); - } - - // 4. Load content via discover + read (CLI-style), then apply via content-based API - const content = await loadDeclarativeSchema(tempDir); - const result = await applyDeclarativeSchema({ - content, - pool: mainSession, - maxRounds: 50, - validateFunctionBodies: true, - disableCheckFunctionBodies: true, - }); - - const { apply: applyResult } = result; - - // 5. Verify the result - expect(applyResult.status).toBe("success"); - expect(applyResult.totalApplied).toBeGreaterThan(0); - - // 6. Verify the schema matches by diffing main vs branch - const mainCatalog = await extractCatalog(mainSession); - const branchCatalog = await extractCatalog(branchSession); - const remainingChanges = diffCatalogs(mainCatalog, branchCatalog); - const sortedRemaining = sortChanges( - { mainCatalog, branchCatalog }, - remainingChanges, - ); - - // Verify no remaining differences between main and branch - expect(sortedRemaining).toHaveLength(0); - - return { - apply: applyResult, - diagnostics: result.diagnostics, - totalStatements: result.totalStatements, - }; - } finally { - await rm(tempDir, { recursive: true, force: true }); - } -} - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`declarative-apply round-based (pg${pgVersion})`, () => { - test( - "simple table with index", - withDb(pgVersion, async (db) => { - const result = await testDeclarativeApply({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text NOT NULL - ); - CREATE INDEX users_name_idx ON test_schema.users (name); - `, - }); - - // Should complete in a small number of rounds (pg-topo orders well) - expect(result.apply.totalRounds).toBeLessThanOrEqual(3); - }), - ); - - test( - "multiple schemas with cross-references", - withDb(pgVersion, async (db) => { - await testDeclarativeApply({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE SCHEMA schema_a; - CREATE SCHEMA schema_b; - CREATE TABLE schema_a.parent (id integer PRIMARY KEY); - CREATE TABLE schema_b.child ( - id integer PRIMARY KEY, - parent_id integer REFERENCES schema_a.parent(id) - ); - `, - }); - }), - ); - - test( - "views and functions", - withDb(pgVersion, async (db) => { - await testDeclarativeApply({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users (id integer, name text); - CREATE VIEW test_schema.user_names AS - SELECT name FROM test_schema.users; - CREATE FUNCTION test_schema.get_user_count() - RETURNS integer - AS $$ SELECT count(*)::integer FROM test_schema.users; $$ - LANGUAGE sql; - `, - }); - }), - ); - - test( - "complex dependency chain resolves across rounds", - withDb(pgVersion, async (db) => { - await testDeclarativeApply({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE SCHEMA app; - CREATE TYPE app.user_status AS ENUM ('active', 'inactive'); - CREATE SEQUENCE app.users_id_seq; - CREATE TABLE app.users ( - id integer DEFAULT nextval('app.users_id_seq') PRIMARY KEY, - name text NOT NULL, - status app.user_status DEFAULT 'active' - ); - ALTER SEQUENCE app.users_id_seq OWNED BY app.users.id; - CREATE TABLE app.posts ( - id integer PRIMARY KEY, - author_id integer REFERENCES app.users(id), - title text - ); - CREATE INDEX posts_author_idx ON app.posts (author_id); - CREATE VIEW app.user_posts AS - SELECT u.name, p.title - FROM app.users u - JOIN app.posts p ON p.author_id = u.id; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/declarative-schema-export.test.ts b/packages/pg-delta/tests/integration/declarative-schema-export.test.ts deleted file mode 100644 index e3c3dae22..000000000 --- a/packages/pg-delta/tests/integration/declarative-schema-export.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { testDeclarativeExport } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`declarative schema export (pg${pgVersion})`, () => { - test( - "simple table", - withDb(pgVersion, async (db) => { - await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text NOT NULL - ); - `, - }); - }), - ); - - test( - "table with index", - withDb(pgVersion, async (db) => { - const output = await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text NOT NULL - ); - CREATE INDEX users_name_idx ON test_schema.users (name); - `, - }); - - // Index should be in the same table file - const tableFile = output.files.find((file) => - file.path.includes("tables/users.sql"), - ); - expect(tableFile).toBeDefined(); - expect(tableFile?.sql).toContain("users_name_idx"); - }), - ); - - test( - "multiple schemas", - withDb(pgVersion, async (db) => { - await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE SCHEMA schema_a; - CREATE SCHEMA schema_b; - CREATE TABLE schema_a.table1 (id integer); - CREATE TABLE schema_b.table2 (id integer); - `, - }); - }), - ); - - test( - "roles and extensions", - withDb(pgVersion, async (db) => { - await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE ROLE test_role; - CREATE EXTENSION IF NOT EXISTS pg_trgm; - `, - }); - }), - ); - - test( - "views and functions", - withDb(pgVersion, async (db) => { - await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users (id integer, name text); - CREATE VIEW test_schema.user_view AS - SELECT * FROM test_schema.users; - CREATE FUNCTION test_schema.get_users() - RETURNS SETOF test_schema.users - AS $$ SELECT * FROM test_schema.users; $$ - LANGUAGE sql; - `, - }); - }), - ); - - test( - "foreign key constraints in table file", - withDb(pgVersion, async (db) => { - const output = await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users (id integer PRIMARY KEY); - CREATE TABLE test_schema.posts ( - id integer PRIMARY KEY, - user_id integer REFERENCES test_schema.users(id) - ); - `, - }); - - // FK constraints should be in the table file, not a separate foreign_keys/ dir - const tableFile = output.files.find((file) => - file.path.includes("tables/posts.sql"), - ); - expect(tableFile).toBeDefined(); - expect(tableFile?.sql).toContain("REFERENCES"); - expect(tableFile?.sql).toContain("test_schema.users"); - - // No separate foreign_keys directory - const fkFile = output.files.find((file) => - file.path.includes("foreign_keys/"), - ); - expect(fkFile).toBeUndefined(); - }), - ); - - test( - "triggers in table file", - withDb(pgVersion, async (db) => { - const output = await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users (id integer); - CREATE FUNCTION test_schema.trigger_fn() RETURNS trigger - AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql; - CREATE TRIGGER users_trigger - BEFORE INSERT ON test_schema.users - FOR EACH ROW EXECUTE FUNCTION test_schema.trigger_fn(); - `, - }); - - // Trigger should be in the table file - const tableFile = output.files.find((file) => - file.path.includes("tables/users.sql"), - ); - expect(tableFile).toBeDefined(); - expect(tableFile?.sql).toContain("CREATE TRIGGER"); - expect(tableFile?.sql).toContain("users_trigger"); - - // No separate policies directory - const policyFile = output.files.find((file) => - file.path.includes("policies/"), - ); - expect(policyFile).toBeUndefined(); - }), - ); - - test( - "RLS policies in table file", - withDb(pgVersion, async (db) => { - const output = await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users (id integer, owner_id integer); - ALTER TABLE test_schema.users ENABLE ROW LEVEL SECURITY; - CREATE POLICY user_policy ON test_schema.users - FOR SELECT USING (owner_id = current_setting('app.user_id')::integer); - `, - }); - - // RLS policy should be in the table file - const tableFile = output.files.find((file) => - file.path.includes("tables/users.sql"), - ); - expect(tableFile).toBeDefined(); - expect(tableFile?.sql).toContain("CREATE POLICY"); - expect(tableFile?.sql).toContain("user_policy"); - - // No separate policies directory - const policyFile = output.files.find((file) => - file.path.includes("policies/"), - ); - expect(policyFile).toBeUndefined(); - }), - ); - - test( - "partitioned tables", - withDb(pgVersion, async (db) => { - const output = await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.measurements ( - id integer, - date date - ) PARTITION BY RANGE (date); - CREATE TABLE test_schema.measurements_2024 - PARTITION OF test_schema.measurements - FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - `, - }); - - const parentFile = output.files.find((file) => - file.path.includes("tables/measurements.sql"), - ); - expect(parentFile).toBeDefined(); - // Partition should be grouped with the parent table - expect(parentFile?.sql).toMatchInlineSnapshot(` - "CREATE TABLE test_schema.measurements ( - id integer, - date date - ) PARTITION BY RANGE (date); - - CREATE TABLE test_schema.measurements_2024 PARTITION OF test_schema.measurements FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');" - `); - }), - ); - - test( - "materialized views with indexes", - withDb(pgVersion, async (db) => { - const output = await testDeclarativeExport({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users (id integer, name text); - CREATE MATERIALIZED VIEW test_schema.user_summary AS - SELECT * FROM test_schema.users; - CREATE INDEX user_summary_idx ON test_schema.user_summary (id); - `, - }); - - // Index on matview should be in the matview file - const viewFile = output.files.find((file) => - file.path.includes("matviews/user_summary.sql"), - ); - expect(viewFile).toBeDefined(); - expect(viewFile?.sql).toContain("user_summary_idx"); - - // No separate indexes directory - const indexFile = output.files.find((file) => - file.path.includes("indexes/"), - ); - expect(indexFile).toBeUndefined(); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/default-privileges-dependency-ordering.test.ts b/packages/pg-delta/tests/integration/default-privileges-dependency-ordering.test.ts deleted file mode 100644 index 0cb238a62..000000000 --- a/packages/pg-delta/tests/integration/default-privileges-dependency-ordering.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Integration test to verify that CREATE ROLE and CREATE SCHEMA statements - * are correctly ordered before ALTER DEFAULT PRIVILEGES statements that depend on them. - */ - -import { describe, test } from "bun:test"; -import type { Change } from "../../src/core/change.types.ts"; -import { - GrantRoleDefaultPrivileges, - RevokeRoleDefaultPrivileges, -} from "../../src/core/objects/role/changes/role.privilege.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { roundtripFidelityTest } from "../integration/roundtrip.ts"; -import { withDbIsolated } from "../utils.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`default privileges dependency ordering (pg${pgVersion})`, () => { - test( - "CREATE ROLE must come before ALTER DEFAULT PRIVILEGES FOR ROLE", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Empty initial setup - `, - testSql: ` - -- Create a new role - CREATE ROLE app_user; - - -- Set default privileges for that role - ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public - GRANT SELECT ON TABLES TO app_user; - `, - sortChangesCallback: (a, b) => { - // Force ALTER DEFAULT PRIVILEGES before CREATE ROLE to ensure dependency sorting fixes the order - const priority = (change: Change) => { - if ( - change instanceof GrantRoleDefaultPrivileges || - change instanceof RevokeRoleDefaultPrivileges - ) { - return 0; // ALTER DEFAULT PRIVILEGES first (wrong order) - } - if ( - change.objectType === "role" && - change.scope === "object" && - change.operation === "create" - ) { - return 1; // CREATE ROLE second (wrong order) - } - return 2; - }; - - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "CREATE SCHEMA must come before ALTER DEFAULT PRIVILEGES IN SCHEMA", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create a role that will be used - CREATE ROLE app_user; - `, - testSql: ` - -- Create a new schema - CREATE SCHEMA app; - - -- Set default privileges in that schema - ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA app - GRANT ALL ON TABLES TO app_user; - `, - sortChangesCallback: (a, b) => { - // Force ALTER DEFAULT PRIVILEGES before CREATE SCHEMA to ensure dependency sorting fixes the order - const priority = (change: Change) => { - if ( - change instanceof GrantRoleDefaultPrivileges || - change instanceof RevokeRoleDefaultPrivileges - ) { - return 0; // ALTER DEFAULT PRIVILEGES first (wrong order) - } - if ( - change.objectType === "schema" && - change.scope === "object" && - change.operation === "create" - ) { - return 1; // CREATE SCHEMA second (wrong order) - } - return 2; - }; - - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "CREATE ROLE and CREATE SCHEMA must come before ALTER DEFAULT PRIVILEGES", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Empty initial setup - `, - testSql: ` - -- Create a new role - CREATE ROLE app_user; - - -- Create a new schema - CREATE SCHEMA app; - - -- Set default privileges for that role in that schema - ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA app - GRANT ALL ON TABLES TO app_user; - `, - sortChangesCallback: (a, b) => { - // Force ALTER DEFAULT PRIVILEGES before CREATE ROLE and CREATE SCHEMA - // to ensure dependency sorting fixes the order - const priority = (change: Change) => { - if ( - change instanceof GrantRoleDefaultPrivileges || - change instanceof RevokeRoleDefaultPrivileges - ) { - return 0; // ALTER DEFAULT PRIVILEGES first (wrong order) - } - if ( - change.objectType === "role" && - change.scope === "object" && - change.operation === "create" - ) { - return 1; // CREATE ROLE second (wrong order) - } - if ( - change.objectType === "schema" && - change.scope === "object" && - change.operation === "create" - ) { - return 1; // CREATE SCHEMA second (wrong order) - } - return 2; - }; - - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "constraint spec ensures ALTER DEFAULT PRIVILEGES comes before CREATE TABLE even with dependencies", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create roles and schema - CREATE ROLE app_user; - CREATE SCHEMA app; - `, - testSql: ` - -- Alter default privileges - ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA app - GRANT ALL ON TABLES TO app_user; - - -- Create a table (should use the new defaults) - CREATE TABLE app.test_table ( - id integer PRIMARY KEY - ); - `, - sortChangesCallback: (a, b) => { - // Force CREATE TABLE before ALTER DEFAULT PRIVILEGES to ensure - // the constraint spec (which ensures ALTER DEFAULT PRIVILEGES comes before CREATE) - // fixes the order even when dependencies would allow CREATE first - const priority = (change: Change) => { - if ( - change.objectType === "table" && - change.scope === "object" && - change.operation === "create" - ) { - return 0; // CREATE TABLE first (wrong order - constraint spec should fix this) - } - if ( - change instanceof GrantRoleDefaultPrivileges || - change instanceof RevokeRoleDefaultPrivileges - ) { - return 1; // ALTER DEFAULT PRIVILEGES second (wrong order) - } - return 2; - }; - - return priority(a) - priority(b); - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/default-privileges-edge-case.test.ts b/packages/pg-delta/tests/integration/default-privileges-edge-case.test.ts deleted file mode 100644 index ab56da107..000000000 --- a/packages/pg-delta/tests/integration/default-privileges-edge-case.test.ts +++ /dev/null @@ -1,646 +0,0 @@ -/** - * Integration test for default privileges edge case with Supabase roles. - * - * This test covers a specific edge case where: - * 1. Default privileges are set to grant all on tables to postgres, anon, authenticated, service_role - * 2. A user creates a table and explicitly revokes access from anon role - * 3. When diffing against an empty database, the tool should account for default privileges - * and not generate grants that would conflict with the user's intent - */ - -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { roundtripFidelityTest } from "../integration/roundtrip.ts"; -import { withDbIsolated } from "../utils.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`default privileges edge case (pg${pgVersion})`, () => { - test( - "table revoke a privilege that is granted by default", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles (simulating Supabase environment) - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for all new tables in public schema - -- This simulates Supabase's default behavior - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - CREATE TABLE public.test ( - id integer PRIMARY KEY, - data text - ); - `, - testSql: ` - REVOKE ALL ON public.test FROM anon; - `, - expectedSqlTerms: ["REVOKE ALL ON public.test FROM anon"], - }); - }), - ); - - test( - "table creation with selective REVOKE on default SELECT grant converges in one pass", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE ROLE reader; - - ALTER DEFAULT PRIVILEGES IN SCHEMA test_schema - GRANT SELECT ON TABLES TO reader; - - CREATE TABLE test_schema.public_data ( - id integer PRIMARY KEY, - info text - ); - `, - testSql: ` - CREATE TABLE test_schema.secret_data ( - id integer PRIMARY KEY, - secret text - ); - - REVOKE SELECT ON test_schema.secret_data FROM reader; - `, - expectedSqlTerms: [ - "CREATE TABLE test_schema.secret_data (id integer NOT NULL, secret text)", - "ALTER TABLE test_schema.secret_data ADD CONSTRAINT secret_data_pkey PRIMARY KEY (id)", - "REVOKE SELECT ON test_schema.secret_data FROM reader", - ], - }); - }), - ); - // This test verifies that when a user creates a table and explicitly revokes - // access from the anon role, the diff tool correctly accounts for default - // privileges and doesn't generate conflicting grants. - // Expected behavior: - // - The table should be created - // - The anon role should be explicitly revoked (not just omitted) - // - The authenticated and service_role should retain their grants - // - The generated SQL should reflect the user's intent, not just the - // current privilege state - test( - "table creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles (simulating Supabase environment) - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for all new tables in public schema - -- This simulates Supabase's default behavior - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a table and explicitly revokes anon access - -- This represents the user's desired state - CREATE TABLE public.test ( - id integer PRIMARY KEY, - data text - ); - - REVOKE ALL ON public.test FROM anon; - `, - expectedSqlTerms: [ - "CREATE TABLE public.test (id integer NOT NULL, data text)", - "ALTER TABLE public.test ADD CONSTRAINT test_pkey PRIMARY KEY (id)", - "REVOKE ALL ON public.test FROM anon", - ], - }); - }), - ); - - test( - "table creation with multiple role revocations should handle default privileges correctly", - withDbIsolated(pgVersion, async (db) => { - // This test verifies that when a user creates a table and revokes access - // from multiple roles that have default privileges, the diff tool correctly - // handles the explicit revocations. - // Expected behavior: - // - The table should be created - // - Both anon and authenticated roles should be explicitly revoked - // - Only service_role should retain access (along with postgres) - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a table and revokes access from both anon and authenticated - CREATE TABLE public.restricted_table ( - id integer PRIMARY KEY, - sensitive_data text - ); - - REVOKE ALL ON public.restricted_table FROM anon; - REVOKE ALL ON public.restricted_table FROM authenticated; - `, - expectedSqlTerms: [ - "CREATE TABLE public.restricted_table (id integer NOT NULL, sensitive_data text)", - "ALTER TABLE public.restricted_table ADD CONSTRAINT restricted_table_pkey PRIMARY KEY (id)", - "REVOKE ALL ON public.restricted_table FROM anon", - "REVOKE ALL ON public.restricted_table FROM authenticated", - ], - }); - }), - ); - - test( - "table creation with selective privilege grants should override default privileges", - withDbIsolated(pgVersion, async (db) => { - // This test verifies that when a user creates a table and wants to override - // default privileges with specific grants, the diff tool correctly generates - // the explicit privilege statements. - - // Expected behavior: - // - The table should be created - // - All roles should be explicitly revoked first - // - Then specific grants should be applied - // - The generated SQL should reflect the selective privilege model - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a table and grants only specific privileges - CREATE TABLE public.selective_table ( - id integer PRIMARY KEY, - public_data text, - private_data text - ); - - -- Revoke all first, then grant only what's needed - REVOKE ALL ON public.selective_table FROM anon; - REVOKE ALL ON public.selective_table FROM authenticated; - REVOKE ALL ON public.selective_table FROM service_role; - - -- Grant only SELECT to authenticated users - GRANT SELECT ON public.selective_table TO authenticated; - - -- Grant full access to service_role - GRANT ALL ON public.selective_table TO service_role; - `, - expectedSqlTerms: [ - "CREATE TABLE public.selective_table (id integer NOT NULL, public_data text, private_data text)", - "ALTER TABLE public.selective_table ADD CONSTRAINT selective_table_pkey PRIMARY KEY (id)", - "REVOKE ALL ON public.selective_table FROM anon", - pgVersion <= 15 - ? "REVOKE DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON public.selective_table FROM authenticated" - : "REVOKE DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON public.selective_table FROM authenticated", - ], - }); - }), - ); - - test( - "default privileges edge case with schema-specific setup", - withDbIsolated(pgVersion, async (db) => { - // This test verifies that the default privileges edge case works correctly - // with custom schemas, not just the public schema. - // Expected behavior: - // - The table should be created in the app schema - // - The anon role should be explicitly revoked - // - Other roles should retain their default privileges - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Create a custom schema - CREATE SCHEMA app; - - -- Set up default privileges for the custom schema - ALTER DEFAULT PRIVILEGES IN SCHEMA app - GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a table in custom schema and revokes anon access - CREATE TABLE app.user_data ( - id integer PRIMARY KEY, - username text UNIQUE NOT NULL, - email text - ); - - REVOKE ALL ON app.user_data FROM anon; - `, - expectedSqlTerms: [ - "CREATE TABLE app.user_data (id integer NOT NULL, username text NOT NULL, email text)", - "ALTER TABLE app.user_data ADD CONSTRAINT user_data_pkey PRIMARY KEY (id)", - "ALTER TABLE app.user_data ADD CONSTRAINT user_data_username_key UNIQUE (username)", - "REVOKE ALL ON app.user_data FROM anon", - ], - }); - }), - ); - - test( - "altering default privileges ensures correct final state regardless of creation order", - withDbIsolated(pgVersion, async (db) => { - // This test verifies that when default privileges are altered, the migration script - // correctly generates SQL to reach the final desired state, regardless of the order - // operations were performed in the branch database. - // - // The migration script runs ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec), - // so all created objects use the final default privileges state. The script doesn't - // need to reproduce the exact sequence from the branch - it just needs to ensure - // the final state matches. - // - // Expected behavior: - // - ALTER DEFAULT PRIVILEGES runs before CREATE (via constraint spec) - // - Both tables are created with final defaults (no anon) - // - No REVOKE statements needed since final state already matches - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up initial default privileges - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- Create first table (gets initial defaults: ALL to anon) - CREATE TABLE public.first_table ( - id integer PRIMARY KEY, - data text - ); - - -- Alter default privileges to remove anon access - ALTER DEFAULT PRIVILEGES IN SCHEMA public - REVOKE ALL ON TABLES FROM anon; - - -- Create second table (gets final defaults: no anon) - CREATE TABLE public.second_table ( - id integer PRIMARY KEY, - data text - ); - - -- Explicitly revoke from first table to match desired final state - REVOKE ALL ON public.first_table FROM anon; - `, - expectedSqlTerms: [ - "ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon", - "CREATE TABLE public.first_table (id integer NOT NULL, data text)", - "ALTER TABLE public.first_table ADD CONSTRAINT first_table_pkey PRIMARY KEY (id)", - "CREATE TABLE public.second_table (id integer NOT NULL, data text)", - "ALTER TABLE public.second_table ADD CONSTRAINT second_table_pkey PRIMARY KEY (id)", - // Note: Since ALTER DEFAULT PRIVILEGES runs before CREATE (via constraint spec), - // both tables are created with final defaults (no anon), which matches the branch state. - // No REVOKE statements are needed because the final state is already correct. - ], - }); - }), - ); - - test( - "view creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for views - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a view and explicitly revokes anon access - CREATE VIEW public.test_view AS SELECT 1 AS id; - - REVOKE ALL ON public.test_view FROM anon; - `, - expectedSqlTerms: [ - "CREATE VIEW public.test_view AS SELECT 1 AS id", - "REVOKE ALL ON public.test_view FROM anon", - ], - }); - }), - ); - - test( - "sequence creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for sequences - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a sequence and explicitly revokes anon access - CREATE SEQUENCE public.test_seq; - - REVOKE ALL ON public.test_seq FROM anon; - `, - expectedSqlTerms: [ - "CREATE SEQUENCE public.test_seq", - "REVOKE ALL ON SEQUENCE public.test_seq FROM anon", - ], - }); - }), - ); - - test( - "materialized view creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for materialized views - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a materialized view and explicitly revokes anon access - CREATE MATERIALIZED VIEW public.test_mv AS SELECT 1 AS id; - - REVOKE ALL ON public.test_mv FROM anon; - `, - expectedSqlTerms: [ - "CREATE MATERIALIZED VIEW public.test_mv AS SELECT 1 AS id WITH DATA", - "REVOKE ALL ON public.test_mv FROM anon", - ], - }); - }), - ); - - test( - "procedure creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for functions/procedures - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; - `, - testSql: dedent` - -- User creates a procedure and explicitly revokes anon access - CREATE PROCEDURE public.test_proc() - LANGUAGE sql - AS $$ SELECT 1; $$; - - REVOKE ALL ON PROCEDURE public.test_proc() FROM anon; - `, - expectedSqlTerms: [ - "SET check_function_bodies = false", - "CREATE PROCEDURE public.test_proc()\n LANGUAGE sql\nAS $procedure$ SELECT 1; $procedure$", - "REVOKE ALL ON PROCEDURE public.test_proc() FROM anon", - ], - }); - }), - ); - - test( - "aggregate creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for functions/aggregates - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates an aggregate and explicitly revokes anon access - CREATE AGGREGATE public.test_agg(int) ( - SFUNC = int4pl, - STYPE = int - ); - - REVOKE ALL ON FUNCTION public.test_agg(int) FROM anon; - `, - expectedSqlTerms: [ - "SET check_function_bodies = false", - "CREATE AGGREGATE public.test_agg(integer) (SFUNC = int4pl, STYPE = integer)", - "REVOKE ALL ON FUNCTION public.test_agg(integer) FROM anon", - ], - }); - }), - ); - - test( - "schema creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for schemas (global, not schema-specific) - ALTER DEFAULT PRIVILEGES - GRANT ALL ON SCHEMAS TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a schema and explicitly revokes anon access - CREATE SCHEMA test_schema; - - REVOKE ALL ON SCHEMA test_schema FROM anon; - `, - expectedSqlTerms: [ - "CREATE SCHEMA test_schema AUTHORIZATION postgres", - "REVOKE ALL ON SCHEMA test_schema FROM anon", - ], - }); - }), - ); - - test( - "domain creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for types/domains - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TYPES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a domain and explicitly revokes anon access - CREATE DOMAIN public.test_domain AS integer; - - REVOKE ALL ON DOMAIN public.test_domain FROM anon; - `, - expectedSqlTerms: [ - "CREATE DOMAIN public.test_domain AS integer", - "REVOKE ALL ON DOMAIN public.test_domain FROM anon", - ], - }); - }), - ); - - test( - "enum creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for types/enums - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TYPES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates an enum and explicitly revokes anon access - CREATE TYPE public.test_enum AS ENUM ('value1', 'value2'); - - REVOKE ALL ON TYPE public.test_enum FROM anon; - `, - expectedSqlTerms: [ - "CREATE TYPE public.test_enum AS ENUM ('value1', 'value2')", - "REVOKE ALL ON TYPE public.test_enum FROM anon", - ], - }); - }), - ); - - test( - "composite type creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for types/composite types - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TYPES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a composite type and explicitly revokes anon access - CREATE TYPE public.test_composite AS ( - field1 integer, - field2 text - ); - - REVOKE ALL ON TYPE public.test_composite FROM anon; - `, - expectedSqlTerms: [ - "CREATE TYPE public.test_composite AS (field1 integer, field2 text)", - "REVOKE ALL ON TYPE public.test_composite FROM anon", - "REVOKE ALL ON TYPE public.test_composite FROM authenticated", - "REVOKE ALL ON TYPE public.test_composite FROM service_role", - ], - }); - }), - ); - - test( - "range type creation with anon role revocation should account for default privileges", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - -- Create Supabase roles - CREATE ROLE anon; - CREATE ROLE authenticated; - CREATE ROLE service_role; - - -- Set up default privileges for types/range types - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT ALL ON TYPES TO postgres, anon, authenticated, service_role; - `, - testSql: ` - -- User creates a range type and explicitly revokes anon access - CREATE TYPE public.test_range AS RANGE (SUBTYPE = int4); - - REVOKE ALL ON TYPE public.test_range FROM anon; - `, - expectedSqlTerms: [ - "CREATE TYPE public.test_range AS RANGE (SUBTYPE = integer)", - "REVOKE ALL ON TYPE public.test_range FROM anon", - ], - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/depend-extraction.test.ts b/packages/pg-delta/tests/integration/depend-extraction.test.ts deleted file mode 100644 index 9a9e52f69..000000000 --- a/packages/pg-delta/tests/integration/depend-extraction.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Integration tests that exercise extractDepends (depend.ts) by creating - * a rich schema so more dependency branches (object deps, ACLs, default - * privileges, memberships) are present when extractCatalog runs. - */ - -import { describe, expect, test } from "bun:test"; -import dedent from "dedent"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`depend extraction (pg${pgVersion})`, () => { - test( - "extractCatalog returns depends with object and privilege edges for rich schema", - withDbIsolated(pgVersion, async (db) => { - await db.branch.query(`CREATE ROLE parent_role_dep`); - await db.branch.query(dedent` - CREATE SCHEMA dep_schema; - CREATE TABLE dep_schema.tab (id int); - CREATE VIEW dep_schema.vw AS SELECT * FROM dep_schema.tab; - CREATE SEQUENCE dep_schema.seq; - CREATE MATERIALIZED VIEW dep_schema.mv AS SELECT 1 AS x; - CREATE ROLE dep_role_a; - CREATE ROLE dep_role_b; - GRANT SELECT ON dep_schema.tab TO dep_role_a; - GRANT SELECT ON dep_schema.vw TO dep_role_b; - GRANT USAGE ON SEQUENCE dep_schema.seq TO dep_role_a; - GRANT parent_role_dep TO dep_role_b; - `); - await db.branch.query( - `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA dep_schema GRANT SELECT ON TABLES TO dep_role_a`, - ); - - const catalog = await extractCatalog(db.branch); - - expect(catalog.depends).toBeDefined(); - expect(Array.isArray(catalog.depends)).toBe(true); - expect(catalog.depends.length).toBeGreaterThan(0); - - const dependentIds = new Set( - catalog.depends.map((d) => d.dependent_stable_id), - ); - const referencedIds = new Set( - catalog.depends.map((d) => d.referenced_stable_id), - ); - - expect( - dependentIds.has("view:dep_schema.vw") || - referencedIds.has("view:dep_schema.vw"), - ).toBe(true); - expect( - dependentIds.has("table:dep_schema.tab") || - referencedIds.has("table:dep_schema.tab"), - ).toBe(true); - - const aclOrDefaclOrMembership = catalog.depends.filter( - (d) => - d.dependent_stable_id.startsWith("acl:") || - d.dependent_stable_id.startsWith("aclcol:") || - d.dependent_stable_id.startsWith("defacl:") || - d.dependent_stable_id.startsWith("membership:"), - ); - expect(aclOrDefaclOrMembership.length).toBeGreaterThan(0); - }), - ); - - test( - "extractCatalog from main and branch both populate depends", - withDbIsolated(pgVersion, async (db) => { - await db.main.query(dedent` - CREATE SCHEMA s1; - CREATE TABLE s1.t1 (a int); - CREATE ROLE r1; - GRANT SELECT ON s1.t1 TO r1; - `); - await db.branch.query(dedent` - CREATE SCHEMA s1; - CREATE TABLE s1.t1 (a int); - CREATE ROLE r1; - GRANT SELECT ON s1.t1 TO r1; - `); - - const [mainCatalog, branchCatalog] = await Promise.all([ - extractCatalog(db.main), - extractCatalog(db.branch), - ]); - - expect(mainCatalog.depends.length).toBeGreaterThan(0); - expect(branchCatalog.depends.length).toBeGreaterThan(0); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/dependencies-cycles.test.ts b/packages/pg-delta/tests/integration/dependencies-cycles.test.ts deleted file mode 100644 index 5eaa751be..000000000 --- a/packages/pg-delta/tests/integration/dependencies-cycles.test.ts +++ /dev/null @@ -1,911 +0,0 @@ -/** - * Integration tests to identify and validate dependency cycles in statement sorting. - * - * This test suite focuses on identifying the specific cycles that occur when - * sorting statements, particularly the cycle between sequences owned by columns - * and tables created with columns that reference those sequences via DEFAULT. - */ - -import { describe, expect, test } from "bun:test"; -import type { PgDepend } from "../../src/core/depend.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`dependency cycles (pg${pgVersion})`, () => { - test( - "sequence owned by column cycle with table default", - withDb(pgVersion, async (db) => { - /** - * This test identifies the ONLY current cycle we have when sorting statements: - * - * CYCLE DESCRIPTION: - * - A sequence is owned by a table column (via OWNED BY) - * - A table is created with a column that uses that sequence via DEFAULT nextval(...) - * - * DEPENDENCIES CREATING THE CYCLE: - * 1. Column default (pg_attrdef) → Sequence (via pg_depend: column default depends on sequence) - * - This creates: column:test_schema.users.id → sequence:test_schema.user_id_seq - * 2. Sequence → Column/Table (via pg_depend: sequence ownership, deptype='a') - * - This creates: sequence:test_schema.user_id_seq → column:test_schema.users.id - * - OR: sequence:test_schema.user_id_seq → table:test_schema.users - * - * CYCLE PATH: - * sequence:test_schema.user_id_seq → column:test_schema.users.id → sequence:test_schema.user_id_seq - * OR - * sequence:test_schema.user_id_seq → table:test_schema.users → sequence:test_schema.user_id_seq - * - * HOW IT'S BROKEN: - * The dependency-filter.ts filters out the ownership dependency FROM the sequence - * TO the table/column it's owned by, breaking the cycle. This is safe because: - * - CREATE phase: sequences should be created before tables (ownership set via ALTER SEQUENCE OWNED BY after both exist) - * - DROP phase: prevents cycles when dropping sequences owned by tables that aren't being dropped - * - * EXPECTED ORDER (after cycle breaking): - * 1. CREATE SEQUENCE (no dependencies) - * 2. CREATE TABLE (depends on sequence via column default) - * 3. ALTER SEQUENCE OWNED BY (depends on table/column) - */ - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE SEQUENCE test_schema.user_id_seq; - - CREATE TABLE test_schema.users ( - id bigint PRIMARY KEY DEFAULT nextval('test_schema.user_id_seq') - ); - - ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; - `, - // Validate the expected order: sequence → table → alter sequence → constraint - // Note: PRIMARY KEY constraint is added as a separate ALTER TABLE statement - expectedSqlTerms: [ - "CREATE SEQUENCE test_schema.user_id_seq", - "CREATE TABLE test_schema.users (id bigint DEFAULT nextval('test_schema.user_id_seq'::regclass) NOT NULL)", - "ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id", - "ALTER TABLE test_schema.users ADD CONSTRAINT users_pkey PRIMARY KEY (id)", - ], - // Validate the dependencies that create the cycle - expectedBranchDependencies: [ - // Column default depends on sequence (creates: column → sequence) - { - dependent_stable_id: "column:test_schema.users.id", - referenced_stable_id: "sequence:test_schema.user_id_seq", - deptype: "n", // or "a" - normal or auto dependency - }, - // Sequence ownership dependency (creates: sequence → column/table, deptype='a') - // This is the dependency that gets filtered to break the cycle - { - dependent_stable_id: "sequence:test_schema.user_id_seq", - referenced_stable_id: "column:test_schema.users.id", - deptype: "a", // auto dependency for ownership - }, - ] as PgDepend[], - }); - }), - ); - - test( - "sequence owned by column cycle with ADD COLUMN SET DEFAULT", - withDb(pgVersion, async (db) => { - /** - * This test verifies that the same cycle exists when using ADD COLUMN SET DEFAULT - * on a pre-existing table instead of CREATE TABLE with DEFAULT. - * - * CYCLE DESCRIPTION: - * - A sequence is owned by a table column (via OWNED BY) - * - An existing table has a column added that uses that sequence via DEFAULT nextval(...) - * - * DEPENDENCIES CREATING THE CYCLE: - * Same as the CREATE TABLE case: - * 1. Column default (pg_attrdef) → Sequence (via pg_depend: column default depends on sequence) - * - This creates: column:test_schema.users.id → sequence:test_schema.user_id_seq - * 2. Sequence → Column/Table (via pg_depend: sequence ownership, deptype='a') - * - This creates: sequence:test_schema.user_id_seq → column:test_schema.users.id - * - * EXPECTED ORDER (after cycle breaking): - * 1. CREATE SEQUENCE (no dependencies) - * 2. ALTER TABLE ADD COLUMN SET DEFAULT (depends on sequence via column default) - * 3. ALTER SEQUENCE OWNED BY (depends on table/column) - */ - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - name text NOT NULL - ); - `, - testSql: ` - CREATE SEQUENCE test_schema.user_id_seq; - - ALTER TABLE test_schema.users - ADD COLUMN id bigint DEFAULT nextval('test_schema.user_id_seq'); - - ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; - `, - // Validate the expected order: sequence → alter table add column → alter sequence - expectedSqlTerms: [ - "CREATE SEQUENCE test_schema.user_id_seq", - "ALTER TABLE test_schema.users ADD COLUMN id bigint DEFAULT nextval('test_schema.user_id_seq'::regclass)", - "ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id", - ], - }); - }), - ); - - test( - "drop two tables with mutual FK references should not produce a cycle", - withDb(pgVersion, async (db) => { - /** - * REPRODUCTION for CycleError seen in production: - * - * CycleError: dependency graph contains a cycle involving 2 changes: - * 1. [n] DropTable - * 2. [m] DropTable - * [n] → [m] constraint:public.a.a_b_fkey → column:public.b.id - * [m] → [n] constraint:public.b.b_a_fkey → column:public.a.id - * - * Two tables each hold a FK pointing at the other; both are absent - * from branch so both must be dropped. The pg_depend graph for the FK - * constraints creates: - * constraint(A) → column(B.id) → table(B) - * constraint(B) → column(A.id) → table(A) - * So DropTable(A) requires DropTable(B) AND DropTable(B) requires - * DropTable(A) → cycle that the current cycle-breaking filter - * (CreateSequence-only) does not handle. - * - * Expected (post-fix): this is handled as a post-diff normalization - * step. Once all statements are known, the planner injects explicit - * ALTER TABLE ... DROP CONSTRAINT statements for the mutual FKs and - * rewrites each DropTable so it no longer claims those FK stable IDs. - */ - await db.main.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TABLE public.a ( - id bigserial PRIMARY KEY, - name text NOT NULL - )`, - `CREATE TABLE public.b ( - id bigserial PRIMARY KEY, - name text NOT NULL, - a_id bigint REFERENCES public.a(id) - )`, - `ALTER TABLE public.a - ADD COLUMN b_id bigint`, - `ALTER TABLE public.a - ADD CONSTRAINT a_b_fkey - FOREIGN KEY (b_id) - REFERENCES public.b(id)`, - ].join(";\n\n"), - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "drop SERIAL column on surviving table should not produce DropSequence ↔ AlterTableDropColumn cycle", - withDb(pgVersion, async (db) => { - /** - * Reproduction for the DropSequence cycle family: - * - * CycleError: dependency graph contains a cycle involving 2 changes: - * 1. [N] DropSequence - * 2. [M] - * - * [N] → [M] (source: catalog) - * sequence:. → column:.
.- * [M] → [N] (source: catalog) - * column:.
.→ sequence:. - * - * The whole-table-drop variant is already short-circuited by - * `diffSequences` (the owning-table skip). The other variant — a - * SERIAL / BIGSERIAL column being dropped while its parent table - * survives — is NOT short-circuited, so `DropSequence` is emitted - * alongside `AlterTableDropColumn`. The pg_depend graph has - * bidirectional edges: - * - sequence → column (deptype='a', OWNED BY relationship) - * - column → sequence (deptype='n', column DEFAULT nextval(...)) - * Both sides produce/consume the stable IDs in the drop phase, and - * the current cycle-breaking filter only handles `CreateSequence`, - * so the cycle is unbreakable. - * - * Expected (post-fix): this stays object-local in `diffSequences`. - * The redundant `DropSequence` is elided up front because PostgreSQL - * cascades owned sequences when the column is dropped, so there is no - * multi-statement cycle left for the post-diff or sort stages to fix. - */ - await db.main.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TABLE public.widgets ( - id SERIAL PRIMARY KEY, - label TEXT - )`, - ].join(";\n\n"), - ); - - await db.branch.query( - [ - "SET LOCAL client_min_messages = error", - // Branch keeps the table but drops the SERIAL column; PG cascades - // the owned sequence so branch catalog has neither `id` nor the - // owned sequence. Main still has both — diff must DROP the - // column and the (now orphaned) sequence in the same phase. - `CREATE TABLE public.widgets ( - label TEXT - )`, - ].join(";\n\n"), - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "replace-dependency DropTable + AlterTableDropColumn on same table should not cycle", - withDb(pgVersion, async (db) => { - /** - * Reproduction for CycleError: - * - * CycleError: dependency graph contains a cycle involving 2 changes: - * 1. [N] DropTable - * 2. [M] AlterTableDropColumn - * - * [N] → [M] (source: catalog) - * constraint:.
. → column:.
.- * [M] → [N] (source: explicit) - * column:.
.→ table:.
- * - * Key insight: both edges reference the SAME .
. That - * can only happen if a single table has both `DropTable(T)` and - * `AlterTableDropColumn(T.col)` emitted for it in the same phase, - * which `diffTables` alone never produces (tables are partitioned - * into dropped/altered). The extra `DropTable` must therefore come - * from `expandReplaceDependencies`: when an object being replaced - * (e.g. an enum that lost a label) has a dependent column on table - * T, the expander walks `pg_depend` and enqueues a - * `DropTable(T) + CreateTable(T)` pair. If `diffTables` had also - * emitted `AlterTableDropColumn(T.col)` for a separate column drop - * on T, both changes now exist on the same T in the drop phase, - * and the explicit `column → table` edge closes the cycle against - * the catalog FK edge. - * - * The MRE here: a referenced enum must be REPLACED (label removed - * → `DropEnum + CreateEnum`); the table using that enum also has - * an FK column being dropped. `expandReplaceDependencies` then - * expands the enum replacement to the table, producing the cycle. - * - * Expected (post-fix): `expandReplaceDependencies` still reports the - * dependent table replacement, but a later post-diff normalization pass - * prunes same-table `AlterTableDropColumn/DropConstraint` changes that - * are superseded by the replacement pair before sorting runs. - */ - await db.main.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TYPE public.item_status AS ENUM ('draft', 'published', 'archived')`, - `CREATE TABLE public.parents ( - id INTEGER PRIMARY KEY, - label TEXT - )`, - `CREATE TABLE public.children ( - id INTEGER PRIMARY KEY, - parent_ref INTEGER REFERENCES public.parents(id), - status public.item_status, - notes TEXT - )`, - ].join(";\n\n"), - ); - - await db.branch.query( - [ - "SET LOCAL client_min_messages = error", - // Enum lost a label → diffEnums emits DropEnum+CreateEnum, - // which expandReplaceDependencies propagates to the dependent - // table `children`, adding DropTable(children)+CreateTable. - `CREATE TYPE public.item_status AS ENUM ('draft', 'published')`, - `CREATE TABLE public.parents ( - id INTEGER PRIMARY KEY, - label TEXT - )`, - // children: parent_ref column is gone, forcing diffTables - // to emit AlterTableDropColumn(children.parent_ref) in - // parallel with the replace-dependency DropTable. - `CREATE TABLE public.children ( - id INTEGER PRIMARY KEY, - status public.item_status, - notes TEXT - )`, - ].join(";\n\n"), - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "drop three tables with N=3 FK cycle should not produce a cycle", - withDb(pgVersion, async (db) => { - /** - * Reproduction for CycleError (N=3 variant) seen in production - * (alpha.16): - * - * CycleError: dependency graph contains a cycle involving 3 changes: - * 1. [n] DropTable - * 2. [m] DropTable - * 3. [k] DropTable - * [n] → [m] (catalog) constraint:public.a.a_b_fkey → column:public.b.id - * [m] → [k] (catalog) constraint:public.b.b_c_fkey → column:public.c.id - * [k] → [n] (catalog) constraint:public.c.c_a_fkey → column:public.a.id - * - * Three tables hold FKs forming a 3-cycle (a→b→c→a); branch has none of - * them so all three must drop. The pg_depend graph for the FK - * constraints creates: - * constraint(A) → column(B.id) → table(B) - * constraint(B) → column(C.id) → table(C) - * constraint(C) → column(A.id) → table(A) - * so DropTable(A) requires DropTable(B), DropTable(B) requires - * DropTable(C), DropTable(C) requires DropTable(A) → unbreakable cycle. - * - * The existing post-diff normalization pass - * (`normalizePostDiffChanges` in `post-diff-normalization.ts`) only - * injects pre-drop `ALTER TABLE ... DROP CONSTRAINT` for *mutual* - * 2-cycles — `droppedFkTargets.get(referencedId)?.has(table.stableId)`. - * No edge in a 3-cycle is mutual, so the breaker does nothing and the - * sort phase throws. - * - * Expected (post-fix): the breaker should detect any strongly-connected - * component of `DropTable` changes connected by FK edges (any N≥2), - * inject `ALTER TABLE ... DROP CONSTRAINT` for every FK inside the - * SCC, and remove those constraint stable IDs from the paired - * `DropTable.requires`. This generalizes the existing 2-cycle handling. - */ - await db.main.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TABLE public.a ( - id bigserial PRIMARY KEY, - b_id bigint - )`, - `CREATE TABLE public.b ( - id bigserial PRIMARY KEY, - c_id bigint - )`, - `CREATE TABLE public.c ( - id bigserial PRIMARY KEY, - a_id bigint - )`, - `ALTER TABLE public.a - ADD CONSTRAINT a_b_fkey - FOREIGN KEY (b_id) - REFERENCES public.b(id)`, - `ALTER TABLE public.b - ADD CONSTRAINT b_c_fkey - FOREIGN KEY (c_id) - REFERENCES public.c(id)`, - `ALTER TABLE public.c - ADD CONSTRAINT c_a_fkey - FOREIGN KEY (a_id) - REFERENCES public.a(id)`, - ].join(";\n\n"), - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "many independent FK 2-cycles in one drop phase should all resolve", - withDb(pgVersion, async (db) => { - /** - * Regression coverage for the lazy-cycle-breaker bound. The - * sort-phase change-injection breaker is invoked once per - * unbreakable cycle; each round resolves exactly one cycle and - * restarts. With N independent unbreakable cycles in one phase - * (e.g. N pairs of mutually-FKed tables all dropped at once), - * the breaker must survive at least N+1 rounds. A fixed cap that - * is smaller than realistic big-diff plans causes spurious - * `CycleError` failures on otherwise correct migrations. - * - * Schema: 8 disjoint pairs (a0,b0) … (a7,b7) where a_i and b_i - * each FK-reference the other. All 16 tables drop, producing - * 8 independent FK 2-cycles in the drop phase. - */ - const pairCount = 8; - const setupStatements = ["SET LOCAL client_min_messages = error"]; - for (let i = 0; i < pairCount; i++) { - setupStatements.push( - `CREATE TABLE public.a_${i} (id bigserial PRIMARY KEY, b_id bigint)`, - `CREATE TABLE public.b_${i} (id bigserial PRIMARY KEY, a_id bigint)`, - `ALTER TABLE public.a_${i} ADD CONSTRAINT a_${i}_b_fkey FOREIGN KEY (b_id) REFERENCES public.b_${i}(id)`, - `ALTER TABLE public.b_${i} ADD CONSTRAINT b_${i}_a_fkey FOREIGN KEY (a_id) REFERENCES public.a_${i}(id)`, - ); - } - await db.main.query(setupStatements.join(";\n\n")); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "drop publication-listed column should not produce AlterPublicationDropTables ↔ AlterTableDropColumn cycle", - withDb(pgVersion, async (db) => { - /** - * Reproduction for CycleError seen in production (alpha.16): - * - * CycleError: dependency graph contains a cycle involving 2 changes: - * 1. [0] AlterPublicationDropTables - * 2. [N] AlterTableDropColumn - * [0] → [N] (catalog) - * publication:public. → column:public.
.- * [N] → [0] (explicit) - * column:public.
.→ table:public.
- * - * When a publication is created with an explicit column list and the - * column is later dropped on branch, `diffPublications` emits - * `AlterPublicationDropTables` (the membership column-set diverges → - * `deepEqual({ columns, row_filter })` is false at - * `publication.diff.ts:216`) and `diffTables` emits - * `AlterTableDropColumn` for the same column. The publication's - * pg_depend includes a 'normal' dependency on the listed column, - * giving the catalog edge `publication → column`. The synthesized - * explicit edge `column → table` (from the column-drop's `requires` - * including the parent table) closes the cycle. - * - * Expected (post-fix): root-cause investigation per the issue draft - * — audit whether the explicit `column → table` edge belongs on - * `AlterTableDropColumn`. If the edge is justified, the post-diff - * normalizer should recognize that `AlterPublicationDropTables` can - * always run before any column or table drop and drop that - * publication→column edge in the drop-phase graph. - */ - await db.main.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TABLE public.lab_results ( - id bigint PRIMARY KEY, - flash_summary text - )`, - `CREATE PUBLICATION cycle_repro_realtime - FOR TABLE public.lab_results (id, flash_summary)`, - ].join(";\n\n"), - ); - - await db.branch.query( - [ - "SET LOCAL client_min_messages = error", - // Branch keeps the table and the publication, but the column is - // gone — so the publication's column list shrinks to (id) only. - `CREATE TABLE public.lab_results ( - id bigint PRIMARY KEY - )`, - `CREATE PUBLICATION cycle_repro_realtime - FOR TABLE public.lab_results (id)`, - ].join(";\n\n"), - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "drop publication FK-chain tables and referenced constraint should not produce a cycle", - withDb(pgVersion, async (db) => { - /** - * Reproduction for production CycleError: - * - * CycleError: dependency graph contains a cycle involving 4 changes: - * 1. AlterPublicationDropTables - * 2. DropTable(post_attachments) - * 3. DropTable(posts) - * 4. AlterTableDropConstraint(labs.unique_lab_id) - * - * Cycle path: - * publication:supabase_realtime → table:public.post_attachments - * post_attachments.post_id_fkey → column:public.posts.id - * posts.posts_lab_id_fkey → constraint:public.labs.unique_lab_id - * constraint:public.labs.unique_lab_id → table:public.labs - * - * Expected: sort-phase change injection inserts explicit FK drops for - * the dropped-table FK chain, then the surviving table's unique - * constraint can drop without cycling through the publication change. - */ - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.labs ( - id bigint PRIMARY KEY, - lab_id bigint NOT NULL, - CONSTRAINT unique_lab_id UNIQUE (lab_id) - ); - - CREATE TABLE public.posts ( - id bigint PRIMARY KEY, - lab_id bigint NOT NULL, - CONSTRAINT posts_lab_id_fkey - FOREIGN KEY (lab_id) - REFERENCES public.labs(lab_id) - ); - - CREATE TABLE public.post_attachments ( - id bigint PRIMARY KEY, - post_id bigint NOT NULL, - CONSTRAINT post_attachments_post_id_fkey - FOREIGN KEY (post_id) - REFERENCES public.posts(id) - ); - - CREATE PUBLICATION supabase_realtime - FOR TABLE public.labs, public.posts, public.post_attachments; - `, - testSql: ` - ALTER PUBLICATION supabase_realtime - DROP TABLE public.post_attachments, public.posts, public.labs; - - DROP TABLE public.post_attachments; - DROP TABLE public.posts; - - ALTER TABLE public.labs - DROP CONSTRAINT unique_lab_id; - `, - assertSqlStatements: (statements) => { - expect(statements).toMatchInlineSnapshot(` - [ - "ALTER TABLE public.post_attachments DROP CONSTRAINT post_attachments_post_id_fkey", - "ALTER TABLE public.posts DROP CONSTRAINT posts_lab_id_fkey", - "ALTER TABLE public.labs DROP CONSTRAINT unique_lab_id", - "ALTER PUBLICATION supabase_realtime DROP TABLE public.labs, public.post_attachments, public.posts", - "DROP TABLE public.post_attachments", - "DROP TABLE public.posts", - ] - `); - }, - }); - }), - ); - - test( - "drop publication FK-chain tables with partial publication membership should not produce a cycle", - withDb(pgVersion, async (db) => { - /** - * Reproduction for production CycleError (Sentry SUPABASE-API-7RS, - * CLI-1605, alpha.24): - * - * CycleError: dependency graph contains a cycle involving 4 changes: - * 1. AlterPublicationDropTables (supabase_realtime) - * 2. DropTable(public_offering_events) - * 3. DropTable(trade_status_events) - * 4. AlterTableDropConstraint(trades.trades_trade_id_key) - * - * Cycle path: - * publication:supabase_realtime → table:public.public_offering_events - * public_offering_events_source_event_id_fkey → column:public.trade_status_events.id - * trade_status_events_trade_id_fkey → constraint:public.trades.trades_trade_id_key - * constraint:public.trades.trades_trade_id_key → table:public.trades - * - * Same shape as the previous test, with one twist: the intermediate - * FK-chain table (trade_status_events) is NOT a member of the - * publication — supabase_realtime commonly contains only a subset - * of tables. The Branch C cycle breaker used to require every - * dropped table in the cycle to be a publication member and bailed, - * surfacing the raw CycleError to the user. - */ - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.trades ( - id bigint PRIMARY KEY, - trade_id bigint NOT NULL, - CONSTRAINT trades_trade_id_key UNIQUE (trade_id) - ); - - CREATE TABLE public.trade_status_events ( - id bigint PRIMARY KEY, - trade_id bigint NOT NULL, - CONSTRAINT trade_status_events_trade_id_fkey - FOREIGN KEY (trade_id) - REFERENCES public.trades(trade_id) - ); - - CREATE TABLE public.public_offering_events ( - id bigint PRIMARY KEY, - source_event_id bigint NOT NULL, - CONSTRAINT public_offering_events_source_event_id_fkey - FOREIGN KEY (source_event_id) - REFERENCES public.trade_status_events(id) - ); - - -- trade_status_events is deliberately NOT in the publication. - CREATE PUBLICATION supabase_realtime - FOR TABLE public.trades, public.public_offering_events; - `, - testSql: ` - ALTER PUBLICATION supabase_realtime - DROP TABLE public.public_offering_events, public.trades; - - DROP TABLE public.public_offering_events; - DROP TABLE public.trade_status_events; - - ALTER TABLE public.trades - DROP CONSTRAINT trades_trade_id_key; - `, - assertSqlStatements: (statements) => { - expect(statements).toMatchInlineSnapshot(` - [ - "ALTER TABLE public.public_offering_events DROP CONSTRAINT public_offering_events_source_event_id_fkey", - "ALTER TABLE public.trade_status_events DROP CONSTRAINT trade_status_events_trade_id_fkey", - "DROP TABLE public.trade_status_events", - "ALTER TABLE public.trades DROP CONSTRAINT trades_trade_id_key", - "ALTER PUBLICATION supabase_realtime DROP TABLE public.public_offering_events, public.trades", - "DROP TABLE public.public_offering_events", - ] - `); - }, - }); - }), - ); - - test( - "alter sequence data_type while owning column survives should not produce DropSequence cycle", - withDb(pgVersion, async (db) => { - /** - * Reproduction for the production CycleError observed in - * @supabase/pg-delta@1.0.0-alpha.16 (Sentry SUPABASE-API-7RS). - * - * Real-world diff (anonymised pg_dump): - * origin: CREATE SEQUENCE seq AS integer - * CREATE TABLE bar (id integer DEFAULT nextval('seq'::regclass) NOT NULL) - * ALTER SEQUENCE seq OWNED BY bar.id - * branch: CREATE SEQUENCE seq -- default bigint, no AS - * CREATE TABLE bar (id integer DEFAULT nextval('seq'::regclass) NOT NULL) - * -- NO OWNED BY - * - * Sequence.data_type is in dataFields(), so the sequence shows up - * in the `altered` set of diffSequences. NON_ALTERABLE_FIELDS at - * sequence.diff.ts:153-156 includes "data_type", which makes - * hasNonAlterableChanges() return true, and the replace branch at - * sequence.diff.ts:163-214 emits `DropSequence + CreateSequence` - * unconditionally. expandReplaceDependencies then promotes the - * surviving table (which has DEFAULT nextval(seq) referencing - * the sequence's stableId) to a `DropTable + CreateTable` pair, - * and the bidirectional pg_depend edges between the sequence and - * the column close a 2-cycle in the drop phase that no breaker - * can resolve: - * - * CycleError: dependency graph contains a cycle involving 2 changes: - * 1. [N] DropSequence(public.addons_addon_id_seq) - * 2. [M] DropTable(public.addons) - * - * NB: the existing alpha.15 short-circuit at sequence.diff.ts:117-145 - * only guards the `dropped` loop (the owning table or column being - * dropped). It does not run on this `altered` path. - * - * Expected (post-fix): data_type is alterable in PG10+ via - * `ALTER SEQUENCE foo AS bigint`, so the diff should emit a - * single `AlterSequenceSetOptions` carrying the AS clause plus an - * `AlterSequenceSetOwnedBy(null)`. No DropSequence is emitted, no - * cycle is produced, and the sequence's last_value is preserved - * (the current Drop+Create silently resets it to the START WITH - * value, which is a separate data-loss bug). - */ - await db.main.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TABLE public.addons ( - addon_id integer NOT NULL, - label text NOT NULL - )`, - `CREATE SEQUENCE public.addons_addon_id_seq AS integer`, - `ALTER TABLE public.addons - ALTER COLUMN addon_id - SET DEFAULT nextval('public.addons_addon_id_seq'::regclass)`, - `ALTER SEQUENCE public.addons_addon_id_seq - OWNED BY public.addons.addon_id`, - ].join(";\n\n"), - ); - - await db.branch.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TABLE public.addons ( - addon_id integer NOT NULL, - label text NOT NULL - )`, - // Default sequence type is bigint; no OWNED BY clause. - `CREATE SEQUENCE public.addons_addon_id_seq`, - `ALTER TABLE public.addons - ALTER COLUMN addon_id - SET DEFAULT nextval('public.addons_addon_id_seq'::regclass)`, - ].join(";\n\n"), - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "drop SERIAL sequence on table replaced via dependent enum should not produce DropSequence ↔ DropTable cycle", - withDb(pgVersion, async (db) => { - /** - * Reproduction for the production CycleError observed in - * @supabase/pg-delta@1.0.0-alpha.22 (see Sentry pattern referenced in - * the upstream regression report; the production cycle was on - * `sequence:public.project_link_type_id_seq ↔ column:public.project_link_type.id`). - * - * Real-world shape: - * - * CycleError: dependency graph contains a cycle involving 2 changes: - * 1. [N] DropSequence(
_id_seq) - * 2. [M] DropTable(
) - * Cycle path: - * [N] -> [M] (catalog) sequence:
_id_seq -> column:
.id - * [M] -> [N] (catalog) column:
.id -> sequence:
_id_seq - * - * The alpha.15 short-circuit in `diffSequences.dropped` only suppresses - * `DropSequence` when the OWNED BY table is itself absent from the - * branch catalog. In this scenario the branch DOES contain the table - * (`status` keeps the same enum_E reference, `id` survives as plain - * `integer` without a default), so the short-circuit does not fire and - * `DropSequence` is emitted. - * - * Independently, `expandReplaceDependencies` walks dependents of the - * replaced enum_E (label removed → DropEnum + CreateEnum), reaches - * `column:T.status`, normalizes to `table:T`, and promotes the table - * to a `DropTable + CreateTable` pair. The drop phase now contains - * both `DropSequence(T_id_seq)` and `DropTable(T)`, which the - * bidirectional pg_depend edges between sequence and column close - * into an unbreakable 2-cycle: - * - * sequence -> column (deptype 'a', OWNED BY) - * column -> sequence (deptype 'n', column DEFAULT nextval(...)) - * - * `dependency-filter.ts` only filters this edge pair when a - * `CreateSequence` change is present (CREATE phase); none of the - * sort-phase change-injection breakers in `cycle-breakers.ts` match - * `DropSequence ↔ DropTable`. - */ - await db.main.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TYPE public.project_link_type_kind AS ENUM ('a', 'b', 'c')`, - `CREATE TABLE public.project_link_type ( - id SERIAL PRIMARY KEY, - kind public.project_link_type_kind - )`, - ].join(";\n\n"), - ); - - await db.branch.query( - [ - "SET LOCAL client_min_messages = error", - // Enum lost a label → DropEnum+CreateEnum, which - // expandReplaceDependencies propagates to the dependent table - // `project_link_type`, adding DropTable+CreateTable. The table - // itself stays in the branch catalog, so diffSequences sees the - // OWNED BY sequence's owning table+column as still present and - // emits an explicit DropSequence — closing the cycle. - `CREATE TYPE public.project_link_type_kind AS ENUM ('a', 'b')`, - // `id` column survives but loses SERIAL: no nextval default and - // no owned sequence in branch. The sequence appears in the - // `dropped` set in diffSequences. - `CREATE TABLE public.project_link_type ( - id integer PRIMARY KEY, - kind public.project_link_type_kind - )`, - ].join(";\n\n"), - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "drop table that owns a SERIAL sequence should not produce DropSequence ↔ DropTable cycle", - withDb(pgVersion, async (db) => { - /** - * Regression coverage for the alpha.15 short-circuit at - * `sequence.diff.ts:117-145` (the `dropped` loop): when the owning - * table is absent from `branchTables`, `diffSequences` skips - * emitting `DropSequence` because PostgreSQL cascades owned - * sequences when the table drops. - * - * SCOPE — this test only exercises the `dropped`-loop path. It does - * NOT cover the `altered`-loop replace branch, which is where the - * production CycleError under @supabase/pg-delta@1.0.0-alpha.16 - * (Sentry SUPABASE-API-7RS) actually originated: a sequence whose - * `data_type` changes (integer → bigint) while the owning table - * and column survive. That scenario is exercised by the sibling - * test "alter sequence data_type while owning column survives - * should not produce DropSequence cycle" above and fixed by - * making `data_type` alterable via `ALTER SEQUENCE ... AS `. - */ - await db.main.query( - [ - "SET LOCAL client_min_messages = error", - `CREATE TABLE public.addons ( - addon_id SERIAL PRIMARY KEY, - label TEXT - )`, - ].join(";\n\n"), - ); - - // Branch is empty → table (and its owned sequence) must drop. - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - - test( - "sequence owned by column cycle - multiple sequences", - withDb(pgVersion, async (db) => { - /** - * Test multiple sequences with the same cycle pattern to ensure - * the cycle-breaking logic works consistently across multiple objects. - * - * This test verifies that the cycle-breaking filter works correctly - * even when there are multiple independent cycles in the same migration. - * The exact order of independent sequences/tables may vary, but the - * important thing is that cycles are broken and the migration succeeds. - */ - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE SEQUENCE test_schema.order_id_seq; - CREATE SEQUENCE test_schema.item_id_seq; - - CREATE TABLE test_schema.orders ( - id bigint PRIMARY KEY DEFAULT nextval('test_schema.order_id_seq') - ); - - CREATE TABLE test_schema.items ( - id bigint PRIMARY KEY DEFAULT nextval('test_schema.item_id_seq') - ); - - ALTER SEQUENCE test_schema.order_id_seq OWNED BY test_schema.orders.id; - ALTER SEQUENCE test_schema.item_id_seq OWNED BY test_schema.items.id; - `, - // No strict ordering check - independent sequences/tables can be in any order - // The important thing is that cycles are broken and migration succeeds - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/empty-catalog-export.test.ts b/packages/pg-delta/tests/integration/empty-catalog-export.test.ts deleted file mode 100644 index 510478a07..000000000 --- a/packages/pg-delta/tests/integration/empty-catalog-export.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`empty catalog export (pg${pgVersion})`, () => { - test( - "single-database export produces CREATE statements for all objects", - withDb(pgVersion, async (db) => { - await db.branch.query(` - CREATE SCHEMA app; - CREATE TABLE app.users ( - id serial PRIMARY KEY, - name text NOT NULL - ); - CREATE TABLE app.posts ( - id serial PRIMARY KEY, - user_id int REFERENCES app.users(id), - title text NOT NULL - ); - `); - - // Pool input → falls back to createEmptyCatalog (static baseline) - const result = await createPlan(null, db.branch); - expect(result).not.toBeNull(); - if (result === null) throw new Error("unreachable"); - - const statementsText = flattenPlanStatements(result.plan).join("\n"); - expect(statementsText).toContain("CREATE SCHEMA app"); - expect(statementsText).toContain("CREATE TABLE app.users"); - expect(statementsText).toContain("CREATE TABLE app.posts"); - - const createOps = result.sortedChanges.filter( - (c) => c.operation === "create", - ); - expect(createOps.length).toBeGreaterThan(0); - - const dropOps = result.sortedChanges.filter( - (c) => c.operation === "drop", - ); - expect(dropOps).toHaveLength(0); - }), - ); - - test( - "single-database export does not emit CREATE SCHEMA public", - withDb(pgVersion, async (db) => { - await db.branch.query(` - CREATE TABLE public.items (id serial PRIMARY KEY); - `); - - // Pool input → falls back to createEmptyCatalog (has public pre-populated) - const result = await createPlan(null, db.branch); - expect(result).not.toBeNull(); - if (result === null) throw new Error("unreachable"); - - const createSchemaPublic = flattenPlanStatements(result.plan).filter( - (s) => /CREATE SCHEMA.*public/i.test(s), - ); - expect(createSchemaPublic).toHaveLength(0); - }), - ); - - test( - "single-database export captures all user-created objects (Pool fallback)", - withDbIsolated(pgVersion, async (db) => { - await db.branch.query(` - CREATE SCHEMA app; - CREATE TABLE app.config (key text PRIMARY KEY, value text); - `); - - // Pool inputs use the createEmptyCatalog fallback which is a static - // approximation. Exact statement equality with the two-database approach - // requires string URL inputs (which use template1 on the same server). - const singleDbResult = await createPlan(null, db.branch); - const twoDbResult = await createPlan(db.main, db.branch); - - expect(singleDbResult).not.toBeNull(); - expect(twoDbResult).not.toBeNull(); - if (singleDbResult === null || twoDbResult === null) - throw new Error("unreachable"); - - // Target fingerprint must match (same target catalog) - expect(singleDbResult.plan.target.fingerprint).toBe( - twoDbResult.plan.target.fingerprint, - ); - expect(flattenPlanStatements(twoDbResult.plan)).toEqual( - flattenPlanStatements(singleDbResult.plan), - ); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/event-trigger-operations.test.ts b/packages/pg-delta/tests/integration/event-trigger-operations.test.ts deleted file mode 100644 index 33dec43da..000000000 --- a/packages/pg-delta/tests/integration/event-trigger-operations.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Integration tests for PostgreSQL event trigger operations. - */ - -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import type { Change } from "../../src/core/change.types.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`event trigger operations (pg${pgVersion})`, () => { - test( - "create event trigger with tag filter", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent(` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.log_ddl() - RETURNS event_trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RAISE NOTICE 'DDL event %', TG_TAG; - END; - $$; - `), - testSql: dedent(` - CREATE EVENT TRIGGER ddl_logger - ON ddl_command_start - WHEN TAG IN ('CREATE TABLE', 'ALTER TABLE') - EXECUTE FUNCTION test_schema.log_ddl(); - `), - }); - }), - ); - - test( - "alter event trigger enabled state", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent(` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.log_ddl() - RETURNS event_trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RAISE NOTICE 'DDL event %', TG_TAG; - END; - $$; - CREATE EVENT TRIGGER ddl_logger - ON ddl_command_start - EXECUTE FUNCTION test_schema.log_ddl(); - `), - testSql: "ALTER EVENT TRIGGER ddl_logger DISABLE;", - }); - }), - ); - - test( - "alter event trigger owner and comment", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent(` - CREATE ROLE ddl_owner LOGIN SUPERUSER; - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.log_ddl() - RETURNS event_trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RAISE NOTICE 'DDL event %', TG_TAG; - END; - $$; - CREATE EVENT TRIGGER ddl_logger - ON ddl_command_start - EXECUTE FUNCTION test_schema.log_ddl(); - `), - testSql: dedent(` - ALTER EVENT TRIGGER ddl_logger OWNER TO ddl_owner; - COMMENT ON EVENT TRIGGER ddl_logger IS 'Logs DDL statements'; - `), - }); - }), - ); - - test( - "drop event trigger", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent(` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.log_ddl() - RETURNS event_trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RAISE NOTICE 'DDL event %', TG_TAG; - END; - $$; - CREATE EVENT TRIGGER ddl_logger - ON ddl_command_start - EXECUTE FUNCTION test_schema.log_ddl(); - `), - testSql: "DROP EVENT TRIGGER ddl_logger;", - }); - }), - ); - - test( - "event trigger comment removal", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent(` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.log_ddl() - RETURNS event_trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RAISE NOTICE 'DDL event %', TG_TAG; - END; - $$; - CREATE EVENT TRIGGER ddl_logger - ON ddl_command_start - EXECUTE FUNCTION test_schema.log_ddl(); - COMMENT ON EVENT TRIGGER ddl_logger IS 'Logs DDL statements'; - `), - testSql: "COMMENT ON EVENT TRIGGER ddl_logger IS NULL;", - }); - }), - ); - - test( - "event trigger creation depends on function order", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent(` - CREATE FUNCTION test_schema.log_ddl_dependency() - RETURNS event_trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RAISE NOTICE 'dependency %', TG_TAG; - END; - $$; - - CREATE EVENT TRIGGER ddl_logger_dependency - ON ddl_command_start - EXECUTE FUNCTION test_schema.log_ddl_dependency(); - `), - sortChangesCallback: (a, b) => { - // Force event trigger creation ahead of its supporting function to verify dependency sorting - const priority = (change: Change) => { - if ( - change.objectType === "event_trigger" && - change.scope === "object" && - change.operation === "create" - ) { - return 0; - } - if ( - change.objectType === "procedure" && - change.scope === "object" && - change.operation === "create" - ) { - return 1; - } - return 2; - }; - - return priority(a) - priority(b); - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/extension-operations.test.ts b/packages/pg-delta/tests/integration/extension-operations.test.ts deleted file mode 100644 index 5bccdd612..000000000 --- a/packages/pg-delta/tests/integration/extension-operations.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import dedent from "dedent"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import type { Change } from "../../src/core/change.types.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { SUPABASE_POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbSupabaseIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of SUPABASE_POSTGRES_VERSIONS) { - describe(`extension operations (pg${pgVersion})`, () => { - test( - "create extension", - withDbSupabaseIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE EXTENSION vector WITH SCHEMA extensions; - CREATE TABLE test_table (vec extensions.vector); - `, - sortChangesCallback: (a, b) => { - const priority = (change: Change) => { - if ( - change.objectType === "extension" && - change.operation === "create" && - change.scope === "object" - ) { - return 0; - } - if ( - change.objectType === "table" && - change.operation === "create" - ) { - return 1; - } - if ( - change.objectType === "extension" && - change.operation === "create" && - change.scope === "comment" - ) { - return 2; - } - return 3; - }; - return priority(a) - priority(b); - }, - }); - }), - 120_000, - ); - - test( - "extension with comment", - withDbSupabaseIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA IF NOT EXISTS extensions;", - testSql: dedent` - CREATE EXTENSION vector WITH SCHEMA extensions; - COMMENT ON EXTENSION vector IS 'Vector similarity search'; - `, - sortChangesCallback: (a, b) => { - const priority = (change: Change) => { - if ( - change.objectType === "extension" && - change.operation === "create" && - change.scope === "object" - ) { - return 0; - } - if ( - change.objectType === "extension" && - change.operation === "create" && - change.scope === "comment" - ) { - return 1; - } - return 2; - }; - return priority(a) - priority(b); - }, - }); - }), - 120_000, - ); - - test( - "preserves pgvector typmod dimensions in catalog extraction and diff SQL", - withDbSupabaseIsolated(pgVersion, async (db) => { - const setupSql = dedent` - CREATE SCHEMA test_schema; - CREATE EXTENSION IF NOT EXISTS vector SCHEMA test_schema; - - CREATE TABLE test_schema.embeddings ( - id serial PRIMARY KEY, - title text NOT NULL, - embedding test_schema.halfvec(384) NOT NULL - ); - - CREATE INDEX embeddings_hnsw_idx - ON test_schema.embeddings - USING hnsw (embedding test_schema.halfvec_l2_ops) - WITH (m = 16, ef_construction = 64); - `; - - await db.main.query(setupSql); - await db.branch.query(setupSql); - await db.branch.query(dedent` - ALTER TABLE test_schema.embeddings - ADD COLUMN embedding_v2 test_schema.vector(768); - `); - - const branchCatalog = await extractCatalog(db.branch); - const embeddings = Object.values(branchCatalog.tables).find( - (table) => - table.schema === "test_schema" && table.name === "embeddings", - ); - - expect(embeddings).toBeDefined(); - expect( - embeddings?.columns.find((column) => column.name === "embedding") - ?.data_type_str, - ).toContain("halfvec(384)"); - expect( - embeddings?.columns.find((column) => column.name === "embedding_v2") - ?.data_type_str, - ).toContain("vector(768)"); - - const planResult = await createPlan(db.main, db.branch); - expect(planResult).not.toBeNull(); - expect(flattenPlanStatements(planResult!.plan)).toMatchInlineSnapshot(` - [ - "ALTER TABLE test_schema.embeddings ADD COLUMN embedding_v2 test_schema.vector(768)", - ] - `); - }), - 120_000, - ); - }); -} diff --git a/packages/pg-delta/tests/integration/fdw-option-secret-redaction.test.ts b/packages/pg-delta/tests/integration/fdw-option-secret-redaction.test.ts deleted file mode 100644 index 0f87d61fb..000000000 --- a/packages/pg-delta/tests/integration/fdw-option-secret-redaction.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * CLI-1467 regression: pg-delta must never emit foreign-data-wrapper, - * foreign-server, user-mapping, or foreign-table option secrets in any of - * its output channels — plan SQL, catalog export, declarative export, - * fingerprints. - * - * If this test ever fails, an output path is leaking credentials in - * cleartext. Treat as critical and revert the regression. - */ - -import { describe, expect, test } from "bun:test"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { - serializeCatalog, - stringifyCatalogSnapshot, -} from "../../src/core/catalog.snapshot.ts"; -import { exportDeclarativeSchema } from "../../src/core/export/index.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -const SECRET_VALUES = [ - "real-user-password", - "/etc/secrets/passfile", - "krb-passcode", - "ssl-secret", - "fdw-shared-secret", - "fdw-api-key", - "table-shared-secret", -]; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`FDW option secret redaction (pg${pgVersion})`, () => { - test( - "plan SQL, catalog snapshot, and declarative export never leak option secrets across FDW / server / user-mapping / foreign-table", - withDbIsolated(pgVersion, async (db) => { - // Setup: plant secrets at EVERY object layer that carries OPTIONS. - // The custom FDW (no handler/validator) lets us drop arbitrary keys - // into FDW-, server-, user-mapping-, and foreign-table-level - // OPTIONS without postgres_fdw's restrictions. - const setupSql = ` - CREATE FOREIGN DATA WRAPPER cli1467_fdw OPTIONS ( - use_remote_estimate 'true', - password 'fdw-shared-secret', - api_key 'fdw-api-key' - ); - CREATE SERVER cli1467_server FOREIGN DATA WRAPPER cli1467_fdw OPTIONS ( - host 'remote.example.com', - port '5432', - password 'real-user-password', - passfile '/etc/secrets/passfile' - ); - CREATE USER MAPPING FOR CURRENT_USER SERVER cli1467_server OPTIONS ( - "user" 'fdw_reader', - password 'real-user-password', - passcode 'krb-passcode', - sslpassword 'ssl-secret' - ); - CREATE FOREIGN TABLE cli1467_table (id integer) SERVER cli1467_server OPTIONS ( - schema_name 'remote_schema', - password 'table-shared-secret' - ); - `; - await db.branch.query(setupSql); - - // ----- Plan SQL ----- - const planResult = await createPlan(db.main, db.branch); - expect(planResult).not.toBeNull(); - // biome-ignore lint/style/noNonNullAssertion: just asserted not null - const planSql = flattenPlanStatements(planResult!.plan).join("\n"); - for (const secret of SECRET_VALUES) { - expect(planSql).not.toContain(secret); - } - // Non-secret options must still roundtrip. - expect(planSql).toContain("host 'remote.example.com'"); - expect(planSql).toContain("port '5432'"); - expect(planSql).toContain("user 'fdw_reader'"); - expect(planSql).toContain("use_remote_estimate 'true'"); - expect(planSql).toContain("schema_name 'remote_schema'"); - // Sensitive keys must be replaced with the redaction placeholder. - expect(planSql).toContain("password '__OPTION_PASSWORD__'"); - expect(planSql).toContain("passfile '__OPTION_PASSFILE__'"); - expect(planSql).toContain("passcode '__OPTION_PASSCODE__'"); - expect(planSql).toContain("sslpassword '__OPTION_SSLPASSWORD__'"); - expect(planSql).toContain("api_key '__OPTION_API_KEY__'"); - - // ----- Catalog export (snapshot) ----- - const branchCatalog = await extractCatalog(db.branch); - const snapshotJson = stringifyCatalogSnapshot( - serializeCatalog(branchCatalog), - ); - for (const secret of SECRET_VALUES) { - expect(snapshotJson).not.toContain(secret); - } - - // ----- Declarative export ----- - // biome-ignore lint/style/noNonNullAssertion: just asserted not null - const declarative = exportDeclarativeSchema(planResult!, {}); - const declarativeText = JSON.stringify(declarative); - for (const secret of SECRET_VALUES) { - expect(declarativeText).not.toContain(secret); - } - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/filter-wildcard.test.ts b/packages/pg-delta/tests/integration/filter-wildcard.test.ts deleted file mode 100644 index 8f77f9bec..000000000 --- a/packages/pg-delta/tests/integration/filter-wildcard.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Integration tests for the wildcard-based filter DSL. - * - * Validates that path-based patterns correctly filter changes - * against real PostgreSQL databases. - */ - -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/index.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`wildcard-based filter DSL (pg${pgVersion})`, () => { - test( - "*/schema filters by schema across object types", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE SCHEMA app"); - await db.branch.query("CREATE TABLE public.pub_t (id integer)"); - await db.branch.query("CREATE TABLE app.app_t (id integer)"); - await db.branch.query( - "CREATE VIEW app.app_v AS SELECT id FROM app.app_t", - ); - - const resultWithoutFilter = await createPlan(db.main, db.branch); - const stmts = resultWithoutFilter - ? flattenPlanStatements(resultWithoutFilter.plan) - : []; - expect(stmts).toHaveLength(4); - expect(stmts[0]).toBe("CREATE SCHEMA app AUTHORIZATION postgres"); - expect(stmts[1]).toBe("CREATE TABLE app.app_t (id integer)"); - // View SQL varies across PG versions: PG15 qualifies columns (app_t.id) - // while PG17 does not (id), so we use a regex instead of an inline snapshot. - expect(stmts[2]).toMatch( - /CREATE VIEW app\.app_v AS SELECT (app_t\.)?id\s+FROM app\.app_t/, - ); - expect(stmts[3]).toBe("CREATE TABLE public.pub_t (id integer)"); - - const result = await createPlan(db.main, db.branch, { - filter: { "*/schema": "app" }, - }); - - expect(result).not.toBeNull(); - const filtered = result ? flattenPlanStatements(result.plan) : []; - expect(filtered).toHaveLength(3); - expect(filtered[0]).toBe("CREATE SCHEMA app AUTHORIZATION postgres"); - expect(filtered[1]).toBe("CREATE TABLE app.app_t (id integer)"); - // See comment above — view SQL varies across PG versions. - expect(filtered[2]).toMatch( - /CREATE VIEW app\.app_v AS SELECT (app_t\.)?id\s+FROM app\.app_t/, - ); - }), - ); - - test( - "objectType filters by change type", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE TABLE public.t1 (id integer)"); - await db.branch.query( - "CREATE VIEW public.v1 AS SELECT id FROM public.t1", - ); - - const result = await createPlan(db.main, db.branch, { - filter: { objectType: "table" }, - }); - - expect(result).not.toBeNull(); - expect(flattenPlanStatements(result!.plan)).toMatchInlineSnapshot(` - [ - "CREATE TABLE public.t1 (id integer)", - ] - `); - }), - ); - - test( - "not with */schema excludes schema", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE SCHEMA excluded"); - await db.branch.query("CREATE TABLE excluded.secret (id integer)"); - await db.branch.query("CREATE TABLE public.visible (id integer)"); - - const result = await createPlan(db.main, db.branch, { - filter: { - not: { - or: [{ "*/schema": "excluded" }], - }, - }, - }); - - expect(result).not.toBeNull(); - expect(flattenPlanStatements(result!.plan)).toMatchInlineSnapshot(` - [ - "CREATE TABLE public.visible (id integer)", - ] - `); - }), - ); - - test( - "boolean matching on table/is_partition", - withDb(pgVersion, async (db) => { - await db.branch.query( - "CREATE TABLE public.parent (id integer) PARTITION BY RANGE (id)", - ); - await db.branch.query( - "CREATE TABLE public.child PARTITION OF public.parent FOR VALUES FROM (0) TO (100)", - ); - await db.branch.query("CREATE TABLE public.regular (id integer)"); - - const result = await createPlan(db.main, db.branch, { - filter: { - objectType: "table", - scope: "object", - operation: "create", - "table/is_partition": false, - }, - }); - - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - const tableNames = result.sortedChanges - .filter((c) => c.objectType === "table" && c.scope === "object") - .map((c) => { - if (c.objectType === "table") return c.table.name; - return ""; - }); - expect(tableNames).toContain("parent"); - expect(tableNames).toContain("regular"); - expect(tableNames).not.toContain("child"); - }), - ); - - test( - "regex matching on requires", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE SCHEMA myschema"); - await db.branch.query("CREATE TABLE myschema.t1 (id integer)"); - - const result = await createPlan(db.main, db.branch, { - filter: { - requires: { op: "regex", value: "^schema:myschema$" }, - }, - }); - - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - // Only changes that require myschema should be included - for (const change of result.sortedChanges) { - expect( - change.requires.some((r: string) => /^schema:myschema$/.test(r)), - ).toBe(true); - } - }), - ); - - test( - "--filter AND-combines with integration filter", - withDb(pgVersion, async (db) => { - await db.branch.query("CREATE TABLE public.t1 (id integer)"); - await db.branch.query( - "CREATE VIEW public.v1 AS SELECT id FROM public.t1", - ); - - // Integration filter: only public schema - // Additional filter: only tables - const result = await createPlan(db.main, db.branch, { - filter: { - and: [{ "*/schema": "public" }, { objectType: "table" }], - }, - }); - - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - const types = result.sortedChanges.map((c) => c.objectType); - expect(types.every((t) => t === "table")).toBe(true); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/fixtures/supabase-base-init/15_fullstack_container_init.sql b/packages/pg-delta/tests/integration/fixtures/supabase-base-init/15_fullstack_container_init.sql deleted file mode 100644 index 34ae31d41..000000000 --- a/packages/pg-delta/tests/integration/fixtures/supabase-base-init/15_fullstack_container_init.sql +++ /dev/null @@ -1,3678 +0,0 @@ --- Risk: data-loss (2) -SET check_function_bodies = false; - -ALTER TABLE auth.users - DROP COLUMN confirmed_at; - -ALTER TABLE auth.users - DROP COLUMN email_change_token; - -ALTER TABLE auth.users - DROP CONSTRAINT users_email_key; - -DROP INDEX auth.refresh_tokens_token_idx; - -DROP INDEX auth.users_instance_id_email_idx; - -CREATE SCHEMA _realtime AUTHORIZATION postgres; - -CREATE TABLE _realtime.extensions ( - id uuid NOT NULL, - type text, - settings jsonb, - tenant_external_id text, - inserted_at timestamp(0) without time zone NOT NULL, - updated_at timestamp(0) without time zone NOT NULL -); - -ALTER TABLE _realtime.extensions - ADD CONSTRAINT extensions_pkey PRIMARY KEY (id); - -CREATE INDEX extensions_tenant_external_id_index ON _realtime.extensions (tenant_external_id); - -CREATE UNIQUE INDEX extensions_tenant_external_id_type_index - ON _realtime.extensions (tenant_external_id, type); - -CREATE TABLE _realtime.schema_migrations ( - version bigint NOT NULL, - inserted_at timestamp(0) without time zone -); - -ALTER TABLE _realtime.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - -CREATE TABLE _realtime.tenants ( - id uuid NOT NULL, - name text, - external_id text, - jwt_secret text, - max_concurrent_users integer DEFAULT 200 NOT NULL, - inserted_at timestamp(0) without time zone NOT NULL, - updated_at timestamp(0) without time zone NOT NULL, - max_events_per_second integer DEFAULT 100 NOT NULL, - postgres_cdc_default text DEFAULT - 'postgres_cdc_rls'::text, - max_bytes_per_second integer DEFAULT 100000 NOT NULL, - max_channels_per_client integer DEFAULT 100 NOT NULL, - max_joins_per_second integer DEFAULT 500 NOT NULL, - suspend boolean DEFAULT false, - jwt_jwks jsonb, - notify_private_alpha boolean DEFAULT false, - private_only boolean DEFAULT false NOT NULL, - migrations_ran integer DEFAULT 0, - broadcast_adapter character varying(255) DEFAULT 'gen_rpc'::character - varying, - max_presence_events_per_second integer DEFAULT 1000, - max_payload_size_in_kb integer DEFAULT 3000, - max_client_presence_events_per_window integer, - client_presence_window_ms integer, - presence_enabled boolean DEFAULT false NOT NULL -); - -ALTER TABLE _realtime.tenants - ADD CONSTRAINT jwt_secret_or_jwt_jwks_required CHECK (jwt_secret IS NOT NULL OR jwt_jwks IS - NOT NULL); - -ALTER TABLE _realtime.tenants - ADD CONSTRAINT tenants_pkey PRIMARY KEY (id); - -CREATE UNIQUE INDEX tenants_external_id_index ON _realtime.tenants (external_id); - -ALTER TABLE _realtime.extensions - ADD CONSTRAINT extensions_tenant_external_id_fkey FOREIGN KEY (tenant_external_id) - REFERENCES _realtime.tenants(external_id) ON DELETE CASCADE; - -CREATE ROLE supabase_functions_admin WITH CREATEROLE NOINHERIT LOGIN; - -GRANT supabase_functions_admin TO postgres; - -ALTER ROLE supabase_functions_admin SET search_path TO supabase_functions; - -CREATE ROLE supabase_realtime_admin WITH NOINHERIT; - -GRANT supabase_realtime_admin TO postgres; - -CREATE TYPE auth.aal_level AS ENUM ( - 'aal1', - 'aal2', - 'aal3' -); - -ALTER TYPE auth.aal_level OWNER TO supabase_auth_admin; - -CREATE TYPE auth.code_challenge_method AS ENUM ( - 's256', - 'plain' -); - -ALTER TYPE auth.code_challenge_method OWNER TO supabase_auth_admin; - -CREATE TYPE auth.factor_status AS ENUM ( - 'unverified', - 'verified' -); - -ALTER TYPE auth.factor_status OWNER TO supabase_auth_admin; - -CREATE TYPE auth.factor_type AS ENUM ( - 'totp', - 'webauthn', - 'phone' -); - -ALTER TYPE auth.factor_type OWNER TO supabase_auth_admin; - -CREATE TYPE auth.oauth_authorization_status AS ENUM ( - 'pending', - 'approved', - 'denied', - 'expired' -); - -ALTER TYPE auth.oauth_authorization_status OWNER TO supabase_auth_admin; - -CREATE TYPE auth.oauth_client_type AS ENUM ( - 'public', - 'confidential' -); - -ALTER TYPE auth.oauth_client_type OWNER TO supabase_auth_admin; - -CREATE TYPE auth.oauth_registration_type AS ENUM ( - 'dynamic', - 'manual' -); - -ALTER TYPE auth.oauth_registration_type OWNER TO supabase_auth_admin; - -CREATE TYPE auth.oauth_response_type AS ENUM ( - 'code' -); - -ALTER TYPE auth.oauth_response_type OWNER TO supabase_auth_admin; - -CREATE TYPE auth.one_time_token_type AS ENUM ( - 'confirmation_token', - 'reauthentication_token', - 'recovery_token', - 'email_change_token_new', - 'email_change_token_current', - 'phone_change_token' -); - -ALTER TYPE auth.one_time_token_type OWNER TO supabase_auth_admin; - -CREATE OR REPLACE FUNCTION auth.email() - RETURNS text - LANGUAGE sql - STABLE - AS $function$ - select - coalesce( - nullif(current_setting('request.jwt.claim.email', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') - )::text -$function$; - -COMMENT ON FUNCTION auth.email() IS 'Deprecated. Use auth.jwt() -> ''email'' instead.'; - -CREATE FUNCTION auth.jwt() - RETURNS jsonb - LANGUAGE sql - STABLE - AS $function$ - select - coalesce( - nullif(current_setting('request.jwt.claim', true), ''), - nullif(current_setting('request.jwt.claims', true), '') - )::jsonb -$function$; - -ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin; - -GRANT ALL ON FUNCTION auth.jwt() TO postgres; - -GRANT ALL ON FUNCTION auth.jwt() TO dashboard_user; - -CREATE OR REPLACE FUNCTION auth.role() - RETURNS text - LANGUAGE sql - STABLE - AS $function$ - select - coalesce( - nullif(current_setting('request.jwt.claim.role', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role') - )::text -$function$; - -COMMENT ON FUNCTION auth.role() IS 'Deprecated. Use auth.jwt() -> ''role'' instead.'; - -CREATE OR REPLACE FUNCTION auth.uid() - RETURNS uuid - LANGUAGE sql - STABLE - AS $function$ - select - coalesce( - nullif(current_setting('request.jwt.claim.sub', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') - )::uuid -$function$; - -COMMENT ON FUNCTION auth.uid() IS 'Deprecated. Use auth.jwt() -> ''sub'' instead.'; - -ALTER TABLE auth.audit_log_entries - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.audit_log_entries - ADD COLUMN ip_address character varying(64) DEFAULT ''::character varying NOT NULL; - -GRANT SELECT ON auth.audit_log_entries TO postgres WITH GRANT OPTION; - -CREATE TABLE auth.custom_oauth_providers ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - provider_type text NOT NULL, - identifier text NOT NULL, - name text NOT NULL, - client_id text NOT NULL, - client_secret text NOT NULL, - acceptable_client_ids text[] DEFAULT '{}'::text[] NOT NULL, - scopes text[] DEFAULT '{}'::text[] NOT NULL, - pkce_enabled boolean DEFAULT true NOT NULL, - attribute_mapping jsonb DEFAULT '{}'::jsonb NOT NULL, - authorization_params jsonb DEFAULT '{}'::jsonb NOT NULL, - enabled boolean DEFAULT true NOT NULL, - email_optional boolean DEFAULT false NOT NULL, - issuer text, - discovery_url text, - skip_nonce_check boolean DEFAULT false NOT NULL, - cached_discovery jsonb, - discovery_cached_at timestamp with time zone, - authorization_url text, - token_url text, - userinfo_url text, - jwks_uri text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE auth.custom_oauth_providers - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_authorization_url_https - CHECK (authorization_url IS NULL OR authorization_url ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_authorization_url_length - CHECK (authorization_url IS NULL OR char_length(authorization_url) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_client_id_length - CHECK (char_length(client_id) >= 1 AND char_length(client_id) <= 512); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_discovery_url_length - CHECK (discovery_url IS NULL OR char_length(discovery_url) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_identifier_format - CHECK (identifier ~ '^[a-z0-9][a-z0-9:-]{0,48}[a-z0-9]$'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_identifier_key UNIQUE (identifier); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_issuer_length - CHECK (issuer IS NULL OR char_length(issuer) >= 1 AND char_length(issuer) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_jwks_uri_https - CHECK (jwks_uri IS NULL OR jwks_uri ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_jwks_uri_length - CHECK (jwks_uri IS NULL OR char_length(jwks_uri) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_name_length - CHECK (char_length(name) >= 1 AND char_length(name) <= 100); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_oauth2_requires_endpoints - CHECK (provider_type <> 'oauth2'::text OR authorization_url IS NOT NULL AND token_url IS - NOT NULL AND userinfo_url IS NOT NULL); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_oidc_discovery_url_https - CHECK - (provider_type <> 'oidc'::text OR discovery_url IS NULL OR discovery_url ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_oidc_issuer_https - CHECK (provider_type <> 'oidc'::text OR issuer IS NULL OR issuer ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_oidc_requires_issuer - CHECK (provider_type <> 'oidc'::text OR issuer IS NOT NULL); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_pkey PRIMARY KEY (id); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_provider_type_check - CHECK (provider_type = ANY (ARRAY['oauth2'::text, 'oidc'::text])); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_token_url_https - CHECK (token_url IS NULL OR token_url ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_token_url_length - CHECK (token_url IS NULL OR char_length(token_url) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_userinfo_url_https - CHECK (userinfo_url IS NULL OR userinfo_url ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_userinfo_url_length - CHECK (userinfo_url IS NULL OR char_length(userinfo_url) <= 2048); - -GRANT ALL ON auth.custom_oauth_providers TO postgres; - -GRANT ALL ON auth.custom_oauth_providers TO dashboard_user; - -CREATE INDEX custom_oauth_providers_created_at_idx ON auth.custom_oauth_providers (created_at); - -CREATE INDEX custom_oauth_providers_enabled_idx ON auth.custom_oauth_providers (enabled); - -CREATE INDEX custom_oauth_providers_provider_type_idx ON auth.custom_oauth_providers (provider_type); - -CREATE INDEX custom_oauth_providers_identifier_idx ON auth.custom_oauth_providers (identifier); - -CREATE TABLE auth.flow_state ( - id uuid NOT NULL, - user_id uuid, - auth_code text, - code_challenge_method auth.code_challenge_method, - code_challenge text, - provider_type text NOT NULL, - provider_access_token text, - provider_refresh_token text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - authentication_method text NOT NULL, - auth_code_issued_at timestamp with time zone, - invite_token text, - referrer text, - oauth_client_state_id uuid, - linking_target_id uuid, - email_optional boolean DEFAULT false NOT NULL -); - -COMMENT ON TABLE auth.flow_state IS 'Stores metadata for all OAuth/SSO login flows'; - -ALTER TABLE auth.flow_state - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.flow_state - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.flow_state - ADD CONSTRAINT flow_state_pkey PRIMARY KEY (id); - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.flow_state TO postgres; - -GRANT SELECT ON auth.flow_state TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.flow_state TO dashboard_user; - -CREATE INDEX idx_auth_code ON auth.flow_state (auth_code); - -CREATE INDEX idx_user_id_auth_method ON auth.flow_state (user_id, authentication_method); - -CREATE INDEX flow_state_created_at_idx ON auth.flow_state (created_at DESC); - -CREATE TABLE auth.identities ( - provider_id text NOT NULL, - user_id uuid NOT NULL, - identity_data jsonb NOT NULL, - provider text NOT NULL, - last_sign_in_at timestamp with time zone, - created_at timestamp with time zone, - updated_at timestamp with time zone, - email text GENERATED ALWAYS AS - (lower((identity_data ->> 'email'::text))) STORED, - id uuid DEFAULT gen_random_uuid() NOT NULL -); - -COMMENT ON TABLE auth.identities IS 'Auth: Stores identities associated to a user.'; - -COMMENT ON COLUMN auth.identities.email IS 'Auth: Email is a generated column that references the optional email property in the identity_data'; - -ALTER TABLE auth.identities - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.identities - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.identities - ADD CONSTRAINT identities_pkey PRIMARY KEY (id); - -ALTER TABLE auth.identities - ADD CONSTRAINT identities_provider_id_provider_unique UNIQUE (provider_id, provider); - -ALTER TABLE auth.identities - ADD CONSTRAINT identities_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.identities TO postgres; - -GRANT SELECT ON auth.identities TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.identities TO dashboard_user; - -CREATE INDEX identities_user_id_idx ON auth.identities (user_id); - -CREATE INDEX identities_email_idx ON auth.identities (email text_pattern_ops); - -ALTER TABLE auth.instances - ENABLE ROW LEVEL SECURITY; - -GRANT SELECT ON auth.instances TO postgres WITH GRANT OPTION; - -CREATE TABLE auth.mfa_amr_claims ( - session_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - authentication_method text NOT NULL, - id uuid NOT NULL -); - -COMMENT ON TABLE auth.mfa_amr_claims IS 'auth: stores authenticator method reference claims for multi factor authentication'; - -ALTER TABLE auth.mfa_amr_claims - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.mfa_amr_claims - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.mfa_amr_claims - ADD CONSTRAINT amr_id_pk PRIMARY KEY (id); - -ALTER TABLE auth.mfa_amr_claims - ADD - CONSTRAINT mfa_amr_claims_session_id_authentication_method_pkey UNIQUE - (session_id, authentication_method); - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.mfa_amr_claims TO postgres; - -GRANT SELECT ON auth.mfa_amr_claims TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.mfa_amr_claims TO dashboard_user; - -CREATE TABLE auth.mfa_challenges ( - id uuid NOT NULL, - factor_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - verified_at timestamp with time zone, - ip_address inet NOT NULL, - otp_code text, - web_authn_session_data jsonb -); - -COMMENT ON TABLE auth.mfa_challenges IS 'auth: stores metadata about challenge requests made'; - -ALTER TABLE auth.mfa_challenges - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.mfa_challenges - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.mfa_challenges - ADD CONSTRAINT mfa_challenges_pkey PRIMARY KEY (id); - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.mfa_challenges TO postgres; - -GRANT SELECT ON auth.mfa_challenges TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.mfa_challenges TO dashboard_user; - -CREATE INDEX mfa_challenge_created_at_idx ON auth.mfa_challenges (created_at DESC); - -CREATE TABLE auth.mfa_factors ( - id uuid NOT NULL, - user_id uuid NOT NULL, - friendly_name text, - factor_type auth.factor_type NOT NULL, - status auth.factor_status NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - secret text, - phone text, - last_challenged_at timestamp with time zone, - web_authn_credential jsonb, - web_authn_aaguid uuid, - last_webauthn_challenge_data jsonb -); - -COMMENT ON TABLE auth.mfa_factors IS 'auth: stores metadata about factors'; - -COMMENT ON COLUMN auth.mfa_factors.last_webauthn_challenge_data IS 'Stores the latest WebAuthn challenge data including attestation/assertion for customer verification'; - -ALTER TABLE auth.mfa_factors - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.mfa_factors - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.mfa_factors - ADD CONSTRAINT mfa_factors_last_challenged_at_key UNIQUE (last_challenged_at); - -ALTER TABLE auth.mfa_factors - ADD CONSTRAINT mfa_factors_pkey PRIMARY KEY (id); - -ALTER TABLE auth.mfa_challenges - ADD CONSTRAINT mfa_challenges_auth_factor_id_fkey FOREIGN KEY (factor_id) - REFERENCES auth.mfa_factors(id) ON DELETE CASCADE; - -ALTER TABLE auth.mfa_factors - ADD CONSTRAINT mfa_factors_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.mfa_factors TO postgres; - -GRANT SELECT ON auth.mfa_factors TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.mfa_factors TO dashboard_user; - -CREATE INDEX mfa_factors_user_id_idx ON auth.mfa_factors (user_id); - -CREATE UNIQUE INDEX unique_phone_factor_per_user ON auth.mfa_factors (user_id, phone); - -CREATE UNIQUE INDEX mfa_factors_user_friendly_name_unique - ON auth.mfa_factors (friendly_name, user_id) - WHERE TRIM(BOTH FROM friendly_name) <> ''::text; - -CREATE INDEX factor_id_created_at_idx ON auth.mfa_factors (user_id, created_at); - -CREATE TABLE auth.oauth_authorizations ( - id uuid NOT NULL, - authorization_id text NOT NULL, - client_id uuid NOT NULL, - user_id uuid, - redirect_uri text NOT NULL, - scope text NOT NULL, - state text, - resource text, - code_challenge text, - code_challenge_method auth.code_challenge_method, - response_type auth.oauth_response_type DEFAULT 'code'::auth.oauth_response_type - NOT NULL, - status auth.oauth_authorization_status DEFAULT - 'pending'::auth.oauth_authorization_status NOT NULL, - authorization_code text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - expires_at timestamp with time zone DEFAULT (now() + '00:03:00'::interval) - NOT NULL, - approved_at timestamp with time zone, - nonce text -); - -ALTER TABLE auth.oauth_authorizations - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_authorization_code_key UNIQUE (authorization_code); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_authorization_code_length - CHECK (char_length(authorization_code) <= 255); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_authorization_id_key UNIQUE (authorization_id); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_code_challenge_length - CHECK (char_length(code_challenge) <= 128); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_expires_at_future CHECK (expires_at > created_at); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_nonce_length CHECK (char_length(nonce) <= 255); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_pkey PRIMARY KEY (id); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_redirect_uri_length CHECK (char_length(redirect_uri) <= 2048); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_resource_length CHECK (char_length(resource) <= 2048); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_scope_length CHECK (char_length(scope) <= 4096); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_state_length CHECK (char_length(state) <= 4096); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT ALL ON auth.oauth_authorizations TO postgres; - -GRANT ALL ON auth.oauth_authorizations TO dashboard_user; - -CREATE INDEX oauth_auth_pending_exp_idx ON auth.oauth_authorizations (expires_at) - WHERE status = 'pending'::auth.oauth_authorization_status; - -CREATE TABLE auth.oauth_client_states ( - id uuid NOT NULL, - provider_type text NOT NULL, - code_verifier text, - created_at timestamp with time zone NOT NULL -); - -COMMENT ON TABLE auth.oauth_client_states IS 'Stores OAuth states for third-party provider authentication flows where Supabase acts as the OAuth client.'; - -ALTER TABLE auth.oauth_client_states - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.oauth_client_states - ADD CONSTRAINT oauth_client_states_pkey PRIMARY KEY (id); - -GRANT ALL ON auth.oauth_client_states TO postgres; - -GRANT ALL ON auth.oauth_client_states TO dashboard_user; - -CREATE INDEX idx_oauth_client_states_created_at ON auth.oauth_client_states (created_at); - -CREATE TABLE auth.oauth_clients ( - id uuid NOT NULL, - client_secret_hash text, - registration_type auth.oauth_registration_type NOT NULL, - redirect_uris text NOT NULL, - grant_types text NOT NULL, - client_name text, - client_uri text, - logo_uri text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - deleted_at timestamp with time zone, - client_type auth.oauth_client_type DEFAULT - 'confidential'::auth.oauth_client_type NOT NULL, - token_endpoint_auth_method text NOT NULL -); - -ALTER TABLE auth.oauth_clients - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_client_name_length CHECK (char_length(client_name) <= 1024); - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_client_uri_length CHECK (char_length(client_uri) <= 2048); - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_logo_uri_length CHECK (char_length(logo_uri) <= 2048); - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_pkey PRIMARY KEY (id); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_client_id_fkey FOREIGN KEY (client_id) - REFERENCES auth.oauth_clients(id) ON DELETE CASCADE; - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_token_endpoint_auth_method_check - CHECK - (token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, - 'client_secret_post'::text, 'none'::text])); - -GRANT ALL ON auth.oauth_clients TO postgres; - -GRANT ALL ON auth.oauth_clients TO dashboard_user; - -CREATE INDEX oauth_clients_deleted_at_idx ON auth.oauth_clients (deleted_at); - -CREATE TABLE auth.oauth_consents ( - id uuid NOT NULL, - user_id uuid NOT NULL, - client_id uuid NOT NULL, - scopes text NOT NULL, - granted_at timestamp with time zone DEFAULT now() NOT NULL, - revoked_at timestamp with time zone -); - -ALTER TABLE auth.oauth_consents - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_client_id_fkey FOREIGN KEY (client_id) - REFERENCES auth.oauth_clients(id) ON DELETE CASCADE; - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_pkey PRIMARY KEY (id); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_revoked_after_granted - CHECK (revoked_at IS NULL OR revoked_at >= granted_at); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_scopes_length CHECK (char_length(scopes) <= 2048); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_scopes_not_empty CHECK (char_length(TRIM(BOTH FROM scopes)) > 0); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_user_client_unique UNIQUE (user_id, client_id); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT ALL ON auth.oauth_consents TO postgres; - -GRANT ALL ON auth.oauth_consents TO dashboard_user; - -CREATE INDEX oauth_consents_active_user_client_idx ON auth.oauth_consents (user_id, client_id) - WHERE revoked_at IS NULL; - -CREATE INDEX oauth_consents_user_order_idx ON auth.oauth_consents (user_id, granted_at DESC); - -CREATE INDEX oauth_consents_active_client_idx ON auth.oauth_consents (client_id) - WHERE revoked_at IS NULL; - -CREATE TABLE auth.one_time_tokens ( - id uuid NOT NULL, - user_id uuid NOT NULL, - token_type auth.one_time_token_type NOT NULL, - token_hash text NOT NULL, - relates_to text NOT NULL, - created_at timestamp without time zone DEFAULT now() NOT NULL, - updated_at timestamp without time zone DEFAULT now() NOT NULL -); - -ALTER TABLE auth.one_time_tokens - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.one_time_tokens - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.one_time_tokens - ADD CONSTRAINT one_time_tokens_pkey PRIMARY KEY (id); - -ALTER TABLE auth.one_time_tokens - ADD CONSTRAINT one_time_tokens_token_hash_check CHECK (char_length(token_hash) > 0); - -ALTER TABLE auth.one_time_tokens - ADD CONSTRAINT one_time_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.one_time_tokens TO postgres; - -GRANT SELECT ON auth.one_time_tokens TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.one_time_tokens TO dashboard_user; - -CREATE UNIQUE INDEX one_time_tokens_user_id_token_type_key - ON auth.one_time_tokens (user_id, token_type); - -CREATE INDEX one_time_tokens_relates_to_hash_idx ON auth.one_time_tokens USING hash (relates_to); - -CREATE INDEX one_time_tokens_token_hash_hash_idx ON auth.one_time_tokens USING hash (token_hash); - -ALTER TABLE auth.refresh_tokens - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_token_unique UNIQUE (token); - -ALTER TABLE auth.refresh_tokens - ADD COLUMN parent character varying(255); - -ALTER TABLE auth.refresh_tokens - ADD COLUMN session_id uuid; - -GRANT SELECT ON auth.refresh_tokens TO postgres WITH GRANT OPTION; - -CREATE INDEX refresh_tokens_session_id_revoked_idx ON auth.refresh_tokens (session_id, revoked); - -CREATE INDEX refresh_tokens_updated_at_idx ON auth.refresh_tokens (updated_at DESC); - -CREATE INDEX refresh_tokens_parent_idx ON auth.refresh_tokens (parent); - -CREATE TABLE auth.saml_providers ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - entity_id text NOT NULL, - metadata_xml text NOT NULL, - metadata_url text, - attribute_mapping jsonb, - created_at timestamp with time zone, - updated_at timestamp with time zone, - name_id_format text -); - -COMMENT ON TABLE auth.saml_providers IS 'Auth: Manages SAML Identity Provider connections.'; - -ALTER TABLE auth.saml_providers - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.saml_providers - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT "entity_id not empty" CHECK (char_length(entity_id) > 0); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT "metadata_url not empty" - CHECK (metadata_url = NULL::text OR char_length(metadata_url) > 0); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT "metadata_xml not empty" CHECK (char_length(metadata_xml) > 0); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT saml_providers_entity_id_key UNIQUE (entity_id); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT saml_providers_pkey PRIMARY KEY (id); - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.saml_providers TO postgres; - -GRANT SELECT ON auth.saml_providers TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.saml_providers TO dashboard_user; - -CREATE INDEX saml_providers_sso_provider_id_idx ON auth.saml_providers (sso_provider_id); - -CREATE TABLE auth.saml_relay_states ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - request_id text NOT NULL, - for_email text, - redirect_to text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - flow_state_id uuid -); - -COMMENT ON TABLE auth.saml_relay_states IS 'Auth: Contains SAML Relay State information for each Service Provider initiated login.'; - -ALTER TABLE auth.saml_relay_states - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.saml_relay_states - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.saml_relay_states - ADD CONSTRAINT "request_id not empty" CHECK (char_length(request_id) > 0); - -ALTER TABLE auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_flow_state_id_fkey FOREIGN KEY (flow_state_id) - REFERENCES auth.flow_state(id) ON DELETE CASCADE; - -ALTER TABLE auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_pkey PRIMARY KEY (id); - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.saml_relay_states TO postgres; - -GRANT SELECT ON auth.saml_relay_states TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.saml_relay_states TO dashboard_user; - -CREATE INDEX saml_relay_states_for_email_idx ON auth.saml_relay_states (for_email); - -CREATE INDEX saml_relay_states_sso_provider_id_idx ON auth.saml_relay_states (sso_provider_id); - -CREATE INDEX saml_relay_states_created_at_idx ON auth.saml_relay_states (created_at DESC); - -ALTER TABLE auth.schema_migrations - ENABLE ROW LEVEL SECURITY; - -GRANT SELECT ON auth.schema_migrations TO postgres WITH GRANT OPTION; - -CREATE TABLE auth.sessions ( - id uuid NOT NULL, - user_id uuid NOT NULL, - created_at timestamp with time zone, - updated_at timestamp with time zone, - factor_id uuid, - aal auth.aal_level, - not_after timestamp with time zone, - refreshed_at timestamp without time zone, - user_agent text, - ip inet, - tag text, - oauth_client_id uuid, - refresh_token_hmac_key text, - refresh_token_counter bigint, - scopes text -); - -COMMENT ON TABLE auth.sessions IS 'Auth: Stores session data associated to a user.'; - -COMMENT ON COLUMN auth.sessions.not_after IS 'Auth: Not after is a nullable column that contains a timestamp after which the session should be regarded as expired.'; - -COMMENT ON COLUMN auth.sessions.refresh_token_hmac_key IS 'Holds a HMAC-SHA256 key used to sign refresh tokens for this session.'; - -COMMENT ON COLUMN auth.sessions.refresh_token_counter IS 'Holds the ID (counter) of the last issued refresh token.'; - -ALTER TABLE auth.sessions - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.sessions - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.sessions - ADD CONSTRAINT sessions_oauth_client_id_fkey FOREIGN KEY (oauth_client_id) - REFERENCES auth.oauth_clients(id) ON DELETE CASCADE; - -ALTER TABLE auth.sessions - ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); - -ALTER TABLE auth.mfa_amr_claims - ADD CONSTRAINT mfa_amr_claims_session_id_fkey FOREIGN KEY (session_id) - REFERENCES auth.sessions(id) ON DELETE CASCADE; - -ALTER TABLE auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_session_id_fkey FOREIGN KEY (session_id) - REFERENCES auth.sessions(id) ON DELETE CASCADE; - -ALTER TABLE auth.sessions - ADD CONSTRAINT sessions_scopes_length CHECK (char_length(scopes) <= 4096); - -ALTER TABLE auth.sessions - ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.sessions TO postgres; - -GRANT SELECT ON auth.sessions TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.sessions TO dashboard_user; - -CREATE INDEX user_id_created_at_idx ON auth.sessions (user_id, created_at); - -CREATE INDEX sessions_user_id_idx ON auth.sessions (user_id); - -CREATE INDEX sessions_not_after_idx ON auth.sessions (not_after DESC); - -CREATE INDEX sessions_oauth_client_id_idx ON auth.sessions (oauth_client_id); - -CREATE TABLE auth.sso_domains ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - domain text NOT NULL, - created_at timestamp with time zone, - updated_at timestamp with time zone -); - -COMMENT ON TABLE auth.sso_domains IS 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.'; - -ALTER TABLE auth.sso_domains - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.sso_domains - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.sso_domains - ADD CONSTRAINT "domain not empty" CHECK (char_length(domain) > 0); - -ALTER TABLE auth.sso_domains - ADD CONSTRAINT sso_domains_pkey PRIMARY KEY (id); - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.sso_domains TO postgres; - -GRANT SELECT ON auth.sso_domains TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.sso_domains TO dashboard_user; - -CREATE UNIQUE INDEX sso_domains_domain_idx ON auth.sso_domains (lower(domain)); - -CREATE INDEX sso_domains_sso_provider_id_idx ON auth.sso_domains (sso_provider_id); - -CREATE TABLE auth.sso_providers ( - id uuid NOT NULL, - resource_id text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - disabled boolean -); - -COMMENT ON TABLE auth.sso_providers IS 'Auth: Manages SSO identity provider information; see saml_providers for SAML.'; - -COMMENT ON COLUMN auth.sso_providers.resource_id IS 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.'; - -ALTER TABLE auth.sso_providers - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.sso_providers - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.sso_providers - ADD CONSTRAINT "resource_id not empty" - CHECK (resource_id = NULL::text OR char_length(resource_id) > 0); - -ALTER TABLE auth.sso_providers - ADD CONSTRAINT sso_providers_pkey PRIMARY KEY (id); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT saml_providers_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) - REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - -ALTER TABLE auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) - REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - -ALTER TABLE auth.sso_domains - ADD CONSTRAINT sso_domains_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) - REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - -GRANT DELETE, INSERT, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.sso_providers TO postgres; - -GRANT SELECT ON auth.sso_providers TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.sso_providers TO dashboard_user; - -CREATE INDEX sso_providers_resource_id_pattern_idx - ON auth.sso_providers (resource_id text_pattern_ops); - -CREATE UNIQUE INDEX sso_providers_resource_id_idx ON auth.sso_providers (lower(resource_id)); - -ALTER TABLE auth.users - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.users - ADD COLUMN email_confirmed_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN email_change_token_new character varying(255); - -ALTER TABLE auth.users - ADD COLUMN phone text DEFAULT NULL::character varying; - -ALTER TABLE auth.users - ADD CONSTRAINT users_phone_key UNIQUE (phone); - -ALTER TABLE auth.users - ADD COLUMN phone_confirmed_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN phone_change text DEFAULT ''::character varying; - -ALTER TABLE auth.users - ADD COLUMN phone_change_token character varying(255) DEFAULT ''::character varying; - -ALTER TABLE auth.users - ADD COLUMN phone_change_sent_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN email_change_token_current character varying(255) DEFAULT ''::character varying; - -ALTER TABLE auth.users - ADD COLUMN email_change_confirm_status smallint DEFAULT 0; - -ALTER TABLE auth.users - ADD CONSTRAINT users_email_change_confirm_status_check - CHECK (email_change_confirm_status >= 0 AND email_change_confirm_status <= 2); - -ALTER TABLE auth.users - ADD COLUMN banned_until timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN reauthentication_token character varying(255) DEFAULT ''::character varying; - -ALTER TABLE auth.users - ADD COLUMN reauthentication_sent_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN is_sso_user boolean DEFAULT false NOT NULL; - -COMMENT ON COLUMN auth.users.is_sso_user IS 'Auth: Set this column to true when the account comes from SSO. These accounts can have duplicate emails.'; - -ALTER TABLE auth.users - ADD COLUMN deleted_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN is_anonymous boolean DEFAULT false NOT NULL; - -ALTER TABLE auth.users - ADD COLUMN confirmed_at timestamp - with time zone GENERATED ALWAYS AS (LEAST(email_confirmed_at, phone_confirmed_at)) STORED; - -GRANT SELECT ON auth.users TO postgres WITH GRANT OPTION; - -CREATE UNIQUE INDEX email_change_token_current_idx ON auth.users (email_change_token_current) - WHERE email_change_token_current::text !~ '^[0-9 ]*$'::text; - -CREATE UNIQUE INDEX email_change_token_new_idx ON auth.users (email_change_token_new) - WHERE email_change_token_new::text !~ '^[0-9 ]*$'::text; - -CREATE UNIQUE INDEX reauthentication_token_idx ON auth.users (reauthentication_token) - WHERE reauthentication_token::text !~ '^[0-9 ]*$'::text; - -CREATE UNIQUE INDEX users_email_partial_key ON auth.users (email) - WHERE is_sso_user = false; - -CREATE UNIQUE INDEX confirmation_token_idx ON auth.users (confirmation_token) - WHERE confirmation_token::text !~ '^[0-9 ]*$'::text; - -CREATE UNIQUE INDEX recovery_token_idx ON auth.users (recovery_token) - WHERE recovery_token::text !~ '^[0-9 ]*$'::text; - -CREATE INDEX users_is_anonymous_idx ON auth.users (is_anonymous); - -CREATE INDEX users_instance_id_email_idx ON auth.users (instance_id, lower(email::text)); - -CREATE TABLE auth.webauthn_challenges ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - user_id uuid, - challenge_type text NOT NULL, - session_data jsonb NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - expires_at timestamp with time zone NOT NULL -); - -ALTER TABLE auth.webauthn_challenges - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.webauthn_challenges - ADD CONSTRAINT webauthn_challenges_challenge_type_check - CHECK - (challenge_type = ANY (ARRAY['signup'::text, 'registration'::text, 'authentication'::text])); - -ALTER TABLE auth.webauthn_challenges - ADD CONSTRAINT webauthn_challenges_pkey PRIMARY KEY (id); - -ALTER TABLE auth.webauthn_challenges - ADD CONSTRAINT webauthn_challenges_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT ALL ON auth.webauthn_challenges TO postgres; - -GRANT ALL ON auth.webauthn_challenges TO dashboard_user; - -CREATE INDEX webauthn_challenges_expires_at_idx ON auth.webauthn_challenges (expires_at); - -CREATE INDEX webauthn_challenges_user_id_idx ON auth.webauthn_challenges (user_id); - -CREATE TABLE auth.webauthn_credentials ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - user_id uuid NOT NULL, - credential_id bytea NOT NULL, - public_key bytea NOT NULL, - attestation_type text DEFAULT ''::text NOT NULL, - aaguid uuid, - sign_count bigint DEFAULT 0 NOT NULL, - transports jsonb DEFAULT '[]'::jsonb NOT NULL, - backup_eligible boolean DEFAULT false NOT NULL, - backed_up boolean DEFAULT false NOT NULL, - friendly_name text DEFAULT ''::text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - last_used_at timestamp with time zone -); - -ALTER TABLE auth.webauthn_credentials - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.webauthn_credentials - ADD CONSTRAINT webauthn_credentials_pkey PRIMARY KEY (id); - -ALTER TABLE auth.webauthn_credentials - ADD CONSTRAINT webauthn_credentials_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT ALL ON auth.webauthn_credentials TO postgres; - -GRANT ALL ON auth.webauthn_credentials TO dashboard_user; - -CREATE UNIQUE INDEX webauthn_credentials_credential_id_key - ON auth.webauthn_credentials (credential_id); - -CREATE INDEX webauthn_credentials_user_id_idx ON auth.webauthn_credentials (user_id); - -COMMENT ON INDEX auth.identities_email_idx IS 'Auth: Ensures indexed queries on the email column'; - -COMMENT ON INDEX auth.users_email_partial_key IS 'Auth: A partial unique index that applies only when is_sso_user is false'; - -CREATE EXTENSION pg_net WITH SCHEMA extensions; - -COMMENT ON EXTENSION pg_net IS 'Async HTTP'; - -CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() - RETURNS event_trigger - LANGUAGE plpgsql - AS $function$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_event_trigger_ddl_commands() AS ev - JOIN pg_extension AS ext - ON ev.objid = ext.oid - WHERE ext.extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; -END; -$function$; - -GRANT USAGE ON SCHEMA realtime TO anon; - -GRANT USAGE ON SCHEMA realtime TO authenticated; - -GRANT USAGE ON SCHEMA realtime TO service_role; - -GRANT ALL ON SCHEMA realtime TO supabase_realtime_admin; - -CREATE TYPE realtime.action AS ENUM ( - 'INSERT', - 'UPDATE', - 'DELETE', - 'TRUNCATE', - 'ERROR' -); - -CREATE TYPE realtime.equality_op AS ENUM ( - 'eq', - 'neq', - 'lt', - 'lte', - 'gt', - 'gte', - 'in' -); - -CREATE TYPE realtime.user_defined_filter AS ( - column_name text, - op realtime.equality_op, - value text -); - -CREATE TYPE realtime.wal_column AS ( - name text, - type_name text, - type_oid oid, - value jsonb, - is_pkey boolean, - is_selectable boolean -); - -CREATE TYPE realtime.wal_rls AS ( - wal jsonb, - is_rls_enabled boolean, - subscription_ids uuid[], - errors text[] -); - -CREATE FUNCTION realtime."cast" ( - val text, - type_ regtype -) - RETURNS jsonb - LANGUAGE plpgsql - IMMUTABLE - AS $function$ -declare - res jsonb; -begin - if type_::text = 'bytea' then - return to_jsonb(val); - end if; - execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res; - return res; -end -$function$; - -GRANT ALL ON FUNCTION realtime."cast"(text, regtype) TO anon; - -GRANT ALL ON FUNCTION realtime."cast"(text, regtype) TO authenticated; - -GRANT ALL ON FUNCTION realtime."cast"(text, regtype) TO service_role; - -GRANT ALL ON FUNCTION realtime."cast"(text, regtype) TO supabase_realtime_admin; - -CREATE FUNCTION realtime.apply_rls ( - wal jsonb, - max_record_bytes integer DEFAULT (1024 * 1024) -) - RETURNS SETOF realtime.wal_rls - LANGUAGE plpgsql - AS $function$ -declare --- Regclass of the table e.g. public.notes -entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass; - --- I, U, D, T: insert, update ... -action realtime.action = ( - case wal ->> 'action' - when 'I' then 'INSERT' - when 'U' then 'UPDATE' - when 'D' then 'DELETE' - else 'ERROR' - end -); - --- Is row level security enabled for the table -is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_; - -subscriptions realtime.subscription[] = array_agg(subs) - from - realtime.subscription subs - where - subs.entity = entity_ - -- Filter by action early - only get subscriptions interested in this action - -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE' - and (subs.action_filter = '*' or subs.action_filter = action::text); - --- Subscription vars -roles regrole[] = array_agg(distinct us.claims_role::text) - from - unnest(subscriptions) us; - -working_role regrole; -claimed_role regrole; -claims jsonb; - -subscription_id uuid; -subscription_has_access bool; -visible_to_subscription_ids uuid[] = '{}'; - --- structured info for wal's columns -columns realtime.wal_column[]; --- previous identity values for update/delete -old_columns realtime.wal_column[]; - -error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes; - --- Primary jsonb output for record -output jsonb; - -begin -perform set_config('role', null, true); - -columns = - array_agg( - ( - x->>'name', - x->>'type', - x->>'typeoid', - realtime.cast( - (x->'value') #>> '{}', - coalesce( - (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 - (x->>'type')::regtype - ) - ), - (pks ->> 'name') is not null, - true - )::realtime.wal_column - ) - from - jsonb_array_elements(wal -> 'columns') x - left join jsonb_array_elements(wal -> 'pk') pks - on (x ->> 'name') = (pks ->> 'name'); - -old_columns = - array_agg( - ( - x->>'name', - x->>'type', - x->>'typeoid', - realtime.cast( - (x->'value') #>> '{}', - coalesce( - (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 - (x->>'type')::regtype - ) - ), - (pks ->> 'name') is not null, - true - )::realtime.wal_column - ) - from - jsonb_array_elements(wal -> 'identity') x - left join jsonb_array_elements(wal -> 'pk') pks - on (x ->> 'name') = (pks ->> 'name'); - -for working_role in select * from unnest(roles) loop - - -- Update `is_selectable` for columns and old_columns - columns = - array_agg( - ( - c.name, - c.type_name, - c.type_oid, - c.value, - c.is_pkey, - pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') - )::realtime.wal_column - ) - from - unnest(columns) c; - - old_columns = - array_agg( - ( - c.name, - c.type_name, - c.type_oid, - c.value, - c.is_pkey, - pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') - )::realtime.wal_column - ) - from - unnest(old_columns) c; - - if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then - return next ( - jsonb_build_object( - 'schema', wal ->> 'schema', - 'table', wal ->> 'table', - 'type', action - ), - is_rls_enabled, - -- subscriptions is already filtered by entity - (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role), - array['Error 400: Bad Request, no primary key'] - )::realtime.wal_rls; - - -- The claims role does not have SELECT permission to the primary key of entity - elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then - return next ( - jsonb_build_object( - 'schema', wal ->> 'schema', - 'table', wal ->> 'table', - 'type', action - ), - is_rls_enabled, - (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role), - array['Error 401: Unauthorized'] - )::realtime.wal_rls; - - else - output = jsonb_build_object( - 'schema', wal ->> 'schema', - 'table', wal ->> 'table', - 'type', action, - 'commit_timestamp', to_char( - ((wal ->> 'timestamp')::timestamptz at time zone 'utc'), - 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' - ), - 'columns', ( - select - jsonb_agg( - jsonb_build_object( - 'name', pa.attname, - 'type', pt.typname - ) - order by pa.attnum asc - ) - from - pg_attribute pa - join pg_type pt - on pa.atttypid = pt.oid - where - attrelid = entity_ - and attnum > 0 - and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT') - ) - ) - -- Add "record" key for insert and update - || case - when action in ('INSERT', 'UPDATE') then - jsonb_build_object( - 'record', - ( - select - jsonb_object_agg( - -- if unchanged toast, get column name and value from old record - coalesce((c).name, (oc).name), - case - when (c).name is null then (oc).value - else (c).value - end - ) - from - unnest(columns) c - full outer join unnest(old_columns) oc - on (c).name = (oc).name - where - coalesce((c).is_selectable, (oc).is_selectable) - and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) - ) - ) - else '{}'::jsonb - end - -- Add "old_record" key for update and delete - || case - when action = 'UPDATE' then - jsonb_build_object( - 'old_record', - ( - select jsonb_object_agg((c).name, (c).value) - from unnest(old_columns) c - where - (c).is_selectable - and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) - ) - ) - when action = 'DELETE' then - jsonb_build_object( - 'old_record', - ( - select jsonb_object_agg((c).name, (c).value) - from unnest(old_columns) c - where - (c).is_selectable - and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) - and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey - ) - ) - else '{}'::jsonb - end; - - -- Create the prepared statement - if is_rls_enabled and action <> 'DELETE' then - if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then - deallocate walrus_rls_stmt; - end if; - execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns); - end if; - - visible_to_subscription_ids = '{}'; - - for subscription_id, claims in ( - select - subs.subscription_id, - subs.claims - from - unnest(subscriptions) subs - where - subs.entity = entity_ - and subs.claims_role = working_role - and ( - realtime.is_visible_through_filters(columns, subs.filters) - or ( - action = 'DELETE' - and realtime.is_visible_through_filters(old_columns, subs.filters) - ) - ) - ) loop - - if not is_rls_enabled or action = 'DELETE' then - visible_to_subscription_ids = visible_to_subscription_ids || subscription_id; - else - -- Check if RLS allows the role to see the record - perform - -- Trim leading and trailing quotes from working_role because set_config - -- doesn't recognize the role as valid if they are included - set_config('role', trim(both '"' from working_role::text), true), - set_config('request.jwt.claims', claims::text, true); - - execute 'execute walrus_rls_stmt' into subscription_has_access; - - if subscription_has_access then - visible_to_subscription_ids = visible_to_subscription_ids || subscription_id; - end if; - end if; - end loop; - - perform set_config('role', null, true); - - return next ( - output, - is_rls_enabled, - visible_to_subscription_ids, - case - when error_record_exceeds_max_size then array['Error 413: Payload Too Large'] - else '{}' - end - )::realtime.wal_rls; - - end if; -end loop; - -perform set_config('role', null, true); -end; -$function$; - -GRANT ALL ON FUNCTION realtime.apply_rls(jsonb, integer) TO anon; - -GRANT ALL ON FUNCTION realtime.apply_rls(jsonb, integer) TO authenticated; - -GRANT ALL ON FUNCTION realtime.apply_rls(jsonb, integer) TO service_role; - -GRANT ALL ON FUNCTION realtime.apply_rls(jsonb, integer) TO supabase_realtime_admin; - -CREATE FUNCTION realtime.broadcast_changes ( - topic_name text, - event_name text, - operation text, - table_name text, - table_schema text, - new record, - old record, - level text DEFAULT 'ROW'::text -) - RETURNS void - LANGUAGE plpgsql - AS $function$ -DECLARE - -- Declare a variable to hold the JSONB representation of the row - row_data jsonb := '{}'::jsonb; -BEGIN - IF level = 'STATEMENT' THEN - RAISE EXCEPTION 'function can only be triggered for each row, not for each statement'; - END IF; - -- Check the operation type and handle accordingly - IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN - row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema); - PERFORM realtime.send (row_data, event_name, topic_name); - ELSE - RAISE EXCEPTION 'Unexpected operation type: %', operation; - END IF; -EXCEPTION - WHEN OTHERS THEN - RAISE EXCEPTION 'Failed to process the row: %', SQLERRM; -END; - -$function$; - -CREATE FUNCTION realtime.build_prepared_statement_sql ( - prepared_statement_name text, - entity regclass, - columns realtime.wal_column[] -) - RETURNS text - LANGUAGE sql - AS $function$ - /* - Builds a sql string that, if executed, creates a prepared statement to - tests retrive a row from *entity* by its primary key columns. - Example - select realtime.build_prepared_statement_sql('public.notes', '{"id"}'::text[], '{"bigint"}'::text[]) - */ - select - 'prepare ' || prepared_statement_name || ' as - select - exists( - select - 1 - from - ' || entity || ' - where - ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || ' - )' - from - unnest(columns) pkc - where - pkc.is_pkey - group by - entity - $function$; - -GRANT ALL ON FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) - TO anon; - -GRANT ALL ON FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) - TO authenticated; - -GRANT ALL ON FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) - TO service_role; - -GRANT ALL ON FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) - TO supabase_realtime_admin; - -CREATE FUNCTION realtime.check_equality_op ( - op realtime.equality_op, - type_ regtype, - val_1 text, - val_2 text -) - RETURNS boolean - LANGUAGE plpgsql - IMMUTABLE - AS $function$ - /* - Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness - */ - declare - op_symbol text = ( - case - when op = 'eq' then '=' - when op = 'neq' then '!=' - when op = 'lt' then '<' - when op = 'lte' then '<=' - when op = 'gt' then '>' - when op = 'gte' then '>=' - when op = 'in' then '= any' - else 'UNKNOWN OP' - end - ); - res boolean; - begin - execute format( - 'select %L::'|| type_::text || ' ' || op_symbol - || ' ( %L::' - || ( - case - when op = 'in' then type_::text || '[]' - else type_::text end - ) - || ')', val_1, val_2) into res; - return res; - end; - $function$; - -GRANT ALL ON FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) TO anon; - -GRANT ALL ON FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) TO - authenticated; - -GRANT ALL ON FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) TO - service_role; - -GRANT ALL ON FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) TO - supabase_realtime_admin; - -CREATE FUNCTION realtime.is_visible_through_filters ( - columns realtime.wal_column[], - filters realtime.user_defined_filter[] -) - RETURNS boolean - LANGUAGE sql - IMMUTABLE - AS $function$ - /* - Should the record be visible (true) or filtered out (false) after *filters* are applied - */ - select - -- Default to allowed when no filters present - $2 is null -- no filters. this should not happen because subscriptions has a default - or array_length($2, 1) is null -- array length of an empty array is null - or bool_and( - coalesce( - realtime.check_equality_op( - op:=f.op, - type_:=coalesce( - col.type_oid::regtype, -- null when wal2json version <= 2.4 - col.type_name::regtype - ), - -- cast jsonb to text - val_1:=col.value #>> '{}', - val_2:=f.value - ), - false -- if null, filter does not match - ) - ) - from - unnest(filters) f - join unnest(columns) col - on f.column_name = col.name; - $function$; - -GRANT ALL ON FUNCTION - realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) TO anon; - -GRANT ALL ON FUNCTION - realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) TO - authenticated; - -GRANT ALL ON FUNCTION - realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) TO - service_role; - -GRANT ALL ON FUNCTION - realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) TO - supabase_realtime_admin; - -CREATE FUNCTION realtime.list_changes ( - publication name, - slot_name name, - max_changes integer, - max_record_bytes integer -) - RETURNS TABLE ( - wal jsonb, - is_rls_enabled boolean, - subscription_ids uuid[], - errors text[], - slot_changes_count bigint - ) - LANGUAGE sql - SET log_min_messages TO 'fatal' - AS $function$ - WITH pub AS ( - SELECT - concat_ws( - ',', - CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END, - CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END, - CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END - ) AS w2j_actions, - coalesce( - string_agg( - realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass), - ',' - ) filter (WHERE ppt.tablename IS NOT NULL AND ppt.tablename NOT LIKE '% %'), - '' - ) AS w2j_add_tables - FROM pg_publication pp - LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname - WHERE pp.pubname = publication - GROUP BY pp.pubname - LIMIT 1 - ), - -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once - w2j AS MATERIALIZED ( - SELECT x.*, pub.w2j_add_tables - FROM pub, - pg_logical_slot_get_changes( - slot_name, null, max_changes, - 'include-pk', 'true', - 'include-transaction', 'false', - 'include-timestamp', 'true', - 'include-type-oids', 'true', - 'format-version', '2', - 'actions', pub.w2j_actions, - 'add-tables', pub.w2j_add_tables - ) x - ), - -- Count raw slot entries before apply_rls/subscription filter - slot_count AS ( - SELECT count(*)::bigint AS cnt - FROM w2j - WHERE w2j.w2j_add_tables <> '' - ), - -- Apply RLS and filter as before - rls_filtered AS ( - SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors - FROM w2j, - realtime.apply_rls( - wal := w2j.data::jsonb, - max_record_bytes := max_record_bytes - ) xyz(wal, is_rls_enabled, subscription_ids, errors) - WHERE w2j.w2j_add_tables <> '' - AND xyz.subscription_ids[1] IS NOT NULL - ) - -- Real rows with slot count attached - SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt - FROM rls_filtered rf, slot_count sc - - UNION ALL - - -- Sentinel row: always returned when no real rows exist so Elixir can - -- always read slot_changes_count. Identified by wal IS NULL. - SELECT null, null, null, null, sc.cnt - FROM slot_count sc - WHERE NOT EXISTS (SELECT 1 FROM rls_filtered) -$function$; - -CREATE FUNCTION realtime.quote_wal2json ( - entity regclass -) - RETURNS text - LANGUAGE sql - IMMUTABLE - STRICT - AS $function$ - select - ( - select string_agg('' || ch,'') - from unnest(string_to_array(nsp.nspname::text, null)) with ordinality x(ch, idx) - where - not (x.idx = 1 and x.ch = '"') - and not ( - x.idx = array_length(string_to_array(nsp.nspname::text, null), 1) - and x.ch = '"' - ) - ) - || '.' - || ( - select string_agg('' || ch,'') - from unnest(string_to_array(pc.relname::text, null)) with ordinality x(ch, idx) - where - not (x.idx = 1 and x.ch = '"') - and not ( - x.idx = array_length(string_to_array(nsp.nspname::text, null), 1) - and x.ch = '"' - ) - ) - from - pg_class pc - join pg_namespace nsp - on pc.relnamespace = nsp.oid - where - pc.oid = entity - $function$; - -GRANT ALL ON FUNCTION realtime.quote_wal2json(regclass) TO anon; - -GRANT ALL ON FUNCTION realtime.quote_wal2json(regclass) TO authenticated; - -GRANT ALL ON FUNCTION realtime.quote_wal2json(regclass) TO service_role; - -GRANT ALL ON FUNCTION realtime.quote_wal2json(regclass) TO supabase_realtime_admin; - -CREATE FUNCTION realtime.send ( - payload jsonb, - event text, - topic text, - private boolean DEFAULT true -) - RETURNS void - LANGUAGE plpgsql - AS $function$ -DECLARE - generated_id uuid; - final_payload jsonb; -BEGIN - BEGIN - -- Generate a new UUID for the id - generated_id := gen_random_uuid(); - - -- Check if payload has an 'id' key, if not, add the generated UUID - IF payload ? 'id' THEN - final_payload := payload; - ELSE - final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id)); - END IF; - - -- Set the topic configuration - EXECUTE format('SET LOCAL realtime.topic TO %L', topic); - - -- Attempt to insert the message - INSERT INTO realtime.messages (id, payload, event, topic, private, extension) - VALUES (generated_id, final_payload, event, topic, private, 'broadcast'); - EXCEPTION - WHEN OTHERS THEN - -- Capture and notify the error - RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM; - END; -END; -$function$; - -CREATE FUNCTION realtime.subscription_check_filters() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - /* - Validates that the user defined filters for a subscription: - - refer to valid columns that the claimed role may access - - values are coercable to the correct column type - */ - declare - col_names text[] = coalesce( - array_agg(c.column_name order by c.ordinal_position), - '{}'::text[] - ) - from - information_schema.columns c - where - format('%I.%I', c.table_schema, c.table_name)::regclass = new.entity - and pg_catalog.has_column_privilege( - (new.claims ->> 'role'), - format('%I.%I', c.table_schema, c.table_name)::regclass, - c.column_name, - 'SELECT' - ); - filter realtime.user_defined_filter; - col_type regtype; - - in_val jsonb; - begin - for filter in select * from unnest(new.filters) loop - -- Filtered column is valid - if not filter.column_name = any(col_names) then - raise exception 'invalid column for filter %', filter.column_name; - end if; - - -- Type is sanitized and safe for string interpolation - col_type = ( - select atttypid::regtype - from pg_catalog.pg_attribute - where attrelid = new.entity - and attname = filter.column_name - ); - if col_type is null then - raise exception 'failed to lookup type for column %', filter.column_name; - end if; - - -- Set maximum number of entries for in filter - if filter.op = 'in'::realtime.equality_op then - in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype); - if coalesce(jsonb_array_length(in_val), 0) > 100 then - raise exception 'too many values for `in` filter. Maximum 100'; - end if; - else - -- raises an exception if value is not coercable to type - perform realtime.cast(filter.value, col_type); - end if; - - end loop; - - -- Apply consistent order to filters so the unique constraint on - -- (subscription_id, entity, filters) can't be tricked by a different filter order - new.filters = coalesce( - array_agg(f order by f.column_name, f.op, f.value), - '{}' - ) from unnest(new.filters) f; - - return new; - end; - $function$; - -GRANT ALL ON FUNCTION realtime.subscription_check_filters() TO anon; - -GRANT ALL ON FUNCTION realtime.subscription_check_filters() TO authenticated; - -GRANT ALL ON FUNCTION realtime.subscription_check_filters() TO service_role; - -GRANT ALL ON FUNCTION realtime.subscription_check_filters() TO supabase_realtime_admin; - -CREATE FUNCTION realtime.to_regrole ( - role_name text -) - RETURNS regrole - LANGUAGE sql - IMMUTABLE - AS $function$ select role_name::regrole $function$; - -GRANT ALL ON FUNCTION realtime.to_regrole(text) TO anon; - -GRANT ALL ON FUNCTION realtime.to_regrole(text) TO authenticated; - -GRANT ALL ON FUNCTION realtime.to_regrole(text) TO service_role; - -GRANT ALL ON FUNCTION realtime.to_regrole(text) TO supabase_realtime_admin; - -CREATE FUNCTION realtime.topic() - RETURNS text - LANGUAGE sql - STABLE - AS $function$ -select nullif(current_setting('realtime.topic', true), '')::text; -$function$; - -ALTER FUNCTION realtime.topic() OWNER TO supabase_realtime_admin; - -CREATE TABLE realtime.messages ( - topic text NOT NULL, - extension text NOT NULL, - payload jsonb, - event text, - private boolean DEFAULT false, - updated_at timestamp without time zone DEFAULT now() NOT NULL, - inserted_at timestamp without time zone DEFAULT now() NOT NULL, - id uuid DEFAULT gen_random_uuid() NOT NULL -) PARTITION BY RANGE (inserted_at); - -ALTER TABLE realtime.messages - OWNER TO supabase_realtime_admin; - -ALTER TABLE realtime.messages - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE realtime.messages - ADD CONSTRAINT messages_pkey PRIMARY KEY (id, inserted_at); - -GRANT INSERT, SELECT, UPDATE ON realtime.messages TO anon; - -GRANT INSERT, SELECT, UPDATE ON realtime.messages TO authenticated; - -GRANT INSERT, SELECT, UPDATE ON realtime.messages TO service_role; - -CREATE INDEX messages_inserted_at_topic_index ON realtime.messages (inserted_at DESC, topic) - WHERE extension = 'broadcast'::text AND private IS TRUE; - -CREATE TABLE realtime.messages_2026_04_15 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-15 00:00:00') TO ('2026-04-16 00:00:00'); - -CREATE TABLE realtime.messages_2026_04_16 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-16 00:00:00') TO ('2026-04-17 00:00:00'); - -CREATE TABLE realtime.messages_2026_04_17 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-17 00:00:00') TO ('2026-04-18 00:00:00'); - -CREATE TABLE realtime.messages_2026_04_18 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-18 00:00:00') TO ('2026-04-19 00:00:00'); - -CREATE TABLE realtime.messages_2026_04_19 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-19 00:00:00') TO ('2026-04-20 00:00:00'); - -CREATE TABLE realtime.schema_migrations ( - version bigint NOT NULL, - inserted_at timestamp(0) without time zone -); - -ALTER TABLE realtime.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - -GRANT SELECT ON realtime.schema_migrations TO anon; - -GRANT SELECT ON realtime.schema_migrations TO authenticated; - -GRANT SELECT ON realtime.schema_migrations TO service_role; - -GRANT ALL ON realtime.schema_migrations TO supabase_realtime_admin; - -CREATE TABLE realtime.subscription ( - id bigint GENERATED ALWAYS AS IDENTITY NOT NULL, - subscription_id uuid NOT NULL, - entity regclass NOT NULL, - filters realtime.user_defined_filter[] DEFAULT '{}'::realtime.user_defined_filter[] - NOT NULL, - claims jsonb NOT NULL, - claims_role regrole GENERATED ALWAYS AS - (realtime.to_regrole((claims ->> 'role'::text))) STORED NOT NULL, - created_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, - action_filter text DEFAULT '*'::text -); - -ALTER TABLE realtime.subscription - ADD CONSTRAINT pk_subscription PRIMARY KEY (id); - -ALTER TABLE realtime.subscription - ADD CONSTRAINT subscription_action_filter_check - CHECK (action_filter = ANY (ARRAY['*'::text, 'INSERT'::text, 'UPDATE'::text, 'DELETE'::text])); - -GRANT SELECT ON realtime.subscription TO anon; - -GRANT SELECT ON realtime.subscription TO authenticated; - -GRANT SELECT ON realtime.subscription TO service_role; - -GRANT ALL ON realtime.subscription TO supabase_realtime_admin; - -CREATE INDEX ix_realtime_subscription_entity ON realtime.subscription (entity); - -CREATE UNIQUE INDEX subscription_subscription_id_entity_filters_action_filter_key - ON realtime.subscription (subscription_id, entity, filters, action_filter); - -CREATE TRIGGER tr_check_filters - BEFORE INSERT OR UPDATE ON realtime.subscription - FOR EACH ROW - EXECUTE FUNCTION realtime.subscription_check_filters(); - -CREATE TYPE storage.buckettype AS ENUM ( - 'STANDARD', - 'ANALYTICS', - 'VECTOR' -); - -ALTER TYPE storage.buckettype OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.allow_any_operation ( - expected_operations text[] -) - RETURNS boolean - LANGUAGE sql - STABLE - AS $function$ - WITH current_operation AS ( - SELECT storage.operation() AS raw_operation - ), - normalized AS ( - SELECT CASE - WHEN raw_operation LIKE 'storage.%' THEN substr(raw_operation, 9) - ELSE raw_operation - END AS current_operation - FROM current_operation - ) - SELECT EXISTS ( - SELECT 1 - FROM normalized n - CROSS JOIN LATERAL unnest(expected_operations) AS expected_operation - WHERE expected_operation IS NOT NULL - AND expected_operation <> '' - AND n.current_operation = CASE - WHEN expected_operation LIKE 'storage.%' THEN substr(expected_operation, 9) - ELSE expected_operation - END - ); -$function$; - -ALTER FUNCTION storage.allow_any_operation(text[]) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.allow_only_operation ( - expected_operation text -) - RETURNS boolean - LANGUAGE sql - STABLE - AS $function$ - WITH current_operation AS ( - SELECT storage.operation() AS raw_operation - ), - normalized AS ( - SELECT - CASE - WHEN raw_operation LIKE 'storage.%' THEN substr(raw_operation, 9) - ELSE raw_operation - END AS current_operation, - CASE - WHEN expected_operation LIKE 'storage.%' THEN substr(expected_operation, 9) - ELSE expected_operation - END AS requested_operation - FROM current_operation - ) - SELECT CASE - WHEN requested_operation IS NULL OR requested_operation = '' THEN FALSE - ELSE COALESCE(current_operation = requested_operation, FALSE) - END - FROM normalized; -$function$; - -ALTER FUNCTION storage.allow_only_operation(text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.can_insert_object ( - bucketid text, - name text, - owner uuid, - metadata jsonb -) - RETURNS void - LANGUAGE plpgsql - AS $function$ -BEGIN - INSERT INTO "storage"."objects" ("bucket_id", "name", "owner", "metadata") VALUES (bucketid, name, owner, metadata); - -- hack to rollback the successful insert - RAISE sqlstate 'PT200' using - message = 'ROLLBACK', - detail = 'rollback successful insert'; -END -$function$; - -ALTER FUNCTION storage.can_insert_object(text, text, uuid, jsonb) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.enforce_bucket_name_length() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ -begin - if length(new.name) > 100 then - raise exception 'bucket name "%" is too long (% characters). Max is 100.', new.name, length(new.name); - end if; - return new; -end; -$function$; - -ALTER FUNCTION storage.enforce_bucket_name_length() OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.extension ( - name text -) - RETURNS text - LANGUAGE plpgsql - AS $function$ -DECLARE -_parts text[]; -_filename text; -BEGIN - select string_to_array(name, '/') into _parts; - select _parts[array_length(_parts,1)] into _filename; - -- @todo return the last part instead of 2 - return reverse(split_part(reverse(_filename), '.', 1)); -END -$function$; - -ALTER FUNCTION storage.extension(text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.filename ( - name text -) - RETURNS text - LANGUAGE plpgsql - AS $function$ -DECLARE -_parts text[]; -BEGIN - select string_to_array(name, '/') into _parts; - return _parts[array_length(_parts,1)]; -END -$function$; - -ALTER FUNCTION storage.filename(text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.foldername ( - name text -) - RETURNS text[] - LANGUAGE plpgsql - AS $function$ -DECLARE -_parts text[]; -BEGIN - select string_to_array(name, '/') into _parts; - return _parts[1:array_length(_parts,1)-1]; -END -$function$; - -ALTER FUNCTION storage.foldername(text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.get_common_prefix ( - p_key text, - p_prefix text, - p_delimiter text -) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$ -SELECT CASE - WHEN position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1)) > 0 - THEN left(p_key, length(p_prefix) + position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1))) - ELSE NULL -END; -$function$; - -ALTER FUNCTION storage.get_common_prefix(text, text, text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.get_size_by_bucket() - RETURNS TABLE ( - size bigint, - bucket_id text - ) - LANGUAGE plpgsql - AS $function$ -BEGIN - return query - select sum((metadata->>'size')::int) as size, obj.bucket_id - from "storage".objects as obj - group by obj.bucket_id; -END -$function$; - -ALTER FUNCTION storage.get_size_by_bucket() OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.list_multipart_uploads_with_delimiter ( - bucket_id text, - prefix_param text, - delimiter_param text, - max_keys integer DEFAULT 100, - next_key_token text DEFAULT ''::text, - next_upload_token text DEFAULT ''::text -) - RETURNS TABLE ( - key text, - id text, - created_at timestamp with time zone - ) - LANGUAGE plpgsql - AS $function$ -BEGIN - RETURN QUERY EXECUTE - 'SELECT DISTINCT ON(key COLLATE "C") * from ( - SELECT - CASE - WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN - substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) - ELSE - key - END AS key, id, created_at - FROM - storage.s3_multipart_uploads - WHERE - bucket_id = $5 AND - key ILIKE $1 || ''%'' AND - CASE - WHEN $4 != '''' AND $6 = '''' THEN - CASE - WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN - substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) COLLATE "C" > $4 - ELSE - key COLLATE "C" > $4 - END - ELSE - true - END AND - CASE - WHEN $6 != '''' THEN - id COLLATE "C" > $6 - ELSE - true - END - ORDER BY - key COLLATE "C" ASC, created_at ASC) as e order by key COLLATE "C" LIMIT $3' - USING prefix_param, delimiter_param, max_keys, next_key_token, bucket_id, next_upload_token; -END; -$function$; - -ALTER FUNCTION storage.list_multipart_uploads_with_delimiter(text, text, text, integer, text, text) - OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.list_objects_with_delimiter ( - _bucket_id text, - prefix_param text, - delimiter_param text, - max_keys integer DEFAULT 100, - start_after text DEFAULT ''::text, - next_token text DEFAULT ''::text, - sort_order text DEFAULT 'asc'::text -) - RETURNS TABLE ( - name text, - id uuid, - metadata jsonb, - updated_at timestamp with time zone, - created_at timestamp with time zone, - last_accessed_at timestamp with time zone - ) - LANGUAGE plpgsql - STABLE - AS $function$ -DECLARE - v_peek_name TEXT; - v_current RECORD; - v_common_prefix TEXT; - - -- Configuration - v_is_asc BOOLEAN; - v_prefix TEXT; - v_start TEXT; - v_upper_bound TEXT; - v_file_batch_size INT; - - -- Seek state - v_next_seek TEXT; - v_count INT := 0; - - -- Dynamic SQL for batch query only - v_batch_query TEXT; - -BEGIN - -- ======================================================================== - -- INITIALIZATION - -- ======================================================================== - v_is_asc := lower(coalesce(sort_order, 'asc')) = 'asc'; - v_prefix := coalesce(prefix_param, ''); - v_start := CASE WHEN coalesce(next_token, '') <> '' THEN next_token ELSE coalesce(start_after, '') END; - v_file_batch_size := LEAST(GREATEST(max_keys * 2, 100), 1000); - - -- Calculate upper bound for prefix filtering (bytewise, using COLLATE "C") - IF v_prefix = '' THEN - v_upper_bound := NULL; - ELSIF right(v_prefix, 1) = delimiter_param THEN - v_upper_bound := left(v_prefix, -1) || chr(ascii(delimiter_param) + 1); - ELSE - v_upper_bound := left(v_prefix, -1) || chr(ascii(right(v_prefix, 1)) + 1); - END IF; - - -- Build batch query (dynamic SQL - called infrequently, amortized over many rows) - IF v_is_asc THEN - IF v_upper_bound IS NOT NULL THEN - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' || - 'AND o.name COLLATE "C" < $3 ORDER BY o.name COLLATE "C" ASC LIMIT $4'; - ELSE - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' || - 'ORDER BY o.name COLLATE "C" ASC LIMIT $4'; - END IF; - ELSE - IF v_upper_bound IS NOT NULL THEN - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' || - 'AND o.name COLLATE "C" >= $3 ORDER BY o.name COLLATE "C" DESC LIMIT $4'; - ELSE - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' || - 'ORDER BY o.name COLLATE "C" DESC LIMIT $4'; - END IF; - END IF; - - -- ======================================================================== - -- SEEK INITIALIZATION: Determine starting position - -- ======================================================================== - IF v_start = '' THEN - IF v_is_asc THEN - v_next_seek := v_prefix; - ELSE - -- DESC without cursor: find the last item in range - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_next_seek FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix AND o.name COLLATE "C" < v_upper_bound - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - ELSIF v_prefix <> '' THEN - SELECT o.name INTO v_next_seek FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - ELSE - SELECT o.name INTO v_next_seek FROM storage.objects o - WHERE o.bucket_id = _bucket_id - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - END IF; - - IF v_next_seek IS NOT NULL THEN - v_next_seek := v_next_seek || delimiter_param; - ELSE - RETURN; - END IF; - END IF; - ELSE - -- Cursor provided: determine if it refers to a folder or leaf - IF EXISTS ( - SELECT 1 FROM storage.objects o - WHERE o.bucket_id = _bucket_id - AND o.name COLLATE "C" LIKE v_start || delimiter_param || '%' - LIMIT 1 - ) THEN - -- Cursor refers to a folder - IF v_is_asc THEN - v_next_seek := v_start || chr(ascii(delimiter_param) + 1); - ELSE - v_next_seek := v_start || delimiter_param; - END IF; - ELSE - -- Cursor refers to a leaf object - IF v_is_asc THEN - v_next_seek := v_start || delimiter_param; - ELSE - v_next_seek := v_start; - END IF; - END IF; - END IF; - - -- ======================================================================== - -- MAIN LOOP: Hybrid peek-then-batch algorithm - -- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch - -- ======================================================================== - LOOP - EXIT WHEN v_count >= max_keys; - - -- STEP 1: PEEK using STATIC SQL (plan cached, very fast) - IF v_is_asc THEN - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek AND o.name COLLATE "C" < v_upper_bound - ORDER BY o.name COLLATE "C" ASC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek - ORDER BY o.name COLLATE "C" ASC LIMIT 1; - END IF; - ELSE - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - ELSIF v_prefix <> '' THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - END IF; - END IF; - - EXIT WHEN v_peek_name IS NULL; - - -- STEP 2: Check if this is a FOLDER or FILE - v_common_prefix := storage.get_common_prefix(v_peek_name, v_prefix, delimiter_param); - - IF v_common_prefix IS NOT NULL THEN - -- FOLDER: Emit and skip to next folder (no heap access needed) - name := rtrim(v_common_prefix, delimiter_param); - id := NULL; - updated_at := NULL; - created_at := NULL; - last_accessed_at := NULL; - metadata := NULL; - RETURN NEXT; - v_count := v_count + 1; - - -- Advance seek past the folder range - IF v_is_asc THEN - v_next_seek := left(v_common_prefix, -1) || chr(ascii(delimiter_param) + 1); - ELSE - v_next_seek := v_common_prefix; - END IF; - ELSE - -- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows) - -- For ASC: upper_bound is the exclusive upper limit (< condition) - -- For DESC: prefix is the inclusive lower limit (>= condition) - FOR v_current IN EXECUTE v_batch_query USING _bucket_id, v_next_seek, - CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix) ELSE v_prefix END, v_file_batch_size - LOOP - v_common_prefix := storage.get_common_prefix(v_current.name, v_prefix, delimiter_param); - - IF v_common_prefix IS NOT NULL THEN - -- Hit a folder: exit batch, let peek handle it - v_next_seek := v_current.name; - EXIT; - END IF; - - -- Emit file - name := v_current.name; - id := v_current.id; - updated_at := v_current.updated_at; - created_at := v_current.created_at; - last_accessed_at := v_current.last_accessed_at; - metadata := v_current.metadata; - RETURN NEXT; - v_count := v_count + 1; - - -- Advance seek past this file - IF v_is_asc THEN - v_next_seek := v_current.name || delimiter_param; - ELSE - v_next_seek := v_current.name; - END IF; - - EXIT WHEN v_count >= max_keys; - END LOOP; - END IF; - END LOOP; -END; -$function$; - -ALTER FUNCTION storage.list_objects_with_delimiter(text, text, text, integer, text, text, text) - OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.operation() - RETURNS text - LANGUAGE plpgsql - STABLE - AS $function$ -BEGIN - RETURN current_setting('storage.operation', true); -END; -$function$; - -ALTER FUNCTION storage.operation() OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.protect_delete() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ -BEGIN - -- Check if storage.allow_delete_query is set to 'true' - IF COALESCE(current_setting('storage.allow_delete_query', true), 'false') != 'true' THEN - RAISE EXCEPTION 'Direct deletion from storage tables is not allowed. Use the Storage API instead.' - USING HINT = 'This prevents accidental data loss from orphaned objects.', - ERRCODE = '42501'; - END IF; - RETURN NULL; -END; -$function$; - -ALTER FUNCTION storage.protect_delete() OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.search_by_timestamp ( - p_prefix text, - p_bucket_id text, - p_limit integer, - p_level integer, - p_start_after text, - p_sort_order text, - p_sort_column text, - p_sort_column_after text -) - RETURNS TABLE ( - key text, - name text, - id uuid, - updated_at timestamp with time zone, - created_at timestamp with time zone, - last_accessed_at timestamp with time zone, - metadata jsonb - ) - LANGUAGE plpgsql - STABLE - AS $function$ -DECLARE - v_cursor_op text; - v_query text; - v_prefix text; -BEGIN - v_prefix := coalesce(p_prefix, ''); - - IF p_sort_order = 'asc' THEN - v_cursor_op := '>'; - ELSE - v_cursor_op := '<'; - END IF; - - v_query := format($sql$ - WITH raw_objects AS ( - SELECT - o.name AS obj_name, - o.id AS obj_id, - o.updated_at AS obj_updated_at, - o.created_at AS obj_created_at, - o.last_accessed_at AS obj_last_accessed_at, - o.metadata AS obj_metadata, - storage.get_common_prefix(o.name, $1, '/') AS common_prefix - FROM storage.objects o - WHERE o.bucket_id = $2 - AND o.name COLLATE "C" LIKE $1 || '%%' - ), - -- Aggregate common prefixes (folders) - -- Both created_at and updated_at use MIN(obj_created_at) to match the old prefixes table behavior - aggregated_prefixes AS ( - SELECT - rtrim(common_prefix, '/') AS name, - NULL::uuid AS id, - MIN(obj_created_at) AS updated_at, - MIN(obj_created_at) AS created_at, - NULL::timestamptz AS last_accessed_at, - NULL::jsonb AS metadata, - TRUE AS is_prefix - FROM raw_objects - WHERE common_prefix IS NOT NULL - GROUP BY common_prefix - ), - leaf_objects AS ( - SELECT - obj_name AS name, - obj_id AS id, - obj_updated_at AS updated_at, - obj_created_at AS created_at, - obj_last_accessed_at AS last_accessed_at, - obj_metadata AS metadata, - FALSE AS is_prefix - FROM raw_objects - WHERE common_prefix IS NULL - ), - combined AS ( - SELECT * FROM aggregated_prefixes - UNION ALL - SELECT * FROM leaf_objects - ), - filtered AS ( - SELECT * - FROM combined - WHERE ( - $5 = '' - OR ROW( - date_trunc('milliseconds', %I), - name COLLATE "C" - ) %s ROW( - COALESCE(NULLIF($6, '')::timestamptz, 'epoch'::timestamptz), - $5 - ) - ) - ) - SELECT - split_part(name, '/', $3) AS key, - name, - id, - updated_at, - created_at, - last_accessed_at, - metadata - FROM filtered - ORDER BY - COALESCE(date_trunc('milliseconds', %I), 'epoch'::timestamptz) %s, - name COLLATE "C" %s - LIMIT $4 - $sql$, - p_sort_column, - v_cursor_op, - p_sort_column, - p_sort_order, - p_sort_order - ); - - RETURN QUERY EXECUTE v_query - USING v_prefix, p_bucket_id, p_level, p_limit, p_start_after, p_sort_column_after; -END; -$function$; - -ALTER FUNCTION storage.search_by_timestamp(text, text, integer, integer, text, text, text, text) - OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.search_v2 ( - prefix text, - bucket_name text, - limits integer DEFAULT 100, - levels integer DEFAULT 1, - start_after text DEFAULT ''::text, - sort_order text DEFAULT 'asc'::text, - sort_column text DEFAULT 'name'::text, - sort_column_after text DEFAULT ''::text -) - RETURNS TABLE ( - key text, - name text, - id uuid, - updated_at timestamp with time zone, - created_at timestamp with time zone, - last_accessed_at timestamp with time zone, - metadata jsonb - ) - LANGUAGE plpgsql - STABLE - AS $function$ -DECLARE - v_sort_col text; - v_sort_ord text; - v_limit int; -BEGIN - -- Cap limit to maximum of 1500 records - v_limit := LEAST(coalesce(limits, 100), 1500); - - -- Validate and normalize sort_order - v_sort_ord := lower(coalesce(sort_order, 'asc')); - IF v_sort_ord NOT IN ('asc', 'desc') THEN - v_sort_ord := 'asc'; - END IF; - - -- Validate and normalize sort_column - v_sort_col := lower(coalesce(sort_column, 'name')); - IF v_sort_col NOT IN ('name', 'updated_at', 'created_at') THEN - v_sort_col := 'name'; - END IF; - - -- Route to appropriate implementation - IF v_sort_col = 'name' THEN - -- Use list_objects_with_delimiter for name sorting (most efficient: O(k * log n)) - RETURN QUERY - SELECT - split_part(l.name, '/', levels) AS key, - l.name AS name, - l.id, - l.updated_at, - l.created_at, - l.last_accessed_at, - l.metadata - FROM storage.list_objects_with_delimiter( - bucket_name, - coalesce(prefix, ''), - '/', - v_limit, - start_after, - '', - v_sort_ord - ) l; - ELSE - -- Use aggregation approach for timestamp sorting - -- Not efficient for large datasets but supports correct pagination - RETURN QUERY SELECT * FROM storage.search_by_timestamp( - prefix, bucket_name, v_limit, levels, start_after, - v_sort_ord, v_sort_col, sort_column_after - ); - END IF; -END; -$function$; - -ALTER FUNCTION storage.search_v2(text, text, integer, integer, text, text, text, text) OWNER TO - supabase_storage_admin; - -CREATE FUNCTION storage.search ( - prefix text, - bucketname text, - limits integer DEFAULT 100, - levels integer DEFAULT 1, - offsets integer DEFAULT 0, - search text DEFAULT ''::text, - sortcolumn text DEFAULT 'name'::text, - sortorder text DEFAULT 'asc'::text -) - RETURNS TABLE ( - name text, - id uuid, - updated_at timestamp with time zone, - created_at timestamp with time zone, - last_accessed_at timestamp with time zone, - metadata jsonb - ) - LANGUAGE plpgsql - STABLE - AS $function$ -DECLARE - v_peek_name TEXT; - v_current RECORD; - v_common_prefix TEXT; - v_delimiter CONSTANT TEXT := '/'; - - -- Configuration - v_limit INT; - v_prefix TEXT; - v_prefix_lower TEXT; - v_is_asc BOOLEAN; - v_order_by TEXT; - v_sort_order TEXT; - v_upper_bound TEXT; - v_file_batch_size INT; - - -- Dynamic SQL for batch query only - v_batch_query TEXT; - - -- Seek state - v_next_seek TEXT; - v_count INT := 0; - v_skipped INT := 0; -BEGIN - -- ======================================================================== - -- INITIALIZATION - -- ======================================================================== - v_limit := LEAST(coalesce(limits, 100), 1500); - v_prefix := coalesce(prefix, '') || coalesce(search, ''); - v_prefix_lower := lower(v_prefix); - v_is_asc := lower(coalesce(sortorder, 'asc')) = 'asc'; - v_file_batch_size := LEAST(GREATEST(v_limit * 2, 100), 1000); - - -- Validate sort column - CASE lower(coalesce(sortcolumn, 'name')) - WHEN 'name' THEN v_order_by := 'name'; - WHEN 'updated_at' THEN v_order_by := 'updated_at'; - WHEN 'created_at' THEN v_order_by := 'created_at'; - WHEN 'last_accessed_at' THEN v_order_by := 'last_accessed_at'; - ELSE v_order_by := 'name'; - END CASE; - - v_sort_order := CASE WHEN v_is_asc THEN 'asc' ELSE 'desc' END; - - -- ======================================================================== - -- NON-NAME SORTING: Use path_tokens approach (unchanged) - -- ======================================================================== - IF v_order_by != 'name' THEN - RETURN QUERY EXECUTE format( - $sql$ - WITH folders AS ( - SELECT path_tokens[$1] AS folder - FROM storage.objects - WHERE objects.name ILIKE $2 || '%%' - AND bucket_id = $3 - AND array_length(objects.path_tokens, 1) <> $1 - GROUP BY folder - ORDER BY folder %s - ) - (SELECT folder AS "name", - NULL::uuid AS id, - NULL::timestamptz AS updated_at, - NULL::timestamptz AS created_at, - NULL::timestamptz AS last_accessed_at, - NULL::jsonb AS metadata FROM folders) - UNION ALL - (SELECT path_tokens[$1] AS "name", - id, updated_at, created_at, last_accessed_at, metadata - FROM storage.objects - WHERE objects.name ILIKE $2 || '%%' - AND bucket_id = $3 - AND array_length(objects.path_tokens, 1) = $1 - ORDER BY %I %s) - LIMIT $4 OFFSET $5 - $sql$, v_sort_order, v_order_by, v_sort_order - ) USING levels, v_prefix, bucketname, v_limit, offsets; - RETURN; - END IF; - - -- ======================================================================== - -- NAME SORTING: Hybrid skip-scan with batch optimization - -- ======================================================================== - - -- Calculate upper bound for prefix filtering - IF v_prefix_lower = '' THEN - v_upper_bound := NULL; - ELSIF right(v_prefix_lower, 1) = v_delimiter THEN - v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(v_delimiter) + 1); - ELSE - v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(right(v_prefix_lower, 1)) + 1); - END IF; - - -- Build batch query (dynamic SQL - called infrequently, amortized over many rows) - IF v_is_asc THEN - IF v_upper_bound IS NOT NULL THEN - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' || - 'AND lower(o.name) COLLATE "C" < $3 ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4'; - ELSE - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' || - 'ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4'; - END IF; - ELSE - IF v_upper_bound IS NOT NULL THEN - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' || - 'AND lower(o.name) COLLATE "C" >= $3 ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4'; - ELSE - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' || - 'ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4'; - END IF; - END IF; - - -- Initialize seek position - IF v_is_asc THEN - v_next_seek := v_prefix_lower; - ELSE - -- DESC: find the last item in range first (static SQL) - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower AND lower(o.name) COLLATE "C" < v_upper_bound - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - ELSIF v_prefix_lower <> '' THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - END IF; - - IF v_peek_name IS NOT NULL THEN - v_next_seek := lower(v_peek_name) || v_delimiter; - ELSE - RETURN; - END IF; - END IF; - - -- ======================================================================== - -- MAIN LOOP: Hybrid peek-then-batch algorithm - -- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch - -- ======================================================================== - LOOP - EXIT WHEN v_count >= v_limit; - - -- STEP 1: PEEK using STATIC SQL (plan cached, very fast) - IF v_is_asc THEN - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek AND lower(o.name) COLLATE "C" < v_upper_bound - ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek - ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1; - END IF; - ELSE - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - ELSIF v_prefix_lower <> '' THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - END IF; - END IF; - - EXIT WHEN v_peek_name IS NULL; - - -- STEP 2: Check if this is a FOLDER or FILE - v_common_prefix := storage.get_common_prefix(lower(v_peek_name), v_prefix_lower, v_delimiter); - - IF v_common_prefix IS NOT NULL THEN - -- FOLDER: Handle offset, emit if needed, skip to next folder - IF v_skipped < offsets THEN - v_skipped := v_skipped + 1; - ELSE - name := split_part(rtrim(storage.get_common_prefix(v_peek_name, v_prefix, v_delimiter), v_delimiter), v_delimiter, levels); - id := NULL; - updated_at := NULL; - created_at := NULL; - last_accessed_at := NULL; - metadata := NULL; - RETURN NEXT; - v_count := v_count + 1; - END IF; - - -- Advance seek past the folder range - IF v_is_asc THEN - v_next_seek := lower(left(v_common_prefix, -1)) || chr(ascii(v_delimiter) + 1); - ELSE - v_next_seek := lower(v_common_prefix); - END IF; - ELSE - -- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows) - -- For ASC: upper_bound is the exclusive upper limit (< condition) - -- For DESC: prefix_lower is the inclusive lower limit (>= condition) - FOR v_current IN EXECUTE v_batch_query - USING bucketname, v_next_seek, - CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix_lower) ELSE v_prefix_lower END, v_file_batch_size - LOOP - v_common_prefix := storage.get_common_prefix(lower(v_current.name), v_prefix_lower, v_delimiter); - - IF v_common_prefix IS NOT NULL THEN - -- Hit a folder: exit batch, let peek handle it - v_next_seek := lower(v_current.name); - EXIT; - END IF; - - -- Handle offset skipping - IF v_skipped < offsets THEN - v_skipped := v_skipped + 1; - ELSE - -- Emit file - name := split_part(v_current.name, v_delimiter, levels); - id := v_current.id; - updated_at := v_current.updated_at; - created_at := v_current.created_at; - last_accessed_at := v_current.last_accessed_at; - metadata := v_current.metadata; - RETURN NEXT; - v_count := v_count + 1; - END IF; - - -- Advance seek past this file - IF v_is_asc THEN - v_next_seek := lower(v_current.name) || v_delimiter; - ELSE - v_next_seek := lower(v_current.name); - END IF; - - EXIT WHEN v_count >= v_limit; - END LOOP; - END IF; - END LOOP; -END; -$function$; - -ALTER FUNCTION storage.search(text, text, integer, integer, integer, text, text, text) OWNER TO - supabase_storage_admin; - -CREATE FUNCTION storage.update_updated_at_column() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -$function$; - -ALTER FUNCTION storage.update_updated_at_column() OWNER TO supabase_storage_admin; - -CREATE TABLE storage.buckets ( - id text NOT NULL, - name text NOT NULL, - owner uuid, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now(), - public boolean DEFAULT false, - avif_autodetection boolean DEFAULT false, - file_size_limit bigint, - allowed_mime_types text[], - owner_id text, - type storage.buckettype DEFAULT 'STANDARD'::storage.buckettype NOT NULL -); - -COMMENT ON COLUMN storage.buckets.owner IS 'Field is deprecated, use owner_id instead'; - -ALTER TABLE storage.buckets - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.buckets - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.buckets - ADD CONSTRAINT buckets_pkey PRIMARY KEY (id); - -GRANT ALL ON storage.buckets TO postgres WITH GRANT OPTION; - -GRANT ALL ON storage.buckets TO anon; - -GRANT ALL ON storage.buckets TO authenticated; - -GRANT ALL ON storage.buckets TO service_role; - -CREATE UNIQUE INDEX bname ON storage.buckets (name); - -CREATE TRIGGER enforce_bucket_name_length_trigger - BEFORE INSERT OR UPDATE OF name ON storage.buckets - FOR EACH ROW - EXECUTE FUNCTION storage.enforce_bucket_name_length(); - -CREATE TRIGGER protect_buckets_delete - BEFORE DELETE ON storage.buckets - FOR EACH STATEMENT - EXECUTE FUNCTION storage.protect_delete(); - -CREATE TABLE storage.buckets_analytics ( - name text NOT NULL, - type storage.buckettype DEFAULT 'ANALYTICS'::storage.buckettype NOT NULL, - format text DEFAULT 'ICEBERG'::text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - id uuid DEFAULT gen_random_uuid() NOT NULL, - deleted_at timestamp with time zone -); - -ALTER TABLE storage.buckets_analytics - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.buckets_analytics - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.buckets_analytics - ADD CONSTRAINT buckets_analytics_pkey PRIMARY KEY (id); - -GRANT ALL ON storage.buckets_analytics TO anon; - -GRANT ALL ON storage.buckets_analytics TO authenticated; - -GRANT ALL ON storage.buckets_analytics TO service_role; - -CREATE UNIQUE INDEX buckets_analytics_unique_name_idx ON storage.buckets_analytics (name) - WHERE deleted_at IS NULL; - -CREATE TABLE storage.buckets_vectors ( - id text NOT NULL, - type storage.buckettype DEFAULT 'VECTOR'::storage.buckettype NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE storage.buckets_vectors - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.buckets_vectors - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.buckets_vectors - ADD CONSTRAINT buckets_vectors_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.buckets_vectors TO anon; - -GRANT SELECT ON storage.buckets_vectors TO authenticated; - -GRANT SELECT ON storage.buckets_vectors TO service_role; - -CREATE TABLE storage.iceberg_namespaces ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - bucket_name text NOT NULL, - name text COLLATE "C" NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - metadata jsonb DEFAULT '{}'::jsonb NOT NULL, - catalog_id uuid NOT NULL -); - -ALTER TABLE storage.iceberg_namespaces - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.iceberg_namespaces - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.iceberg_namespaces - ADD CONSTRAINT iceberg_namespaces_catalog_id_fkey FOREIGN KEY (catalog_id) - REFERENCES storage.buckets_analytics(id) ON DELETE CASCADE; - -ALTER TABLE storage.iceberg_namespaces - ADD CONSTRAINT iceberg_namespaces_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.iceberg_namespaces TO anon; - -GRANT SELECT ON storage.iceberg_namespaces TO authenticated; - -GRANT ALL ON storage.iceberg_namespaces TO service_role; - -CREATE UNIQUE INDEX idx_iceberg_namespaces_bucket_id - ON storage.iceberg_namespaces (catalog_id, name); - -CREATE TABLE storage.iceberg_tables ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - namespace_id uuid NOT NULL, - bucket_name text NOT NULL, - name text COLLATE "C" NOT NULL, - location text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - remote_table_id text, - shard_key text, - shard_id text, - catalog_id uuid NOT NULL -); - -ALTER TABLE storage.iceberg_tables - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.iceberg_tables - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.iceberg_tables - ADD CONSTRAINT iceberg_tables_catalog_id_fkey FOREIGN KEY (catalog_id) - REFERENCES storage.buckets_analytics(id) ON DELETE CASCADE; - -ALTER TABLE storage.iceberg_tables - ADD CONSTRAINT iceberg_tables_namespace_id_fkey FOREIGN KEY (namespace_id) - REFERENCES storage.iceberg_namespaces(id) ON DELETE CASCADE; - -ALTER TABLE storage.iceberg_tables - ADD CONSTRAINT iceberg_tables_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.iceberg_tables TO anon; - -GRANT SELECT ON storage.iceberg_tables TO authenticated; - -GRANT ALL ON storage.iceberg_tables TO service_role; - -CREATE UNIQUE INDEX idx_iceberg_tables_namespace_id - ON storage.iceberg_tables (catalog_id, namespace_id, name); - -CREATE UNIQUE INDEX idx_iceberg_tables_location ON storage.iceberg_tables (location); - -CREATE TABLE storage.migrations ( - id integer NOT NULL, - name character varying(100) NOT NULL, - hash character varying(40) NOT NULL, - executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER TABLE storage.migrations - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.migrations - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.migrations - ADD CONSTRAINT migrations_name_key UNIQUE (name); - -ALTER TABLE storage.migrations - ADD CONSTRAINT migrations_pkey PRIMARY KEY (id); - -CREATE TABLE storage.objects ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - bucket_id text, - name text, - owner uuid, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now(), - last_accessed_at timestamp with time zone DEFAULT now(), - metadata jsonb, - path_tokens text[] GENERATED ALWAYS AS (string_to_array(name, '/'::text)) - STORED, - version text, - owner_id text, - user_metadata jsonb -); - -COMMENT ON COLUMN storage.objects.owner IS 'Field is deprecated, use owner_id instead'; - -ALTER TABLE storage.objects - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.objects - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.objects - ADD CONSTRAINT "objects_bucketId_fkey" FOREIGN KEY (bucket_id) REFERENCES storage.buckets(id); - -ALTER TABLE storage.objects - ADD CONSTRAINT objects_pkey PRIMARY KEY (id); - -GRANT ALL ON storage.objects TO postgres WITH GRANT OPTION; - -GRANT ALL ON storage.objects TO anon; - -GRANT ALL ON storage.objects TO authenticated; - -GRANT ALL ON storage.objects TO service_role; - -CREATE INDEX idx_objects_bucket_id_name_lower - ON storage.objects (bucket_id, lower(name) COLLATE "C"); - -CREATE INDEX idx_objects_bucket_id_name ON storage.objects (bucket_id, name COLLATE "C"); - -CREATE UNIQUE INDEX bucketid_objname ON storage.objects (bucket_id, name); - -CREATE INDEX name_prefix_search ON storage.objects (name text_pattern_ops); - -CREATE TRIGGER protect_objects_delete - BEFORE DELETE ON storage.objects - FOR EACH STATEMENT - EXECUTE FUNCTION storage.protect_delete(); - -CREATE TRIGGER update_objects_updated_at - BEFORE UPDATE ON storage.objects - FOR EACH ROW - EXECUTE FUNCTION storage.update_updated_at_column(); - -CREATE TABLE storage.s3_multipart_uploads ( - id text NOT NULL, - in_progress_size bigint DEFAULT 0 NOT NULL, - upload_signature text NOT NULL, - bucket_id text NOT NULL, - key text COLLATE "C" NOT NULL, - version text NOT NULL, - owner_id text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - user_metadata jsonb, - metadata jsonb -); - -ALTER TABLE storage.s3_multipart_uploads - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.s3_multipart_uploads - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.s3_multipart_uploads - ADD CONSTRAINT s3_multipart_uploads_bucket_id_fkey FOREIGN KEY (bucket_id) - REFERENCES storage.buckets(id); - -ALTER TABLE storage.s3_multipart_uploads - ADD CONSTRAINT s3_multipart_uploads_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.s3_multipart_uploads TO anon; - -GRANT SELECT ON storage.s3_multipart_uploads TO authenticated; - -GRANT ALL ON storage.s3_multipart_uploads TO service_role; - -CREATE INDEX idx_multipart_uploads_list ON storage.s3_multipart_uploads (bucket_id, key, created_at); - -CREATE TABLE storage.s3_multipart_uploads_parts ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - upload_id text NOT NULL, - size bigint DEFAULT 0 NOT NULL, - part_number integer NOT NULL, - bucket_id text NOT NULL, - key text COLLATE "C" NOT NULL, - etag text NOT NULL, - owner_id text, - version text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE storage.s3_multipart_uploads_parts - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.s3_multipart_uploads_parts - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.s3_multipart_uploads_parts - ADD CONSTRAINT s3_multipart_uploads_parts_bucket_id_fkey FOREIGN KEY (bucket_id) - REFERENCES storage.buckets(id); - -ALTER TABLE storage.s3_multipart_uploads_parts - ADD CONSTRAINT s3_multipart_uploads_parts_pkey PRIMARY KEY (id); - -ALTER TABLE storage.s3_multipart_uploads_parts - ADD CONSTRAINT s3_multipart_uploads_parts_upload_id_fkey FOREIGN KEY (upload_id) - REFERENCES storage.s3_multipart_uploads(id) ON DELETE CASCADE; - -GRANT SELECT ON storage.s3_multipart_uploads_parts TO anon; - -GRANT SELECT ON storage.s3_multipart_uploads_parts TO authenticated; - -GRANT ALL ON storage.s3_multipart_uploads_parts TO service_role; - -CREATE TABLE storage.vector_indexes ( - id text DEFAULT gen_random_uuid() NOT NULL, - name text COLLATE "C" NOT NULL, - bucket_id text NOT NULL, - data_type text NOT NULL, - dimension integer NOT NULL, - distance_metric text NOT NULL, - metadata_configuration jsonb, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE storage.vector_indexes - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.vector_indexes - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.vector_indexes - ADD CONSTRAINT vector_indexes_bucket_id_fkey FOREIGN KEY (bucket_id) - REFERENCES storage.buckets_vectors(id); - -ALTER TABLE storage.vector_indexes - ADD CONSTRAINT vector_indexes_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.vector_indexes TO anon; - -GRANT SELECT ON storage.vector_indexes TO authenticated; - -GRANT SELECT ON storage.vector_indexes TO service_role; - -CREATE UNIQUE INDEX vector_indexes_name_bucket_id_idx ON storage.vector_indexes (name, bucket_id); - -CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; - -GRANT USAGE ON SCHEMA supabase_functions TO postgres; - -GRANT USAGE ON SCHEMA supabase_functions TO anon; - -GRANT USAGE ON SCHEMA supabase_functions TO authenticated; - -GRANT USAGE ON SCHEMA supabase_functions TO service_role; - -GRANT ALL ON SCHEMA supabase_functions TO supabase_functions_admin; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO - anon; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES - TO anon; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON ROUTINES - TO anon; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO - authenticated; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES - TO authenticated; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON ROUTINES - TO authenticated; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO - postgres; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES - TO postgres; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON ROUTINES - TO postgres; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO - service_role; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES - TO service_role; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON ROUTINES - TO service_role; - -CREATE SEQUENCE supabase_functions.hooks_id_seq; - -CREATE FUNCTION supabase_functions.http_request() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path TO 'supabase_functions' - AS $function$ - DECLARE - request_id bigint; - payload jsonb; - url text := TG_ARGV[0]::text; - method text := TG_ARGV[1]::text; - headers jsonb DEFAULT '{}'::jsonb; - params jsonb DEFAULT '{}'::jsonb; - timeout_ms integer DEFAULT 1000; - BEGIN - IF url IS NULL OR url = 'null' THEN - RAISE EXCEPTION 'url argument is missing'; - END IF; - - IF method IS NULL OR method = 'null' THEN - RAISE EXCEPTION 'method argument is missing'; - END IF; - - IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN - headers = '{"Content-Type": "application/json"}'::jsonb; - ELSE - headers = TG_ARGV[2]::jsonb; - END IF; - - IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN - params = '{}'::jsonb; - ELSE - params = TG_ARGV[3]::jsonb; - END IF; - - IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN - timeout_ms = 1000; - ELSE - timeout_ms = TG_ARGV[4]::integer; - END IF; - - CASE - WHEN method = 'GET' THEN - SELECT http_get INTO request_id FROM net.http_get( - url, - params, - headers, - timeout_ms - ); - WHEN method = 'POST' THEN - payload = jsonb_build_object( - 'old_record', OLD, - 'record', NEW, - 'type', TG_OP, - 'table', TG_TABLE_NAME, - 'schema', TG_TABLE_SCHEMA - ); - - SELECT http_post INTO request_id FROM net.http_post( - url, - payload, - params, - headers, - timeout_ms - ); - ELSE - RAISE EXCEPTION 'method argument % is invalid', method; - END CASE; - - INSERT INTO supabase_functions.hooks - (hook_table_id, hook_name, request_id) - VALUES - (TG_RELID, TG_NAME, request_id); - - RETURN NEW; - END -$function$; - -ALTER FUNCTION supabase_functions.http_request() OWNER TO supabase_functions_admin; - -CREATE TABLE supabase_functions.hooks ( - id bigint DEFAULT - nextval('supabase_functions.hooks_id_seq'::regclass) NOT NULL, - hook_table_id integer NOT NULL, - hook_name text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - request_id bigint -); - -ALTER SEQUENCE supabase_functions.hooks_id_seq OWNED BY supabase_functions.hooks.id; - -COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; - -ALTER TABLE supabase_functions.hooks - OWNER TO supabase_functions_admin; - -ALTER TABLE supabase_functions.hooks - ADD CONSTRAINT hooks_pkey PRIMARY KEY (id); - -CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks (request_id); - -CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx - ON supabase_functions.hooks (hook_table_id, hook_name); - -CREATE TABLE supabase_functions.migrations ( - version text NOT NULL, - inserted_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE supabase_functions.migrations - OWNER TO supabase_functions_admin; - -ALTER TABLE supabase_functions.migrations - ADD CONSTRAINT migrations_pkey PRIMARY KEY (version); \ No newline at end of file diff --git a/packages/pg-delta/tests/integration/fixtures/supabase-base-init/17_fullstack_container_init.sql b/packages/pg-delta/tests/integration/fixtures/supabase-base-init/17_fullstack_container_init.sql deleted file mode 100644 index fea882cb1..000000000 --- a/packages/pg-delta/tests/integration/fixtures/supabase-base-init/17_fullstack_container_init.sql +++ /dev/null @@ -1,3686 +0,0 @@ --- Risk: data-loss (2) -SET check_function_bodies = false; - -ALTER TABLE auth.users - DROP COLUMN confirmed_at; - -ALTER TABLE auth.users - DROP COLUMN email_change_token; - -ALTER TABLE auth.users - DROP CONSTRAINT users_email_key; - -DROP INDEX auth.refresh_tokens_token_idx; - -DROP INDEX auth.users_instance_id_email_idx; - -CREATE SCHEMA _realtime AUTHORIZATION postgres; - -CREATE TABLE _realtime.extensions ( - id uuid NOT NULL, - type text, - settings jsonb, - tenant_external_id text, - inserted_at timestamp(0) without time zone NOT NULL, - updated_at timestamp(0) without time zone NOT NULL -); - -ALTER TABLE _realtime.extensions - ADD CONSTRAINT extensions_pkey PRIMARY KEY (id); - -CREATE UNIQUE INDEX extensions_tenant_external_id_type_index - ON _realtime.extensions (tenant_external_id, type); - -CREATE INDEX extensions_tenant_external_id_index ON _realtime.extensions (tenant_external_id); - -CREATE TABLE _realtime.schema_migrations ( - version bigint NOT NULL, - inserted_at timestamp(0) without time zone -); - -ALTER TABLE _realtime.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - -CREATE TABLE _realtime.tenants ( - id uuid NOT NULL, - name text, - external_id text, - jwt_secret text, - max_concurrent_users integer DEFAULT 200 NOT NULL, - inserted_at timestamp(0) without time zone NOT NULL, - updated_at timestamp(0) without time zone NOT NULL, - max_events_per_second integer DEFAULT 100 NOT NULL, - postgres_cdc_default text DEFAULT - 'postgres_cdc_rls'::text, - max_bytes_per_second integer DEFAULT 100000 NOT NULL, - max_channels_per_client integer DEFAULT 100 NOT NULL, - max_joins_per_second integer DEFAULT 500 NOT NULL, - suspend boolean DEFAULT false, - jwt_jwks jsonb, - notify_private_alpha boolean DEFAULT false, - private_only boolean DEFAULT false NOT NULL, - migrations_ran integer DEFAULT 0, - broadcast_adapter character varying(255) DEFAULT 'gen_rpc'::character - varying, - max_presence_events_per_second integer DEFAULT 1000, - max_payload_size_in_kb integer DEFAULT 3000, - max_client_presence_events_per_window integer, - client_presence_window_ms integer, - presence_enabled boolean DEFAULT false NOT NULL -); - -ALTER TABLE _realtime.tenants - ADD CONSTRAINT jwt_secret_or_jwt_jwks_required CHECK (jwt_secret IS NOT NULL OR jwt_jwks IS - NOT NULL); - -ALTER TABLE _realtime.tenants - ADD CONSTRAINT tenants_pkey PRIMARY KEY (id); - -CREATE UNIQUE INDEX tenants_external_id_index ON _realtime.tenants (external_id); - -ALTER TABLE _realtime.extensions - ADD CONSTRAINT extensions_tenant_external_id_fkey FOREIGN KEY (tenant_external_id) - REFERENCES _realtime.tenants(external_id) ON DELETE CASCADE; - -CREATE ROLE supabase_functions_admin WITH CREATEROLE NOINHERIT LOGIN; - -GRANT supabase_functions_admin TO postgres; - -ALTER ROLE supabase_functions_admin SET search_path TO supabase_functions; - -CREATE ROLE supabase_realtime_admin WITH NOINHERIT; - -GRANT supabase_realtime_admin TO postgres; - -CREATE TYPE auth.aal_level AS ENUM ( - 'aal1', - 'aal2', - 'aal3' -); - -ALTER TYPE auth.aal_level OWNER TO supabase_auth_admin; - -CREATE TYPE auth.code_challenge_method AS ENUM ( - 's256', - 'plain' -); - -ALTER TYPE auth.code_challenge_method OWNER TO supabase_auth_admin; - -CREATE TYPE auth.factor_status AS ENUM ( - 'unverified', - 'verified' -); - -ALTER TYPE auth.factor_status OWNER TO supabase_auth_admin; - -CREATE TYPE auth.factor_type AS ENUM ( - 'totp', - 'webauthn', - 'phone' -); - -ALTER TYPE auth.factor_type OWNER TO supabase_auth_admin; - -CREATE TYPE auth.oauth_authorization_status AS ENUM ( - 'pending', - 'approved', - 'denied', - 'expired' -); - -ALTER TYPE auth.oauth_authorization_status OWNER TO supabase_auth_admin; - -CREATE TYPE auth.oauth_client_type AS ENUM ( - 'public', - 'confidential' -); - -ALTER TYPE auth.oauth_client_type OWNER TO supabase_auth_admin; - -CREATE TYPE auth.oauth_registration_type AS ENUM ( - 'dynamic', - 'manual' -); - -ALTER TYPE auth.oauth_registration_type OWNER TO supabase_auth_admin; - -CREATE TYPE auth.oauth_response_type AS ENUM ( - 'code' -); - -ALTER TYPE auth.oauth_response_type OWNER TO supabase_auth_admin; - -CREATE TYPE auth.one_time_token_type AS ENUM ( - 'confirmation_token', - 'reauthentication_token', - 'recovery_token', - 'email_change_token_new', - 'email_change_token_current', - 'phone_change_token' -); - -ALTER TYPE auth.one_time_token_type OWNER TO supabase_auth_admin; - -CREATE OR REPLACE FUNCTION auth.email() - RETURNS text - LANGUAGE sql - STABLE - AS $function$ - select - coalesce( - nullif(current_setting('request.jwt.claim.email', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') - )::text -$function$; - -COMMENT ON FUNCTION auth.email() IS 'Deprecated. Use auth.jwt() -> ''email'' instead.'; - -CREATE FUNCTION auth.jwt() - RETURNS jsonb - LANGUAGE sql - STABLE - AS $function$ - select - coalesce( - nullif(current_setting('request.jwt.claim', true), ''), - nullif(current_setting('request.jwt.claims', true), '') - )::jsonb -$function$; - -ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin; - -GRANT ALL ON FUNCTION auth.jwt() TO postgres; - -GRANT ALL ON FUNCTION auth.jwt() TO dashboard_user; - -CREATE OR REPLACE FUNCTION auth.role() - RETURNS text - LANGUAGE sql - STABLE - AS $function$ - select - coalesce( - nullif(current_setting('request.jwt.claim.role', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role') - )::text -$function$; - -COMMENT ON FUNCTION auth.role() IS 'Deprecated. Use auth.jwt() -> ''role'' instead.'; - -CREATE OR REPLACE FUNCTION auth.uid() - RETURNS uuid - LANGUAGE sql - STABLE - AS $function$ - select - coalesce( - nullif(current_setting('request.jwt.claim.sub', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') - )::uuid -$function$; - -COMMENT ON FUNCTION auth.uid() IS 'Deprecated. Use auth.jwt() -> ''sub'' instead.'; - -ALTER TABLE auth.audit_log_entries - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.audit_log_entries - ADD COLUMN ip_address character varying(64) DEFAULT ''::character varying NOT NULL; - -GRANT SELECT ON auth.audit_log_entries TO postgres WITH GRANT OPTION; - -CREATE TABLE auth.custom_oauth_providers ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - provider_type text NOT NULL, - identifier text NOT NULL, - name text NOT NULL, - client_id text NOT NULL, - client_secret text NOT NULL, - acceptable_client_ids text[] DEFAULT '{}'::text[] NOT NULL, - scopes text[] DEFAULT '{}'::text[] NOT NULL, - pkce_enabled boolean DEFAULT true NOT NULL, - attribute_mapping jsonb DEFAULT '{}'::jsonb NOT NULL, - authorization_params jsonb DEFAULT '{}'::jsonb NOT NULL, - enabled boolean DEFAULT true NOT NULL, - email_optional boolean DEFAULT false NOT NULL, - issuer text, - discovery_url text, - skip_nonce_check boolean DEFAULT false NOT NULL, - cached_discovery jsonb, - discovery_cached_at timestamp with time zone, - authorization_url text, - token_url text, - userinfo_url text, - jwks_uri text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE auth.custom_oauth_providers - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_authorization_url_https - CHECK (authorization_url IS NULL OR authorization_url ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_authorization_url_length - CHECK (authorization_url IS NULL OR char_length(authorization_url) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_client_id_length - CHECK (char_length(client_id) >= 1 AND char_length(client_id) <= 512); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_discovery_url_length - CHECK (discovery_url IS NULL OR char_length(discovery_url) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_identifier_format - CHECK (identifier ~ '^[a-z0-9][a-z0-9:-]{0,48}[a-z0-9]$'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_identifier_key UNIQUE (identifier); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_issuer_length - CHECK (issuer IS NULL OR char_length(issuer) >= 1 AND char_length(issuer) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_jwks_uri_https - CHECK (jwks_uri IS NULL OR jwks_uri ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_jwks_uri_length - CHECK (jwks_uri IS NULL OR char_length(jwks_uri) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_name_length - CHECK (char_length(name) >= 1 AND char_length(name) <= 100); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_oauth2_requires_endpoints - CHECK (provider_type <> 'oauth2'::text OR authorization_url IS NOT NULL AND token_url IS - NOT NULL AND userinfo_url IS NOT NULL); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_oidc_discovery_url_https - CHECK - (provider_type <> 'oidc'::text OR discovery_url IS NULL OR discovery_url ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_oidc_issuer_https - CHECK (provider_type <> 'oidc'::text OR issuer IS NULL OR issuer ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_oidc_requires_issuer - CHECK (provider_type <> 'oidc'::text OR issuer IS NOT NULL); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_pkey PRIMARY KEY (id); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_provider_type_check - CHECK (provider_type = ANY (ARRAY['oauth2'::text, 'oidc'::text])); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_token_url_https - CHECK (token_url IS NULL OR token_url ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_token_url_length - CHECK (token_url IS NULL OR char_length(token_url) <= 2048); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_userinfo_url_https - CHECK (userinfo_url IS NULL OR userinfo_url ~~ 'https://%'::text); - -ALTER TABLE auth.custom_oauth_providers - ADD CONSTRAINT custom_oauth_providers_userinfo_url_length - CHECK (userinfo_url IS NULL OR char_length(userinfo_url) <= 2048); - -GRANT ALL ON auth.custom_oauth_providers TO postgres; - -GRANT ALL ON auth.custom_oauth_providers TO dashboard_user; - -CREATE INDEX custom_oauth_providers_created_at_idx ON auth.custom_oauth_providers (created_at); - -CREATE INDEX custom_oauth_providers_enabled_idx ON auth.custom_oauth_providers (enabled); - -CREATE INDEX custom_oauth_providers_identifier_idx ON auth.custom_oauth_providers (identifier); - -CREATE INDEX custom_oauth_providers_provider_type_idx ON auth.custom_oauth_providers (provider_type); - -CREATE TABLE auth.flow_state ( - id uuid NOT NULL, - user_id uuid, - auth_code text, - code_challenge_method auth.code_challenge_method, - code_challenge text, - provider_type text NOT NULL, - provider_access_token text, - provider_refresh_token text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - authentication_method text NOT NULL, - auth_code_issued_at timestamp with time zone, - invite_token text, - referrer text, - oauth_client_state_id uuid, - linking_target_id uuid, - email_optional boolean DEFAULT false NOT NULL -); - -COMMENT ON TABLE auth.flow_state IS 'Stores metadata for all OAuth/SSO login flows'; - -ALTER TABLE auth.flow_state - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.flow_state - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.flow_state - ADD CONSTRAINT flow_state_pkey PRIMARY KEY (id); - -GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.flow_state TO postgres; - -GRANT SELECT ON auth.flow_state TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.flow_state TO dashboard_user; - -CREATE INDEX idx_user_id_auth_method ON auth.flow_state (user_id, authentication_method); - -CREATE INDEX flow_state_created_at_idx ON auth.flow_state (created_at DESC); - -CREATE INDEX idx_auth_code ON auth.flow_state (auth_code); - -CREATE TABLE auth.identities ( - provider_id text NOT NULL, - user_id uuid NOT NULL, - identity_data jsonb NOT NULL, - provider text NOT NULL, - last_sign_in_at timestamp with time zone, - created_at timestamp with time zone, - updated_at timestamp with time zone, - email text GENERATED ALWAYS AS - (lower((identity_data ->> 'email'::text))) STORED, - id uuid DEFAULT gen_random_uuid() NOT NULL -); - -COMMENT ON TABLE auth.identities IS 'Auth: Stores identities associated to a user.'; - -COMMENT ON COLUMN auth.identities.email IS 'Auth: Email is a generated column that references the optional email property in the identity_data'; - -ALTER TABLE auth.identities - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.identities - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.identities - ADD CONSTRAINT identities_pkey PRIMARY KEY (id); - -ALTER TABLE auth.identities - ADD CONSTRAINT identities_provider_id_provider_unique UNIQUE (provider_id, provider); - -ALTER TABLE auth.identities - ADD CONSTRAINT identities_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.identities TO postgres; - -GRANT SELECT ON auth.identities TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.identities TO dashboard_user; - -CREATE INDEX identities_user_id_idx ON auth.identities (user_id); - -CREATE INDEX identities_email_idx ON auth.identities (email text_pattern_ops); - -ALTER TABLE auth.instances - ENABLE ROW LEVEL SECURITY; - -GRANT SELECT ON auth.instances TO postgres WITH GRANT OPTION; - -CREATE TABLE auth.mfa_amr_claims ( - session_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - authentication_method text NOT NULL, - id uuid NOT NULL -); - -COMMENT ON TABLE auth.mfa_amr_claims IS 'auth: stores authenticator method reference claims for multi factor authentication'; - -ALTER TABLE auth.mfa_amr_claims - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.mfa_amr_claims - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.mfa_amr_claims - ADD CONSTRAINT amr_id_pk PRIMARY KEY (id); - -ALTER TABLE auth.mfa_amr_claims - ADD - CONSTRAINT mfa_amr_claims_session_id_authentication_method_pkey UNIQUE - (session_id, authentication_method); - -GRANT DELETE, - INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.mfa_amr_claims TO postgres; - -GRANT SELECT ON auth.mfa_amr_claims TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.mfa_amr_claims TO dashboard_user; - -CREATE TABLE auth.mfa_challenges ( - id uuid NOT NULL, - factor_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - verified_at timestamp with time zone, - ip_address inet NOT NULL, - otp_code text, - web_authn_session_data jsonb -); - -COMMENT ON TABLE auth.mfa_challenges IS 'auth: stores metadata about challenge requests made'; - -ALTER TABLE auth.mfa_challenges - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.mfa_challenges - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.mfa_challenges - ADD CONSTRAINT mfa_challenges_pkey PRIMARY KEY (id); - -GRANT DELETE, - INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.mfa_challenges TO postgres; - -GRANT SELECT ON auth.mfa_challenges TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.mfa_challenges TO dashboard_user; - -CREATE INDEX mfa_challenge_created_at_idx ON auth.mfa_challenges (created_at DESC); - -CREATE TABLE auth.mfa_factors ( - id uuid NOT NULL, - user_id uuid NOT NULL, - friendly_name text, - factor_type auth.factor_type NOT NULL, - status auth.factor_status NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - secret text, - phone text, - last_challenged_at timestamp with time zone, - web_authn_credential jsonb, - web_authn_aaguid uuid, - last_webauthn_challenge_data jsonb -); - -COMMENT ON TABLE auth.mfa_factors IS 'auth: stores metadata about factors'; - -COMMENT ON COLUMN auth.mfa_factors.last_webauthn_challenge_data IS 'Stores the latest WebAuthn challenge data including attestation/assertion for customer verification'; - -ALTER TABLE auth.mfa_factors - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.mfa_factors - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.mfa_factors - ADD CONSTRAINT mfa_factors_last_challenged_at_key UNIQUE (last_challenged_at); - -ALTER TABLE auth.mfa_factors - ADD CONSTRAINT mfa_factors_pkey PRIMARY KEY (id); - -ALTER TABLE auth.mfa_challenges - ADD CONSTRAINT mfa_challenges_auth_factor_id_fkey FOREIGN KEY (factor_id) - REFERENCES auth.mfa_factors(id) ON DELETE CASCADE; - -ALTER TABLE auth.mfa_factors - ADD CONSTRAINT mfa_factors_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT DELETE, - INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.mfa_factors TO postgres; - -GRANT SELECT ON auth.mfa_factors TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.mfa_factors TO dashboard_user; - -CREATE INDEX factor_id_created_at_idx ON auth.mfa_factors (user_id, created_at); - -CREATE INDEX mfa_factors_user_id_idx ON auth.mfa_factors (user_id); - -CREATE UNIQUE INDEX mfa_factors_user_friendly_name_unique - ON auth.mfa_factors (friendly_name, user_id) - WHERE TRIM(BOTH FROM friendly_name) <> ''::text; - -CREATE UNIQUE INDEX unique_phone_factor_per_user ON auth.mfa_factors (user_id, phone); - -CREATE TABLE auth.oauth_authorizations ( - id uuid NOT NULL, - authorization_id text NOT NULL, - client_id uuid NOT NULL, - user_id uuid, - redirect_uri text NOT NULL, - scope text NOT NULL, - state text, - resource text, - code_challenge text, - code_challenge_method auth.code_challenge_method, - response_type auth.oauth_response_type DEFAULT 'code'::auth.oauth_response_type - NOT NULL, - status auth.oauth_authorization_status DEFAULT - 'pending'::auth.oauth_authorization_status NOT NULL, - authorization_code text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - expires_at timestamp with time zone DEFAULT (now() + '00:03:00'::interval) - NOT NULL, - approved_at timestamp with time zone, - nonce text -); - -ALTER TABLE auth.oauth_authorizations - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_authorization_code_key UNIQUE (authorization_code); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_authorization_code_length - CHECK (char_length(authorization_code) <= 255); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_authorization_id_key UNIQUE (authorization_id); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_code_challenge_length - CHECK (char_length(code_challenge) <= 128); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_expires_at_future CHECK (expires_at > created_at); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_nonce_length CHECK (char_length(nonce) <= 255); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_pkey PRIMARY KEY (id); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_redirect_uri_length CHECK (char_length(redirect_uri) <= 2048); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_resource_length CHECK (char_length(resource) <= 2048); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_scope_length CHECK (char_length(scope) <= 4096); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_state_length CHECK (char_length(state) <= 4096); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT ALL ON auth.oauth_authorizations TO postgres; - -GRANT ALL ON auth.oauth_authorizations TO dashboard_user; - -CREATE INDEX oauth_auth_pending_exp_idx ON auth.oauth_authorizations (expires_at) - WHERE status = 'pending'::auth.oauth_authorization_status; - -CREATE TABLE auth.oauth_client_states ( - id uuid NOT NULL, - provider_type text NOT NULL, - code_verifier text, - created_at timestamp with time zone NOT NULL -); - -COMMENT ON TABLE auth.oauth_client_states IS 'Stores OAuth states for third-party provider authentication flows where Supabase acts as the OAuth client.'; - -ALTER TABLE auth.oauth_client_states - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.oauth_client_states - ADD CONSTRAINT oauth_client_states_pkey PRIMARY KEY (id); - -GRANT ALL ON auth.oauth_client_states TO postgres; - -GRANT ALL ON auth.oauth_client_states TO dashboard_user; - -CREATE INDEX idx_oauth_client_states_created_at ON auth.oauth_client_states (created_at); - -CREATE TABLE auth.oauth_clients ( - id uuid NOT NULL, - client_secret_hash text, - registration_type auth.oauth_registration_type NOT NULL, - redirect_uris text NOT NULL, - grant_types text NOT NULL, - client_name text, - client_uri text, - logo_uri text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - deleted_at timestamp with time zone, - client_type auth.oauth_client_type DEFAULT - 'confidential'::auth.oauth_client_type NOT NULL, - token_endpoint_auth_method text NOT NULL -); - -ALTER TABLE auth.oauth_clients - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_client_name_length CHECK (char_length(client_name) <= 1024); - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_client_uri_length CHECK (char_length(client_uri) <= 2048); - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_logo_uri_length CHECK (char_length(logo_uri) <= 2048); - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_pkey PRIMARY KEY (id); - -ALTER TABLE auth.oauth_authorizations - ADD CONSTRAINT oauth_authorizations_client_id_fkey FOREIGN KEY (client_id) - REFERENCES auth.oauth_clients(id) ON DELETE CASCADE; - -ALTER TABLE auth.oauth_clients - ADD CONSTRAINT oauth_clients_token_endpoint_auth_method_check - CHECK - (token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, - 'client_secret_post'::text, 'none'::text])); - -GRANT ALL ON auth.oauth_clients TO postgres; - -GRANT ALL ON auth.oauth_clients TO dashboard_user; - -CREATE INDEX oauth_clients_deleted_at_idx ON auth.oauth_clients (deleted_at); - -CREATE TABLE auth.oauth_consents ( - id uuid NOT NULL, - user_id uuid NOT NULL, - client_id uuid NOT NULL, - scopes text NOT NULL, - granted_at timestamp with time zone DEFAULT now() NOT NULL, - revoked_at timestamp with time zone -); - -ALTER TABLE auth.oauth_consents - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_client_id_fkey FOREIGN KEY (client_id) - REFERENCES auth.oauth_clients(id) ON DELETE CASCADE; - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_pkey PRIMARY KEY (id); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_revoked_after_granted - CHECK (revoked_at IS NULL OR revoked_at >= granted_at); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_scopes_length CHECK (char_length(scopes) <= 2048); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_scopes_not_empty CHECK (char_length(TRIM(BOTH FROM scopes)) > 0); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_user_client_unique UNIQUE (user_id, client_id); - -ALTER TABLE auth.oauth_consents - ADD CONSTRAINT oauth_consents_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT ALL ON auth.oauth_consents TO postgres; - -GRANT ALL ON auth.oauth_consents TO dashboard_user; - -CREATE INDEX oauth_consents_user_order_idx ON auth.oauth_consents (user_id, granted_at DESC); - -CREATE INDEX oauth_consents_active_user_client_idx ON auth.oauth_consents (user_id, client_id) - WHERE revoked_at IS NULL; - -CREATE INDEX oauth_consents_active_client_idx ON auth.oauth_consents (client_id) - WHERE revoked_at IS NULL; - -CREATE TABLE auth.one_time_tokens ( - id uuid NOT NULL, - user_id uuid NOT NULL, - token_type auth.one_time_token_type NOT NULL, - token_hash text NOT NULL, - relates_to text NOT NULL, - created_at timestamp without time zone DEFAULT now() NOT NULL, - updated_at timestamp without time zone DEFAULT now() NOT NULL -); - -ALTER TABLE auth.one_time_tokens - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.one_time_tokens - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.one_time_tokens - ADD CONSTRAINT one_time_tokens_pkey PRIMARY KEY (id); - -ALTER TABLE auth.one_time_tokens - ADD CONSTRAINT one_time_tokens_token_hash_check CHECK (char_length(token_hash) > 0); - -ALTER TABLE auth.one_time_tokens - ADD CONSTRAINT one_time_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT DELETE, - INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.one_time_tokens TO postgres; - -GRANT SELECT ON auth.one_time_tokens TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.one_time_tokens TO dashboard_user; - -CREATE INDEX one_time_tokens_token_hash_hash_idx ON auth.one_time_tokens USING hash (token_hash); - -CREATE INDEX one_time_tokens_relates_to_hash_idx ON auth.one_time_tokens USING hash (relates_to); - -CREATE UNIQUE INDEX one_time_tokens_user_id_token_type_key - ON auth.one_time_tokens (user_id, token_type); - -ALTER TABLE auth.refresh_tokens - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_token_unique UNIQUE (token); - -ALTER TABLE auth.refresh_tokens - ADD COLUMN parent character varying(255); - -ALTER TABLE auth.refresh_tokens - ADD COLUMN session_id uuid; - -GRANT SELECT ON auth.refresh_tokens TO postgres WITH GRANT OPTION; - -CREATE INDEX refresh_tokens_updated_at_idx ON auth.refresh_tokens (updated_at DESC); - -CREATE INDEX refresh_tokens_parent_idx ON auth.refresh_tokens (parent); - -CREATE INDEX refresh_tokens_session_id_revoked_idx ON auth.refresh_tokens (session_id, revoked); - -CREATE TABLE auth.saml_providers ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - entity_id text NOT NULL, - metadata_xml text NOT NULL, - metadata_url text, - attribute_mapping jsonb, - created_at timestamp with time zone, - updated_at timestamp with time zone, - name_id_format text -); - -COMMENT ON TABLE auth.saml_providers IS 'Auth: Manages SAML Identity Provider connections.'; - -ALTER TABLE auth.saml_providers - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.saml_providers - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT "entity_id not empty" CHECK (char_length(entity_id) > 0); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT "metadata_url not empty" - CHECK (metadata_url = NULL::text OR char_length(metadata_url) > 0); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT "metadata_xml not empty" CHECK (char_length(metadata_xml) > 0); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT saml_providers_entity_id_key UNIQUE (entity_id); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT saml_providers_pkey PRIMARY KEY (id); - -GRANT DELETE, - INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.saml_providers TO postgres; - -GRANT SELECT ON auth.saml_providers TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.saml_providers TO dashboard_user; - -CREATE INDEX saml_providers_sso_provider_id_idx ON auth.saml_providers (sso_provider_id); - -CREATE TABLE auth.saml_relay_states ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - request_id text NOT NULL, - for_email text, - redirect_to text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - flow_state_id uuid -); - -COMMENT ON TABLE auth.saml_relay_states IS 'Auth: Contains SAML Relay State information for each Service Provider initiated login.'; - -ALTER TABLE auth.saml_relay_states - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.saml_relay_states - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.saml_relay_states - ADD CONSTRAINT "request_id not empty" CHECK (char_length(request_id) > 0); - -ALTER TABLE auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_flow_state_id_fkey FOREIGN KEY (flow_state_id) - REFERENCES auth.flow_state(id) ON DELETE CASCADE; - -ALTER TABLE auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_pkey PRIMARY KEY (id); - -GRANT DELETE, - INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.saml_relay_states TO postgres; - -GRANT SELECT ON auth.saml_relay_states TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.saml_relay_states TO dashboard_user; - -CREATE INDEX saml_relay_states_for_email_idx ON auth.saml_relay_states (for_email); - -CREATE INDEX saml_relay_states_created_at_idx ON auth.saml_relay_states (created_at DESC); - -CREATE INDEX saml_relay_states_sso_provider_id_idx ON auth.saml_relay_states (sso_provider_id); - -ALTER TABLE auth.schema_migrations - ENABLE ROW LEVEL SECURITY; - -GRANT SELECT ON auth.schema_migrations TO postgres WITH GRANT OPTION; - -CREATE TABLE auth.sessions ( - id uuid NOT NULL, - user_id uuid NOT NULL, - created_at timestamp with time zone, - updated_at timestamp with time zone, - factor_id uuid, - aal auth.aal_level, - not_after timestamp with time zone, - refreshed_at timestamp without time zone, - user_agent text, - ip inet, - tag text, - oauth_client_id uuid, - refresh_token_hmac_key text, - refresh_token_counter bigint, - scopes text -); - -COMMENT ON TABLE auth.sessions IS 'Auth: Stores session data associated to a user.'; - -COMMENT ON COLUMN auth.sessions.not_after IS 'Auth: Not after is a nullable column that contains a timestamp after which the session should be regarded as expired.'; - -COMMENT ON COLUMN auth.sessions.refresh_token_hmac_key IS 'Holds a HMAC-SHA256 key used to sign refresh tokens for this session.'; - -COMMENT ON COLUMN auth.sessions.refresh_token_counter IS 'Holds the ID (counter) of the last issued refresh token.'; - -ALTER TABLE auth.sessions - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.sessions - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.sessions - ADD CONSTRAINT sessions_oauth_client_id_fkey FOREIGN KEY (oauth_client_id) - REFERENCES auth.oauth_clients(id) ON DELETE CASCADE; - -ALTER TABLE auth.sessions - ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); - -ALTER TABLE auth.mfa_amr_claims - ADD CONSTRAINT mfa_amr_claims_session_id_fkey FOREIGN KEY (session_id) - REFERENCES auth.sessions(id) ON DELETE CASCADE; - -ALTER TABLE auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_session_id_fkey FOREIGN KEY (session_id) - REFERENCES auth.sessions(id) ON DELETE CASCADE; - -ALTER TABLE auth.sessions - ADD CONSTRAINT sessions_scopes_length CHECK (char_length(scopes) <= 4096); - -ALTER TABLE auth.sessions - ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT DELETE, INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.sessions TO postgres; - -GRANT SELECT ON auth.sessions TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.sessions TO dashboard_user; - -CREATE INDEX user_id_created_at_idx ON auth.sessions (user_id, created_at); - -CREATE INDEX sessions_user_id_idx ON auth.sessions (user_id); - -CREATE INDEX sessions_not_after_idx ON auth.sessions (not_after DESC); - -CREATE INDEX sessions_oauth_client_id_idx ON auth.sessions (oauth_client_id); - -CREATE TABLE auth.sso_domains ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - domain text NOT NULL, - created_at timestamp with time zone, - updated_at timestamp with time zone -); - -COMMENT ON TABLE auth.sso_domains IS 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.'; - -ALTER TABLE auth.sso_domains - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.sso_domains - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.sso_domains - ADD CONSTRAINT "domain not empty" CHECK (char_length(domain) > 0); - -ALTER TABLE auth.sso_domains - ADD CONSTRAINT sso_domains_pkey PRIMARY KEY (id); - -GRANT DELETE, - INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.sso_domains TO postgres; - -GRANT SELECT ON auth.sso_domains TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.sso_domains TO dashboard_user; - -CREATE UNIQUE INDEX sso_domains_domain_idx ON auth.sso_domains (lower(domain)); - -CREATE INDEX sso_domains_sso_provider_id_idx ON auth.sso_domains (sso_provider_id); - -CREATE TABLE auth.sso_providers ( - id uuid NOT NULL, - resource_id text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - disabled boolean -); - -COMMENT ON TABLE auth.sso_providers IS 'Auth: Manages SSO identity provider information; see saml_providers for SAML.'; - -COMMENT ON COLUMN auth.sso_providers.resource_id IS 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.'; - -ALTER TABLE auth.sso_providers - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.sso_providers - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.sso_providers - ADD CONSTRAINT "resource_id not empty" - CHECK (resource_id = NULL::text OR char_length(resource_id) > 0); - -ALTER TABLE auth.sso_providers - ADD CONSTRAINT sso_providers_pkey PRIMARY KEY (id); - -ALTER TABLE auth.saml_providers - ADD CONSTRAINT saml_providers_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) - REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - -ALTER TABLE auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) - REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - -ALTER TABLE auth.sso_domains - ADD CONSTRAINT sso_domains_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) - REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - -GRANT DELETE, - INSERT, MAINTAIN, REFERENCES, TRIGGER, TRUNCATE, UPDATE ON auth.sso_providers TO postgres; - -GRANT SELECT ON auth.sso_providers TO postgres WITH GRANT OPTION; - -GRANT ALL ON auth.sso_providers TO dashboard_user; - -CREATE UNIQUE INDEX sso_providers_resource_id_idx ON auth.sso_providers (lower(resource_id)); - -CREATE INDEX sso_providers_resource_id_pattern_idx - ON auth.sso_providers (resource_id text_pattern_ops); - -ALTER TABLE auth.users - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE auth.users - ADD COLUMN email_confirmed_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN email_change_token_new character varying(255); - -ALTER TABLE auth.users - ADD COLUMN phone text DEFAULT NULL::character varying; - -ALTER TABLE auth.users - ADD CONSTRAINT users_phone_key UNIQUE (phone); - -ALTER TABLE auth.users - ADD COLUMN phone_confirmed_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN phone_change text DEFAULT ''::character varying; - -ALTER TABLE auth.users - ADD COLUMN phone_change_token character varying(255) DEFAULT ''::character varying; - -ALTER TABLE auth.users - ADD COLUMN phone_change_sent_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN email_change_token_current character varying(255) DEFAULT ''::character varying; - -ALTER TABLE auth.users - ADD COLUMN email_change_confirm_status smallint DEFAULT 0; - -ALTER TABLE auth.users - ADD CONSTRAINT users_email_change_confirm_status_check - CHECK (email_change_confirm_status >= 0 AND email_change_confirm_status <= 2); - -ALTER TABLE auth.users - ADD COLUMN banned_until timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN reauthentication_token character varying(255) DEFAULT ''::character varying; - -ALTER TABLE auth.users - ADD COLUMN reauthentication_sent_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN is_sso_user boolean DEFAULT false NOT NULL; - -COMMENT ON COLUMN auth.users.is_sso_user IS 'Auth: Set this column to true when the account comes from SSO. These accounts can have duplicate emails.'; - -ALTER TABLE auth.users - ADD COLUMN deleted_at timestamp with time zone; - -ALTER TABLE auth.users - ADD COLUMN is_anonymous boolean DEFAULT false NOT NULL; - -ALTER TABLE auth.users - ADD COLUMN confirmed_at timestamp - with time zone GENERATED ALWAYS AS (LEAST(email_confirmed_at, phone_confirmed_at)) STORED; - -GRANT SELECT ON auth.users TO postgres WITH GRANT OPTION; - -CREATE UNIQUE INDEX confirmation_token_idx ON auth.users (confirmation_token) - WHERE confirmation_token::text !~ '^[0-9 ]*$'::text; - -CREATE UNIQUE INDEX users_email_partial_key ON auth.users (email) - WHERE is_sso_user = false; - -CREATE INDEX users_is_anonymous_idx ON auth.users (is_anonymous); - -CREATE UNIQUE INDEX reauthentication_token_idx ON auth.users (reauthentication_token) - WHERE reauthentication_token::text !~ '^[0-9 ]*$'::text; - -CREATE UNIQUE INDEX email_change_token_new_idx ON auth.users (email_change_token_new) - WHERE email_change_token_new::text !~ '^[0-9 ]*$'::text; - -CREATE UNIQUE INDEX email_change_token_current_idx ON auth.users (email_change_token_current) - WHERE email_change_token_current::text !~ '^[0-9 ]*$'::text; - -CREATE UNIQUE INDEX recovery_token_idx ON auth.users (recovery_token) - WHERE recovery_token::text !~ '^[0-9 ]*$'::text; - -CREATE INDEX users_instance_id_email_idx ON auth.users (instance_id, lower(email::text)); - -CREATE TABLE auth.webauthn_challenges ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - user_id uuid, - challenge_type text NOT NULL, - session_data jsonb NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - expires_at timestamp with time zone NOT NULL -); - -ALTER TABLE auth.webauthn_challenges - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.webauthn_challenges - ADD CONSTRAINT webauthn_challenges_challenge_type_check - CHECK - (challenge_type = ANY (ARRAY['signup'::text, 'registration'::text, 'authentication'::text])); - -ALTER TABLE auth.webauthn_challenges - ADD CONSTRAINT webauthn_challenges_pkey PRIMARY KEY (id); - -ALTER TABLE auth.webauthn_challenges - ADD CONSTRAINT webauthn_challenges_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT ALL ON auth.webauthn_challenges TO postgres; - -GRANT ALL ON auth.webauthn_challenges TO dashboard_user; - -CREATE INDEX webauthn_challenges_expires_at_idx ON auth.webauthn_challenges (expires_at); - -CREATE INDEX webauthn_challenges_user_id_idx ON auth.webauthn_challenges (user_id); - -CREATE TABLE auth.webauthn_credentials ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - user_id uuid NOT NULL, - credential_id bytea NOT NULL, - public_key bytea NOT NULL, - attestation_type text DEFAULT ''::text NOT NULL, - aaguid uuid, - sign_count bigint DEFAULT 0 NOT NULL, - transports jsonb DEFAULT '[]'::jsonb NOT NULL, - backup_eligible boolean DEFAULT false NOT NULL, - backed_up boolean DEFAULT false NOT NULL, - friendly_name text DEFAULT ''::text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - last_used_at timestamp with time zone -); - -ALTER TABLE auth.webauthn_credentials - OWNER TO supabase_auth_admin; - -ALTER TABLE auth.webauthn_credentials - ADD CONSTRAINT webauthn_credentials_pkey PRIMARY KEY (id); - -ALTER TABLE auth.webauthn_credentials - ADD CONSTRAINT webauthn_credentials_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) - ON DELETE CASCADE; - -GRANT ALL ON auth.webauthn_credentials TO postgres; - -GRANT ALL ON auth.webauthn_credentials TO dashboard_user; - -CREATE INDEX webauthn_credentials_user_id_idx ON auth.webauthn_credentials (user_id); - -CREATE UNIQUE INDEX webauthn_credentials_credential_id_key - ON auth.webauthn_credentials (credential_id); - -COMMENT ON INDEX auth.identities_email_idx IS 'Auth: Ensures indexed queries on the email column'; - -COMMENT ON INDEX auth.users_email_partial_key IS 'Auth: A partial unique index that applies only when is_sso_user is false'; - -CREATE EXTENSION pg_net WITH SCHEMA extensions; - -COMMENT ON EXTENSION pg_net IS 'Async HTTP'; - -CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() - RETURNS event_trigger - LANGUAGE plpgsql - AS $function$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_event_trigger_ddl_commands() AS ev - JOIN pg_extension AS ext - ON ev.objid = ext.oid - WHERE ext.extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; -END; -$function$; - -GRANT USAGE ON SCHEMA realtime TO anon; - -GRANT USAGE ON SCHEMA realtime TO authenticated; - -GRANT USAGE ON SCHEMA realtime TO service_role; - -GRANT ALL ON SCHEMA realtime TO supabase_realtime_admin; - -CREATE TYPE realtime.action AS ENUM ( - 'INSERT', - 'UPDATE', - 'DELETE', - 'TRUNCATE', - 'ERROR' -); - -CREATE TYPE realtime.equality_op AS ENUM ( - 'eq', - 'neq', - 'lt', - 'lte', - 'gt', - 'gte', - 'in' -); - -CREATE TYPE realtime.user_defined_filter AS ( - column_name text, - op realtime.equality_op, - value text -); - -CREATE TYPE realtime.wal_column AS ( - name text, - type_name text, - type_oid oid, - value jsonb, - is_pkey boolean, - is_selectable boolean -); - -CREATE TYPE realtime.wal_rls AS ( - wal jsonb, - is_rls_enabled boolean, - subscription_ids uuid[], - errors text[] -); - -CREATE FUNCTION realtime."cast" ( - val text, - type_ regtype -) - RETURNS jsonb - LANGUAGE plpgsql - IMMUTABLE - AS $function$ -declare - res jsonb; -begin - if type_::text = 'bytea' then - return to_jsonb(val); - end if; - execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res; - return res; -end -$function$; - -GRANT ALL ON FUNCTION realtime."cast"(text, regtype) TO anon; - -GRANT ALL ON FUNCTION realtime."cast"(text, regtype) TO authenticated; - -GRANT ALL ON FUNCTION realtime."cast"(text, regtype) TO service_role; - -GRANT ALL ON FUNCTION realtime."cast"(text, regtype) TO supabase_realtime_admin; - -CREATE FUNCTION realtime.apply_rls ( - wal jsonb, - max_record_bytes integer DEFAULT (1024 * 1024) -) - RETURNS SETOF realtime.wal_rls - LANGUAGE plpgsql - AS $function$ -declare --- Regclass of the table e.g. public.notes -entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass; - --- I, U, D, T: insert, update ... -action realtime.action = ( - case wal ->> 'action' - when 'I' then 'INSERT' - when 'U' then 'UPDATE' - when 'D' then 'DELETE' - else 'ERROR' - end -); - --- Is row level security enabled for the table -is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_; - -subscriptions realtime.subscription[] = array_agg(subs) - from - realtime.subscription subs - where - subs.entity = entity_ - -- Filter by action early - only get subscriptions interested in this action - -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE' - and (subs.action_filter = '*' or subs.action_filter = action::text); - --- Subscription vars -roles regrole[] = array_agg(distinct us.claims_role::text) - from - unnest(subscriptions) us; - -working_role regrole; -claimed_role regrole; -claims jsonb; - -subscription_id uuid; -subscription_has_access bool; -visible_to_subscription_ids uuid[] = '{}'; - --- structured info for wal's columns -columns realtime.wal_column[]; --- previous identity values for update/delete -old_columns realtime.wal_column[]; - -error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes; - --- Primary jsonb output for record -output jsonb; - -begin -perform set_config('role', null, true); - -columns = - array_agg( - ( - x->>'name', - x->>'type', - x->>'typeoid', - realtime.cast( - (x->'value') #>> '{}', - coalesce( - (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 - (x->>'type')::regtype - ) - ), - (pks ->> 'name') is not null, - true - )::realtime.wal_column - ) - from - jsonb_array_elements(wal -> 'columns') x - left join jsonb_array_elements(wal -> 'pk') pks - on (x ->> 'name') = (pks ->> 'name'); - -old_columns = - array_agg( - ( - x->>'name', - x->>'type', - x->>'typeoid', - realtime.cast( - (x->'value') #>> '{}', - coalesce( - (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4 - (x->>'type')::regtype - ) - ), - (pks ->> 'name') is not null, - true - )::realtime.wal_column - ) - from - jsonb_array_elements(wal -> 'identity') x - left join jsonb_array_elements(wal -> 'pk') pks - on (x ->> 'name') = (pks ->> 'name'); - -for working_role in select * from unnest(roles) loop - - -- Update `is_selectable` for columns and old_columns - columns = - array_agg( - ( - c.name, - c.type_name, - c.type_oid, - c.value, - c.is_pkey, - pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') - )::realtime.wal_column - ) - from - unnest(columns) c; - - old_columns = - array_agg( - ( - c.name, - c.type_name, - c.type_oid, - c.value, - c.is_pkey, - pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT') - )::realtime.wal_column - ) - from - unnest(old_columns) c; - - if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then - return next ( - jsonb_build_object( - 'schema', wal ->> 'schema', - 'table', wal ->> 'table', - 'type', action - ), - is_rls_enabled, - -- subscriptions is already filtered by entity - (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role), - array['Error 400: Bad Request, no primary key'] - )::realtime.wal_rls; - - -- The claims role does not have SELECT permission to the primary key of entity - elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then - return next ( - jsonb_build_object( - 'schema', wal ->> 'schema', - 'table', wal ->> 'table', - 'type', action - ), - is_rls_enabled, - (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role), - array['Error 401: Unauthorized'] - )::realtime.wal_rls; - - else - output = jsonb_build_object( - 'schema', wal ->> 'schema', - 'table', wal ->> 'table', - 'type', action, - 'commit_timestamp', to_char( - ((wal ->> 'timestamp')::timestamptz at time zone 'utc'), - 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' - ), - 'columns', ( - select - jsonb_agg( - jsonb_build_object( - 'name', pa.attname, - 'type', pt.typname - ) - order by pa.attnum asc - ) - from - pg_attribute pa - join pg_type pt - on pa.atttypid = pt.oid - where - attrelid = entity_ - and attnum > 0 - and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT') - ) - ) - -- Add "record" key for insert and update - || case - when action in ('INSERT', 'UPDATE') then - jsonb_build_object( - 'record', - ( - select - jsonb_object_agg( - -- if unchanged toast, get column name and value from old record - coalesce((c).name, (oc).name), - case - when (c).name is null then (oc).value - else (c).value - end - ) - from - unnest(columns) c - full outer join unnest(old_columns) oc - on (c).name = (oc).name - where - coalesce((c).is_selectable, (oc).is_selectable) - and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) - ) - ) - else '{}'::jsonb - end - -- Add "old_record" key for update and delete - || case - when action = 'UPDATE' then - jsonb_build_object( - 'old_record', - ( - select jsonb_object_agg((c).name, (c).value) - from unnest(old_columns) c - where - (c).is_selectable - and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) - ) - ) - when action = 'DELETE' then - jsonb_build_object( - 'old_record', - ( - select jsonb_object_agg((c).name, (c).value) - from unnest(old_columns) c - where - (c).is_selectable - and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64)) - and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey - ) - ) - else '{}'::jsonb - end; - - -- Create the prepared statement - if is_rls_enabled and action <> 'DELETE' then - if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then - deallocate walrus_rls_stmt; - end if; - execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns); - end if; - - visible_to_subscription_ids = '{}'; - - for subscription_id, claims in ( - select - subs.subscription_id, - subs.claims - from - unnest(subscriptions) subs - where - subs.entity = entity_ - and subs.claims_role = working_role - and ( - realtime.is_visible_through_filters(columns, subs.filters) - or ( - action = 'DELETE' - and realtime.is_visible_through_filters(old_columns, subs.filters) - ) - ) - ) loop - - if not is_rls_enabled or action = 'DELETE' then - visible_to_subscription_ids = visible_to_subscription_ids || subscription_id; - else - -- Check if RLS allows the role to see the record - perform - -- Trim leading and trailing quotes from working_role because set_config - -- doesn't recognize the role as valid if they are included - set_config('role', trim(both '"' from working_role::text), true), - set_config('request.jwt.claims', claims::text, true); - - execute 'execute walrus_rls_stmt' into subscription_has_access; - - if subscription_has_access then - visible_to_subscription_ids = visible_to_subscription_ids || subscription_id; - end if; - end if; - end loop; - - perform set_config('role', null, true); - - return next ( - output, - is_rls_enabled, - visible_to_subscription_ids, - case - when error_record_exceeds_max_size then array['Error 413: Payload Too Large'] - else '{}' - end - )::realtime.wal_rls; - - end if; -end loop; - -perform set_config('role', null, true); -end; -$function$; - -GRANT ALL ON FUNCTION realtime.apply_rls(jsonb, integer) TO anon; - -GRANT ALL ON FUNCTION realtime.apply_rls(jsonb, integer) TO authenticated; - -GRANT ALL ON FUNCTION realtime.apply_rls(jsonb, integer) TO service_role; - -GRANT ALL ON FUNCTION realtime.apply_rls(jsonb, integer) TO supabase_realtime_admin; - -CREATE FUNCTION realtime.broadcast_changes ( - topic_name text, - event_name text, - operation text, - table_name text, - table_schema text, - new record, - old record, - level text DEFAULT 'ROW'::text -) - RETURNS void - LANGUAGE plpgsql - AS $function$ -DECLARE - -- Declare a variable to hold the JSONB representation of the row - row_data jsonb := '{}'::jsonb; -BEGIN - IF level = 'STATEMENT' THEN - RAISE EXCEPTION 'function can only be triggered for each row, not for each statement'; - END IF; - -- Check the operation type and handle accordingly - IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN - row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema); - PERFORM realtime.send (row_data, event_name, topic_name); - ELSE - RAISE EXCEPTION 'Unexpected operation type: %', operation; - END IF; -EXCEPTION - WHEN OTHERS THEN - RAISE EXCEPTION 'Failed to process the row: %', SQLERRM; -END; - -$function$; - -CREATE FUNCTION realtime.build_prepared_statement_sql ( - prepared_statement_name text, - entity regclass, - columns realtime.wal_column[] -) - RETURNS text - LANGUAGE sql - AS $function$ - /* - Builds a sql string that, if executed, creates a prepared statement to - tests retrive a row from *entity* by its primary key columns. - Example - select realtime.build_prepared_statement_sql('public.notes', '{"id"}'::text[], '{"bigint"}'::text[]) - */ - select - 'prepare ' || prepared_statement_name || ' as - select - exists( - select - 1 - from - ' || entity || ' - where - ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || ' - )' - from - unnest(columns) pkc - where - pkc.is_pkey - group by - entity - $function$; - -GRANT ALL ON FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) - TO anon; - -GRANT ALL ON FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) - TO authenticated; - -GRANT ALL ON FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) - TO service_role; - -GRANT ALL ON FUNCTION realtime.build_prepared_statement_sql(text, regclass, realtime.wal_column[]) - TO supabase_realtime_admin; - -CREATE FUNCTION realtime.check_equality_op ( - op realtime.equality_op, - type_ regtype, - val_1 text, - val_2 text -) - RETURNS boolean - LANGUAGE plpgsql - IMMUTABLE - AS $function$ - /* - Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness - */ - declare - op_symbol text = ( - case - when op = 'eq' then '=' - when op = 'neq' then '!=' - when op = 'lt' then '<' - when op = 'lte' then '<=' - when op = 'gt' then '>' - when op = 'gte' then '>=' - when op = 'in' then '= any' - else 'UNKNOWN OP' - end - ); - res boolean; - begin - execute format( - 'select %L::'|| type_::text || ' ' || op_symbol - || ' ( %L::' - || ( - case - when op = 'in' then type_::text || '[]' - else type_::text end - ) - || ')', val_1, val_2) into res; - return res; - end; - $function$; - -GRANT ALL ON FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) TO anon; - -GRANT ALL ON FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) TO - authenticated; - -GRANT ALL ON FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) TO - service_role; - -GRANT ALL ON FUNCTION realtime.check_equality_op(realtime.equality_op, regtype, text, text) TO - supabase_realtime_admin; - -CREATE FUNCTION realtime.is_visible_through_filters ( - columns realtime.wal_column[], - filters realtime.user_defined_filter[] -) - RETURNS boolean - LANGUAGE sql - IMMUTABLE - AS $function$ - /* - Should the record be visible (true) or filtered out (false) after *filters* are applied - */ - select - -- Default to allowed when no filters present - $2 is null -- no filters. this should not happen because subscriptions has a default - or array_length($2, 1) is null -- array length of an empty array is null - or bool_and( - coalesce( - realtime.check_equality_op( - op:=f.op, - type_:=coalesce( - col.type_oid::regtype, -- null when wal2json version <= 2.4 - col.type_name::regtype - ), - -- cast jsonb to text - val_1:=col.value #>> '{}', - val_2:=f.value - ), - false -- if null, filter does not match - ) - ) - from - unnest(filters) f - join unnest(columns) col - on f.column_name = col.name; - $function$; - -GRANT ALL ON FUNCTION - realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) TO anon; - -GRANT ALL ON FUNCTION - realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) TO - authenticated; - -GRANT ALL ON FUNCTION - realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) TO - service_role; - -GRANT ALL ON FUNCTION - realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]) TO - supabase_realtime_admin; - -CREATE FUNCTION realtime.list_changes ( - publication name, - slot_name name, - max_changes integer, - max_record_bytes integer -) - RETURNS TABLE ( - wal jsonb, - is_rls_enabled boolean, - subscription_ids uuid[], - errors text[], - slot_changes_count bigint - ) - LANGUAGE sql - SET log_min_messages TO 'fatal' - AS $function$ - WITH pub AS ( - SELECT - concat_ws( - ',', - CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END, - CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END, - CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END - ) AS w2j_actions, - coalesce( - string_agg( - realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass), - ',' - ) filter (WHERE ppt.tablename IS NOT NULL AND ppt.tablename NOT LIKE '% %'), - '' - ) AS w2j_add_tables - FROM pg_publication pp - LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname - WHERE pp.pubname = publication - GROUP BY pp.pubname - LIMIT 1 - ), - -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once - w2j AS MATERIALIZED ( - SELECT x.*, pub.w2j_add_tables - FROM pub, - pg_logical_slot_get_changes( - slot_name, null, max_changes, - 'include-pk', 'true', - 'include-transaction', 'false', - 'include-timestamp', 'true', - 'include-type-oids', 'true', - 'format-version', '2', - 'actions', pub.w2j_actions, - 'add-tables', pub.w2j_add_tables - ) x - ), - -- Count raw slot entries before apply_rls/subscription filter - slot_count AS ( - SELECT count(*)::bigint AS cnt - FROM w2j - WHERE w2j.w2j_add_tables <> '' - ), - -- Apply RLS and filter as before - rls_filtered AS ( - SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors - FROM w2j, - realtime.apply_rls( - wal := w2j.data::jsonb, - max_record_bytes := max_record_bytes - ) xyz(wal, is_rls_enabled, subscription_ids, errors) - WHERE w2j.w2j_add_tables <> '' - AND xyz.subscription_ids[1] IS NOT NULL - ) - -- Real rows with slot count attached - SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt - FROM rls_filtered rf, slot_count sc - - UNION ALL - - -- Sentinel row: always returned when no real rows exist so Elixir can - -- always read slot_changes_count. Identified by wal IS NULL. - SELECT null, null, null, null, sc.cnt - FROM slot_count sc - WHERE NOT EXISTS (SELECT 1 FROM rls_filtered) -$function$; - -CREATE FUNCTION realtime.quote_wal2json ( - entity regclass -) - RETURNS text - LANGUAGE sql - IMMUTABLE - STRICT - AS $function$ - select - ( - select string_agg('' || ch,'') - from unnest(string_to_array(nsp.nspname::text, null)) with ordinality x(ch, idx) - where - not (x.idx = 1 and x.ch = '"') - and not ( - x.idx = array_length(string_to_array(nsp.nspname::text, null), 1) - and x.ch = '"' - ) - ) - || '.' - || ( - select string_agg('' || ch,'') - from unnest(string_to_array(pc.relname::text, null)) with ordinality x(ch, idx) - where - not (x.idx = 1 and x.ch = '"') - and not ( - x.idx = array_length(string_to_array(nsp.nspname::text, null), 1) - and x.ch = '"' - ) - ) - from - pg_class pc - join pg_namespace nsp - on pc.relnamespace = nsp.oid - where - pc.oid = entity - $function$; - -GRANT ALL ON FUNCTION realtime.quote_wal2json(regclass) TO anon; - -GRANT ALL ON FUNCTION realtime.quote_wal2json(regclass) TO authenticated; - -GRANT ALL ON FUNCTION realtime.quote_wal2json(regclass) TO service_role; - -GRANT ALL ON FUNCTION realtime.quote_wal2json(regclass) TO supabase_realtime_admin; - -CREATE FUNCTION realtime.send ( - payload jsonb, - event text, - topic text, - private boolean DEFAULT true -) - RETURNS void - LANGUAGE plpgsql - AS $function$ -DECLARE - generated_id uuid; - final_payload jsonb; -BEGIN - BEGIN - -- Generate a new UUID for the id - generated_id := gen_random_uuid(); - - -- Check if payload has an 'id' key, if not, add the generated UUID - IF payload ? 'id' THEN - final_payload := payload; - ELSE - final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id)); - END IF; - - -- Set the topic configuration - EXECUTE format('SET LOCAL realtime.topic TO %L', topic); - - -- Attempt to insert the message - INSERT INTO realtime.messages (id, payload, event, topic, private, extension) - VALUES (generated_id, final_payload, event, topic, private, 'broadcast'); - EXCEPTION - WHEN OTHERS THEN - -- Capture and notify the error - RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM; - END; -END; -$function$; - -CREATE FUNCTION realtime.subscription_check_filters() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - /* - Validates that the user defined filters for a subscription: - - refer to valid columns that the claimed role may access - - values are coercable to the correct column type - */ - declare - col_names text[] = coalesce( - array_agg(c.column_name order by c.ordinal_position), - '{}'::text[] - ) - from - information_schema.columns c - where - format('%I.%I', c.table_schema, c.table_name)::regclass = new.entity - and pg_catalog.has_column_privilege( - (new.claims ->> 'role'), - format('%I.%I', c.table_schema, c.table_name)::regclass, - c.column_name, - 'SELECT' - ); - filter realtime.user_defined_filter; - col_type regtype; - - in_val jsonb; - begin - for filter in select * from unnest(new.filters) loop - -- Filtered column is valid - if not filter.column_name = any(col_names) then - raise exception 'invalid column for filter %', filter.column_name; - end if; - - -- Type is sanitized and safe for string interpolation - col_type = ( - select atttypid::regtype - from pg_catalog.pg_attribute - where attrelid = new.entity - and attname = filter.column_name - ); - if col_type is null then - raise exception 'failed to lookup type for column %', filter.column_name; - end if; - - -- Set maximum number of entries for in filter - if filter.op = 'in'::realtime.equality_op then - in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype); - if coalesce(jsonb_array_length(in_val), 0) > 100 then - raise exception 'too many values for `in` filter. Maximum 100'; - end if; - else - -- raises an exception if value is not coercable to type - perform realtime.cast(filter.value, col_type); - end if; - - end loop; - - -- Apply consistent order to filters so the unique constraint on - -- (subscription_id, entity, filters) can't be tricked by a different filter order - new.filters = coalesce( - array_agg(f order by f.column_name, f.op, f.value), - '{}' - ) from unnest(new.filters) f; - - return new; - end; - $function$; - -GRANT ALL ON FUNCTION realtime.subscription_check_filters() TO anon; - -GRANT ALL ON FUNCTION realtime.subscription_check_filters() TO authenticated; - -GRANT ALL ON FUNCTION realtime.subscription_check_filters() TO service_role; - -GRANT ALL ON FUNCTION realtime.subscription_check_filters() TO supabase_realtime_admin; - -CREATE FUNCTION realtime.to_regrole ( - role_name text -) - RETURNS regrole - LANGUAGE sql - IMMUTABLE - AS $function$ select role_name::regrole $function$; - -GRANT ALL ON FUNCTION realtime.to_regrole(text) TO anon; - -GRANT ALL ON FUNCTION realtime.to_regrole(text) TO authenticated; - -GRANT ALL ON FUNCTION realtime.to_regrole(text) TO service_role; - -GRANT ALL ON FUNCTION realtime.to_regrole(text) TO supabase_realtime_admin; - -CREATE FUNCTION realtime.topic() - RETURNS text - LANGUAGE sql - STABLE - AS $function$ -select nullif(current_setting('realtime.topic', true), '')::text; -$function$; - -ALTER FUNCTION realtime.topic() OWNER TO supabase_realtime_admin; - -CREATE TABLE realtime.messages ( - topic text NOT NULL, - extension text NOT NULL, - payload jsonb, - event text, - private boolean DEFAULT false, - updated_at timestamp without time zone DEFAULT now() NOT NULL, - inserted_at timestamp without time zone DEFAULT now() NOT NULL, - id uuid DEFAULT gen_random_uuid() NOT NULL -) PARTITION BY RANGE (inserted_at); - -ALTER TABLE realtime.messages - OWNER TO supabase_realtime_admin; - -ALTER TABLE realtime.messages - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE realtime.messages - ADD CONSTRAINT messages_pkey PRIMARY KEY (id, inserted_at); - -GRANT INSERT, SELECT, UPDATE ON realtime.messages TO anon; - -GRANT INSERT, SELECT, UPDATE ON realtime.messages TO authenticated; - -GRANT INSERT, SELECT, UPDATE ON realtime.messages TO service_role; - -CREATE INDEX messages_inserted_at_topic_index ON realtime.messages (inserted_at DESC, topic) - WHERE extension = 'broadcast'::text AND private IS TRUE; - -CREATE TABLE realtime.messages_2026_04_15 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-15 00:00:00') TO ('2026-04-16 00:00:00'); - -CREATE TABLE realtime.messages_2026_04_16 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-16 00:00:00') TO ('2026-04-17 00:00:00'); - -CREATE TABLE realtime.messages_2026_04_17 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-17 00:00:00') TO ('2026-04-18 00:00:00'); - -CREATE TABLE realtime.messages_2026_04_18 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-18 00:00:00') TO ('2026-04-19 00:00:00'); - -CREATE TABLE realtime.messages_2026_04_19 PARTITION OF realtime.messages FOR VALUES FROM - ('2026-04-19 00:00:00') TO ('2026-04-20 00:00:00'); - -CREATE TABLE realtime.schema_migrations ( - version bigint NOT NULL, - inserted_at timestamp(0) without time zone -); - -ALTER TABLE realtime.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - -GRANT SELECT ON realtime.schema_migrations TO anon; - -GRANT SELECT ON realtime.schema_migrations TO authenticated; - -GRANT SELECT ON realtime.schema_migrations TO service_role; - -GRANT ALL ON realtime.schema_migrations TO supabase_realtime_admin; - -CREATE TABLE realtime.subscription ( - id bigint GENERATED ALWAYS AS IDENTITY NOT NULL, - subscription_id uuid NOT NULL, - entity regclass NOT NULL, - filters realtime.user_defined_filter[] DEFAULT '{}'::realtime.user_defined_filter[] - NOT NULL, - claims jsonb NOT NULL, - claims_role regrole GENERATED ALWAYS AS - (realtime.to_regrole((claims ->> 'role'::text))) STORED NOT NULL, - created_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, - action_filter text DEFAULT '*'::text -); - -ALTER TABLE realtime.subscription - ADD CONSTRAINT pk_subscription PRIMARY KEY (id); - -ALTER TABLE realtime.subscription - ADD CONSTRAINT subscription_action_filter_check - CHECK (action_filter = ANY (ARRAY['*'::text, 'INSERT'::text, 'UPDATE'::text, 'DELETE'::text])); - -GRANT SELECT ON realtime.subscription TO anon; - -GRANT SELECT ON realtime.subscription TO authenticated; - -GRANT SELECT ON realtime.subscription TO service_role; - -GRANT ALL ON realtime.subscription TO supabase_realtime_admin; - -CREATE INDEX ix_realtime_subscription_entity ON realtime.subscription (entity); - -CREATE UNIQUE INDEX subscription_subscription_id_entity_filters_action_filter_key - ON realtime.subscription (subscription_id, entity, filters, action_filter); - -CREATE TRIGGER tr_check_filters - BEFORE INSERT OR UPDATE ON realtime.subscription - FOR EACH ROW - EXECUTE FUNCTION realtime.subscription_check_filters(); - -CREATE TYPE storage.buckettype AS ENUM ( - 'STANDARD', - 'ANALYTICS', - 'VECTOR' -); - -ALTER TYPE storage.buckettype OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.allow_any_operation ( - expected_operations text[] -) - RETURNS boolean - LANGUAGE sql - STABLE - AS $function$ - WITH current_operation AS ( - SELECT storage.operation() AS raw_operation - ), - normalized AS ( - SELECT CASE - WHEN raw_operation LIKE 'storage.%' THEN substr(raw_operation, 9) - ELSE raw_operation - END AS current_operation - FROM current_operation - ) - SELECT EXISTS ( - SELECT 1 - FROM normalized n - CROSS JOIN LATERAL unnest(expected_operations) AS expected_operation - WHERE expected_operation IS NOT NULL - AND expected_operation <> '' - AND n.current_operation = CASE - WHEN expected_operation LIKE 'storage.%' THEN substr(expected_operation, 9) - ELSE expected_operation - END - ); -$function$; - -ALTER FUNCTION storage.allow_any_operation(text[]) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.allow_only_operation ( - expected_operation text -) - RETURNS boolean - LANGUAGE sql - STABLE - AS $function$ - WITH current_operation AS ( - SELECT storage.operation() AS raw_operation - ), - normalized AS ( - SELECT - CASE - WHEN raw_operation LIKE 'storage.%' THEN substr(raw_operation, 9) - ELSE raw_operation - END AS current_operation, - CASE - WHEN expected_operation LIKE 'storage.%' THEN substr(expected_operation, 9) - ELSE expected_operation - END AS requested_operation - FROM current_operation - ) - SELECT CASE - WHEN requested_operation IS NULL OR requested_operation = '' THEN FALSE - ELSE COALESCE(current_operation = requested_operation, FALSE) - END - FROM normalized; -$function$; - -ALTER FUNCTION storage.allow_only_operation(text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.can_insert_object ( - bucketid text, - name text, - owner uuid, - metadata jsonb -) - RETURNS void - LANGUAGE plpgsql - AS $function$ -BEGIN - INSERT INTO "storage"."objects" ("bucket_id", "name", "owner", "metadata") VALUES (bucketid, name, owner, metadata); - -- hack to rollback the successful insert - RAISE sqlstate 'PT200' using - message = 'ROLLBACK', - detail = 'rollback successful insert'; -END -$function$; - -ALTER FUNCTION storage.can_insert_object(text, text, uuid, jsonb) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.enforce_bucket_name_length() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ -begin - if length(new.name) > 100 then - raise exception 'bucket name "%" is too long (% characters). Max is 100.', new.name, length(new.name); - end if; - return new; -end; -$function$; - -ALTER FUNCTION storage.enforce_bucket_name_length() OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.extension ( - name text -) - RETURNS text - LANGUAGE plpgsql - AS $function$ -DECLARE -_parts text[]; -_filename text; -BEGIN - select string_to_array(name, '/') into _parts; - select _parts[array_length(_parts,1)] into _filename; - -- @todo return the last part instead of 2 - return reverse(split_part(reverse(_filename), '.', 1)); -END -$function$; - -ALTER FUNCTION storage.extension(text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.filename ( - name text -) - RETURNS text - LANGUAGE plpgsql - AS $function$ -DECLARE -_parts text[]; -BEGIN - select string_to_array(name, '/') into _parts; - return _parts[array_length(_parts,1)]; -END -$function$; - -ALTER FUNCTION storage.filename(text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.foldername ( - name text -) - RETURNS text[] - LANGUAGE plpgsql - AS $function$ -DECLARE -_parts text[]; -BEGIN - select string_to_array(name, '/') into _parts; - return _parts[1:array_length(_parts,1)-1]; -END -$function$; - -ALTER FUNCTION storage.foldername(text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.get_common_prefix ( - p_key text, - p_prefix text, - p_delimiter text -) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$ -SELECT CASE - WHEN position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1)) > 0 - THEN left(p_key, length(p_prefix) + position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1))) - ELSE NULL -END; -$function$; - -ALTER FUNCTION storage.get_common_prefix(text, text, text) OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.get_size_by_bucket() - RETURNS TABLE ( - size bigint, - bucket_id text - ) - LANGUAGE plpgsql - AS $function$ -BEGIN - return query - select sum((metadata->>'size')::int) as size, obj.bucket_id - from "storage".objects as obj - group by obj.bucket_id; -END -$function$; - -ALTER FUNCTION storage.get_size_by_bucket() OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.list_multipart_uploads_with_delimiter ( - bucket_id text, - prefix_param text, - delimiter_param text, - max_keys integer DEFAULT 100, - next_key_token text DEFAULT ''::text, - next_upload_token text DEFAULT ''::text -) - RETURNS TABLE ( - key text, - id text, - created_at timestamp with time zone - ) - LANGUAGE plpgsql - AS $function$ -BEGIN - RETURN QUERY EXECUTE - 'SELECT DISTINCT ON(key COLLATE "C") * from ( - SELECT - CASE - WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN - substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) - ELSE - key - END AS key, id, created_at - FROM - storage.s3_multipart_uploads - WHERE - bucket_id = $5 AND - key ILIKE $1 || ''%'' AND - CASE - WHEN $4 != '''' AND $6 = '''' THEN - CASE - WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN - substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) COLLATE "C" > $4 - ELSE - key COLLATE "C" > $4 - END - ELSE - true - END AND - CASE - WHEN $6 != '''' THEN - id COLLATE "C" > $6 - ELSE - true - END - ORDER BY - key COLLATE "C" ASC, created_at ASC) as e order by key COLLATE "C" LIMIT $3' - USING prefix_param, delimiter_param, max_keys, next_key_token, bucket_id, next_upload_token; -END; -$function$; - -ALTER FUNCTION storage.list_multipart_uploads_with_delimiter(text, text, text, integer, text, text) - OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.list_objects_with_delimiter ( - _bucket_id text, - prefix_param text, - delimiter_param text, - max_keys integer DEFAULT 100, - start_after text DEFAULT ''::text, - next_token text DEFAULT ''::text, - sort_order text DEFAULT 'asc'::text -) - RETURNS TABLE ( - name text, - id uuid, - metadata jsonb, - updated_at timestamp with time zone, - created_at timestamp with time zone, - last_accessed_at timestamp with time zone - ) - LANGUAGE plpgsql - STABLE - AS $function$ -DECLARE - v_peek_name TEXT; - v_current RECORD; - v_common_prefix TEXT; - - -- Configuration - v_is_asc BOOLEAN; - v_prefix TEXT; - v_start TEXT; - v_upper_bound TEXT; - v_file_batch_size INT; - - -- Seek state - v_next_seek TEXT; - v_count INT := 0; - - -- Dynamic SQL for batch query only - v_batch_query TEXT; - -BEGIN - -- ======================================================================== - -- INITIALIZATION - -- ======================================================================== - v_is_asc := lower(coalesce(sort_order, 'asc')) = 'asc'; - v_prefix := coalesce(prefix_param, ''); - v_start := CASE WHEN coalesce(next_token, '') <> '' THEN next_token ELSE coalesce(start_after, '') END; - v_file_batch_size := LEAST(GREATEST(max_keys * 2, 100), 1000); - - -- Calculate upper bound for prefix filtering (bytewise, using COLLATE "C") - IF v_prefix = '' THEN - v_upper_bound := NULL; - ELSIF right(v_prefix, 1) = delimiter_param THEN - v_upper_bound := left(v_prefix, -1) || chr(ascii(delimiter_param) + 1); - ELSE - v_upper_bound := left(v_prefix, -1) || chr(ascii(right(v_prefix, 1)) + 1); - END IF; - - -- Build batch query (dynamic SQL - called infrequently, amortized over many rows) - IF v_is_asc THEN - IF v_upper_bound IS NOT NULL THEN - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' || - 'AND o.name COLLATE "C" < $3 ORDER BY o.name COLLATE "C" ASC LIMIT $4'; - ELSE - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' || - 'ORDER BY o.name COLLATE "C" ASC LIMIT $4'; - END IF; - ELSE - IF v_upper_bound IS NOT NULL THEN - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' || - 'AND o.name COLLATE "C" >= $3 ORDER BY o.name COLLATE "C" DESC LIMIT $4'; - ELSE - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' || - 'ORDER BY o.name COLLATE "C" DESC LIMIT $4'; - END IF; - END IF; - - -- ======================================================================== - -- SEEK INITIALIZATION: Determine starting position - -- ======================================================================== - IF v_start = '' THEN - IF v_is_asc THEN - v_next_seek := v_prefix; - ELSE - -- DESC without cursor: find the last item in range - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_next_seek FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix AND o.name COLLATE "C" < v_upper_bound - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - ELSIF v_prefix <> '' THEN - SELECT o.name INTO v_next_seek FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - ELSE - SELECT o.name INTO v_next_seek FROM storage.objects o - WHERE o.bucket_id = _bucket_id - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - END IF; - - IF v_next_seek IS NOT NULL THEN - v_next_seek := v_next_seek || delimiter_param; - ELSE - RETURN; - END IF; - END IF; - ELSE - -- Cursor provided: determine if it refers to a folder or leaf - IF EXISTS ( - SELECT 1 FROM storage.objects o - WHERE o.bucket_id = _bucket_id - AND o.name COLLATE "C" LIKE v_start || delimiter_param || '%' - LIMIT 1 - ) THEN - -- Cursor refers to a folder - IF v_is_asc THEN - v_next_seek := v_start || chr(ascii(delimiter_param) + 1); - ELSE - v_next_seek := v_start || delimiter_param; - END IF; - ELSE - -- Cursor refers to a leaf object - IF v_is_asc THEN - v_next_seek := v_start || delimiter_param; - ELSE - v_next_seek := v_start; - END IF; - END IF; - END IF; - - -- ======================================================================== - -- MAIN LOOP: Hybrid peek-then-batch algorithm - -- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch - -- ======================================================================== - LOOP - EXIT WHEN v_count >= max_keys; - - -- STEP 1: PEEK using STATIC SQL (plan cached, very fast) - IF v_is_asc THEN - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek AND o.name COLLATE "C" < v_upper_bound - ORDER BY o.name COLLATE "C" ASC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek - ORDER BY o.name COLLATE "C" ASC LIMIT 1; - END IF; - ELSE - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - ELSIF v_prefix <> '' THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek - ORDER BY o.name COLLATE "C" DESC LIMIT 1; - END IF; - END IF; - - EXIT WHEN v_peek_name IS NULL; - - -- STEP 2: Check if this is a FOLDER or FILE - v_common_prefix := storage.get_common_prefix(v_peek_name, v_prefix, delimiter_param); - - IF v_common_prefix IS NOT NULL THEN - -- FOLDER: Emit and skip to next folder (no heap access needed) - name := rtrim(v_common_prefix, delimiter_param); - id := NULL; - updated_at := NULL; - created_at := NULL; - last_accessed_at := NULL; - metadata := NULL; - RETURN NEXT; - v_count := v_count + 1; - - -- Advance seek past the folder range - IF v_is_asc THEN - v_next_seek := left(v_common_prefix, -1) || chr(ascii(delimiter_param) + 1); - ELSE - v_next_seek := v_common_prefix; - END IF; - ELSE - -- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows) - -- For ASC: upper_bound is the exclusive upper limit (< condition) - -- For DESC: prefix is the inclusive lower limit (>= condition) - FOR v_current IN EXECUTE v_batch_query USING _bucket_id, v_next_seek, - CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix) ELSE v_prefix END, v_file_batch_size - LOOP - v_common_prefix := storage.get_common_prefix(v_current.name, v_prefix, delimiter_param); - - IF v_common_prefix IS NOT NULL THEN - -- Hit a folder: exit batch, let peek handle it - v_next_seek := v_current.name; - EXIT; - END IF; - - -- Emit file - name := v_current.name; - id := v_current.id; - updated_at := v_current.updated_at; - created_at := v_current.created_at; - last_accessed_at := v_current.last_accessed_at; - metadata := v_current.metadata; - RETURN NEXT; - v_count := v_count + 1; - - -- Advance seek past this file - IF v_is_asc THEN - v_next_seek := v_current.name || delimiter_param; - ELSE - v_next_seek := v_current.name; - END IF; - - EXIT WHEN v_count >= max_keys; - END LOOP; - END IF; - END LOOP; -END; -$function$; - -ALTER FUNCTION storage.list_objects_with_delimiter(text, text, text, integer, text, text, text) - OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.operation() - RETURNS text - LANGUAGE plpgsql - STABLE - AS $function$ -BEGIN - RETURN current_setting('storage.operation', true); -END; -$function$; - -ALTER FUNCTION storage.operation() OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.protect_delete() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ -BEGIN - -- Check if storage.allow_delete_query is set to 'true' - IF COALESCE(current_setting('storage.allow_delete_query', true), 'false') != 'true' THEN - RAISE EXCEPTION 'Direct deletion from storage tables is not allowed. Use the Storage API instead.' - USING HINT = 'This prevents accidental data loss from orphaned objects.', - ERRCODE = '42501'; - END IF; - RETURN NULL; -END; -$function$; - -ALTER FUNCTION storage.protect_delete() OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.search_by_timestamp ( - p_prefix text, - p_bucket_id text, - p_limit integer, - p_level integer, - p_start_after text, - p_sort_order text, - p_sort_column text, - p_sort_column_after text -) - RETURNS TABLE ( - key text, - name text, - id uuid, - updated_at timestamp with time zone, - created_at timestamp with time zone, - last_accessed_at timestamp with time zone, - metadata jsonb - ) - LANGUAGE plpgsql - STABLE - AS $function$ -DECLARE - v_cursor_op text; - v_query text; - v_prefix text; -BEGIN - v_prefix := coalesce(p_prefix, ''); - - IF p_sort_order = 'asc' THEN - v_cursor_op := '>'; - ELSE - v_cursor_op := '<'; - END IF; - - v_query := format($sql$ - WITH raw_objects AS ( - SELECT - o.name AS obj_name, - o.id AS obj_id, - o.updated_at AS obj_updated_at, - o.created_at AS obj_created_at, - o.last_accessed_at AS obj_last_accessed_at, - o.metadata AS obj_metadata, - storage.get_common_prefix(o.name, $1, '/') AS common_prefix - FROM storage.objects o - WHERE o.bucket_id = $2 - AND o.name COLLATE "C" LIKE $1 || '%%' - ), - -- Aggregate common prefixes (folders) - -- Both created_at and updated_at use MIN(obj_created_at) to match the old prefixes table behavior - aggregated_prefixes AS ( - SELECT - rtrim(common_prefix, '/') AS name, - NULL::uuid AS id, - MIN(obj_created_at) AS updated_at, - MIN(obj_created_at) AS created_at, - NULL::timestamptz AS last_accessed_at, - NULL::jsonb AS metadata, - TRUE AS is_prefix - FROM raw_objects - WHERE common_prefix IS NOT NULL - GROUP BY common_prefix - ), - leaf_objects AS ( - SELECT - obj_name AS name, - obj_id AS id, - obj_updated_at AS updated_at, - obj_created_at AS created_at, - obj_last_accessed_at AS last_accessed_at, - obj_metadata AS metadata, - FALSE AS is_prefix - FROM raw_objects - WHERE common_prefix IS NULL - ), - combined AS ( - SELECT * FROM aggregated_prefixes - UNION ALL - SELECT * FROM leaf_objects - ), - filtered AS ( - SELECT * - FROM combined - WHERE ( - $5 = '' - OR ROW( - date_trunc('milliseconds', %I), - name COLLATE "C" - ) %s ROW( - COALESCE(NULLIF($6, '')::timestamptz, 'epoch'::timestamptz), - $5 - ) - ) - ) - SELECT - split_part(name, '/', $3) AS key, - name, - id, - updated_at, - created_at, - last_accessed_at, - metadata - FROM filtered - ORDER BY - COALESCE(date_trunc('milliseconds', %I), 'epoch'::timestamptz) %s, - name COLLATE "C" %s - LIMIT $4 - $sql$, - p_sort_column, - v_cursor_op, - p_sort_column, - p_sort_order, - p_sort_order - ); - - RETURN QUERY EXECUTE v_query - USING v_prefix, p_bucket_id, p_level, p_limit, p_start_after, p_sort_column_after; -END; -$function$; - -ALTER FUNCTION storage.search_by_timestamp(text, text, integer, integer, text, text, text, text) - OWNER TO supabase_storage_admin; - -CREATE FUNCTION storage.search_v2 ( - prefix text, - bucket_name text, - limits integer DEFAULT 100, - levels integer DEFAULT 1, - start_after text DEFAULT ''::text, - sort_order text DEFAULT 'asc'::text, - sort_column text DEFAULT 'name'::text, - sort_column_after text DEFAULT ''::text -) - RETURNS TABLE ( - key text, - name text, - id uuid, - updated_at timestamp with time zone, - created_at timestamp with time zone, - last_accessed_at timestamp with time zone, - metadata jsonb - ) - LANGUAGE plpgsql - STABLE - AS $function$ -DECLARE - v_sort_col text; - v_sort_ord text; - v_limit int; -BEGIN - -- Cap limit to maximum of 1500 records - v_limit := LEAST(coalesce(limits, 100), 1500); - - -- Validate and normalize sort_order - v_sort_ord := lower(coalesce(sort_order, 'asc')); - IF v_sort_ord NOT IN ('asc', 'desc') THEN - v_sort_ord := 'asc'; - END IF; - - -- Validate and normalize sort_column - v_sort_col := lower(coalesce(sort_column, 'name')); - IF v_sort_col NOT IN ('name', 'updated_at', 'created_at') THEN - v_sort_col := 'name'; - END IF; - - -- Route to appropriate implementation - IF v_sort_col = 'name' THEN - -- Use list_objects_with_delimiter for name sorting (most efficient: O(k * log n)) - RETURN QUERY - SELECT - split_part(l.name, '/', levels) AS key, - l.name AS name, - l.id, - l.updated_at, - l.created_at, - l.last_accessed_at, - l.metadata - FROM storage.list_objects_with_delimiter( - bucket_name, - coalesce(prefix, ''), - '/', - v_limit, - start_after, - '', - v_sort_ord - ) l; - ELSE - -- Use aggregation approach for timestamp sorting - -- Not efficient for large datasets but supports correct pagination - RETURN QUERY SELECT * FROM storage.search_by_timestamp( - prefix, bucket_name, v_limit, levels, start_after, - v_sort_ord, v_sort_col, sort_column_after - ); - END IF; -END; -$function$; - -ALTER FUNCTION storage.search_v2(text, text, integer, integer, text, text, text, text) OWNER TO - supabase_storage_admin; - -CREATE FUNCTION storage.search ( - prefix text, - bucketname text, - limits integer DEFAULT 100, - levels integer DEFAULT 1, - offsets integer DEFAULT 0, - search text DEFAULT ''::text, - sortcolumn text DEFAULT 'name'::text, - sortorder text DEFAULT 'asc'::text -) - RETURNS TABLE ( - name text, - id uuid, - updated_at timestamp with time zone, - created_at timestamp with time zone, - last_accessed_at timestamp with time zone, - metadata jsonb - ) - LANGUAGE plpgsql - STABLE - AS $function$ -DECLARE - v_peek_name TEXT; - v_current RECORD; - v_common_prefix TEXT; - v_delimiter CONSTANT TEXT := '/'; - - -- Configuration - v_limit INT; - v_prefix TEXT; - v_prefix_lower TEXT; - v_is_asc BOOLEAN; - v_order_by TEXT; - v_sort_order TEXT; - v_upper_bound TEXT; - v_file_batch_size INT; - - -- Dynamic SQL for batch query only - v_batch_query TEXT; - - -- Seek state - v_next_seek TEXT; - v_count INT := 0; - v_skipped INT := 0; -BEGIN - -- ======================================================================== - -- INITIALIZATION - -- ======================================================================== - v_limit := LEAST(coalesce(limits, 100), 1500); - v_prefix := coalesce(prefix, '') || coalesce(search, ''); - v_prefix_lower := lower(v_prefix); - v_is_asc := lower(coalesce(sortorder, 'asc')) = 'asc'; - v_file_batch_size := LEAST(GREATEST(v_limit * 2, 100), 1000); - - -- Validate sort column - CASE lower(coalesce(sortcolumn, 'name')) - WHEN 'name' THEN v_order_by := 'name'; - WHEN 'updated_at' THEN v_order_by := 'updated_at'; - WHEN 'created_at' THEN v_order_by := 'created_at'; - WHEN 'last_accessed_at' THEN v_order_by := 'last_accessed_at'; - ELSE v_order_by := 'name'; - END CASE; - - v_sort_order := CASE WHEN v_is_asc THEN 'asc' ELSE 'desc' END; - - -- ======================================================================== - -- NON-NAME SORTING: Use path_tokens approach (unchanged) - -- ======================================================================== - IF v_order_by != 'name' THEN - RETURN QUERY EXECUTE format( - $sql$ - WITH folders AS ( - SELECT path_tokens[$1] AS folder - FROM storage.objects - WHERE objects.name ILIKE $2 || '%%' - AND bucket_id = $3 - AND array_length(objects.path_tokens, 1) <> $1 - GROUP BY folder - ORDER BY folder %s - ) - (SELECT folder AS "name", - NULL::uuid AS id, - NULL::timestamptz AS updated_at, - NULL::timestamptz AS created_at, - NULL::timestamptz AS last_accessed_at, - NULL::jsonb AS metadata FROM folders) - UNION ALL - (SELECT path_tokens[$1] AS "name", - id, updated_at, created_at, last_accessed_at, metadata - FROM storage.objects - WHERE objects.name ILIKE $2 || '%%' - AND bucket_id = $3 - AND array_length(objects.path_tokens, 1) = $1 - ORDER BY %I %s) - LIMIT $4 OFFSET $5 - $sql$, v_sort_order, v_order_by, v_sort_order - ) USING levels, v_prefix, bucketname, v_limit, offsets; - RETURN; - END IF; - - -- ======================================================================== - -- NAME SORTING: Hybrid skip-scan with batch optimization - -- ======================================================================== - - -- Calculate upper bound for prefix filtering - IF v_prefix_lower = '' THEN - v_upper_bound := NULL; - ELSIF right(v_prefix_lower, 1) = v_delimiter THEN - v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(v_delimiter) + 1); - ELSE - v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(right(v_prefix_lower, 1)) + 1); - END IF; - - -- Build batch query (dynamic SQL - called infrequently, amortized over many rows) - IF v_is_asc THEN - IF v_upper_bound IS NOT NULL THEN - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' || - 'AND lower(o.name) COLLATE "C" < $3 ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4'; - ELSE - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' || - 'ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4'; - END IF; - ELSE - IF v_upper_bound IS NOT NULL THEN - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' || - 'AND lower(o.name) COLLATE "C" >= $3 ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4'; - ELSE - v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' || - 'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' || - 'ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4'; - END IF; - END IF; - - -- Initialize seek position - IF v_is_asc THEN - v_next_seek := v_prefix_lower; - ELSE - -- DESC: find the last item in range first (static SQL) - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower AND lower(o.name) COLLATE "C" < v_upper_bound - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - ELSIF v_prefix_lower <> '' THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - END IF; - - IF v_peek_name IS NOT NULL THEN - v_next_seek := lower(v_peek_name) || v_delimiter; - ELSE - RETURN; - END IF; - END IF; - - -- ======================================================================== - -- MAIN LOOP: Hybrid peek-then-batch algorithm - -- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch - -- ======================================================================== - LOOP - EXIT WHEN v_count >= v_limit; - - -- STEP 1: PEEK using STATIC SQL (plan cached, very fast) - IF v_is_asc THEN - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek AND lower(o.name) COLLATE "C" < v_upper_bound - ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek - ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1; - END IF; - ELSE - IF v_upper_bound IS NOT NULL THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - ELSIF v_prefix_lower <> '' THEN - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - ELSE - SELECT o.name INTO v_peek_name FROM storage.objects o - WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek - ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1; - END IF; - END IF; - - EXIT WHEN v_peek_name IS NULL; - - -- STEP 2: Check if this is a FOLDER or FILE - v_common_prefix := storage.get_common_prefix(lower(v_peek_name), v_prefix_lower, v_delimiter); - - IF v_common_prefix IS NOT NULL THEN - -- FOLDER: Handle offset, emit if needed, skip to next folder - IF v_skipped < offsets THEN - v_skipped := v_skipped + 1; - ELSE - name := split_part(rtrim(storage.get_common_prefix(v_peek_name, v_prefix, v_delimiter), v_delimiter), v_delimiter, levels); - id := NULL; - updated_at := NULL; - created_at := NULL; - last_accessed_at := NULL; - metadata := NULL; - RETURN NEXT; - v_count := v_count + 1; - END IF; - - -- Advance seek past the folder range - IF v_is_asc THEN - v_next_seek := lower(left(v_common_prefix, -1)) || chr(ascii(v_delimiter) + 1); - ELSE - v_next_seek := lower(v_common_prefix); - END IF; - ELSE - -- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows) - -- For ASC: upper_bound is the exclusive upper limit (< condition) - -- For DESC: prefix_lower is the inclusive lower limit (>= condition) - FOR v_current IN EXECUTE v_batch_query - USING bucketname, v_next_seek, - CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix_lower) ELSE v_prefix_lower END, v_file_batch_size - LOOP - v_common_prefix := storage.get_common_prefix(lower(v_current.name), v_prefix_lower, v_delimiter); - - IF v_common_prefix IS NOT NULL THEN - -- Hit a folder: exit batch, let peek handle it - v_next_seek := lower(v_current.name); - EXIT; - END IF; - - -- Handle offset skipping - IF v_skipped < offsets THEN - v_skipped := v_skipped + 1; - ELSE - -- Emit file - name := split_part(v_current.name, v_delimiter, levels); - id := v_current.id; - updated_at := v_current.updated_at; - created_at := v_current.created_at; - last_accessed_at := v_current.last_accessed_at; - metadata := v_current.metadata; - RETURN NEXT; - v_count := v_count + 1; - END IF; - - -- Advance seek past this file - IF v_is_asc THEN - v_next_seek := lower(v_current.name) || v_delimiter; - ELSE - v_next_seek := lower(v_current.name); - END IF; - - EXIT WHEN v_count >= v_limit; - END LOOP; - END IF; - END LOOP; -END; -$function$; - -ALTER FUNCTION storage.search(text, text, integer, integer, integer, text, text, text) OWNER TO - supabase_storage_admin; - -CREATE FUNCTION storage.update_updated_at_column() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -$function$; - -ALTER FUNCTION storage.update_updated_at_column() OWNER TO supabase_storage_admin; - -CREATE TABLE storage.buckets ( - id text NOT NULL, - name text NOT NULL, - owner uuid, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now(), - public boolean DEFAULT false, - avif_autodetection boolean DEFAULT false, - file_size_limit bigint, - allowed_mime_types text[], - owner_id text, - type storage.buckettype DEFAULT 'STANDARD'::storage.buckettype NOT NULL -); - -COMMENT ON COLUMN storage.buckets.owner IS 'Field is deprecated, use owner_id instead'; - -ALTER TABLE storage.buckets - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.buckets - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.buckets - ADD CONSTRAINT buckets_pkey PRIMARY KEY (id); - -GRANT ALL ON storage.buckets TO postgres WITH GRANT OPTION; - -GRANT ALL ON storage.buckets TO anon; - -GRANT ALL ON storage.buckets TO authenticated; - -GRANT ALL ON storage.buckets TO service_role; - -CREATE UNIQUE INDEX bname ON storage.buckets (name); - -CREATE TRIGGER enforce_bucket_name_length_trigger - BEFORE INSERT OR UPDATE OF name ON storage.buckets - FOR EACH ROW - EXECUTE FUNCTION storage.enforce_bucket_name_length(); - -CREATE TRIGGER protect_buckets_delete - BEFORE DELETE ON storage.buckets - FOR EACH STATEMENT - EXECUTE FUNCTION storage.protect_delete(); - -CREATE TABLE storage.buckets_analytics ( - name text NOT NULL, - type storage.buckettype DEFAULT 'ANALYTICS'::storage.buckettype NOT NULL, - format text DEFAULT 'ICEBERG'::text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - id uuid DEFAULT gen_random_uuid() NOT NULL, - deleted_at timestamp with time zone -); - -ALTER TABLE storage.buckets_analytics - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.buckets_analytics - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.buckets_analytics - ADD CONSTRAINT buckets_analytics_pkey PRIMARY KEY (id); - -GRANT ALL ON storage.buckets_analytics TO anon; - -GRANT ALL ON storage.buckets_analytics TO authenticated; - -GRANT ALL ON storage.buckets_analytics TO service_role; - -CREATE UNIQUE INDEX buckets_analytics_unique_name_idx ON storage.buckets_analytics (name) - WHERE deleted_at IS NULL; - -CREATE TABLE storage.buckets_vectors ( - id text NOT NULL, - type storage.buckettype DEFAULT 'VECTOR'::storage.buckettype NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE storage.buckets_vectors - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.buckets_vectors - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.buckets_vectors - ADD CONSTRAINT buckets_vectors_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.buckets_vectors TO anon; - -GRANT SELECT ON storage.buckets_vectors TO authenticated; - -GRANT SELECT ON storage.buckets_vectors TO service_role; - -CREATE TABLE storage.iceberg_namespaces ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - bucket_name text NOT NULL, - name text COLLATE "C" NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - metadata jsonb DEFAULT '{}'::jsonb NOT NULL, - catalog_id uuid NOT NULL -); - -ALTER TABLE storage.iceberg_namespaces - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.iceberg_namespaces - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.iceberg_namespaces - ADD CONSTRAINT iceberg_namespaces_catalog_id_fkey FOREIGN KEY (catalog_id) - REFERENCES storage.buckets_analytics(id) ON DELETE CASCADE; - -ALTER TABLE storage.iceberg_namespaces - ADD CONSTRAINT iceberg_namespaces_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.iceberg_namespaces TO anon; - -GRANT SELECT ON storage.iceberg_namespaces TO authenticated; - -GRANT ALL ON storage.iceberg_namespaces TO service_role; - -CREATE UNIQUE INDEX idx_iceberg_namespaces_bucket_id - ON storage.iceberg_namespaces (catalog_id, name); - -CREATE TABLE storage.iceberg_tables ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - namespace_id uuid NOT NULL, - bucket_name text NOT NULL, - name text COLLATE "C" NOT NULL, - location text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - remote_table_id text, - shard_key text, - shard_id text, - catalog_id uuid NOT NULL -); - -ALTER TABLE storage.iceberg_tables - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.iceberg_tables - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.iceberg_tables - ADD CONSTRAINT iceberg_tables_catalog_id_fkey FOREIGN KEY (catalog_id) - REFERENCES storage.buckets_analytics(id) ON DELETE CASCADE; - -ALTER TABLE storage.iceberg_tables - ADD CONSTRAINT iceberg_tables_namespace_id_fkey FOREIGN KEY (namespace_id) - REFERENCES storage.iceberg_namespaces(id) ON DELETE CASCADE; - -ALTER TABLE storage.iceberg_tables - ADD CONSTRAINT iceberg_tables_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.iceberg_tables TO anon; - -GRANT SELECT ON storage.iceberg_tables TO authenticated; - -GRANT ALL ON storage.iceberg_tables TO service_role; - -CREATE UNIQUE INDEX idx_iceberg_tables_location ON storage.iceberg_tables (location); - -CREATE UNIQUE INDEX idx_iceberg_tables_namespace_id - ON storage.iceberg_tables (catalog_id, namespace_id, name); - -CREATE TABLE storage.migrations ( - id integer NOT NULL, - name character varying(100) NOT NULL, - hash character varying(40) NOT NULL, - executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER TABLE storage.migrations - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.migrations - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.migrations - ADD CONSTRAINT migrations_name_key UNIQUE (name); - -ALTER TABLE storage.migrations - ADD CONSTRAINT migrations_pkey PRIMARY KEY (id); - -CREATE TABLE storage.objects ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - bucket_id text, - name text, - owner uuid, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now(), - last_accessed_at timestamp with time zone DEFAULT now(), - metadata jsonb, - path_tokens text[] GENERATED ALWAYS AS (string_to_array(name, '/'::text)) - STORED, - version text, - owner_id text, - user_metadata jsonb -); - -COMMENT ON COLUMN storage.objects.owner IS 'Field is deprecated, use owner_id instead'; - -ALTER TABLE storage.objects - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.objects - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.objects - ADD CONSTRAINT "objects_bucketId_fkey" FOREIGN KEY (bucket_id) REFERENCES storage.buckets(id); - -ALTER TABLE storage.objects - ADD CONSTRAINT objects_pkey PRIMARY KEY (id); - -GRANT ALL ON storage.objects TO postgres WITH GRANT OPTION; - -GRANT ALL ON storage.objects TO anon; - -GRANT ALL ON storage.objects TO authenticated; - -GRANT ALL ON storage.objects TO service_role; - -CREATE UNIQUE INDEX bucketid_objname ON storage.objects (bucket_id, name); - -CREATE INDEX idx_objects_bucket_id_name ON storage.objects (bucket_id, name COLLATE "C"); - -CREATE INDEX name_prefix_search ON storage.objects (name text_pattern_ops); - -CREATE INDEX idx_objects_bucket_id_name_lower - ON storage.objects (bucket_id, lower(name) COLLATE "C"); - -CREATE TRIGGER protect_objects_delete - BEFORE DELETE ON storage.objects - FOR EACH STATEMENT - EXECUTE FUNCTION storage.protect_delete(); - -CREATE TRIGGER update_objects_updated_at - BEFORE UPDATE ON storage.objects - FOR EACH ROW - EXECUTE FUNCTION storage.update_updated_at_column(); - -CREATE TABLE storage.s3_multipart_uploads ( - id text NOT NULL, - in_progress_size bigint DEFAULT 0 NOT NULL, - upload_signature text NOT NULL, - bucket_id text NOT NULL, - key text COLLATE "C" NOT NULL, - version text NOT NULL, - owner_id text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - user_metadata jsonb, - metadata jsonb -); - -ALTER TABLE storage.s3_multipart_uploads - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.s3_multipart_uploads - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.s3_multipart_uploads - ADD CONSTRAINT s3_multipart_uploads_bucket_id_fkey FOREIGN KEY (bucket_id) - REFERENCES storage.buckets(id); - -ALTER TABLE storage.s3_multipart_uploads - ADD CONSTRAINT s3_multipart_uploads_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.s3_multipart_uploads TO anon; - -GRANT SELECT ON storage.s3_multipart_uploads TO authenticated; - -GRANT ALL ON storage.s3_multipart_uploads TO service_role; - -CREATE INDEX idx_multipart_uploads_list ON storage.s3_multipart_uploads (bucket_id, key, created_at); - -CREATE TABLE storage.s3_multipart_uploads_parts ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - upload_id text NOT NULL, - size bigint DEFAULT 0 NOT NULL, - part_number integer NOT NULL, - bucket_id text NOT NULL, - key text COLLATE "C" NOT NULL, - etag text NOT NULL, - owner_id text, - version text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE storage.s3_multipart_uploads_parts - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.s3_multipart_uploads_parts - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.s3_multipart_uploads_parts - ADD CONSTRAINT s3_multipart_uploads_parts_bucket_id_fkey FOREIGN KEY (bucket_id) - REFERENCES storage.buckets(id); - -ALTER TABLE storage.s3_multipart_uploads_parts - ADD CONSTRAINT s3_multipart_uploads_parts_pkey PRIMARY KEY (id); - -ALTER TABLE storage.s3_multipart_uploads_parts - ADD CONSTRAINT s3_multipart_uploads_parts_upload_id_fkey FOREIGN KEY (upload_id) - REFERENCES storage.s3_multipart_uploads(id) ON DELETE CASCADE; - -GRANT SELECT ON storage.s3_multipart_uploads_parts TO anon; - -GRANT SELECT ON storage.s3_multipart_uploads_parts TO authenticated; - -GRANT ALL ON storage.s3_multipart_uploads_parts TO service_role; - -CREATE TABLE storage.vector_indexes ( - id text DEFAULT gen_random_uuid() NOT NULL, - name text COLLATE "C" NOT NULL, - bucket_id text NOT NULL, - data_type text NOT NULL, - dimension integer NOT NULL, - distance_metric text NOT NULL, - metadata_configuration jsonb, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE storage.vector_indexes - OWNER TO supabase_storage_admin; - -ALTER TABLE storage.vector_indexes - ENABLE ROW LEVEL SECURITY; - -ALTER TABLE storage.vector_indexes - ADD CONSTRAINT vector_indexes_bucket_id_fkey FOREIGN KEY (bucket_id) - REFERENCES storage.buckets_vectors(id); - -ALTER TABLE storage.vector_indexes - ADD CONSTRAINT vector_indexes_pkey PRIMARY KEY (id); - -GRANT SELECT ON storage.vector_indexes TO anon; - -GRANT SELECT ON storage.vector_indexes TO authenticated; - -GRANT SELECT ON storage.vector_indexes TO service_role; - -CREATE UNIQUE INDEX vector_indexes_name_bucket_id_idx ON storage.vector_indexes (name, bucket_id); - -CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; - -GRANT USAGE ON SCHEMA supabase_functions TO postgres; - -GRANT USAGE ON SCHEMA supabase_functions TO anon; - -GRANT USAGE ON SCHEMA supabase_functions TO authenticated; - -GRANT USAGE ON SCHEMA supabase_functions TO service_role; - -GRANT ALL ON SCHEMA supabase_functions TO supabase_functions_admin; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO - anon; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES - TO anon; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON ROUTINES - TO anon; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO - authenticated; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES - TO authenticated; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON ROUTINES - TO authenticated; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO - postgres; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES - TO postgres; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON ROUTINES - TO postgres; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO - service_role; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES - TO service_role; - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON ROUTINES - TO service_role; - -CREATE SEQUENCE supabase_functions.hooks_id_seq; - -CREATE FUNCTION supabase_functions.http_request() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path TO 'supabase_functions' - AS $function$ - DECLARE - request_id bigint; - payload jsonb; - url text := TG_ARGV[0]::text; - method text := TG_ARGV[1]::text; - headers jsonb DEFAULT '{}'::jsonb; - params jsonb DEFAULT '{}'::jsonb; - timeout_ms integer DEFAULT 1000; - BEGIN - IF url IS NULL OR url = 'null' THEN - RAISE EXCEPTION 'url argument is missing'; - END IF; - - IF method IS NULL OR method = 'null' THEN - RAISE EXCEPTION 'method argument is missing'; - END IF; - - IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN - headers = '{"Content-Type": "application/json"}'::jsonb; - ELSE - headers = TG_ARGV[2]::jsonb; - END IF; - - IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN - params = '{}'::jsonb; - ELSE - params = TG_ARGV[3]::jsonb; - END IF; - - IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN - timeout_ms = 1000; - ELSE - timeout_ms = TG_ARGV[4]::integer; - END IF; - - CASE - WHEN method = 'GET' THEN - SELECT http_get INTO request_id FROM net.http_get( - url, - params, - headers, - timeout_ms - ); - WHEN method = 'POST' THEN - payload = jsonb_build_object( - 'old_record', OLD, - 'record', NEW, - 'type', TG_OP, - 'table', TG_TABLE_NAME, - 'schema', TG_TABLE_SCHEMA - ); - - SELECT http_post INTO request_id FROM net.http_post( - url, - payload, - params, - headers, - timeout_ms - ); - ELSE - RAISE EXCEPTION 'method argument % is invalid', method; - END CASE; - - INSERT INTO supabase_functions.hooks - (hook_table_id, hook_name, request_id) - VALUES - (TG_RELID, TG_NAME, request_id); - - RETURN NEW; - END -$function$; - -ALTER FUNCTION supabase_functions.http_request() OWNER TO supabase_functions_admin; - -CREATE TABLE supabase_functions.hooks ( - id bigint DEFAULT - nextval('supabase_functions.hooks_id_seq'::regclass) NOT NULL, - hook_table_id integer NOT NULL, - hook_name text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - request_id bigint -); - -ALTER SEQUENCE supabase_functions.hooks_id_seq OWNED BY supabase_functions.hooks.id; - -COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; - -ALTER TABLE supabase_functions.hooks - OWNER TO supabase_functions_admin; - -ALTER TABLE supabase_functions.hooks - ADD CONSTRAINT hooks_pkey PRIMARY KEY (id); - -CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks (request_id); - -CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx - ON supabase_functions.hooks (hook_table_id, hook_name); - -CREATE TABLE supabase_functions.migrations ( - version text NOT NULL, - inserted_at timestamp with time zone DEFAULT now() NOT NULL -); - -ALTER TABLE supabase_functions.migrations - OWNER TO supabase_functions_admin; - -ALTER TABLE supabase_functions.migrations - ADD CONSTRAINT migrations_pkey PRIMARY KEY (version); \ No newline at end of file diff --git a/packages/pg-delta/tests/integration/fk-constraint-ordering.test.ts b/packages/pg-delta/tests/integration/fk-constraint-ordering.test.ts deleted file mode 100644 index b168a483b..000000000 --- a/packages/pg-delta/tests/integration/fk-constraint-ordering.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Integration tests to validate the specific FK constraint ordering theory. - * - * This test validates the theory mentioned in the PR about the stableId fix - * for AlterTableAddConstraint where foreign key constraints were being created - * before the referenced table existed. - */ - -import { describe, expect, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`FK constraint ordering validation (pg${pgVersion})`, () => { - test( - "FK constraint created before referenced table - should fail without stableId fix", - withDb(pgVersion, async (db) => { - // This test reproduces the exact scenario mentioned in the PR - // where the FK constraint was being created before the referenced table - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create the referencing table first (this is the problematic scenario) - CREATE TABLE test_schema.orders ( - id integer PRIMARY KEY, - customer_id integer NOT NULL, - order_date date - ); - - -- Create the referenced table second - CREATE TABLE test_schema.customers ( - id integer PRIMARY KEY, - name text NOT NULL, - email text UNIQUE - ); - - -- Add foreign key constraint - this should work because customers table exists - -- But without the stableId fix, this might be ordered incorrectly - ALTER TABLE test_schema.orders - ADD CONSTRAINT orders_customer_fkey - FOREIGN KEY (customer_id) REFERENCES test_schema.customers(id); - `, - description: - "FK constraint created before referenced table - should fail without stableId fix", - }); - }), - ); - - test( - "complex FK constraint chain with multiple references", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA ecommerce; - `, - testSql: ` - -- Create tables in a potentially problematic order - CREATE TABLE ecommerce.order_items ( - id integer PRIMARY KEY, - order_id integer NOT NULL, - product_id integer NOT NULL, - quantity integer NOT NULL - ); - - CREATE TABLE ecommerce.orders ( - id integer PRIMARY KEY, - customer_id integer NOT NULL, - order_date date NOT NULL - ); - - CREATE TABLE ecommerce.customers ( - id integer PRIMARY KEY, - name text NOT NULL, - email text UNIQUE NOT NULL - ); - - CREATE TABLE ecommerce.products ( - id integer PRIMARY KEY, - name text NOT NULL, - price decimal NOT NULL - ); - - -- Add foreign key constraints in the order they were discovered - -- This tests the stableId fix for multiple FK constraints - ALTER TABLE ecommerce.orders - ADD CONSTRAINT orders_customer_fkey - FOREIGN KEY (customer_id) REFERENCES ecommerce.customers(id); - - ALTER TABLE ecommerce.order_items - ADD CONSTRAINT order_items_order_fkey - FOREIGN KEY (order_id) REFERENCES ecommerce.orders(id); - - ALTER TABLE ecommerce.order_items - ADD CONSTRAINT order_items_product_fkey - FOREIGN KEY (product_id) REFERENCES ecommerce.products(id); - `, - }); - }), - ); - - test( - "FK constraint with deferred validation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create tables - CREATE TABLE test_schema.parent ( - id integer PRIMARY KEY, - name text NOT NULL - ); - - CREATE TABLE test_schema.child ( - id integer PRIMARY KEY, - parent_id integer, - name text NOT NULL - ); - - -- Add foreign key constraint with deferred validation - ALTER TABLE test_schema.child - ADD CONSTRAINT child_parent_fkey - FOREIGN KEY (parent_id) REFERENCES test_schema.parent(id) - DEFERRABLE INITIALLY DEFERRED; - `, - }); - }), - ); - - test( - "self-referencing FK constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create a table with self-referencing foreign key - CREATE TABLE test_schema.categories ( - id integer PRIMARY KEY, - name text NOT NULL, - parent_id integer - ); - - -- Add self-referencing foreign key constraint - ALTER TABLE test_schema.categories - ADD CONSTRAINT categories_parent_fkey - FOREIGN KEY (parent_id) REFERENCES test_schema.categories(id); - `, - }); - }), - ); - - test( - "FK constraint with ON DELETE/UPDATE actions", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create tables - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text NOT NULL - ); - - CREATE TABLE test_schema.orders ( - id integer PRIMARY KEY, - user_id integer NOT NULL, - status text NOT NULL - ); - - -- Add foreign key constraint with CASCADE actions - ALTER TABLE test_schema.orders - ADD CONSTRAINT orders_user_fkey - FOREIGN KEY (user_id) REFERENCES test_schema.users(id) - ON DELETE CASCADE ON UPDATE CASCADE; - `, - }); - }), - ); - - test( - "drop referencing table before referenced table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.challenge_levels ( - id BIGSERIAL PRIMARY KEY - ); - - CREATE TABLE public.user_challenge_progress ( - id BIGSERIAL PRIMARY KEY, - level_id BIGINT REFERENCES public.challenge_levels(id) - ); - `, - testSql: ` - DROP TABLE public.user_challenge_progress; - DROP TABLE public.challenge_levels; - `, - assertSqlStatements: (statements) => { - const dropTableStatements = statements.filter((statement) => - statement.startsWith("DROP TABLE public."), - ); - - expect(dropTableStatements).toMatchInlineSnapshot(` - [ - "DROP TABLE public.user_challenge_progress", - "DROP TABLE public.challenge_levels", - ] - `); - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/foreign-data-wrapper-operations.test.ts b/packages/pg-delta/tests/integration/foreign-data-wrapper-operations.test.ts deleted file mode 100644 index 105cbfacc..000000000 --- a/packages/pg-delta/tests/integration/foreign-data-wrapper-operations.test.ts +++ /dev/null @@ -1,602 +0,0 @@ -/** - * Integration tests for PostgreSQL Foreign Data Wrapper operations. - */ - -import { describe, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`foreign-data-wrapper operations (pg${pgVersion})`, () => { - test( - "create foreign data wrapper basic", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - `, - }); - }), - ); - - // Note: Handler and validator tests are skipped as they require C modules - // which are not available in the test environment - - test( - "create foreign data wrapper with options", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE FOREIGN DATA WRAPPER test_fdw OPTIONS (debug 'true'); - `, - }); - }), - ); - - test( - "create foreign data wrapper with multiple options", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE FOREIGN DATA WRAPPER test_fdw OPTIONS (debug 'true', option1 'value1', option2 'value2'); - `, - }); - }), - ); - - // Note: Owner change test skipped - requires superuser privileges - // FDW owners must be superusers, which is not available in test environment - - test( - "alter foreign data wrapper options", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw OPTIONS (debug 'true'); - `, - testSql: ` - ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (ADD option1 'value1', SET debug 'false'); - `, - }); - }), - ); - - test( - "drop foreign data wrapper", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - DROP FOREIGN DATA WRAPPER test_fdw; - `, - }); - }), - ); - - test( - "create server basic", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - `, - }); - }), - ); - - test( - "create server with type and version", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - CREATE SERVER test_server TYPE 'postgres_fdw' VERSION '1.0' FOREIGN DATA WRAPPER test_fdw; - `, - }); - }), - ); - - test( - "create server with options", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw OPTIONS (host 'localhost', port '5432'); - `, - }); - }), - ); - - test( - "alter server owner", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE ROLE server_owner; - `, - testSql: ` - ALTER SERVER test_server OWNER TO server_owner; - `, - }); - }), - ); - - test( - "alter server version", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - ALTER SERVER test_server VERSION '2.0'; - `, - }); - }), - ); - - test( - "alter server options", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw OPTIONS (host 'localhost'); - `, - testSql: ` - ALTER SERVER test_server OPTIONS (ADD port '5432', SET host 'newhost'); - `, - }); - }), - ); - - test( - "drop server", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - DROP SERVER test_server; - `, - }); - }), - ); - - test( - "create user mapping basic", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - CREATE USER MAPPING FOR CURRENT_USER SERVER test_server; - `, - }); - }), - ); - - test( - "create user mapping for PUBLIC", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - CREATE USER MAPPING FOR PUBLIC SERVER test_server; - `, - }); - }), - ); - - test( - "create user mapping with options", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE ROLE test_user; - `, - testSql: ` - CREATE USER MAPPING FOR test_user SERVER test_server OPTIONS (user 'remote_user', password 'secret'); - `, - }); - }), - ); - - test( - "alter user mapping options", - withDbIsolated(pgVersion, async (db) => { - // Non-secret options diff normally (SET user 'new_user'); sensitive - // options like password are redacted in the emitted ALTER. - // Since postgres_fdw only supports user/password options, we test with a custom FDW. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE USER MAPPING FOR CURRENT_USER SERVER test_server OPTIONS (user 'remote_user'); - `, - testSql: ` - ALTER USER MAPPING FOR CURRENT_USER SERVER test_server OPTIONS (ADD password 'secret', SET user 'new_user'); - `, - expectedSqlTerms: [ - "ALTER USER MAPPING FOR postgres SERVER test_server OPTIONS (SET user 'new_user', ADD password '__OPTION_PASSWORD__')", - ], - }); - }), - ); - - test( - "drop user mapping", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE USER MAPPING FOR CURRENT_USER SERVER test_server; - `, - testSql: ` - DROP USER MAPPING FOR CURRENT_USER SERVER test_server; - `, - }); - }), - ); - - test( - "create foreign table basic", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - CREATE FOREIGN TABLE test_schema.test_table ( - id integer, - name text - ) SERVER test_server; - `, - }); - }), - ); - - test( - "create foreign table with options", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - `, - testSql: ` - CREATE FOREIGN TABLE test_schema.test_table ( - id integer, - name text - ) SERVER test_server OPTIONS (schema_name 'remote_schema', table_name 'remote_table'); - `, - }); - }), - ); - - test( - "alter foreign table owner", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer - ) SERVER test_server; - CREATE ROLE table_owner; - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table OWNER TO table_owner; - `, - }); - }), - ); - - test( - "alter foreign table add column", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer - ) SERVER test_server; - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table ADD COLUMN name text; - `, - }); - }), - ); - - test( - "alter foreign table drop column", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer, - name text - ) SERVER test_server; - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table DROP COLUMN name; - `, - }); - }), - ); - - test( - "alter foreign table alter column type", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer - ) SERVER test_server; - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table ALTER COLUMN id TYPE bigint; - `, - }); - }), - ); - - test( - "alter foreign table alter column set default", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer - ) SERVER test_server; - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table ALTER COLUMN id SET DEFAULT 0; - `, - }); - }), - ); - - test( - "alter foreign table alter column drop default", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer DEFAULT 0 - ) SERVER test_server; - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table ALTER COLUMN id DROP DEFAULT; - `, - }); - }), - ); - - test( - "alter foreign table alter column set not null", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer - ) SERVER test_server; - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table ALTER COLUMN id SET NOT NULL; - `, - }); - }), - ); - - test( - "alter foreign table alter column drop not null", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer NOT NULL - ) SERVER test_server; - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table ALTER COLUMN id DROP NOT NULL; - `, - }); - }), - ); - - test( - "alter foreign table options", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer - ) SERVER test_server OPTIONS (schema_name 'remote_schema'); - `, - testSql: ` - ALTER FOREIGN TABLE test_schema.test_table OPTIONS (ADD table_name 'remote_table', SET schema_name 'new_schema'); - `, - }); - }), - ); - - test( - "drop foreign table", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FOREIGN DATA WRAPPER test_fdw; - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw; - CREATE FOREIGN TABLE test_schema.test_table ( - id integer - ) SERVER test_server; - `, - testSql: ` - DROP FOREIGN TABLE test_schema.test_table; - `, - }); - }), - ); - - test( - "full FDW lifecycle", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - CREATE FOREIGN DATA WRAPPER test_fdw OPTIONS (debug 'true'); - CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw OPTIONS (host 'localhost'); - CREATE USER MAPPING FOR CURRENT_USER SERVER test_server OPTIONS (user 'remote_user'); - CREATE FOREIGN TABLE test_schema.test_table ( - id integer, - name text - ) SERVER test_server OPTIONS (schema_name 'remote_schema'); - `, - }); - }), - ); - - test( - "FDW dependency ordering", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - CREATE FOREIGN DATA WRAPPER fdw1; - CREATE SERVER server1 FOREIGN DATA WRAPPER fdw1; - CREATE SERVER server2 FOREIGN DATA WRAPPER fdw1; - CREATE USER MAPPING FOR CURRENT_USER SERVER server1; - CREATE USER MAPPING FOR PUBLIC SERVER server2; - CREATE FOREIGN TABLE test_schema.table1 ( - id integer - ) SERVER server1; - CREATE FOREIGN TABLE test_schema.table2 ( - id integer - ) SERVER server2; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/function-operations.test.ts b/packages/pg-delta/tests/integration/function-operations.test.ts deleted file mode 100644 index 8e901888b..000000000 --- a/packages/pg-delta/tests/integration/function-operations.test.ts +++ /dev/null @@ -1,676 +0,0 @@ -/** - * Integration tests for PostgreSQL function operations. - */ - -import { describe, expect, test } from "bun:test"; -import dedent from "dedent"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - // TODO: Fix functions stable ids that must be the schema + name + argstypes because the current one is just the function name - describe(`function operations (pg${pgVersion})`, () => { - test( - "keeps functions whose bodies embed non-transactional SQL text in one transactional unit", - withDb(pgVersion, async (db) => { - // The regex-based classifier this design replaced stripped comments, - // naively split on ";", and pattern-matched each fragment — so the - // dollar-quoted body below (multi-statement dynamic SQL mentioning - // CREATE INDEX CONCURRENTLY and VACUUM) was misclassified as - // non-transactional and lost plan atomicity. Trait-based - // classification never inspects rendered SQL, so the whole plan must - // stay one transactional unit and apply cleanly. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: dedent` - CREATE TABLE public.users ( - id integer PRIMARY KEY, - email text - ); - - CREATE FUNCTION public.rebuild_users_index() RETURNS void - LANGUAGE plpgsql - AS $function$ - BEGIN - EXECUTE 'CREATE INDEX CONCURRENTLY users_email_idx ON public.users (email)'; - EXECUTE 'VACUUM FULL public.users; ALTER SYSTEM SET work_mem = ''64MB'''; - END; - $function$; - `, - assertPlan: (plan) => { - expect(plan.units).toHaveLength(1); - expect(plan.units[0].transactionMode).toBe("transactional"); - expect(plan.units[0].reason).toBe("default"); - }, - }); - }), - ); - - test( - "simple function creation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.add_numbers(a integer, b integer) - RETURNS integer - LANGUAGE sql - IMMUTABLE - AS $function$SELECT $1 + $2$function$; - `, - }); - }), - ); - - test( - "plpgsql function with security definer", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.get_user_count() - RETURNS bigint - LANGUAGE plpgsql - STABLE SECURITY DEFINER - AS $function$ - BEGIN - RETURN (SELECT COUNT(*) FROM pg_catalog.pg_user); - END; - $function$; - `, - }); - }), - ); - - test( - "function replacement", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.version_function() - RETURNS text - LANGUAGE sql - IMMUTABLE - AS 'SELECT ''v1.0'''; - `, - testSql: dedent` - CREATE OR REPLACE FUNCTION test_schema.version_function() - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT 'v2.0'$function$; - `, - }); - }), - ); - - test( - "begin atomic sql function replacement", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - - CREATE TABLE test_schema.accounts ( - user_id int PRIMARY KEY, - balance int NOT NULL DEFAULT 0 - ); - - CREATE FUNCTION test_schema.transfer_funds( - sender_id int, receiver_id int, amount numeric - ) - RETURNS void - LANGUAGE SQL - BEGIN ATOMIC - UPDATE test_schema.accounts - SET balance = balance - amount WHERE user_id = sender_id; - END; - `, - testSql: dedent` - CREATE OR REPLACE FUNCTION test_schema.transfer_funds( - sender_id int, receiver_id int, amount numeric - ) - RETURNS void - LANGUAGE SQL - BEGIN ATOMIC - UPDATE test_schema.accounts - SET balance = balance - amount WHERE user_id = sender_id; - UPDATE test_schema.accounts - SET balance = balance + amount WHERE user_id = receiver_id; - END; - `, - }); - }), - ); - - test( - "function signature: parameter type change", - withDb(pgVersion, async (db) => { - // Changes the IN parameter type (text -> uuid). stableId changes - // because argument_types is part of the procedure stableId, so this - // exercises the drop+create path. PostgreSQL rejects - // `CREATE OR REPLACE` for this kind of change. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.process_item(param1 text) - RETURNS void - LANGUAGE plpgsql - AS $function$ - BEGIN - RAISE NOTICE 'Processing: %', param1; - END; - $function$; - `, - testSql: dedent` - DROP FUNCTION test_schema.process_item(text); - CREATE FUNCTION test_schema.process_item(param1 uuid) - RETURNS void - LANGUAGE plpgsql - AS $function$ - BEGIN - RAISE NOTICE 'Processing: %', param1::text; - END; - $function$; - `, - }); - }), - ); - - test( - "function signature: parameter arity change", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.process_item(param1 text) - RETURNS void - LANGUAGE plpgsql - AS $function$ - BEGIN - RAISE NOTICE 'Processing: %', param1; - END; - $function$; - `, - testSql: dedent` - DROP FUNCTION test_schema.process_item(text); - CREATE FUNCTION test_schema.process_item(param1 text, param2 integer) - RETURNS void - LANGUAGE plpgsql - AS $function$ - BEGIN - RAISE NOTICE 'Processing: % (%)', param1, param2; - END; - $function$; - `, - }); - }), - ); - - test( - "function signature: parameter name change only", - withDb(pgVersion, async (db) => { - // Same argument_types means same stableId -> altered path. PostgreSQL - // rejects `CREATE OR REPLACE FUNCTION` that renames an IN parameter - // ("cannot change name of input parameter"), so the diff must emit - // DROP + CREATE. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.process_item(original_name text) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT original_name$function$; - `, - testSql: dedent` - DROP FUNCTION test_schema.process_item(text); - CREATE FUNCTION test_schema.process_item(renamed text) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT renamed$function$; - `, - }); - }), - ); - - test( - "function signature: parameter default removed", - withDb(pgVersion, async (db) => { - // PostgreSQL: "cannot remove parameter defaults from existing function". - // Requires DROP + CREATE. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.get_greeting(name text DEFAULT 'world') - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT 'hello ' || name$function$; - `, - testSql: dedent` - DROP FUNCTION test_schema.get_greeting(text); - CREATE FUNCTION test_schema.get_greeting(name text) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT 'hello ' || name$function$; - `, - }); - }), - ); - - test( - "function signature: return type change", - withDb(pgVersion, async (db) => { - // PostgreSQL: "cannot change return type of existing function". - // Requires DROP + CREATE. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.lookup(id integer) - RETURNS integer - LANGUAGE sql - IMMUTABLE - AS $function$SELECT id$function$; - `, - testSql: dedent` - DROP FUNCTION test_schema.lookup(integer); - CREATE FUNCTION test_schema.lookup(id integer) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT id::text$function$; - `, - }); - }), - ); - - test( - "function signature change cascades through a dependent view", - withDb(pgVersion, async (db) => { - // A signature change on a function referenced by a view must also - // replace the view; the topological sort + replacement expansion is - // responsible for this. The roundtrip asserts apply succeeds end-to-end. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.format_id(id integer) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT 'id:' || id::text$function$; - - CREATE TABLE test_schema.items (id integer); - CREATE VIEW test_schema.items_formatted AS - SELECT test_schema.format_id(id) AS formatted_id FROM test_schema.items; - `, - testSql: dedent` - DROP VIEW test_schema.items_formatted; - DROP FUNCTION test_schema.format_id(integer); - - CREATE FUNCTION test_schema.format_id(id bigint) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT 'id:' || id::text$function$; - - CREATE VIEW test_schema.items_formatted AS - SELECT test_schema.format_id(id::bigint) AS formatted_id FROM test_schema.items; - `, - }); - }), - ); - - test( - "function overloading", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.format_value(input_val integer) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT input_val::text$function$; - - CREATE FUNCTION test_schema.format_value(input_val integer, prefix text) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT prefix || input_val::text$function$; - `, - }); - }), - ); - - test( - "drop function", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.temp_function() - RETURNS text - LANGUAGE sql - AS 'SELECT ''temporary'''; - `, - testSql: dedent` - DROP FUNCTION test_schema.temp_function(); - `, - }); - }), - ); - - test( - "function with complex attributes", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.expensive_function(input_data text) - RETURNS text - LANGUAGE plpgsql - PARALLEL RESTRICTED STRICT COST 1000 - AS $function$ - BEGIN - -- Simulate expensive operation - PERFORM pg_sleep(0.1); - RETURN upper(input_data); - END; - $function$; - `, - }); - }), - ); - - test( - "function with configuration parameters", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.config_function() - RETURNS void - LANGUAGE plpgsql - SET work_mem TO '256MB' - SET statement_timeout TO '30s' - AS $function$ - BEGIN - -- Function with custom configuration - RAISE NOTICE 'Function executed with custom config'; - END; - $function$; - `, - }); - }), - ); - - test( - "function used in table default", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.get_timestamp() - RETURNS timestamp with time zone - LANGUAGE sql - STABLE - AS $function$SELECT NOW()$function$; - - CREATE TABLE test_schema.events (created_at timestamp with time zone DEFAULT test_schema.get_timestamp()); - `, - }); - }), - ); - - test( - "function no changes when identical", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE FUNCTION test_schema.stable_function() - RETURNS integer - LANGUAGE sql - AS 'SELECT 42'; - `, - testSql: ``, - }); - }), - ); - }); - - // Function dependency ordering tests - describe(`function dependency ordering (pg${pgVersion})`, () => { - test( - "function before constraint that uses it", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.validate_email(email text) - RETURNS boolean - LANGUAGE sql - IMMUTABLE - AS $function$ - SELECT email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$' - $function$; - - CREATE TABLE test_schema.users (email text); - - ALTER TABLE test_schema.users ADD CONSTRAINT valid_email CHECK (test_schema.validate_email(email)); - `, - }); - }), - ); - - test( - "function before view that uses it", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE TABLE test_schema.products (price numeric(10,2)); - - CREATE FUNCTION test_schema.format_price(price numeric) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT '$' || price::text$function$; - - CREATE VIEW test_schema.product_display AS SELECT test_schema.format_price(price) AS formatted_price - FROM test_schema.products; - `, - }); - }), - ); - - test( - "plpgsql function body references are accepted even when helper is created later", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE OR REPLACE FUNCTION test_schema.a_wrapper(input text) - RETURNS text - LANGUAGE plpgsql - IMMUTABLE - AS $function$ - BEGIN - RETURN test_schema.z_helper_parse(input) || '!'; - END; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.z_helper_parse(input text) - RETURNS text - LANGUAGE plpgsql - IMMUTABLE - AS $function$ - BEGIN - RETURN upper(input); - END; - $function$; - `, - }); - }), - ); - - test( - "sql function body references are protected by check_function_bodies setting", - withDb(pgVersion, async (db) => { - const schemaSql = "CREATE SCHEMA test_schema;"; - const sqlFunctions = dedent` - SET check_function_bodies = off; - - CREATE OR REPLACE FUNCTION test_schema.a_wrapper(input text) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT test_schema.z_helper_parse(input) || '!'$function$; - - CREATE OR REPLACE FUNCTION test_schema.z_helper_parse(input text) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT upper(input)$function$; - `; - - await db.main.query(schemaSql); - await db.branch.query(schemaSql); - await db.branch.query(sqlFunctions); - - const planResult = await createPlan(db.main, db.branch); - if (!planResult) { - throw new Error( - "Expected a plan for SQL function body reference setup", - ); - } - - expect(flattenPlanStatements(planResult.plan)[0]).toBe( - "SET check_function_bodies = false", - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - }); - }), - ); - }); - - // Complex function scenario test - describe(`complex function scenarios (pg${pgVersion})`, () => { - test( - "function with dependencies roundtrip", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE TABLE test_schema.metrics (name text NOT NULL, total_value numeric DEFAULT 0, count_value integer DEFAULT 0); - - CREATE FUNCTION test_schema.safe_divide(numerator numeric, denominator numeric) - RETURNS numeric - LANGUAGE sql - IMMUTABLE STRICT - AS $function$ - SELECT CASE - WHEN denominator = 0 THEN NULL - ELSE numerator / denominator - END - $function$; - - CREATE VIEW test_schema.metric_averages AS SELECT name, - test_schema.safe_divide(total_value, (count_value)::numeric) AS average_value - FROM test_schema.metrics - WHERE (count_value > 0); - - CREATE FUNCTION test_schema.get_metric_summary(metric_id integer) - RETURNS text - LANGUAGE plpgsql - STABLE - AS $function$ - DECLARE - metric_name text; - avg_val numeric; - BEGIN - SELECT m.name, test_schema.safe_divide(m.total_value, m.count_value::numeric) - INTO metric_name, avg_val - FROM test_schema.metrics m - WHERE m.id = metric_id; - - RETURN metric_name || ': ' || COALESCE(avg_val::text, 'N/A'); - END; - $function$; - `, - }); - }), - ); - - test( - "function comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.greet(name text) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$SELECT 'Hello, ' || name$function$; - - COMMENT ON FUNCTION test_schema.greet(text) IS 'greet function'; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/index-extension-deps.test.ts b/packages/pg-delta/tests/integration/index-extension-deps.test.ts deleted file mode 100644 index cfa156df5..000000000 --- a/packages/pg-delta/tests/integration/index-extension-deps.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Integration tests for index dependency on extensions. - * - * Verifies that CREATE EXTENSION is ordered before CREATE INDEX when the - * index uses an operator class provided by that extension (e.g. gin_trgm_ops - * from pg_trgm). - */ - -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`index extension dependencies (pg${pgVersion})`, () => { - test( - "CREATE EXTENSION pg_trgm ordered before CREATE INDEX using gin_trgm_ops", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE EXTENSION pg_trgm; - CREATE TABLE public.documents ( - id integer, - content text - ); - CREATE INDEX idx_documents_content_trgm - ON public.documents USING gin (content gin_trgm_ops); - `, - }); - }), - ); - - test( - "extension index with cross-schema dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE EXTENSION pg_trgm WITH SCHEMA public; - CREATE SCHEMA app; - CREATE TABLE app.search_items ( - id integer, - name text - ); - CREATE INDEX idx_search_items_name_trgm - ON app.search_items USING gin (name public.gin_trgm_ops); - `, - }); - }), - ); - - test( - "plan from null source orders extension before index", - withDb(pgVersion, async (db) => { - await db.branch.query(` - CREATE EXTENSION pg_trgm; - CREATE TABLE public.items (id integer, label text); - CREATE INDEX idx_items_label_trgm ON public.items USING gin (label gin_trgm_ops); - `); - - const result = await createPlan(null, db.branch); - expect(result).not.toBeNull(); - if (!result) return; - - const statements = flattenPlanStatements(result.plan); - const extIdx = statements.findIndex( - (s) => s.includes("CREATE EXTENSION") && s.includes("pg_trgm"), - ); - const indexIdx = statements.findIndex((s) => - s.includes("idx_items_label_trgm"), - ); - - expect(extIdx).toBeGreaterThanOrEqual(0); - expect(indexIdx).toBeGreaterThanOrEqual(0); - expect(extIdx).toBeLessThan(indexIdx); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/index-operations.test.ts b/packages/pg-delta/tests/integration/index-operations.test.ts deleted file mode 100644 index fb0c4a1d1..000000000 --- a/packages/pg-delta/tests/integration/index-operations.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Integration tests for PostgreSQL index operations. - */ - -import { describe, expect, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - // TODO: Fix index dependency detection issues - describe(`index operations (pg${pgVersion})`, () => { - test( - "create btree index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer, - email character varying(255) - ); - `, - testSql: - "CREATE INDEX idx_users_email ON test_schema.users USING btree (email);", - }); - }), - ); - - test( - "create unique index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.products ( - id integer, - sku character varying(50) - ); - `, - testSql: - "CREATE UNIQUE INDEX idx_products_sku ON test_schema.products USING btree (sku);", - }); - }), - ); - - if (pgVersion >= 15) { - test( - "create unique index with NULLS NOT DISTINCT", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id integer, - email character varying(255) - ); - `, - testSql: - "CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts USING btree (email) NULLS NOT DISTINCT;", - assertSqlStatements: (statements) => { - expect(statements).toMatchInlineSnapshot(` - [ - "CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts (email) NULLS NOT DISTINCT", - ] - `); - }, - }); - }), - ); - - test( - "toggle unique index to NULLS NOT DISTINCT", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id integer, - email character varying(255) - ); - CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts USING btree (email); - `, - testSql: ` - DROP INDEX test_schema.idx_accounts_email; - CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts USING btree (email) NULLS NOT DISTINCT; - `, - assertSqlStatements: (statements) => { - expect(statements).toMatchInlineSnapshot(` - [ - "DROP INDEX test_schema.idx_accounts_email", - "CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts (email) NULLS NOT DISTINCT", - ] - `); - }, - }); - }), - ); - - test( - "toggle unique index from NULLS NOT DISTINCT", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id integer, - email character varying(255) - ); - CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts USING btree (email) NULLS NOT DISTINCT; - `, - testSql: ` - DROP INDEX test_schema.idx_accounts_email; - CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts USING btree (email); - `, - assertSqlStatements: (statements) => { - expect(statements).toMatchInlineSnapshot(` - [ - "DROP INDEX test_schema.idx_accounts_email", - "CREATE UNIQUE INDEX idx_accounts_email ON test_schema.accounts (email)", - ] - `); - }, - }); - }), - ); - } - - test( - "create partial index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.orders ( - id integer, - status character varying(20), - created_at timestamp - ); - `, - testSql: - "CREATE INDEX idx_orders_pending ON test_schema.orders USING btree (created_at) WHERE status::text = 'pending'::text;", - }); - }), - ); - - test( - "create functional index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.customers ( - id integer, - email character varying(255) - ); - `, - testSql: - "CREATE INDEX idx_customers_email_lower ON test_schema.customers USING btree (lower(email::text));", - }); - }), - ); - - test( - "create multicolumn index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.sales ( - id integer, - region character varying(50), - product_id integer, - sale_date date - ); - `, - testSql: - "CREATE INDEX idx_sales_region_date ON test_schema.sales USING btree (region, sale_date);", - }); - }), - ); - - test( - "drop index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.items ( - id integer, - name character varying(100) - ); - CREATE INDEX idx_items_name ON test_schema.items (name); - `, - testSql: ` - DROP INDEX test_schema.idx_items_name; - `, - }); - }), - ); - - test( - "drop primary key does not emit separate drop index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.pk_table ( - id integer PRIMARY KEY, - name text - ); - `, - testSql: ` - ALTER TABLE test_schema.pk_table DROP CONSTRAINT pk_table_pkey; - `, - expectedSqlTerms: [ - "ALTER TABLE test_schema.pk_table DROP CONSTRAINT pk_table_pkey", - ], - }); - }), - ); - - test( - "drop implicit dependent table index", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "drop-implicit-dependent-table-index", - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.test_table ( - id integer PRIMARY KEY, - name text - ); - CREATE INDEX test_table_name_index ON test_schema.test_table (name); - `, - // Drop the table, which will drop the index as well no further changes are needed - testSql: ` - DROP TABLE test_schema.test_table; - `, - }); - }), - ); - - test( - "index comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.items (id integer, name text); - CREATE INDEX idx_items_name ON test_schema.items (name); - `, - testSql: ` - COMMENT ON INDEX test_schema.idx_items_name IS 'items name index'; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/materialized-view-operations.test.ts b/packages/pg-delta/tests/integration/materialized-view-operations.test.ts deleted file mode 100644 index 5583f6be1..000000000 --- a/packages/pg-delta/tests/integration/materialized-view-operations.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * Integration tests for PostgreSQL materialized view operations. - */ - -import { describe, expect, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`materialized view operations (pg${pgVersion})`, () => { - test( - "create new materialized view", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text NOT NULL, - email text, - active boolean DEFAULT true - ); - `, - testSql: dedent` - CREATE MATERIALIZED VIEW test_schema.active_users AS - SELECT id, name, email - FROM test_schema.users - WHERE active = true - WITH NO DATA; - `, - }); - }), - ); - - test( - "drop existing materialized view", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text NOT NULL, - active boolean DEFAULT true - ); - - CREATE MATERIALIZED VIEW test_schema.active_users AS - SELECT id, name - FROM test_schema.users - WHERE active = true - WITH NO DATA; - `, - testSql: ` - DROP MATERIALIZED VIEW test_schema.active_users; - `, - }); - }), - ); - - test( - "replace materialized view definition", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text NOT NULL, - email text, - active boolean DEFAULT true - ); - - CREATE MATERIALIZED VIEW test_schema.user_summary AS - SELECT id, name - FROM test_schema.users - WHERE active = true - WITH NO DATA; - `, - testSql: dedent` - DROP MATERIALIZED VIEW test_schema.user_summary; - CREATE MATERIALIZED VIEW test_schema.user_summary AS - SELECT id, name, email - FROM test_schema.users - WHERE active = true - ORDER BY name - WITH NO DATA; - `, - }); - }), - ); - - test( - "replace materialized view with dependent index and view", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - - CREATE TABLE test_schema.orders ( - id serial PRIMARY KEY, - customer text NOT NULL, - total numeric NOT NULL, - created_at timestamptz DEFAULT now() - ); - - CREATE MATERIALIZED VIEW test_schema.order_summary AS - SELECT customer, sum(total) AS total_spent, count(*) AS order_count - FROM test_schema.orders - GROUP BY customer; - - CREATE UNIQUE INDEX order_summary_customer_idx - ON test_schema.order_summary (customer); - - CREATE VIEW test_schema.top_customers AS - SELECT * FROM test_schema.order_summary - WHERE total_spent > 1000; - `, - testSql: dedent` - DROP VIEW test_schema.top_customers; - DROP INDEX test_schema.order_summary_customer_idx; - DROP MATERIALIZED VIEW test_schema.order_summary; - - CREATE MATERIALIZED VIEW test_schema.order_summary AS - SELECT customer, - sum(total) AS total_spent, - count(*) AS order_count, - max(created_at) AS last_order - FROM test_schema.orders - GROUP BY customer; - - CREATE UNIQUE INDEX order_summary_customer_idx - ON test_schema.order_summary (customer); - - CREATE VIEW test_schema.top_customers AS - SELECT * FROM test_schema.order_summary - WHERE total_spent > 1000; - `, - assertSqlStatements: (statements) => { - // Invariant: the dependent index and view must be dropped before - // the materialized view, and recreated after it. Exact SQL body - // varies between PG versions (pg_get_viewdef / pg_get_mvdef - // qualifies column references with the relation name on PG15 but - // not on PG17+), so this test pins cascade order and the set of - // touched objects rather than byte-for-byte SQL. - const indexOf = (pattern: RegExp) => - statements.findIndex((s) => pattern.test(s)); - - const dropIndexIdx = indexOf( - /^DROP INDEX\s+test_schema\.order_summary_customer_idx\b/i, - ); - const dropViewIdx = indexOf( - /^DROP VIEW\s+test_schema\.top_customers\b/i, - ); - const dropMatviewIdx = indexOf( - /^DROP MATERIALIZED VIEW\s+test_schema\.order_summary\b/i, - ); - const createMatviewIdx = indexOf( - /^CREATE MATERIALIZED VIEW\s+test_schema\.order_summary\b/i, - ); - const createIndexIdx = indexOf( - /^CREATE UNIQUE INDEX\s+order_summary_customer_idx\s+ON\s+test_schema\.order_summary\b/i, - ); - const createViewIdx = indexOf( - /^CREATE(\s+OR\s+REPLACE)?\s+VIEW\s+test_schema\.top_customers\b/i, - ); - - expect(dropIndexIdx).toBeGreaterThanOrEqual(0); - expect(dropViewIdx).toBeGreaterThanOrEqual(0); - expect(dropMatviewIdx).toBeGreaterThanOrEqual(0); - expect(createMatviewIdx).toBeGreaterThanOrEqual(0); - expect(createIndexIdx).toBeGreaterThanOrEqual(0); - expect(createViewIdx).toBeGreaterThanOrEqual(0); - - // Dependents must be dropped before the matview. - expect(dropIndexIdx).toBeLessThan(dropMatviewIdx); - expect(dropViewIdx).toBeLessThan(dropMatviewIdx); - // Matview must be recreated before its dependents. - expect(createMatviewIdx).toBeLessThan(createIndexIdx); - expect(createMatviewIdx).toBeLessThan(createViewIdx); - // The new column must be present in the recreated matview. - expect(statements[createMatviewIdx]).toMatch(/last_order/); - }, - }); - }), - ); - - test( - "restore materialized view metadata when replacing for column type rewrite", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE ROLE test_matview_reader; - - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - age numeric - ); - - CREATE MATERIALIZED VIEW test_schema.user_ages AS - SELECT id, age - FROM test_schema.users - WHERE age > 0 - WITH NO DATA; - - COMMENT ON MATERIALIZED VIEW test_schema.user_ages - IS 'user ages matview'; - - GRANT SELECT ON test_schema.user_ages TO test_matview_reader; - `, - testSql: dedent` - DROP MATERIALIZED VIEW test_schema.user_ages; - - ALTER TABLE test_schema.users - ALTER COLUMN age TYPE integer USING age::integer; - - CREATE MATERIALIZED VIEW test_schema.user_ages AS - SELECT id, age - FROM test_schema.users - WHERE age > 0 - WITH NO DATA; - - COMMENT ON MATERIALIZED VIEW test_schema.user_ages - IS 'user ages matview'; - - GRANT SELECT ON test_schema.user_ages TO test_matview_reader; - `, - assertSqlStatements: (statements) => { - const dropMatviewIdx = statements.findIndex((statement) => - statement.includes( - "DROP MATERIALIZED VIEW test_schema.user_ages", - ), - ); - const alterColumnIdx = statements.findIndex((statement) => - statement.includes( - "ALTER TABLE test_schema.users ALTER COLUMN age TYPE integer", - ), - ); - const createMatviewIdx = statements.findIndex((statement) => - statement.includes( - "CREATE MATERIALIZED VIEW test_schema.user_ages", - ), - ); - const commentMatviewIdx = statements.findIndex((statement) => - statement.includes( - "COMMENT ON MATERIALIZED VIEW test_schema.user_ages", - ), - ); - const grantMatviewIdx = statements.findIndex((statement) => - statement.includes( - "GRANT SELECT ON test_schema.user_ages TO test_matview_reader", - ), - ); - - expect(dropMatviewIdx).toBeGreaterThanOrEqual(0); - expect(alterColumnIdx).toBeGreaterThanOrEqual(0); - expect(createMatviewIdx).toBeGreaterThanOrEqual(0); - expect(commentMatviewIdx).toBeGreaterThanOrEqual(0); - expect(grantMatviewIdx).toBeGreaterThanOrEqual(0); - expect(dropMatviewIdx).toBeLessThan(alterColumnIdx); - expect(alterColumnIdx).toBeLessThan(createMatviewIdx); - expect(createMatviewIdx).toBeLessThan(commentMatviewIdx); - expect(createMatviewIdx).toBeLessThan(grantMatviewIdx); - }, - }); - }), - ); - - test( - "materialized view with aggregations", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA analytics; - CREATE TABLE analytics.sales ( - id integer PRIMARY KEY, - customer_id integer, - amount decimal(10,2), - sale_date date - ); - `, - testSql: dedent` - CREATE MATERIALIZED VIEW analytics.monthly_sales AS - SELECT - DATE_TRUNC('month', sale_date) as month, - COUNT(*) as total_sales, - SUM(amount) as total_revenue - FROM analytics.sales - GROUP BY DATE_TRUNC('month', sale_date) - ORDER BY month - WITH NO DATA; - `, - }); - }), - ); - - test( - "materialized view with joins", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA ecommerce; - CREATE TABLE ecommerce.customers ( - id integer PRIMARY KEY, - name text NOT NULL - ); - - CREATE TABLE ecommerce.orders ( - id integer PRIMARY KEY, - customer_id integer, - total decimal(10,2) - ); - `, - testSql: ` - CREATE MATERIALIZED VIEW ecommerce.customer_orders AS - SELECT - c.id as customer_id, - c.name, - COUNT(o.id) as order_count, - COALESCE(SUM(o.total), 0) as total_spent - FROM ecommerce.customers c - LEFT JOIN ecommerce.orders o ON c.id = o.customer_id - GROUP BY c.id, c.name - WITH NO DATA; - `, - }); - }), - ); - - test( - "materialized view comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text - ); - CREATE MATERIALIZED VIEW test_schema.user_names AS - SELECT id, name FROM test_schema.users WITH NO DATA; - `, - testSql: ` - COMMENT ON MATERIALIZED VIEW test_schema.user_names IS 'user names matview'; - `, - }); - }), - ); - - test( - "refresh materialized view does not trigger a diff", - withDb(pgVersion, async (db) => { - // Issue #133 acceptance: REFRESH MATERIALIZED VIEW changes data but not - // the catalog, so pg-delta must generate an empty plan. If createPlan - // returns null (identical catalogs), roundtripFidelityTest returns - // early; otherwise the assertion below pins the generated statement - // list to zero entries. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA refresh_schema; - CREATE TABLE refresh_schema.orders ( - id integer PRIMARY KEY, - total numeric NOT NULL - ); - INSERT INTO refresh_schema.orders (id, total) - VALUES (1, 100), (2, 200); - CREATE MATERIALIZED VIEW refresh_schema.totals AS - SELECT sum(total) AS all_total FROM refresh_schema.orders; - `, - testSql: dedent` - INSERT INTO refresh_schema.orders (id, total) VALUES (3, 300); - REFRESH MATERIALIZED VIEW refresh_schema.totals; - `, - assertSqlStatements: (statements) => { - expect(statements).toStrictEqual([]); - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/mixed-objects.test.ts b/packages/pg-delta/tests/integration/mixed-objects.test.ts deleted file mode 100644 index 3c232adda..000000000 --- a/packages/pg-delta/tests/integration/mixed-objects.test.ts +++ /dev/null @@ -1,1212 +0,0 @@ -/** - * Integration tests for mixed database objects (schemas + tables). - */ - -import { describe, test } from "bun:test"; -import type { Change } from "../../src/core/change.types.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`mixed objects (pg${pgVersion})`, () => { - test( - "schema and table creation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer, - name text NOT NULL, - email text, - created_at timestamp DEFAULT now() - ); - `, - }); - }), - ); - - test( - "multiple schemas and tables", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: ` - CREATE SCHEMA core; - CREATE SCHEMA analytics; - - CREATE TABLE core.users ( - id integer, - username text NOT NULL, - email text - ); - - CREATE TABLE core.posts ( - id integer, - title text NOT NULL, - content text, - user_id integer - ); - - CREATE TABLE analytics.user_stats ( - user_id integer, - post_count integer DEFAULT 0, - last_login timestamp - ); - `, - }); - }), - ); - - test( - "complex column types", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.complex_table ( - id uuid, - metadata jsonb, - tags text[], - coordinates point, - price numeric(10,2), - is_active boolean DEFAULT true, - created_at timestamptz DEFAULT now() - ); - `, - }); - }), - ); - - test( - "empty database", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: "", - expectedSqlTerms: [], // No SQL terms - expectedMainDependencies: [], // Main has no dependencies (empty state) - expectedBranchDependencies: [], // Branch has no dependencies (empty state) - }); - }), - ); - - test( - "schema only", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: "CREATE SCHEMA empty_schema;", - }); - }), - ); - - test( - "e-commerce with sequences, tables, constraints, and indexes", - withDb(pgVersion, async (db) => { - // TODO: fix this test, if we skip the dependencies checks we get a CycleError exception - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: ` - CREATE SCHEMA ecommerce; - - -- Create customers table with SERIAL primary key - CREATE TABLE ecommerce.customers ( - id SERIAL PRIMARY KEY, - email VARCHAR(255) UNIQUE NOT NULL, - first_name VARCHAR(100) NOT NULL, - last_name VARCHAR(100) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - - -- Create orders table with SERIAL primary key and foreign key - CREATE TABLE ecommerce.orders ( - id SERIAL PRIMARY KEY, - customer_id INTEGER NOT NULL, - order_number VARCHAR(50) UNIQUE NOT NULL, - status VARCHAR(20) DEFAULT 'pending', - total_amount DECIMAL(10,2) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_customer FOREIGN KEY (customer_id) REFERENCES ecommerce.customers(id) - ); - - -- Create index for common queries - CREATE INDEX idx_orders_customer_status ON ecommerce.orders(customer_id, status); - CREATE INDEX idx_customers_email ON ecommerce.customers(email); - `, - }); - }), - ); - - test( - "complex dependency ordering", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema", - testSql: ` - -- Create base tables - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text - ); - - CREATE TABLE test_schema.orders ( - id integer PRIMARY KEY, - user_id integer, - amount numeric - ); - - -- Create view that depends on both tables - CREATE VIEW test_schema.user_orders AS - SELECT u.id, u.name, SUM(o.amount) as total - FROM test_schema.users u - LEFT JOIN test_schema.orders o ON u.id = o.user_id - GROUP BY u.id, u.name; - - -- Create view that depends on the first view - CREATE VIEW test_schema.top_users AS - SELECT * FROM test_schema.user_orders - WHERE total > 1000; - `, - sortChangesCallback: (a, b) => { - const priority = (change: Change) => { - if ( - change.objectType === "view" && - change.operation === "create" - ) { - const viewName = change.view?.name ?? ""; - return viewName === "top_users" - ? 0 - : viewName === "user_orders" - ? 1 - : 2; - } - return 3; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "drop operations with complex dependencies", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - - -- Create a complex dependency chain - CREATE TABLE test_schema.base ( - id integer PRIMARY KEY - ); - - CREATE VIEW test_schema.v1 AS SELECT * FROM test_schema.base; - CREATE VIEW test_schema.v2 AS SELECT * FROM test_schema.v1; - CREATE VIEW test_schema.v3 AS SELECT * FROM test_schema.v2; - `, - testSql: ` - -- Drop everything to test dependency ordering - DROP VIEW test_schema.v3; - DROP VIEW test_schema.v2; - DROP VIEW test_schema.v1; - DROP TABLE test_schema.base; - DROP SCHEMA test_schema; - `, - sortChangesCallback: (a, b) => { - const priority = (change: Change) => { - if (change.objectType === "view" && change.operation === "drop") { - const viewName = change.view?.name ?? ""; - return viewName === "v1" - ? 0 - : viewName === "v2" - ? 1 - : viewName === "v3" - ? 2 - : 3; - } - if ( - change.objectType === "table" && - change.operation === "drop" - ) { - return 4; - } - if ( - change.objectType === "schema" && - change.operation === "drop" - ) { - return 5; - } - return 6; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "mixed create and replace operations", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - - CREATE TABLE test_schema.data ( - id integer PRIMARY KEY, - value text - ); - - CREATE VIEW test_schema.summary AS - SELECT COUNT(*) as cnt FROM test_schema.data; - `, - testSql: ` - -- Add column and update view - ALTER TABLE test_schema.data ADD COLUMN status text; - - CREATE OR REPLACE VIEW test_schema.summary AS - SELECT COUNT(*) as cnt, - COUNT(CASE WHEN status = 'active' THEN 1 END) as active_cnt - FROM test_schema.data; - `, - sortChangesCallback: (a, b) => { - const priority = (change: Change) => { - if ( - change.objectType === "view" && - change.operation === "create" - ) { - return 0; - } - if ( - change.objectType === "table" && - change.operation === "alter" - ) { - return 1; - } - return 2; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "cross-schema view dependencies", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA schema_a; - CREATE SCHEMA schema_b; - - CREATE TABLE schema_a.table_a (id integer PRIMARY KEY); - CREATE TABLE schema_b.table_b (id integer PRIMARY KEY); - - -- View in schema_a that references table in schema_b - CREATE VIEW schema_a.cross_view AS - SELECT a.id as a_id, b.id as b_id - FROM schema_a.table_a a - CROSS JOIN schema_b.table_b b; - `, - testSql: "", // No changes - just test dependency extraction - }); - }), - ); - - test( - "basic table schema dependency validation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: ` - CREATE SCHEMA analytics; - CREATE TABLE analytics.users ( - id integer, - name text - ); - `, - }); - }), - ); - - test( - "multiple independent schema table pairs", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: ` - CREATE SCHEMA app; - CREATE SCHEMA analytics; - CREATE TABLE app.users (id integer); - CREATE TABLE analytics.reports (id integer); - `, - }); - }), - ); - - test( - "drop schema only", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA temp_schema; - `, - testSql: ` - DROP SCHEMA temp_schema; - `, - }); - }), - ); - - test( - "multiple drops with dependency ordering", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE SCHEMA analytics; - CREATE TABLE app.users (id integer); - CREATE TABLE analytics.reports (id integer); - `, - testSql: ` - DROP TABLE app.users; - DROP TABLE analytics.reports; - DROP SCHEMA app; - DROP SCHEMA analytics; - `, - }); - }), - ); - - test( - "complex multi-schema drop scenario", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA core; - CREATE SCHEMA analytics; - CREATE SCHEMA reporting; - CREATE TABLE core.users (id integer); - CREATE TABLE analytics.events (id integer); - CREATE TABLE reporting.summary (id integer); - `, - testSql: ` - DROP TABLE core.users; - DROP TABLE analytics.events; - DROP TABLE reporting.summary; - DROP SCHEMA core; - DROP SCHEMA analytics; - DROP SCHEMA reporting; - `, - }); - }), - ); - - test( - "schema comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: `CREATE SCHEMA test_schema;`, - testSql: ` - COMMENT ON SCHEMA test_schema IS 'a test schema'; - `, - }); - }), - ); - - test( - "enum modification with function dependencies - migra issue reproduction", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - -- Create initial enum type (similar to resource_type from the thread) - CREATE TYPE test_schema.resource_type AS ENUM ('DiskIO', 'CPU', 'Memory', 'DiskSpace', 'MemoryAndSwap'); - - -- Create tables that use the enum - CREATE TABLE test_schema.exhaustion_email_events ( - id integer PRIMARY KEY, - project_id bigint, - resource_type test_schema.resource_type, - inserted_at timestamp without time zone DEFAULT now() - ); - - CREATE TABLE test_schema.resource_exhaustion_notifications ( - id integer PRIMARY KEY, - project_id bigint, - resource_type test_schema.resource_type, - inserted_at timestamp without time zone DEFAULT now() - ); - - -- Create functions that depend on the enum type (similar to the thread) - CREATE OR REPLACE FUNCTION test_schema.get_user_resource_exhaustion_notifications_for_email(since timestamp without time zone) - RETURNS TABLE(project_id bigint, resource_type test_schema.resource_type, latest_at timestamp without time zone, user_email text, project_name text, project_ref text) - LANGUAGE plpgsql - AS $function$ - begin - -- Simplified version of the function from the thread - return query - select - ren.project_id, - ren.resource_type, - max(ren.inserted_at) as latest_at, - 'test@example.com'::text as user_email, - 'Test Project'::text as project_name, - 'test-ref'::text as project_ref - from resource_exhaustion_notifications ren - where ren.inserted_at >= since - group by ren.project_id, ren.resource_type; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.get_latest_user_resource_exhaustion_notifications(since timestamp with time zone) - RETURNS TABLE(user_id bigint, project_id bigint, project_name text, project_ref text, resource_type test_schema.resource_type, latest_at timestamp without time zone, notification_name text) - LANGUAGE plpgsql - AS $function$ - begin - return query - select - 1::bigint as user_id, - ren.project_id, - 'Test Project'::text as project_name, - 'test-ref'::text as project_ref, - ren.resource_type, - ren.inserted_at as latest_at, - ('Exhaust' || ren.resource_type)::text as notification_name - from resource_exhaustion_notifications ren - where ren.inserted_at >= since; - end; - $function$; - `, - testSql: ` - -- This simulates the problematic migration that migra generates: - -- Adding new values to the enum type, which requires recreating the type - -- and updating dependent functions. With pg-diff we are able to handle this - -- because we are able to handle the ADD VALUE syntax - ALTER TYPE test_schema.resource_type ADD VALUE 'AuthRateLimit'; - ALTER TYPE test_schema.resource_type ADD VALUE 'Connections'; - ALTER TYPE test_schema.resource_type ADD VALUE 'PgBouncerPool'; - ALTER TYPE test_schema.resource_type ADD VALUE 'TempFiles'; - `, - }); - }), - ); - - test( - "enum modification with complex function dependencies", - withDb(pgVersion, async (db) => { - // Test a more complex scenario with multiple functions and tables depending on enum - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - -- Create enum type - CREATE TYPE test_schema.order_status AS ENUM ('pending', 'processing', 'shipped'); - - -- Create tables - CREATE TABLE test_schema.orders ( - id integer PRIMARY KEY, - status test_schema.order_status DEFAULT 'pending', - customer_id integer, - total_amount numeric(10,2) - ); - - CREATE TABLE test_schema.order_history ( - id integer PRIMARY KEY, - order_id integer, - old_status test_schema.order_status, - new_status test_schema.order_status, - changed_at timestamp DEFAULT now() - ); - - -- Create functions that depend on the enum - CREATE OR REPLACE FUNCTION test_schema.get_orders_by_status(status_filter test_schema.order_status) - RETURNS TABLE(order_id integer, customer_id integer, total_amount numeric) - LANGUAGE plpgsql - AS $function$ - begin - return query - select o.id, o.customer_id, o.total_amount - from orders o - where o.status = status_filter; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.update_order_status(order_id integer, new_status test_schema.order_status) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - declare - old_status_val test_schema.order_status; - begin - select status into old_status_val from orders where id = order_id; - if old_status_val is null then - return false; - end if; - - update orders set status = new_status where id = order_id; - insert into order_history (order_id, old_status, new_status) - values (order_id, old_status_val, new_status); - - return true; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.get_status_transitions() - RETURNS TABLE(from_status test_schema.order_status, to_status test_schema.order_status, count bigint) - LANGUAGE plpgsql - AS $function$ - begin - return query - select oh.old_status, oh.new_status, count(*)::bigint - from order_history oh - group by oh.old_status, oh.new_status - order by count(*) desc; - end; - $function$; - `, - testSql: ` - -- Add new enum values - ALTER TYPE test_schema.order_status ADD VALUE 'delivered'; - ALTER TYPE test_schema.order_status ADD VALUE 'cancelled'; - ALTER TYPE test_schema.order_status ADD VALUE 'returned'; - `, - }); - }), - ); - - test( - "enum modification with view dependencies", - withDb(pgVersion, async (db) => { - // Test enum modification when views depend on the enum - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - -- Create enum type - CREATE TYPE test_schema.user_role AS ENUM ('admin', 'user', 'moderator'); - - -- Create table - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - username text, - role test_schema.user_role DEFAULT 'user', - created_at timestamp DEFAULT now() - ); - - -- Create views that depend on the enum - CREATE VIEW test_schema.admin_users AS - SELECT id, username, created_at - FROM test_schema.users - WHERE role = 'admin'::test_schema.user_role; - - CREATE VIEW test_schema.user_role_stats AS - SELECT - role, - count(*) as user_count, - min(created_at) as first_user, - max(created_at) as latest_user - FROM test_schema.users - GROUP BY role; - - CREATE VIEW test_schema.role_permissions AS - SELECT - role, - CASE - WHEN role = 'admin'::test_schema.user_role THEN 'full_access' - WHEN role = 'moderator'::test_schema.user_role THEN 'limited_access' - ELSE 'basic_access' - END as permission_level - FROM test_schema.users - GROUP BY role; - `, - testSql: ` - -- Add new enum values - ALTER TYPE test_schema.user_role ADD VALUE 'super_admin'; - ALTER TYPE test_schema.user_role ADD VALUE 'guest'; - `, - }); - }), - ); - - test( - "enum value removal with function dependencies", - withDb(pgVersion, async (db) => { - // Test removing enum values when functions depend on them - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - -- Create enum type with multiple values - CREATE TYPE test_schema.status AS ENUM ('active', 'inactive', 'pending', 'archived', 'deleted'); - - -- Create table using the enum - CREATE TABLE test_schema.records ( - id integer PRIMARY KEY, - name text, - status test_schema.status DEFAULT 'pending', - created_at timestamp DEFAULT now() - ); - - -- Create function that depends on specific enum values - CREATE OR REPLACE FUNCTION test_schema.get_active_records() - RETURNS TABLE(record_id integer, record_name text, record_status test_schema.status) - LANGUAGE plpgsql - AS $function$ - begin - return query - select r.id, r.name, r.status - from records r - where r.status in ('active', 'pending'); - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.archive_record(record_id integer) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - declare - current_status test_schema.status; - begin - select status into current_status from records where id = record_id; - if current_status is null then - return false; - end if; - - -- Only allow archiving from active or inactive status - if current_status not in ('active', 'inactive') then - return false; - end if; - - update records set status = 'archived' where id = record_id; - return true; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.get_status_counts() - RETURNS TABLE(status_name test_schema.status, count bigint) - LANGUAGE plpgsql - AS $function$ - begin - return query - select r.status, count(*)::bigint - from records r - group by r.status - order by r.status; - end; - $function$; - `, - testSql: ` - -- Remove specific enum values that are no longer needed - -- Note: PostgreSQL doesn't support direct removal of enum values, - -- so this would typically require recreating the type and updating dependencies - -- This test verifies that pg-diff can handle the recreation scenario - DROP TYPE test_schema.status CASCADE; - CREATE TYPE test_schema.status AS ENUM ('active', 'inactive', 'archived'); - - -- Recreate the table with the new enum (CASCADE should have dropped it, but let's be safe) - DROP TABLE IF EXISTS test_schema.records CASCADE; - CREATE TABLE test_schema.records ( - id integer PRIMARY KEY, - name text, - status test_schema.status DEFAULT 'active', - created_at timestamp DEFAULT now() - ); - - -- Recreate functions with updated enum references - CREATE OR REPLACE FUNCTION test_schema.get_active_records() - RETURNS TABLE(record_id integer, record_name text, record_status test_schema.status) - LANGUAGE plpgsql - AS $function$ - begin - return query - select r.id, r.name, r.status - from records r - where r.status = 'active'; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.archive_record(record_id integer) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - declare - current_status test_schema.status; - begin - select status into current_status from records where id = record_id; - if current_status is null then - return false; - end if; - - -- Only allow archiving from active status - if current_status != 'active' then - return false; - end if; - - update records set status = 'archived' where id = record_id; - return true; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.get_status_counts() - RETURNS TABLE(status_name test_schema.status, count bigint) - LANGUAGE plpgsql - AS $function$ - begin - return query - select r.status, count(*)::bigint - from records r - group by r.status - order by r.status; - end; - $function$; - `, - }); - }), - ); - - test( - "enum value removal with table and view dependencies", - withDb(pgVersion, async (db) => { - // Test removing enum values when tables and views depend on them - // Those will need global dependencies where types are changed before anything else is changed - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - -- Create enum type with multiple values - CREATE TYPE test_schema.priority AS ENUM ('low', 'medium', 'high', 'critical', 'urgent', 'blocked'); - - -- Create tables using the enum - CREATE TABLE test_schema.tasks ( - id integer PRIMARY KEY, - title text, - priority test_schema.priority DEFAULT 'medium', - assigned_to text, - created_at timestamp DEFAULT now() - ); - - CREATE TABLE test_schema.task_history ( - id integer PRIMARY KEY, - task_id integer, - old_priority test_schema.priority, - new_priority test_schema.priority, - changed_at timestamp DEFAULT now() - ); - - -- Create views that depend on the enum - CREATE VIEW test_schema.high_priority_tasks AS - SELECT id, title, assigned_to, created_at - FROM test_schema.tasks - WHERE priority IN ('high', 'critical', 'urgent'); - - CREATE VIEW test_schema.priority_distribution AS - SELECT - priority, - count(*) as task_count, - min(created_at) as oldest_task, - max(created_at) as newest_task - FROM test_schema.tasks - GROUP BY priority - ORDER BY - CASE priority - WHEN 'critical' THEN 1 - WHEN 'urgent' THEN 2 - WHEN 'high' THEN 3 - WHEN 'medium' THEN 4 - WHEN 'low' THEN 5 - WHEN 'blocked' THEN 6 - END; - - CREATE VIEW test_schema.task_priority_changes AS - SELECT - th.task_id, - t.title, - th.old_priority, - th.new_priority, - th.changed_at - FROM test_schema.task_history th - JOIN test_schema.tasks t ON th.task_id = t.id - WHERE th.old_priority != th.new_priority; - `, - testSql: ` - -- Remove some enum values by recreating the type - DROP TYPE test_schema.priority CASCADE; - CREATE TYPE test_schema.priority AS ENUM ('low', 'medium', 'high', 'critical'); - - -- Recreate tables with the simplified enum (CASCADE should have dropped them, but let's be safe) - DROP TABLE IF EXISTS test_schema.tasks CASCADE; - DROP TABLE IF EXISTS test_schema.task_history CASCADE; - CREATE TABLE test_schema.tasks ( - id integer PRIMARY KEY, - title text, - priority test_schema.priority DEFAULT 'medium', - assigned_to text, - created_at timestamp DEFAULT now() - ); - - CREATE TABLE test_schema.task_history ( - id integer PRIMARY KEY, - task_id integer, - old_priority test_schema.priority, - new_priority test_schema.priority, - changed_at timestamp DEFAULT now() - ); - - -- Recreate views with updated enum references - CREATE VIEW test_schema.high_priority_tasks AS - SELECT id, title, assigned_to, created_at - FROM test_schema.tasks - WHERE priority IN ('high', 'critical'); - - CREATE VIEW test_schema.priority_distribution AS - SELECT - priority, - count(*) as task_count, - min(created_at) as oldest_task, - max(created_at) as newest_task - FROM test_schema.tasks - GROUP BY priority - ORDER BY - CASE priority - WHEN 'critical' THEN 1 - WHEN 'high' THEN 2 - WHEN 'medium' THEN 3 - WHEN 'low' THEN 4 - END; - - CREATE VIEW test_schema.task_priority_changes AS - SELECT - th.task_id, - t.title, - th.old_priority, - th.new_priority, - th.changed_at - FROM test_schema.task_history th - JOIN test_schema.tasks t ON th.task_id = t.id - WHERE th.old_priority != th.new_priority; - `, - }); - }), - ); - - test( - "enum value removal with complex function dependencies", - withDb(pgVersion, async (db) => { - // Test removing enum values with complex function dependencies - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - -- Create enum type with many values - CREATE TYPE test_schema.user_state AS ENUM ( - 'new', 'verified', 'active', 'suspended', 'banned', - 'pending_verification', 'inactive', 'deleted' - ); - - -- Create table using the enum - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - username text, - email text, - state test_schema.user_state DEFAULT 'new', - created_at timestamp DEFAULT now(), - updated_at timestamp DEFAULT now() - ); - - -- Create complex functions that depend on the enum - CREATE OR REPLACE FUNCTION test_schema.get_users_by_state(state_filter test_schema.user_state) - RETURNS TABLE(user_id integer, username text, email text, state test_schema.user_state) - LANGUAGE plpgsql - AS $function$ - begin - return query - select u.id, u.username, u.email, u.state - from users u - where u.state = state_filter; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.transition_user_state( - user_id integer, - new_state test_schema.user_state - ) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - declare - current_state test_schema.user_state; - valid_transition boolean := false; - begin - select state into current_state from users where id = user_id; - if current_state is null then - return false; - end if; - - -- Define valid state transitions - valid_transition := ( - (current_state = 'new' and new_state in ('verified', 'pending_verification', 'deleted')) or - (current_state = 'pending_verification' and new_state in ('verified', 'deleted')) or - (current_state = 'verified' and new_state in ('active', 'suspended', 'deleted')) or - (current_state = 'active' and new_state in ('suspended', 'inactive', 'deleted')) or - (current_state = 'suspended' and new_state in ('active', 'banned', 'deleted')) or - (current_state = 'inactive' and new_state in ('active', 'deleted')) or - (current_state = 'banned' and new_state in ('deleted')) - ); - - if not valid_transition then - return false; - end if; - - update users set state = new_state, updated_at = now() where id = user_id; - return true; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.get_user_state_stats() - RETURNS TABLE( - state_name test_schema.user_state, - user_count bigint, - percentage numeric - ) - LANGUAGE plpgsql - AS $function$ - declare - total_users bigint; - begin - select count(*) into total_users from users; - - return query - select - u.state, - count(*)::bigint as user_count, - round((count(*)::numeric / total_users::numeric) * 100, 2) as percentage - from users u - group by u.state - order by count(*) desc; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.is_user_active(user_id integer) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - declare - user_state test_schema.user_state; - begin - select state into user_state from users where id = user_id; - if user_state is null then - return false; - end if; - - return user_state in ('active', 'verified'); - end; - $function$; - `, - testSql: ` - -- Remove some enum values by recreating the type with fewer values - DROP TYPE test_schema.user_state CASCADE; - CREATE TYPE test_schema.user_state AS ENUM ( - 'new', 'active', 'suspended', 'banned', 'deleted' - ); - - -- Recreate table with simplified enum (CASCADE should have dropped it, but let's be safe) - DROP TABLE IF EXISTS test_schema.users CASCADE; - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - username text, - email text, - state test_schema.user_state DEFAULT 'new', - created_at timestamp DEFAULT now(), - updated_at timestamp DEFAULT now() - ); - - -- Recreate functions with updated enum references - CREATE OR REPLACE FUNCTION test_schema.get_users_by_state(state_filter test_schema.user_state) - RETURNS TABLE(user_id integer, username text, email text, state test_schema.user_state) - LANGUAGE plpgsql - AS $function$ - begin - return query - select u.id, u.username, u.email, u.state - from users u - where u.state = state_filter; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.transition_user_state( - user_id integer, - new_state test_schema.user_state - ) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - declare - current_state test_schema.user_state; - valid_transition boolean := false; - begin - select state into current_state from users where id = user_id; - if current_state is null then - return false; - end if; - - -- Simplified state transitions - valid_transition := ( - (current_state = 'new' and new_state in ('active', 'deleted')) or - (current_state = 'active' and new_state in ('suspended', 'banned', 'deleted')) or - (current_state = 'suspended' and new_state in ('active', 'banned', 'deleted')) or - (current_state = 'banned' and new_state in ('deleted')) - ); - - if not valid_transition then - return false; - end if; - - update users set state = new_state, updated_at = now() where id = user_id; - return true; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.get_user_state_stats() - RETURNS TABLE( - state_name test_schema.user_state, - user_count bigint, - percentage numeric - ) - LANGUAGE plpgsql - AS $function$ - declare - total_users bigint; - begin - select count(*) into total_users from users; - - return query - select - u.state, - count(*)::bigint as user_count, - round((count(*)::numeric / total_users::numeric) * 100, 2) as percentage - from users u - group by u.state - order by count(*) desc; - end; - $function$; - - CREATE OR REPLACE FUNCTION test_schema.is_user_active(user_id integer) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - declare - user_state test_schema.user_state; - begin - select state into user_state from users where id = user_id; - if user_state is null then - return false; - end if; - - return user_state = 'active'; - end; - $function$; - `, - }); - }), - ); - - test.todo( - "enum modification with check constraints", - withDb(pgVersion, async (db) => { - // Test enum modification when check constraints depend on the enum - // TODO: this one is skipped because it require a two step transaction to be executed - // with a COMMIT in between so might be out of the scope of a diff-er - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - -- Create enum type - CREATE TYPE test_schema.priority_level AS ENUM ('low', 'medium', 'high'); - - -- Create table with check constraint using enum - CREATE TABLE test_schema.tasks ( - id integer PRIMARY KEY, - title text, - priority test_schema.priority_level DEFAULT 'medium', - due_date date, - CONSTRAINT valid_priority CHECK (priority IN ('low', 'medium', 'high')) - ); - - -- Create function that validates priority - CREATE OR REPLACE FUNCTION test_schema.validate_task_priority(task_priority test_schema.priority_level) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - begin - return task_priority in ('low', 'medium', 'high'); - end; - $function$; - `, - testSql: ` - -- First transaction: Add enum values - ALTER TYPE test_schema.priority_level ADD VALUE 'urgent'; - ALTER TYPE test_schema.priority_level ADD VALUE 'critical'; - COMMIT; - -- Second transaction: Update constraints and functions - ALTER TABLE test_schema.tasks DROP CONSTRAINT valid_priority; - ALTER TABLE test_schema.tasks ADD CONSTRAINT valid_priority - CHECK (priority IN ('low', 'medium', 'high', 'urgent', 'critical')); - - CREATE OR REPLACE FUNCTION test_schema.validate_task_priority(task_priority test_schema.priority_level) - RETURNS boolean - LANGUAGE plpgsql - AS $function$ - begin - return task_priority in ('low', 'medium', 'high', 'urgent', 'critical'); - end; - $function$; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/non-superuser-extraction.test.ts b/packages/pg-delta/tests/integration/non-superuser-extraction.test.ts deleted file mode 100644 index 29f7f2d98..000000000 --- a/packages/pg-delta/tests/integration/non-superuser-extraction.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Regression tests for non-superuser catalog extraction (supabase/cli#5826, CLI-1919). - * - * When connected as a non-superuser role (e.g. the `postgres` role on Supabase - * hosted projects), extraction must not SELECT from superuser-only catalogs such - * as `pg_user_mapping` or the `pg_subscription.subconninfo` column. - * - * The `role` option only performs `SET ROLE` on every pooled connection when - * `createPlan` is given connection URL strings (via `createManagedPool`) — this - * is exactly the Supabase CLI production path. `withDbIsolated` hands back Pool - * objects (which bypass `SET ROLE` during extraction), so these tests start - * containers directly to obtain connection URIs and drive the string path. - */ - -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; -import { createPool } from "../../src/core/postgres-config.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { - buildPostgresTestImage, - PostgresAlpineContainer, -} from "../postgres-alpine.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`non-superuser extraction (pg${pgVersion})`, () => { - test("non-superuser role can extract a catalog containing user mappings and subscriptions", async () => { - const image = await buildPostgresTestImage(pgVersion); - const [containerMain, containerBranch] = await Promise.all([ - new PostgresAlpineContainer(image).start(), - new PostgresAlpineContainer(image).start(), - ]); - const mainUri = containerMain.getConnectionUri(); - const branchUri = containerBranch.getConnectionUri(); - - // Admin pools (default superuser) to set up the fixtures. - const mainAdmin = createPool(mainUri); - const branchAdmin = createPool(branchUri); - - try { - // Isolated containers don't share roles: create on both sides. - await mainAdmin.query( - "CREATE ROLE pgdelta_nosuper WITH NOLOGIN NOSUPERUSER;", - ); - await branchAdmin.query( - "CREATE ROLE pgdelta_nosuper WITH NOLOGIN NOSUPERUSER;", - ); - - // Branch has the objects the non-superuser reader must be able to see. - await branchAdmin.query("CREATE FOREIGN DATA WRAPPER test_fdw;"); - await branchAdmin.query( - "CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw;", - ); - await branchAdmin.query( - "CREATE USER MAPPING FOR PUBLIC SERVER test_server OPTIONS (user 'remote_user', password 'secret');", - ); - await branchAdmin.query( - "CREATE SUBSCRIPTION test_sub CONNECTION 'host=example.invalid dbname=x' PUBLICATION test_pub WITH (connect = false, slot_name = NONE, enabled = false, create_slot = false);", - ); - - const result = await createPlan(mainUri, branchUri, { - role: "pgdelta_nosuper", - }); - - expect(result).not.toBeNull(); - const statements = flattenPlanStatements(result!.plan); - - // User mapping is created with NO options (unprivileged reader cannot - // see umoptions, so they degrade to an empty option list). - const userMappingStatement = statements.find((s) => - s.includes("CREATE USER MAPPING FOR PUBLIC SERVER test_server"), - ); - expect(userMappingStatement).toBeDefined(); - expect(userMappingStatement).not.toContain("OPTIONS"); - expect(userMappingStatement).not.toContain("remote_user"); - expect(userMappingStatement).not.toContain("secret"); - - // Subscription is created, but conninfo is the redacted placeholder. - const subscriptionStatement = statements.find((s) => - s.includes("CREATE SUBSCRIPTION"), - ); - expect(subscriptionStatement).toBeDefined(); - expect(subscriptionStatement).not.toContain("example.invalid"); - } finally { - await Promise.all([mainAdmin.end(), branchAdmin.end()]); - await Promise.all([containerMain.stop(), containerBranch.stop()]); - } - }); - - test("identical FDW state on both sides diffs clean as non-superuser", async () => { - const image = await buildPostgresTestImage(pgVersion); - const [containerMain, containerBranch] = await Promise.all([ - new PostgresAlpineContainer(image).start(), - new PostgresAlpineContainer(image).start(), - ]); - const mainUri = containerMain.getConnectionUri(); - const branchUri = containerBranch.getConnectionUri(); - - const mainAdmin = createPool(mainUri); - const branchAdmin = createPool(branchUri); - - try { - for (const pool of [mainAdmin, branchAdmin]) { - await pool.query( - "CREATE ROLE pgdelta_nosuper WITH NOLOGIN NOSUPERUSER;", - ); - await pool.query("CREATE FOREIGN DATA WRAPPER test_fdw;"); - await pool.query( - "CREATE SERVER test_server FOREIGN DATA WRAPPER test_fdw;", - ); - await pool.query( - "CREATE USER MAPPING FOR PUBLIC SERVER test_server OPTIONS (user 'remote_user', password 'secret');", - ); - } - - const result = await createPlan(mainUri, branchUri, { - role: "pgdelta_nosuper", - }); - - // Options are hidden symmetrically on both sides, so there is no - // spurious user-mapping/server/fdw diff. - const relevant = result - ? flattenPlanStatements(result.plan).filter( - (s) => - s.includes("USER MAPPING") || - s.includes("SERVER") || - s.includes("FOREIGN DATA WRAPPER"), - ) - : []; - expect(relevant).toEqual([]); - } finally { - await Promise.all([mainAdmin.end(), branchAdmin.end()]); - await Promise.all([containerMain.stop(), containerBranch.stop()]); - } - }); - }); -} diff --git a/packages/pg-delta/tests/integration/not-valid-constraint-convergence.test.ts b/packages/pg-delta/tests/integration/not-valid-constraint-convergence.test.ts deleted file mode 100644 index a4a082bde..000000000 --- a/packages/pg-delta/tests/integration/not-valid-constraint-convergence.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * A NOT VALID table constraint must not get a trailing VALIDATE CONSTRAINT - * step. AlterTableAddConstraint serializes pg_get_constraintdef as-is, and that - * output already includes the NOT VALID suffix, so the ADD on its own matches - * the target. A VALIDATE would mark it convalidated = true, the reverse of what - * we want, and the plan would loop forever. - * - * These cases use the realtime.messages.messages_payload_exclusive constraint - * from Supabase Realtime, whose baseline records - * CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID. - */ - -import { describe, expect, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -const assertNoValidate = (sqlStatements: string[]) => { - expect(sqlStatements.some((sql) => /VALIDATE CONSTRAINT/i.test(sql))).toBe( - false, - ); -}; - -const assertValidateShortcut = (sqlStatements: string[]) => { - const validateCount = sqlStatements.filter((sql) => - /VALIDATE CONSTRAINT/i.test(sql), - ).length; - expect(validateCount).toBe(1); - - expect( - sqlStatements.some((sql) => - /DROP CONSTRAINT\s+messages_payload_exclusive/i.test(sql), - ), - ).toBe(false); - expect( - sqlStatements.some((sql) => - /ADD CONSTRAINT\s+messages_payload_exclusive/i.test(sql), - ), - ).toBe(false); -}; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`NOT VALID constraint convergence (pg${pgVersion})`, () => { - test( - "created NOT VALID check constraint converges without VALIDATE", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.messages ( - payload jsonb, - binary_payload bytea - ); - `, - testSql: ` - ALTER TABLE test_schema.messages - ADD CONSTRAINT messages_payload_exclusive - CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID; - `, - assertSqlStatements: assertNoValidate, - }); - }), - ); - - test( - "validated -> NOT VALID drift converges without re-validating", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.messages ( - payload jsonb, - binary_payload bytea - ); - ALTER TABLE test_schema.messages - ADD CONSTRAINT messages_payload_exclusive - CHECK (payload IS NULL OR binary_payload IS NULL); - `, - testSql: ` - ALTER TABLE test_schema.messages - DROP CONSTRAINT messages_payload_exclusive; - ALTER TABLE test_schema.messages - ADD CONSTRAINT messages_payload_exclusive - CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID; - `, - assertSqlStatements: assertNoValidate, - }); - }), - ); - - test( - "NOT VALID -> validated drift converges via VALIDATE CONSTRAINT (no drop+add)", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.messages ( - payload jsonb, - binary_payload bytea - ); - ALTER TABLE test_schema.messages - ADD CONSTRAINT messages_payload_exclusive - CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID; - `, - testSql: ` - ALTER TABLE test_schema.messages - VALIDATE CONSTRAINT messages_payload_exclusive; - `, - assertSqlStatements: assertValidateShortcut, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/ordering-validation.test.ts b/packages/pg-delta/tests/integration/ordering-validation.test.ts deleted file mode 100644 index b6daa4551..000000000 --- a/packages/pg-delta/tests/integration/ordering-validation.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Integration tests to validate ordering theory for ALTER TABLE operations. - * - * These tests validate the theory about ordering issues with: - * 1. ALTER TABLE ... OWNER TO ... operations and role creation dependencies - * 2. CHECK constraints referencing non-existent objects - * 3. Complex multi-dependency scenarios - */ - -import { describe, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`ordering validation (pg${pgVersion})`, () => { - test( - "table owner change with role creation dependency", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - name text - ); - `, - testSql: ` - -- Create a new role - CREATE ROLE app_user WITH LOGIN; - - -- Change table owner to the new role - ALTER TABLE test_schema.users OWNER TO app_user; - `, - }); - }), - ); - - test( - "complex owner change scenario with multiple tables and roles", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app_schema; - CREATE SCHEMA analytics_schema; - `, - testSql: ` - -- Create multiple roles - CREATE ROLE app_admin WITH LOGIN; - CREATE ROLE analytics_user WITH LOGIN; - CREATE ROLE readonly_user WITH LOGIN; - - -- Create tables in different schemas - CREATE TABLE app_schema.users ( - id integer PRIMARY KEY, - email text UNIQUE - ); - - CREATE TABLE app_schema.orders ( - id integer PRIMARY KEY, - user_id integer, - amount decimal - ); - - CREATE TABLE analytics_schema.reports ( - id integer PRIMARY KEY, - data jsonb - ); - - -- Change owners to different roles - ALTER TABLE app_schema.users OWNER TO app_admin; - ALTER TABLE app_schema.orders OWNER TO app_admin; - ALTER TABLE analytics_schema.reports OWNER TO analytics_user; - `, - description: - "complex owner change scenario with multiple tables and roles", - }); - }), - ); - - test( - "check constraint referencing non-existent objects", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create a table with a CHECK constraint that references a function - -- that doesn't exist yet (this should fail if ordering is wrong) - CREATE TABLE test_schema.products ( - id integer PRIMARY KEY, - name text, - price decimal CHECK (price > 0), - status text CHECK (status IN ('active', 'inactive', 'pending')) - ); - - -- Create a function that the CHECK constraint might reference - CREATE OR REPLACE FUNCTION test_schema.validate_price(price decimal) - RETURNS boolean AS $$ - BEGIN - RETURN price > 0 AND price < 1000000; - END; - $$ LANGUAGE plpgsql; - - -- Add a CHECK constraint that references the function - ALTER TABLE test_schema.products - ADD CONSTRAINT products_price_valid - CHECK (test_schema.validate_price(price)); - `, - }); - }), - ); - - test( - "foreign key constraint ordering with table creation", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create tables in a specific order that might cause FK constraint issues - CREATE TABLE test_schema.orders ( - id integer PRIMARY KEY, - customer_id integer, - order_date date - ); - - CREATE TABLE test_schema.customers ( - id integer PRIMARY KEY, - name text NOT NULL - ); - - -- Add foreign key constraint - this should work because customers table exists - ALTER TABLE test_schema.orders - ADD CONSTRAINT orders_customer_fkey - FOREIGN KEY (customer_id) REFERENCES test_schema.customers(id); - `, - }); - }), - ); - - test( - "complex multi-dependency scenario with owner changes", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app_schema; - `, - testSql: ` - -- Create roles - CREATE ROLE app_user WITH LOGIN; - CREATE ROLE app_admin WITH LOGIN; - - -- Create a complex dependency chain - CREATE TABLE app_schema.users ( - id integer PRIMARY KEY, - email text UNIQUE - ); - - CREATE TABLE app_schema.orders ( - id integer PRIMARY KEY, - user_id integer, - status text - ); - - CREATE TABLE app_schema.order_items ( - id integer PRIMARY KEY, - order_id integer, - product_name text - ); - - -- Add foreign key constraints - ALTER TABLE app_schema.orders - ADD CONSTRAINT orders_user_fkey - FOREIGN KEY (user_id) REFERENCES app_schema.users(id); - - ALTER TABLE app_schema.order_items - ADD CONSTRAINT order_items_order_fkey - FOREIGN KEY (order_id) REFERENCES app_schema.orders(id); - - -- Create a view that depends on all tables - CREATE VIEW app_schema.user_order_summary AS - SELECT u.id, u.email, COUNT(o.id) as order_count - FROM app_schema.users u - LEFT JOIN app_schema.orders o ON u.id = o.user_id - GROUP BY u.id, u.email; - - -- Change owners - ALTER TABLE app_schema.users OWNER TO app_admin; - ALTER TABLE app_schema.orders OWNER TO app_admin; - ALTER TABLE app_schema.order_items OWNER TO app_user; - ALTER VIEW app_schema.user_order_summary OWNER TO app_admin; - `, - }); - }), - ); - - test( - "schema owner change with role dependency", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: ` - -- Create a role - CREATE ROLE schema_owner WITH LOGIN; - - -- Create a schema and immediately change its owner - CREATE SCHEMA test_schema; - ALTER SCHEMA test_schema OWNER TO schema_owner; - - -- Create a table in the schema - CREATE TABLE test_schema.data ( - id integer PRIMARY KEY, - value text - ); - `, - }); - }), - ); - - test( - "type owner change with role dependency", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - `, - testSql: ` - -- Create a role - CREATE ROLE type_owner WITH LOGIN; - - -- Create a custom type - CREATE TYPE test_schema.status_enum AS ENUM ('active', 'inactive', 'pending'); - - -- Change type owner - ALTER TYPE test_schema.status_enum OWNER TO type_owner; - - -- Create a table using the type - CREATE TABLE test_schema.items ( - id integer PRIMARY KEY, - status test_schema.status_enum DEFAULT 'pending' - ); - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/overloaded-functions-roundtrip.test.ts b/packages/pg-delta/tests/integration/overloaded-functions-roundtrip.test.ts deleted file mode 100644 index ba4ca127e..000000000 --- a/packages/pg-delta/tests/integration/overloaded-functions-roundtrip.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Integration test: declarative export/apply roundtrip with overloaded functions. - * - * Reproduces the bug where ALTER FUNCTION ... OWNER TO is emitted without - * an argument list, causing PostgreSQL error 42725 ("function name is not unique") - * when multiple overloads of the same function exist. - * - * Flow: create two overloaded functions in branch -> export declarative schema - * -> apply to main -> verify 0 remaining diff. - */ - -import { describe, expect, test } from "bun:test"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { applyDeclarativeSchema } from "../../src/core/declarative-apply/index.ts"; -import { exportDeclarativeSchema } from "../../src/core/export/index.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { POSTGRES_VERSIONS, type PostgresVersion } from "../constants.ts"; -import { withDb } from "../utils.ts"; - -const OVERLOADED_FUNCTIONS_SQL = ` --- Two overloads of the same function name (like publish_package in dbdev) -create function public.overload_me(a integer, b text) -returns void language plpgsql as $$ begin end; $$; - -create function public.overload_me(x bigint) -returns void language plpgsql as $$ begin end; $$; -`; - -for (const pgVersion of POSTGRES_VERSIONS as PostgresVersion[]) { - describe(`overloaded functions roundtrip (pg${pgVersion})`, () => { - test( - "exported schema with overloaded functions applies and roundtrips to 0 changes", - withDb(pgVersion, async ({ main, branch }) => { - // Branch: add two overloaded functions. Main stays clean. - await branch.query(OVERLOADED_FUNCTIONS_SQL); - - const planResult = await createPlan(main, branch); - if (!planResult) { - throw new Error( - "createPlan returned null -- expected changes (two new functions)", - ); - } - - const output = exportDeclarativeSchema(planResult); - - const applyResult = await applyDeclarativeSchema({ - content: output.files.map((f) => ({ filePath: f.path, sql: f.sql })), - pool: main, - disableCheckFunctionBodies: true, - validateFunctionBodies: false, - }); - - if (applyResult.apply.status !== "success") { - const stuckSql = applyResult.apply.stuckStatements - ?.map((s) => `[${s.code}] ${s.message}\n SQL: ${s.statement.sql}`) - .join("\n"); - const errorSql = applyResult.apply.errors - ?.map((s) => `[${s.code}] ${s.message}\n SQL: ${s.statement.sql}`) - .join("\n"); - throw new Error( - `Declarative apply failed (${applyResult.apply.status}):\n${stuckSql ?? errorSql ?? "(no detail)"}`, - { cause: applyResult }, - ); - } - - const mainCatalog = await extractCatalog(main); - const branchCatalog = await extractCatalog(branch); - const remainingChanges = diffCatalogs(mainCatalog, branchCatalog); - - if (remainingChanges.length > 0) { - const sorted = sortChanges( - { mainCatalog, branchCatalog }, - remainingChanges, - ); - const remainingSql = sorted.map((c) => c.serialize()).join(";\n"); - console.error( - `[overloaded-functions-roundtrip] ${remainingChanges.length} remaining change(s):\n${remainingSql}`, - ); - } - - expect(remainingChanges).toHaveLength(0); - }), - 60 * 1000, - ); - }); -} diff --git a/packages/pg-delta/tests/integration/partitioned-table-operations.test.ts b/packages/pg-delta/tests/integration/partitioned-table-operations.test.ts deleted file mode 100644 index f28f4535c..000000000 --- a/packages/pg-delta/tests/integration/partitioned-table-operations.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * Integration tests for PostgreSQL partitioned table operations. - * Tests that indexes, triggers, and foreign keys are correctly handled - * for partitioned tables (not duplicated on partitions). - */ - -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`partitioned table operations (pg${pgVersion})`, () => { - test( - "partitioned table with indexes on parent", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.orders ( - order_id integer NOT NULL, - created_on date NOT NULL, - customer_id integer, - status text, - amount numeric(10,2) - ) PARTITION BY RANGE (created_on); - - CREATE TABLE test_schema.orders_2024 PARTITION OF test_schema.orders - FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - CREATE TABLE test_schema.orders_2025 PARTITION OF test_schema.orders - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - `, - testSql: ` - -- Indexes on parent should propagate to partitions, not be created separately - CREATE INDEX idx_orders_status ON test_schema.orders (status); - CREATE INDEX idx_orders_customer ON test_schema.orders (customer_id); - CREATE INDEX idx_orders_created_brin ON test_schema.orders USING brin (created_on); - `, - expectedSqlTerms: [ - "CREATE INDEX idx_orders_status ON test_schema.orders (status)", - "CREATE INDEX idx_orders_customer ON test_schema.orders (customer_id)", - "CREATE INDEX idx_orders_created_brin ON test_schema.orders USING brin (created_on)", - ], - }); - }), - ); - - test( - "partitioned table with triggers on parent", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.events ( - event_id integer NOT NULL, - created_at timestamp NOT NULL, - data jsonb - ) PARTITION BY RANGE (created_at); - - CREATE TABLE test_schema.events_2024 PARTITION OF test_schema.events - FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - CREATE TABLE test_schema.events_2025 PARTITION OF test_schema.events - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - - CREATE FUNCTION test_schema.update_timestamp() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RETURN NEW; - END; - $$; - - CREATE FUNCTION test_schema.audit_event() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RETURN NEW; - END; - $$; - `, - testSql: ` - -- Triggers on parent should propagate to partitions, not be created separately - CREATE TRIGGER trg_events_updated_at - BEFORE UPDATE ON test_schema.events - FOR EACH ROW - EXECUTE FUNCTION test_schema.update_timestamp(); - - CREATE TRIGGER trg_events_audit - AFTER INSERT OR UPDATE OR DELETE ON test_schema.events - FOR EACH ROW - EXECUTE FUNCTION test_schema.audit_event(); - `, - expectedSqlTerms: [ - "CREATE TRIGGER trg_events_audit AFTER INSERT OR DELETE OR UPDATE ON test_schema.events FOR EACH ROW EXECUTE FUNCTION test_schema.audit_event()", - "CREATE TRIGGER trg_events_updated_at BEFORE UPDATE ON test_schema.events FOR EACH ROW EXECUTE FUNCTION test_schema.update_timestamp()", - ], - }); - }), - ); - - test( - "foreign key referencing partitioned table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.customers ( - customer_id integer PRIMARY KEY, - name text - ); - - CREATE TABLE test_schema.orders ( - order_id integer NOT NULL, - created_on date NOT NULL, - customer_id integer, - PRIMARY KEY (order_id, created_on) - ) PARTITION BY RANGE (created_on); - - CREATE TABLE test_schema.orders_2024 PARTITION OF test_schema.orders - FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - CREATE TABLE test_schema.orders_2025 PARTITION OF test_schema.orders - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - `, - testSql: ` - CREATE TABLE test_schema.order_items ( - item_id integer PRIMARY KEY, - order_id integer NOT NULL, - order_created_on date NOT NULL, - product_name text - ); - - -- Foreign key should reference parent table, not individual partitions - ALTER TABLE test_schema.order_items - ADD CONSTRAINT fk_order_items_order - FOREIGN KEY (order_id, order_created_on) - REFERENCES test_schema.orders(order_id, created_on) - ON DELETE CASCADE; - `, - expectedSqlTerms: [ - "CREATE TABLE test_schema.order_items (item_id integer NOT NULL, order_id integer NOT NULL, order_created_on date NOT NULL, product_name text)", - "ALTER TABLE test_schema.order_items ADD CONSTRAINT fk_order_items_order FOREIGN KEY (order_id, order_created_on) REFERENCES test_schema.orders(order_id, created_on) ON DELETE CASCADE", - "ALTER TABLE test_schema.order_items ADD CONSTRAINT order_items_pkey PRIMARY KEY (item_id)", - ], - }); - }), - ); - - test( - "comprehensive partitioned table with all features", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - - -- Reference table - CREATE TABLE test_schema.customers ( - customer_id integer PRIMARY KEY, - name text NOT NULL - ); - - -- Partitioned table - CREATE TABLE test_schema.orders ( - order_id integer NOT NULL, - created_on date NOT NULL, - customer_id integer NOT NULL, - status text DEFAULT 'pending', - total_amount numeric(10,2), - updated_at timestamp DEFAULT now(), - PRIMARY KEY (order_id, created_on) - ) PARTITION BY RANGE (created_on); - - CREATE TABLE test_schema.orders_2024 PARTITION OF test_schema.orders - FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - CREATE TABLE test_schema.orders_2025 PARTITION OF test_schema.orders - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - - -- Helper functions - CREATE FUNCTION test_schema.update_updated_at() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - NEW.updated_at = now(); - RETURN NEW; - END; - $$; - - CREATE FUNCTION test_schema.log_order_changes() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RETURN NEW; - END; - $$; - `, - testSql: dedent` - -- Foreign key to partitioned table (should reference parent only) - ALTER TABLE test_schema.orders - ADD CONSTRAINT fk_orders_customer - FOREIGN KEY (customer_id) - REFERENCES test_schema.customers(customer_id) - ON DELETE RESTRICT; - - -- Indexes on parent (should propagate to partitions, not be created separately) - CREATE INDEX idx_orders_status ON test_schema.orders (status); - CREATE INDEX idx_orders_customer ON test_schema.orders (customer_id); - CREATE INDEX idx_orders_created_brin ON test_schema.orders USING brin (created_on); - - -- Triggers on parent (should propagate to partitions, not be created separately) - CREATE TRIGGER trg_orders_updated_at - BEFORE UPDATE ON test_schema.orders - FOR EACH ROW - EXECUTE FUNCTION test_schema.update_updated_at(); - - CREATE TRIGGER trg_orders_audit - AFTER INSERT OR UPDATE OR DELETE ON test_schema.orders - FOR EACH ROW - EXECUTE FUNCTION test_schema.log_order_changes(); - - -- Child table with FK to partitioned table - CREATE TABLE test_schema.order_items ( - item_id integer PRIMARY KEY, - order_id integer NOT NULL, - order_created_on date NOT NULL, - product_name text, - quantity integer - ); - - -- Foreign key should reference parent table, not partitions - ALTER TABLE test_schema.order_items - ADD CONSTRAINT fk_order_items_order - FOREIGN KEY (order_id, order_created_on) - REFERENCES test_schema.orders(order_id, created_on) - ON DELETE CASCADE; - `, - expectedSqlTerms: [ - "CREATE TABLE test_schema.order_items (item_id integer NOT NULL, order_id integer NOT NULL, order_created_on date NOT NULL, product_name text, quantity integer)", - "ALTER TABLE test_schema.order_items ADD CONSTRAINT fk_order_items_order FOREIGN KEY (order_id, order_created_on) REFERENCES test_schema.orders(order_id, created_on) ON DELETE CASCADE", - "ALTER TABLE test_schema.order_items ADD CONSTRAINT order_items_pkey PRIMARY KEY (item_id)", - "ALTER TABLE test_schema.orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES test_schema.customers(customer_id) ON DELETE RESTRICT", - "CREATE INDEX idx_orders_status ON test_schema.orders (status)", - "CREATE INDEX idx_orders_customer ON test_schema.orders (customer_id)", - "CREATE INDEX idx_orders_created_brin ON test_schema.orders USING brin (created_on)", - "CREATE TRIGGER trg_orders_audit AFTER INSERT OR DELETE OR UPDATE ON test_schema.orders FOR EACH ROW EXECUTE FUNCTION test_schema.log_order_changes()", - "CREATE TRIGGER trg_orders_updated_at BEFORE UPDATE ON test_schema.orders FOR EACH ROW EXECUTE FUNCTION test_schema.update_updated_at()", - ], - }); - }), - ); - - test( - "partitioned table with CHECK constraint on parent", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.documents ( - document_id uuid NOT NULL, - file_name text NOT NULL, - tenant_id uuid NOT NULL, - PRIMARY KEY (document_id, tenant_id) - ) PARTITION BY LIST (tenant_id); - - CREATE TABLE test_schema.documents_default - PARTITION OF test_schema.documents DEFAULT; - - CREATE TABLE test_schema.documents_paxafe - PARTITION OF test_schema.documents - FOR VALUES IN ('019b8184-fa49-4a46-b429-4fe4cd9b1a8a'); - `, - testSql: ` - -- CHECK constraint on parent should propagate to partitions, not be re-emitted - -- against each partition (PostgreSQL auto-creates the inherited constraint when - -- the partition itself is created or via the parent ADD CONSTRAINT). - ALTER TABLE test_schema.documents - ADD CONSTRAINT documents_file_name_check - CHECK (char_length(file_name) <= 255); - `, - expectedSqlTerms: [ - "ALTER TABLE test_schema.documents ADD CONSTRAINT documents_file_name_check CHECK (char_length(file_name) <= 255)", - ], - }); - }), - ); - - test( - "partitioned table with unique constraint including partition key", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.products ( - product_id integer NOT NULL, - created_on date NOT NULL, - sku text, - name text, - PRIMARY KEY (product_id, created_on) - ) PARTITION BY RANGE (created_on); - - CREATE TABLE test_schema.products_2024 PARTITION OF test_schema.products - FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - CREATE TABLE test_schema.products_2025 PARTITION OF test_schema.products - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - `, - testSql: ` - -- Unique constraint on parent must include partition key (should propagate to partitions) - ALTER TABLE test_schema.products - ADD CONSTRAINT products_sku_key UNIQUE (sku, created_on); - `, - expectedSqlTerms: [ - "ALTER TABLE test_schema.products ADD CONSTRAINT products_sku_key UNIQUE (sku, created_on)", - ], - }); - }), - ); - - test( - "adding partition to existing partitioned table with indexes and triggers", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.events ( - event_id integer NOT NULL, - created_at timestamp NOT NULL, - data jsonb, - PRIMARY KEY (event_id, created_at) - ) PARTITION BY RANGE (created_at); - - CREATE TABLE test_schema.events_2024 PARTITION OF test_schema.events - FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - CREATE INDEX idx_events_created ON test_schema.events (created_at); - - CREATE FUNCTION test_schema.audit_event() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RETURN NEW; - END; - $$; - - CREATE TRIGGER trg_events_audit - AFTER INSERT OR UPDATE OR DELETE ON test_schema.events - FOR EACH ROW - EXECUTE FUNCTION test_schema.audit_event(); - - -- Pre-create the 2025 partition in main to test adding it in branch - CREATE TABLE test_schema.events_2025 PARTITION OF test_schema.events - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - `, - testSql: ` - -- Adding a new partition should not recreate indexes/triggers on existing partitions - -- This test verifies that when a partition already exists, we don't try to recreate - -- indexes/triggers that were already propagated from the parent - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/pgmq-declarative-roundtrip.test.ts b/packages/pg-delta/tests/integration/pgmq-declarative-roundtrip.test.ts deleted file mode 100644 index 7455ce29d..000000000 --- a/packages/pg-delta/tests/integration/pgmq-declarative-roundtrip.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { applyDeclarativeSchema } from "../../src/core/declarative-apply/index.ts"; -import { exportDeclarativeSchema } from "../../src/core/export/index.ts"; -import { compileFilterDSL } from "../../src/core/integrations/filter/dsl.ts"; -import { compileSerializeDSL } from "../../src/core/integrations/serialize/dsl.ts"; -import { supabase as supabaseIntegration } from "../../src/core/integrations/supabase.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import type { PostgresVersion } from "../constants.ts"; -import { withDbSupabaseIsolated } from "../utils.ts"; - -const pgVersion: PostgresVersion = 15; - -describe(`pgmq declarative roundtrip (pg${pgVersion})`, () => { - test( - "exported schema reapplies cleanly with supabase integration", - withDbSupabaseIsolated(pgVersion, async (db) => { - await db.branch.query(` - CREATE EXTENSION pgmq; - - select from pgmq.create('my_queue'); - select * from pgmq.send('my_queue', '{"hello": "world"}'); - - CREATE FUNCTION public.pgmq_delete ( - queue_name text, - message_id bigint - ) - RETURNS boolean - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path TO 'pgmq' - AS $function$ - DECLARE - result boolean; - BEGIN - -- Add debug logging - RAISE NOTICE 'pgmq_delete called with queue_name=%, message_id=% (type: %)', - queue_name, message_id, pg_typeof(message_id); - - -- Validate input parameters - IF queue_name IS NULL OR queue_name = '' THEN - RAISE EXCEPTION 'queue_name cannot be null or empty'; - END IF; - - IF message_id IS NULL THEN - RAISE EXCEPTION 'message_id cannot be null'; - END IF; - - IF message_id <= 0 THEN - RAISE EXCEPTION 'message_id must be a positive integer, got: %', message_id; - END IF; - - -- Call the actual pgmq.delete function - RAISE NOTICE 'Calling pgmq.delete with queue_name=%, msg_id=%', queue_name, message_id; - SELECT pgmq.delete(queue_name, message_id) INTO result; - RAISE NOTICE 'pgmq.delete returned: %', result; - - RETURN result; - END; - $function$; - - CREATE FUNCTION public.pgmq_read ( - queue_name text, - sleep_seconds integer DEFAULT 0, - n integer DEFAULT 1 - ) - RETURNS SETOF pgmq.message_record - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path TO 'pgmq' - AS $function$ - BEGIN - -- Validate input parameters - IF queue_name IS NULL OR queue_name = '' THEN - RAISE EXCEPTION 'queue_name cannot be null or empty'; - END IF; - - IF sleep_seconds IS NULL OR sleep_seconds < 0 THEN - RAISE EXCEPTION 'sleep_seconds must be non-negative, got: %', sleep_seconds; - END IF; - - IF n IS NULL OR n <= 0 THEN - RAISE EXCEPTION 'n must be a positive integer, got: %', n; - END IF; - - RETURN QUERY - SELECT * FROM pgmq.read(queue_name, sleep_seconds, n); - END; - $function$; - - CREATE FUNCTION public.pgmq_set_vt ( - queue_name text, - message_id bigint, - vt integer - ) - RETURNS boolean - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path TO 'pgmq' - AS $function$ - BEGIN - RETURN pgmq.set_vt(queue_name, message_id, vt); - END; - $function$; - `); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const compiledFilter = compileFilterDSL(supabaseIntegration.filter); - const compiledSerialize = compileSerializeDSL( - supabaseIntegration.serialize, - ); - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - skipDefaultPrivilegeSubtraction: true, - }); - - if (!planResult) { - throw new Error( - "createPlan returned null -- no changes detected between baseline and branch", - ); - } - - const output = exportDeclarativeSchema(planResult, { - integration: { serialize: compiledSerialize }, - }); - - const applyResult = await applyDeclarativeSchema({ - content: output.files.map((file) => ({ - filePath: file.path, - sql: file.sql, - })), - pool: db.main, - disableCheckFunctionBodies: true, - validateFunctionBodies: false, - }); - - if (applyResult.apply.status !== "success") { - throw new Error( - `Declarative apply failed (${applyResult.apply.status})`, - { cause: applyResult }, - ); - } - - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const allChanges = diffCatalogs(mainCatalog, branchCatalog); - const remainingChanges = allChanges.filter(compiledFilter); - - if (remainingChanges.length > 0) { - const sorted = sortChanges( - { mainCatalog, branchCatalog }, - remainingChanges, - ); - const remainingSql = sorted - .map((change) => change.serialize()) - .join(";\n"); - console.error( - `[pgmq-declarative-roundtrip] ${remainingChanges.length} remaining change(s) after roundtrip:\n${remainingSql}`, - ); - } - - expect(remainingChanges).toHaveLength(0); - }), - 2 * 60 * 1000, - ); -}); diff --git a/packages/pg-delta/tests/integration/policy-dependencies.test.ts b/packages/pg-delta/tests/integration/policy-dependencies.test.ts deleted file mode 100644 index 7b05dadd6..000000000 --- a/packages/pg-delta/tests/integration/policy-dependencies.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -/** - * Integration tests for PostgreSQL policy dependencies. - */ - -import { describe, expect, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - // TODO: Fix policy dependency detection issues - describe(`policy dependencies (pg${pgVersion})`, () => { - test( - "policy depends on table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA security; - CREATE TABLE security.users ( - id INTEGER PRIMARY KEY, - username TEXT NOT NULL, - email TEXT UNIQUE - ); - `, - testSql: ` - ALTER TABLE security.users ENABLE ROW LEVEL SECURITY; - CREATE POLICY user_isolation ON security.users - FOR ALL - TO public - USING (true); - `, - }); - }), - ); - - test( - "multiple policies with dependencies", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE TABLE app.posts ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - content TEXT, - author_id INTEGER NOT NULL, - published BOOLEAN DEFAULT false - ); - `, - testSql: ` - ALTER TABLE app.posts ENABLE ROW LEVEL SECURITY; - - -- Read policy for all users - CREATE POLICY read_posts ON app.posts - FOR SELECT - TO public - USING (published = true); - - -- Insert policy for authenticated users - CREATE POLICY insert_own_posts ON app.posts - FOR INSERT - TO public - WITH CHECK (true); - - -- Update policy for authors - CREATE POLICY update_own_posts ON app.posts - FOR UPDATE - TO public - USING (true) - WITH CHECK (true); - `, - }); - }), - ); - - test( - "create table and policy together", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA tenant; - `, - testSql: ` - CREATE TABLE tenant.data ( - id INTEGER PRIMARY KEY, - tenant_id INTEGER NOT NULL, - content TEXT NOT NULL, - created_by INTEGER - ); - - ALTER TABLE tenant.data ENABLE ROW LEVEL SECURITY; - - CREATE POLICY tenant_isolation ON tenant.data - FOR ALL - TO public - USING (true) - WITH CHECK (true); - `, - }); - }), - ); - - test( - "policy USING expression references another new table (EXISTS)", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - `, - testSql: ` - CREATE TABLE app.accounts ( - id INTEGER PRIMARY KEY - ); - - CREATE TABLE app.users ( - id INTEGER PRIMARY KEY - ); - - ALTER TABLE app.accounts ENABLE ROW LEVEL SECURITY; - - CREATE POLICY account_access ON app.accounts - FOR SELECT - TO public - USING (EXISTS (SELECT 1 FROM app.users)); - `, - assertSqlStatements: (statements) => { - const createUsersIdx = statements.findIndex((s) => - s.includes("CREATE TABLE app.users"), - ); - const createPolicyIdx = statements.findIndex((s) => - s.includes("CREATE POLICY account_access"), - ); - expect(createUsersIdx).toBeGreaterThanOrEqual(0); - expect(createPolicyIdx).toBeGreaterThanOrEqual(0); - expect(createUsersIdx).toBeLessThan(createPolicyIdx); - }, - }); - }), - ); - - test( - "policy expression references multiple new tables via IN (SELECT …)", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - `, - testSql: ` - CREATE TABLE app.accounts ( - id INTEGER PRIMARY KEY, - status TEXT NOT NULL - ); - - CREATE TABLE app.memberships ( - account_id INTEGER PRIMARY KEY, - active BOOLEAN NOT NULL - ); - - CREATE TABLE app.statuses ( - status TEXT PRIMARY KEY - ); - - ALTER TABLE app.accounts ENABLE ROW LEVEL SECURITY; - - CREATE POLICY account_access ON app.accounts - FOR SELECT - TO public - USING ( - id IN (SELECT account_id FROM app.memberships WHERE active) - AND status IN (SELECT status FROM app.statuses) - ); - `, - assertSqlStatements: (statements) => { - const createMembershipsIdx = statements.findIndex((s) => - s.includes("CREATE TABLE app.memberships"), - ); - const createStatusesIdx = statements.findIndex((s) => - s.includes("CREATE TABLE app.statuses"), - ); - const createPolicyIdx = statements.findIndex((s) => - s.includes("CREATE POLICY account_access"), - ); - expect(createMembershipsIdx).toBeGreaterThanOrEqual(0); - expect(createStatusesIdx).toBeGreaterThanOrEqual(0); - expect(createPolicyIdx).toBeGreaterThanOrEqual(0); - expect(createMembershipsIdx).toBeLessThan(createPolicyIdx); - expect(createStatusesIdx).toBeLessThan(createPolicyIdx); - }, - }); - }), - ); - - test( - "policy USING expression calls a new function", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - `, - testSql: ` - CREATE TABLE app.accounts ( - id INTEGER PRIMARY KEY - ); - - CREATE FUNCTION app.is_admin() RETURNS BOOLEAN - LANGUAGE sql - STABLE - AS $$ SELECT true $$; - - ALTER TABLE app.accounts ENABLE ROW LEVEL SECURITY; - - CREATE POLICY account_access ON app.accounts - FOR SELECT - TO public - USING (app.is_admin()); - `, - assertSqlStatements: (statements) => { - const createFunctionIdx = statements.findIndex((s) => - s.includes("FUNCTION app.is_admin"), - ); - const createPolicyIdx = statements.findIndex((s) => - s.includes("CREATE POLICY account_access"), - ); - expect(createFunctionIdx).toBeGreaterThanOrEqual(0); - expect(createPolicyIdx).toBeGreaterThanOrEqual(0); - expect(createFunctionIdx).toBeLessThan(createPolicyIdx); - }, - }); - }), - ); - - test( - "policy expression references a new view", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - `, - testSql: ` - CREATE TABLE app.accounts ( - id INTEGER PRIMARY KEY, - active BOOLEAN NOT NULL - ); - - CREATE VIEW app.active_accounts AS - SELECT id FROM app.accounts WHERE active; - - ALTER TABLE app.accounts ENABLE ROW LEVEL SECURITY; - - CREATE POLICY account_access ON app.accounts - FOR SELECT - TO public - USING (id IN (SELECT id FROM app.active_accounts)); - `, - assertSqlStatements: (statements) => { - const createViewIdx = statements.findIndex((s) => - s.includes("CREATE VIEW app.active_accounts"), - ); - const createPolicyIdx = statements.findIndex((s) => - s.includes("CREATE POLICY account_access"), - ); - expect(createViewIdx).toBeGreaterThanOrEqual(0); - expect(createPolicyIdx).toBeGreaterThanOrEqual(0); - expect(createViewIdx).toBeLessThan(createPolicyIdx); - }, - }); - }), - ); - - test( - "policy depending on a replaced function is dropped and recreated", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.alter_function_sign_policy_dependent_profiles ( - id uuid PRIMARY KEY, - role text - ); - - ALTER TABLE public.alter_function_sign_policy_dependent_profiles ENABLE ROW LEVEL SECURITY; - - CREATE OR REPLACE FUNCTION public.alter_function_sign_policy_dependent_check_role( - _id uuid, _role text - ) RETURNS boolean AS $$ - BEGIN RETURN true; END; - $$ LANGUAGE plpgsql; - - CREATE POLICY alter_function_sign_policy_dependent_check_role_policy - ON public.alter_function_sign_policy_dependent_profiles - FOR SELECT USING ( - public.alter_function_sign_policy_dependent_check_role(id, role) - ); - `, - testSql: ` - DROP POLICY alter_function_sign_policy_dependent_check_role_policy - ON public.alter_function_sign_policy_dependent_profiles; - - DROP FUNCTION public.alter_function_sign_policy_dependent_check_role(uuid, text); - - CREATE OR REPLACE FUNCTION public.alter_function_sign_policy_dependent_check_role( - _id uuid, _role text, _extra text DEFAULT 'default'::text - ) RETURNS boolean AS $$ - BEGIN RETURN true; END; - $$ LANGUAGE plpgsql; - - CREATE POLICY alter_function_sign_policy_dependent_check_role_policy - ON public.alter_function_sign_policy_dependent_profiles - FOR SELECT USING ( - public.alter_function_sign_policy_dependent_check_role(id, role) - ); - `, - assertSqlStatements: (statements) => { - const dropPolicyIdx = statements.findIndex((s) => - s.includes( - "DROP POLICY alter_function_sign_policy_dependent_check_role_policy", - ), - ); - const dropFunctionIdx = statements.findIndex((s) => - s.includes( - "DROP FUNCTION public.alter_function_sign_policy_dependent_check_role", - ), - ); - const createFunctionIdx = statements.findIndex((s) => - s.includes( - "CREATE FUNCTION public.alter_function_sign_policy_dependent_check_role", - ), - ); - const createPolicyIdx = statements.findIndex((s) => - s.includes( - "CREATE POLICY alter_function_sign_policy_dependent_check_role_policy", - ), - ); - - expect(dropPolicyIdx).toBeGreaterThanOrEqual(0); - expect(dropFunctionIdx).toBeGreaterThanOrEqual(0); - expect(createFunctionIdx).toBeGreaterThanOrEqual(0); - expect(createPolicyIdx).toBeGreaterThanOrEqual(0); - expect(dropPolicyIdx).toBeLessThan(dropFunctionIdx); - expect(createFunctionIdx).toBeLessThan(createPolicyIdx); - }, - }); - }), - ); - - test( - "policy depending on a column type rewrite is dropped and recreated", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TYPE public.user_role_enum AS ENUM ('admin', 'user', 'guest'); - - CREATE TABLE public.solution_categories_with_policy ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - name text NOT NULL, - role text NOT NULL - ); - - ALTER TABLE public.solution_categories_with_policy ENABLE ROW LEVEL SECURITY; - - CREATE POLICY "categories_admin_manage" ON public.solution_categories_with_policy - FOR ALL - TO public - USING (role = 'admin') - WITH CHECK (role = 'admin'); - `, - testSql: ` - DROP POLICY "categories_admin_manage" ON public.solution_categories_with_policy; - - ALTER TABLE public.solution_categories_with_policy - ALTER COLUMN role TYPE public.user_role_enum USING role::public.user_role_enum; - - CREATE POLICY "categories_admin_manage" ON public.solution_categories_with_policy - FOR ALL TO public - USING (role = 'admin'::public.user_role_enum) - WITH CHECK (role = 'admin'::public.user_role_enum); - `, - assertSqlStatements: (statements) => { - expect(statements.join(";\n")).toMatchInlineSnapshot(` - "DROP POLICY categories_admin_manage ON public.solution_categories_with_policy; - ALTER TABLE public.solution_categories_with_policy ALTER COLUMN role TYPE user_role_enum USING role::user_role_enum; - CREATE POLICY categories_admin_manage ON public.solution_categories_with_policy USING ((role = 'admin'::user_role_enum)) WITH CHECK ((role = 'admin'::user_role_enum))" - `); - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/postgres-config.test.ts b/packages/pg-delta/tests/integration/postgres-config.test.ts deleted file mode 100644 index e47a33f90..000000000 --- a/packages/pg-delta/tests/integration/postgres-config.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createPool, endPool } from "../../src/core/postgres-config.ts"; -import { - POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG, - POSTGRES_VERSIONS, -} from "../constants.ts"; -import { PostgresAlpineContainer } from "../postgres-alpine.ts"; - -function suppressShutdownError(err: Error & { code?: string }) { - if (err.code === "57P01" || err.code === "53100") return; - console.error("Pool error:", err); -} - -function createDeferred() { - let resolve!: () => void; - const promise = new Promise((innerResolve) => { - resolve = innerResolve; - }); - return { promise, resolve }; -} - -const CLIENT_QUERY_DEPRECATION_WARNING_FRAGMENT = - "client is already executing a query"; -// Multiple queued queries against a max=1 pool make the setup/query overlap deterministic. -const CONCURRENT_QUERY_COUNT = 8; -// Give blocked queries a brief chance to resolve if they are not waiting for setup. -const BLOCKED_QUERY_CHECK_DELAY_MS = 10; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`postgres config (pg${pgVersion})`, () => { - test("pool queries wait for async onConnect setup", async () => { - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresAlpineContainer(image).start(); - const warnings: string[] = []; - let setupCompletedCount = 0; - const setupStarted = createDeferred(); - const setupGate = createDeferred(); - const onWarning = (warning: Error) => { - if ( - warning.message.includes(CLIENT_QUERY_DEPRECATION_WARNING_FRAGMENT) - ) { - warnings.push(warning.message); - } - }; - - process.on("warning", onWarning); - - const pool = createPool(container.getConnectionUri(), { - max: 1, - onError: suppressShutdownError, - onConnect: async (client) => { - setupStarted.resolve(); - await setupGate.promise; - await client.query("SET application_name = 'pgdelta_onconnect'"); - setupCompletedCount += 1; - }, - }); - - try { - let queriesResolved = false; - const queryBatch = Promise.all( - Array.from({ length: CONCURRENT_QUERY_COUNT }, () => - pool.query( - "SELECT current_setting('application_name') AS application_name", - ), - ), - ).then((results) => { - queriesResolved = true; - return results; - }); - - await setupStarted.promise; - await new Promise((resolve) => - setTimeout(resolve, BLOCKED_QUERY_CHECK_DELAY_MS), - ); - expect(queriesResolved).toBeFalse(); - - setupGate.resolve(); - const results = await queryBatch; - - for (const result of results) { - expect(result.rows[0]?.application_name).toBe("pgdelta_onconnect"); - } - expect(setupCompletedCount).toBe(1); - expect(warnings).toEqual([]); - } finally { - process.off("warning", onWarning); - await endPool(pool); - await container.stop(); - } - }, 120_000); - }); -} diff --git a/packages/pg-delta/tests/integration/privilege-operations.test.ts b/packages/pg-delta/tests/integration/privilege-operations.test.ts deleted file mode 100644 index a806d4e46..000000000 --- a/packages/pg-delta/tests/integration/privilege-operations.test.ts +++ /dev/null @@ -1,483 +0,0 @@ -/** - * Integration tests for privileges: object, column, default privileges, and memberships. - */ - -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`privilege operations (pg${pgVersion})`, () => { - test( - "object privileges on view (grant)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE VIEW test_schema.v AS SELECT 1 AS a; - CREATE ROLE r_view; - `, - testSql: dedent` - GRANT SELECT ON test_schema.v TO r_view; - `, - }); - }), - ); - - test( - "domain privileges (grant)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE DOMAIN test_schema.dom AS int; - CREATE ROLE r_dom; - `, - testSql: dedent` - GRANT USAGE ON DOMAIN test_schema.dom TO r_dom; - `, - }); - }), - ); - - // GRANT tests - test( - "object privileges on table (grant)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.tg(a int); - CREATE ROLE r_obj_g; - `, - testSql: dedent` - GRANT UPDATE ON TABLE test_schema.tg TO r_obj_g; - `, - }); - }), - ); - - test( - "object privileges grant option addition (GRANT ... WITH GRANT OPTION)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.tg2(a int); - CREATE ROLE r_obj_g2; - GRANT SELECT ON TABLE test_schema.tg2 TO r_obj_g2; - `, - testSql: dedent` - GRANT SELECT ON TABLE test_schema.tg2 TO r_obj_g2 WITH GRANT OPTION; - `, - }); - }), - ); - - test( - "object privileges on table (revoke)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.t(a int); - CREATE ROLE r_obj; - GRANT SELECT, INSERT ON TABLE test_schema.t TO r_obj; - `, - testSql: dedent` - REVOKE INSERT ON TABLE test_schema.t FROM r_obj; - `, - }); - }), - ); - - test( - "object privileges grant option downgrade (REVOKE GRANT OPTION FOR)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.tgo(a int); - CREATE ROLE r_obj_go; - GRANT SELECT, UPDATE ON TABLE test_schema.tgo TO r_obj_go WITH GRANT OPTION; - `, - testSql: dedent` - REVOKE GRANT OPTION FOR UPDATE ON TABLE test_schema.tgo FROM r_obj_go; - `, - }); - }), - ); - - test( - "column privileges on table (grant)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.tcg_g(a int, b int); - CREATE ROLE r_col_g; - `, - testSql: dedent` - GRANT UPDATE (b) ON TABLE test_schema.tcg_g TO r_col_g; - `, - }); - }), - ); - - test( - "column privileges grant option addition", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.tcg_go(a int, b int); - CREATE ROLE r_col_go2; - GRANT UPDATE (a, b) ON TABLE test_schema.tcg_go TO r_col_go2; - `, - testSql: dedent` - GRANT UPDATE (b) ON TABLE test_schema.tcg_go TO r_col_go2 WITH GRANT OPTION; - `, - }); - }), - ); - - test( - "column privileges on table (revoke)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.tc(a int, b int, c int); - CREATE ROLE r_col; - GRANT SELECT (a, b), UPDATE (b) ON TABLE test_schema.tc TO r_col; - `, - testSql: dedent` - REVOKE UPDATE (b) ON TABLE test_schema.tc FROM r_col; - `, - }); - }), - ); - - test( - "column privileges grant option downgrade", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.tcg(a int, b int); - CREATE ROLE r_col_go; - GRANT UPDATE (a, b) ON TABLE test_schema.tcg TO r_col_go WITH GRANT OPTION; - `, - testSql: dedent` - REVOKE GRANT OPTION FOR UPDATE (b) ON TABLE test_schema.tcg FROM r_col_go; - `, - }); - }), - ); - - test( - "default privileges grant", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE ROLE r_def_g; - CREATE ROLE owner_role_g; - `, - testSql: dedent` - ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_g IN SCHEMA test_schema GRANT SELECT ON TABLES TO r_def_g; - `, - }); - }), - ); - - test( - "default privileges grant option addition", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE ROLE r_def_go_add; - CREATE ROLE owner_role_go_add; - ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_go_add IN SCHEMA test_schema GRANT SELECT ON TABLES TO r_def_go_add; - `, - testSql: dedent` - ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_go_add IN SCHEMA test_schema GRANT SELECT ON TABLES TO r_def_go_add WITH GRANT OPTION; - `, - }); - }), - ); - - test( - "default privileges in schema (revoke)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE ROLE r_def; - CREATE ROLE owner_role; - -- Create an object owned by owner_role to ensure FOR ROLE is meaningful - CREATE TABLE test_schema.bootstrap(id int); - ALTER TABLE test_schema.bootstrap OWNER TO owner_role; - ALTER DEFAULT PRIVILEGES FOR ROLE owner_role IN SCHEMA test_schema GRANT SELECT, INSERT ON TABLES TO r_def; - `, - testSql: dedent` - ALTER DEFAULT PRIVILEGES FOR ROLE owner_role IN SCHEMA test_schema REVOKE INSERT ON TABLES FROM r_def; - `, - }); - }), - ); - - test( - "default privileges grant option downgrade", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE ROLE r_def_go; - CREATE ROLE owner_role_go; - ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_go IN SCHEMA test_schema GRANT SELECT, INSERT ON TABLES TO r_def_go WITH GRANT OPTION; - `, - testSql: dedent` - ALTER DEFAULT PRIVILEGES FOR ROLE owner_role_go IN SCHEMA test_schema REVOKE GRANT OPTION FOR INSERT ON TABLES FROM r_def_go; - `, - }); - }), - ); - - test( - "role membership grant with admin option", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE ROLE parent_role_g; - CREATE ROLE child_role_g; - `, - testSql: dedent` - GRANT parent_role_g TO child_role_g WITH ADMIN OPTION; - `, - }); - }), - ); - - test( - "role membership options update (admin off)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE ROLE parent_role; - CREATE ROLE child_role; - GRANT parent_role TO child_role WITH ADMIN OPTION; - `, - testSql: dedent` - REVOKE ADMIN OPTION FOR parent_role FROM child_role; - `, - }); - }), - ); - - // Dependency ordering tests mixing object creation with grants/revokes - test( - "object privileges with object creation (ordering)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: dedent` - CREATE ROLE r_dep_g; - CREATE SCHEMA dep_s; - CREATE TABLE dep_s.dep_t(a int); - GRANT SELECT, UPDATE ON TABLE dep_s.dep_t TO r_dep_g; - `, - }); - }), - ); - - test( - "column privileges with object creation (ordering)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: dedent` - CREATE ROLE r_dep_col; - CREATE SCHEMA dep_s2; - CREATE TABLE dep_s2.dep_tc(a int, b int); - GRANT UPDATE (b) ON TABLE dep_s2.dep_tc TO r_dep_col; - `, - }); - }), - ); - - test( - "default privileges with roles and schema creation (ordering)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: dedent` - CREATE ROLE owner_dep; - CREATE ROLE grantee_dep; - CREATE SCHEMA dep_s3; - ALTER DEFAULT PRIVILEGES FOR ROLE owner_dep IN SCHEMA dep_s3 GRANT SELECT ON TABLES TO grantee_dep; - `, - }); - }), - ); - - test( - "role membership after role creation (ordering)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: dedent` - CREATE ROLE parent_dep; - CREATE ROLE child_dep; - GRANT parent_dep TO child_dep WITH ADMIN OPTION; - `, - }); - }), - ); - - test( - "mixed: create + grant, and drop unrelated object", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA drop_s; - CREATE TABLE drop_s.old_t(a int); - `, - testSql: dedent` - CREATE ROLE r_mix; - CREATE SCHEMA dep_mix; - CREATE TABLE dep_mix.t(a int); - GRANT SELECT ON TABLE dep_mix.t TO r_mix; - DROP TABLE drop_s.old_t; - `, - }); - }), - ); - - test( - "table-level privileges replaced by column-level privileges (revoke before grant ordering)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.t_priv(a int, b int, c int); - CREATE ROLE r_priv; - GRANT INSERT, UPDATE ON TABLE test_schema.t_priv TO r_priv; - `, - testSql: dedent` - REVOKE INSERT, UPDATE ON TABLE test_schema.t_priv FROM r_priv; - GRANT INSERT (a, b) ON TABLE test_schema.t_priv TO r_priv; - GRANT UPDATE (b) ON TABLE test_schema.t_priv TO r_priv; - `, - }); - }), - ); - - test( - "view-level privileges replaced by column-level privileges (revoke before grant ordering)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE VIEW test_schema.v_priv AS SELECT 1 AS a, 2 AS b, 3 AS c; - CREATE ROLE r_view_priv; - GRANT SELECT, UPDATE ON test_schema.v_priv TO r_view_priv; - `, - testSql: dedent` - REVOKE SELECT, UPDATE ON test_schema.v_priv FROM r_view_priv; - GRANT SELECT (a, b) ON test_schema.v_priv TO r_view_priv; - GRANT UPDATE (b) ON test_schema.v_priv TO r_view_priv; - `, - }); - }), - ); - - test( - "object-level privilege swap (revoke one, grant another)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.t_swap(a int); - CREATE ROLE r_swap; - GRANT INSERT ON TABLE test_schema.t_swap TO r_swap; - `, - testSql: dedent` - REVOKE INSERT ON TABLE test_schema.t_swap FROM r_swap; - GRANT UPDATE ON TABLE test_schema.t_swap TO r_swap; - `, - }); - }), - ); - - test( - "privilege changes on table with role membership (combined scenario)", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.t_combined(a int, b int); - CREATE ROLE r_parent; - CREATE ROLE r_child; - GRANT INSERT, UPDATE ON TABLE test_schema.t_combined TO r_child; - `, - testSql: dedent` - GRANT r_parent TO r_child; - REVOKE INSERT, UPDATE ON TABLE test_schema.t_combined FROM r_child; - GRANT INSERT (a) ON TABLE test_schema.t_combined TO r_child; - GRANT UPDATE (b) ON TABLE test_schema.t_combined TO r_child; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/publication-operations.test.ts b/packages/pg-delta/tests/integration/publication-operations.test.ts deleted file mode 100644 index e4254d91c..000000000 --- a/packages/pg-delta/tests/integration/publication-operations.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Change } from "../../src/core/change.types.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`publication operations (pg${pgVersion})`, () => { - test( - "create publication with table filters", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA pub_test; - CREATE TABLE pub_test.accounts ( - id SERIAL PRIMARY KEY, - status TEXT DEFAULT 'inactive', - amount INTEGER - ); - `, - testSql: ` - CREATE PUBLICATION pub_accounts_filtered - FOR TABLE pub_test.accounts (id, amount) - WHERE (status = 'active'); - `, - }); - }), - ); - - test( - "create publication for tables in schema", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA pub_schema_only; - CREATE TABLE pub_schema_only.t1 (id SERIAL PRIMARY KEY); - CREATE TABLE pub_schema_only.t2 (id SERIAL PRIMARY KEY); - `, - testSql: ` - CREATE PUBLICATION pub_schema_pub FOR TABLES IN SCHEMA pub_schema_only; - `, - }); - }), - ); - - test( - "publication dependency ordering", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA pub_dep; - `, - testSql: ` - CREATE SCHEMA pub_dep_extra; - CREATE TABLE pub_dep.source_table (id SERIAL PRIMARY KEY); - CREATE TABLE pub_dep_extra.extra_table (id SERIAL PRIMARY KEY); - CREATE PUBLICATION pub_dep_pub FOR TABLE pub_dep.source_table, TABLES IN SCHEMA pub_dep_extra; - `, - sortChangesCallback: (a: Change, b: Change) => { - // force create publication before its dependent schema and table; dependency graph should fix the order - const priority = (change: Change) => { - if ( - change.objectType === "publication" && - change.operation === "create" - ) { - return 0; - } - if ( - change.objectType === "table" && - change.operation === "create" - ) { - return 1; - } - if ( - change.objectType === "schema" && - change.operation === "create" - ) { - return 1; - } - return 2; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "drop publication", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA pub_test; - CREATE TABLE pub_test.messages (id SERIAL PRIMARY KEY, body TEXT); - CREATE PUBLICATION pub_drop_test FOR TABLE pub_test.messages; - `, - testSql: `DROP PUBLICATION pub_drop_test;`, - }); - }), - ); - - test( - "alter publication publish options", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA pub_test; - CREATE TABLE pub_test.logs (id SERIAL PRIMARY KEY, payload JSONB); - CREATE PUBLICATION pub_opts FOR TABLE pub_test.logs; - `, - testSql: ` - ALTER PUBLICATION pub_opts SET ( - publish = 'insert, update', - publish_via_partition_root = true - ); - `, - }); - }), - ); - - test( - "add and drop publication tables", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA pub_test; - CREATE TABLE pub_test.users (id SERIAL PRIMARY KEY, active BOOLEAN); - CREATE TABLE pub_test.sessions (id SERIAL PRIMARY KEY, user_id INTEGER, active BOOLEAN); - CREATE PUBLICATION pub_tables FOR TABLE pub_test.users; - `, - testSql: ` - ALTER PUBLICATION pub_tables ADD TABLE pub_test.sessions WHERE (active IS TRUE); - ALTER PUBLICATION pub_tables DROP TABLE pub_test.users; - `, - }); - }), - ); - - test( - "alter publication schema list", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA pub_a; - CREATE SCHEMA pub_b; - CREATE TABLE pub_a.alpha (id INT); - CREATE TABLE pub_b.beta (id INT); - CREATE PUBLICATION pub_schemas FOR TABLES IN SCHEMA pub_a; - `, - testSql: ` - ALTER PUBLICATION pub_schemas ADD TABLES IN SCHEMA pub_b; - `, - }); - }), - ); - - test( - "switch publication from all tables to specific list", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA pub_test; - CREATE TABLE pub_test.metrics (id SERIAL PRIMARY KEY, value INTEGER); - CREATE PUBLICATION pub_all FOR ALL TABLES; - `, - testSql: ` - DROP PUBLICATION pub_all; - CREATE PUBLICATION pub_all FOR TABLE pub_test.metrics; - `, - }); - }), - ); - - test( - "publication owner and comment changes", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE ROLE pub_owner; - CREATE SCHEMA pub_test; - CREATE TABLE pub_test.audit (id SERIAL PRIMARY KEY, payload JSONB); - CREATE PUBLICATION pub_metadata FOR TABLE pub_test.audit; - `, - testSql: ` - ALTER PUBLICATION pub_metadata OWNER TO pub_owner; - COMMENT ON PUBLICATION pub_metadata IS 'audit publication'; - `, - }); - }), - ); - - test( - "drop table from publication before dropping table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.challenge_levels ( - id BIGINT PRIMARY KEY - ); - - CREATE PUBLICATION pub_drop_order FOR TABLE public.challenge_levels; - `, - testSql: ` - ALTER PUBLICATION pub_drop_order DROP TABLE public.challenge_levels; - DROP TABLE public.challenge_levels; - `, - assertSqlStatements: (statements) => { - const relevantStatements = statements.filter( - (statement) => - statement === - "ALTER PUBLICATION pub_drop_order DROP TABLE public.challenge_levels" || - statement === "DROP TABLE public.challenge_levels", - ); - - expect(relevantStatements).toMatchInlineSnapshot(` - [ - "ALTER PUBLICATION pub_drop_order DROP TABLE public.challenge_levels", - "DROP TABLE public.challenge_levels", - ] - `); - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/remote-supabase.test.ts b/packages/pg-delta/tests/integration/remote-supabase.test.ts deleted file mode 100644 index 221f421fc..000000000 --- a/packages/pg-delta/tests/integration/remote-supabase.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { test } from "bun:test"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import type { Change } from "../../src/core/change.types.ts"; -import type { ResolvedIntegration } from "../../src/core/integrations/integration.types.ts"; -import { AlterRoleSetOptions } from "../../src/core/objects/role/changes/role.alter.ts"; -import { CreateRole } from "../../src/core/objects/role/changes/role.create.ts"; -import { - GrantRoleDefaultPrivileges, - GrantRoleMembership, - RevokeRoleDefaultPrivileges, - RevokeRoleMembership, -} from "../../src/core/objects/role/changes/role.privilege.ts"; -import { createPool } from "../../src/core/postgres-config.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { withDb } from "../utils.ts"; - -// Test to run manually. -// Don't forget to define the DATABASE_URL environment variable to connect to the remote Supabase instance. -test.skip( - "dump empty remote supabase into vanilla postgres", - withDb(17, async (db) => { - const { main } = db; - - // biome-ignore lint/style/noNonNullAssertion: DATABASE_URL is set in the environment - const remote = createPool(process.env.DATABASE_URL!); - - const [mainCatalog, branchCatalog] = await Promise.all([ - extractCatalog(main), - extractCatalog(remote), - ]); - - const changes = diffCatalogs(mainCatalog, branchCatalog); - - const options: ResolvedIntegration = { - filter: (change: Change) => { - // ALTER ROLE postgres WITH NOSUPERUSER; - const isAlterRolePostgresWithNosuperuser = - change instanceof AlterRoleSetOptions && - change.role.name === "postgres" && - change.options.includes("NOSUPERUSER"); - // Extensions that are not built-in are not supported - const isExtension = - change.objectType === "extension" && - change.extension.name !== '"uuid-ossp"'; - const extensionRoleNames = [ - "pgsodium_keyiduser", - "pgsodium_keyholder", - "pgsodium_keymaker", - ]; - // Filter out CREATE ROLE statements for extension roles - const isCreateExtensionRole = - change instanceof CreateRole && - extensionRoleNames.includes(change.role.name); - // Filter out GRANT/REVOKE membership statements involving extension roles - const isMembershipWithExtensionRole = - (change instanceof GrantRoleMembership || - change instanceof RevokeRoleMembership) && - (extensionRoleNames.includes(change.role.name) || - extensionRoleNames.includes(change.member)); - // Filter out default privilege statements involving extension roles or extension schemas - const extensionSchemaNames = ["pgsodium", "pgsodium_masks"]; - const isDefaultPrivilegeWithExtension = - (change instanceof GrantRoleDefaultPrivileges || - change instanceof RevokeRoleDefaultPrivileges) && - (extensionRoleNames.includes(change.grantee) || - (change.inSchema !== null && - extensionSchemaNames.includes(change.inSchema))); - - return ( - !isAlterRolePostgresWithNosuperuser && - !isExtension && - !isCreateExtensionRole && - !isMembershipWithExtensionRole && - !isDefaultPrivilegeWithExtension - ); - }, - }; - - let filteredChanges = options.filter - ? changes.filter((change) => - // biome-ignore lint/style/noNonNullAssertion: options.filter is guaranteed to be defined - options.filter!(change), - ) - : changes; - - if (filteredChanges.length === 0) { - return; - } - - // randomly sort the changes to test the logical sorting - filteredChanges = filteredChanges.sort(() => { - return Math.random() - 0.5; - }); - - const sortedChanges = sortChanges( - { mainCatalog, branchCatalog }, - filteredChanges, - ); - - const hasRoutineChanges = sortedChanges.some( - (change) => - change.objectType === "procedure" || change.objectType === "aggregate", - ); - const sessionConfig = hasRoutineChanges - ? ["SET check_function_bodies = false"] - : []; - - const migrationScript = `${[ - ...sessionConfig, - ...sortedChanges.map((change) => { - return options.serialize?.(change) ?? change.serialize(); - }), - ].join(";\n\n")};`; - - const reportDir = join(__dirname, "diff-reports"); - await mkdir(reportDir, { recursive: true }); - - try { - await main.query(migrationScript); - - // Verify that the migration was successful by diffing again - const [mainCatalogAfter, branchCatalogAfter] = await Promise.all([ - extractCatalog(main), - extractCatalog(remote), - ]); - - const changesAfter = diffCatalogs(mainCatalogAfter, branchCatalogAfter); - - const filteredChangesAfter = options.filter - ? changesAfter.filter((change) => - // biome-ignore lint/style/noNonNullAssertion: options.filter is guaranteed to be defined - options.filter!(change), - ) - : changesAfter; - - // Verify that there are no remaining changes - if (filteredChangesAfter.length > 0) { - // Generate second migration script for remaining changes - const sortedChangesAfter = sortChanges( - { mainCatalog: mainCatalogAfter, branchCatalog: branchCatalogAfter }, - filteredChangesAfter, - ); - - const hasRoutineChangesAfter = sortedChangesAfter.some( - (change) => - change.objectType === "procedure" || - change.objectType === "aggregate", - ); - const sessionConfigAfter = hasRoutineChangesAfter - ? ["SET check_function_bodies = false"] - : []; - - const secondMigrationScript = `${[ - ...sessionConfigAfter, - ...sortedChangesAfter.map((change) => { - return options.serialize?.(change) ?? change.serialize(); - }), - ].join(";\n\n")};`; - - // Save error report with both migration scripts - const errorFilename = `error-dump-empty-remote-supabase-into-vanilla-postgres.md`; - const errorFilepath = join(reportDir, errorFilename); - const errorContent = ` -# Migration Error Report - -## Error - -\`\`\` -Migration verification failed: Found ${filteredChangesAfter.length} remaining changes after migration -\`\`\` - -## First Migration Script - -\`\`\`sql -${migrationScript} -\`\`\` - -## Second Migration Script (Remaining Changes) - -\`\`\`sql -${secondMigrationScript} -\`\`\` -`; - await writeFile(errorFilepath, errorContent); - - throw new Error( - `Migration verification failed: Found ${filteredChangesAfter.length} remaining changes after migration`, - ); - } - - // Save success report - const successFilename = `success-dump-empty-remote-supabase-into-vanilla-postgres.md`; - const successFilepath = join(reportDir, successFilename); - const successContent = ` -# Migration Success Report - -## Migration Script - -\`\`\`sql -${migrationScript} -\`\`\` - -## Verification - -After running the migration, the databases were diffed again and verified to have no remaining changes. -`; - await writeFile(successFilepath, successContent); - } catch (error) { - // Only save error report if it hasn't been saved already (i.e., migration execution failed) - if ( - error instanceof Error && - !error.message.includes("Migration verification failed") - ) { - // Save error report - const errorFilename = `error-dump-empty-remote-supabase-into-vanilla-postgres.md`; - const errorFilepath = join(reportDir, errorFilename); - const errorContent = ` -# Migration Error Report - -## Error - -\`\`\` -${error.message} -\`\`\` - -## First Migration Script - -\`\`\`sql -${migrationScript} -\`\`\` -`; - await writeFile(errorFilepath, errorContent); - } - throw error; - } finally { - await remote.end(); - } - }), -); diff --git a/packages/pg-delta/tests/integration/rename-roundtrip.test.ts b/packages/pg-delta/tests/integration/rename-roundtrip.test.ts deleted file mode 100644 index cf0074979..000000000 --- a/packages/pg-delta/tests/integration/rename-roundtrip.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Integration tests reproducing the rename scenarios from - * https://github.com/supabase/pg-toolbelt/issues/228. - * - * pg-delta is a state-based diff: a `RENAME` and a `DROP+CREATE` produce - * identical final catalogs and pg-delta cannot tell them apart. The drop+ - * create path must therefore still produce SQL that converges the source - * with the target. These tests pin that behavior end-to-end so the planner - * does not silently regress on dependent objects (sequences owned by the - * dropped column, views referencing the dropped table/column). - */ - -import { describe, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`rename roundtrip (pg${pgVersion})`, () => { - test( - "table rename with SERIAL column converges", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.old_table ( - id serial PRIMARY KEY, - name text - ); - `, - testSql: "ALTER TABLE public.old_table RENAME TO new_table;", - }); - }), - ); - - test( - "column rename with dependent view converges", - withDb(pgVersion, async (db) => { - // CREATE OR REPLACE VIEW cannot rename existing view columns, so the - // realistic target shape (column "full_name" feeding a view column - // also named "full_name") requires drop + create on the view. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.users (id int PRIMARY KEY, name text); - CREATE VIEW public.user_list AS SELECT name FROM public.users; - `, - testSql: ` - DROP VIEW public.user_list; - ALTER TABLE public.users RENAME COLUMN name TO full_name; - CREATE VIEW public.user_list AS SELECT full_name FROM public.users; - `, - }); - }), - ); - - test( - "table rename with dependent view converges", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE TABLE public.users (id int PRIMARY KEY, name text); - CREATE VIEW public.user_count AS SELECT COUNT(*) AS n FROM public.users; - `, - testSql: "ALTER TABLE public.users RENAME TO members;", - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/rls-operations.test.ts b/packages/pg-delta/tests/integration/rls-operations.test.ts deleted file mode 100644 index 4e4df23de..000000000 --- a/packages/pg-delta/tests/integration/rls-operations.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Integration tests for PostgreSQL RLS (Row Level Security) operations. - */ - -import { describe, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - // TODO: Fix RLS and policy dependency detection issues - describe(`RLS operations (pg${pgVersion})`, () => { - test( - "enable RLS on table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE TABLE app.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL - ); - `, - testSql: ` - ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; - `, - }); - }), - ); - - test( - "disable RLS on table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE TABLE app.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL - ); - ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; - `, - testSql: ` - ALTER TABLE app.users DISABLE ROW LEVEL SECURITY; - `, - }); - }), - ); - - // TODO: Fix RLS and policy dependency detection issues - test( - "create basic RLS policy", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE TABLE app.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL - ); - ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; - `, - testSql: ` - CREATE POLICY user_isolation ON app.users - FOR ALL - TO public - USING (true); - `, - }); - }), - ); - - test( - "create policy with WITH CHECK", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA blog; - CREATE TABLE blog.posts ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - content TEXT, - author_id INTEGER NOT NULL, - published BOOLEAN DEFAULT false - ); - ALTER TABLE blog.posts ENABLE ROW LEVEL SECURITY; - `, - testSql: ` - CREATE POLICY insert_own_posts ON blog.posts - FOR INSERT - TO public - WITH CHECK (true); - `, - }); - }), - ); - - test( - "create RESTRICTIVE policy", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA secure; - CREATE TABLE secure.sensitive_data ( - id INTEGER PRIMARY KEY, - data TEXT NOT NULL, - classification TEXT NOT NULL - ); - ALTER TABLE secure.sensitive_data ENABLE ROW LEVEL SECURITY; - `, - testSql: ` - CREATE POLICY admin_only ON secure.sensitive_data - AS RESTRICTIVE - FOR SELECT - TO public - USING (true); - `, - }); - }), - ); - - test( - "drop RLS policy", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE TABLE app.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL - ); - ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; - CREATE POLICY user_isolation ON app.users - FOR ALL - TO public - USING (true); - `, - testSql: ` - DROP POLICY user_isolation ON app.users; - `, - }); - }), - ); - - test( - "multiple policies on same table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA forum; - CREATE TABLE forum.messages ( - id INTEGER PRIMARY KEY, - content TEXT NOT NULL, - author_id INTEGER NOT NULL, - thread_id INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - ALTER TABLE forum.messages ENABLE ROW LEVEL SECURITY; - `, - testSql: ` - -- Read rlsPolicy: users can read all messages - CREATE POLICY read_messages ON forum.messages - FOR SELECT - TO public - USING (true); - - -- Insert rlsPolicy: users can only insert their own messages - CREATE POLICY insert_own_messages ON forum.messages - FOR INSERT - TO public - WITH CHECK (true); - - -- Update rlsPolicy: users can only update their own messages - CREATE POLICY update_own_messages ON forum.messages - FOR UPDATE - TO public - USING (true) - WITH CHECK (true); - `, - }); - }), - ); - - test( - "complete RLS setup with policies", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA tenant; - `, - testSql: ` - -- Create a multi-tenant table - CREATE TABLE tenant.data ( - id INTEGER PRIMARY KEY, - tenant_id INTEGER NOT NULL, - content TEXT NOT NULL, - created_by INTEGER NOT NULL - ); - - -- Enable RLS - ALTER TABLE tenant.data ENABLE ROW LEVEL SECURITY; - - -- Create tenant isolation policy - CREATE POLICY tenant_isolation ON tenant.data - FOR ALL - TO public - USING (true) - WITH CHECK (true); - - -- Create admin bypass policy (PERMISSIVE - default) - CREATE POLICY admin_bypass ON tenant.data - FOR ALL - TO public - USING (true) - WITH CHECK (true); - `, - }); - }), - ); - - test( - "create basic RLS policy on simple table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE TABLE app.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL - ); - ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; - `, - testSql: ` - CREATE POLICY user_policy ON app.users - FOR ALL - TO public - USING (true); - `, - }); - }), - ); - - test( - "drop RLS policy from simple table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE TABLE app.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL - ); - ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; - CREATE POLICY user_policy ON app.users - FOR ALL - TO public - USING (true); - `, - testSql: ` - DROP POLICY user_policy ON app.users; - `, - }); - }), - ); - - test( - "replace function signature referenced by RLS policy", - withDb(pgVersion, async (db) => { - // Regression for https://github.com/supabase/pg-toolbelt/issues/230 - // The policy's USING expression keeps a pg_depend edge to the - // function. When the function signature changes (parameter list), - // pg-delta must remove the policy that references the old signature - // before dropping the old function; otherwise PostgreSQL aborts the - // migration with error 2BP01. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE FUNCTION app.check_access(user_id uuid) - RETURNS boolean AS $$ - BEGIN - RETURN true; - END; - $$ LANGUAGE plpgsql; - - CREATE TABLE app.docs ( - id integer PRIMARY KEY, - owner_id uuid, - content text - ); - ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY; - - CREATE POLICY docs_policy ON app.docs - FOR ALL - TO public - USING (app.check_access(owner_id)); - `, - testSql: ` - DROP POLICY docs_policy ON app.docs; - DROP FUNCTION app.check_access(uuid); - CREATE FUNCTION app.check_access(user_id uuid, resource_id integer) - RETURNS boolean AS $$ - BEGIN - RETURN true; - END; - $$ LANGUAGE plpgsql; - CREATE POLICY docs_policy ON app.docs - FOR ALL - TO public - USING (app.check_access(owner_id, id)); - `, - }); - }), - ); - - test( - "policy comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA app; - CREATE TABLE app.docs ( - id integer PRIMARY KEY, - owner_id integer - ); - ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY; - CREATE POLICY owner_only ON app.docs FOR ALL TO public USING (true); - `, - testSql: ` - COMMENT ON POLICY owner_only ON app.docs IS 'only owners have access'; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/role-config.test.ts b/packages/pg-delta/tests/integration/role-config.test.ts deleted file mode 100644 index 36bca726e..000000000 --- a/packages/pg-delta/tests/integration/role-config.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Integration tests for role-level GUC (pg_db_role_setting) diffing. - * - * Regression coverage for CLI-343: commands of the form - * ALTER ROLE authenticator SET pgrst.db_aggregates_enabled = 'true'; - * live in pg_db_role_setting and must be captured by the diff tool via the - * role `config` catalog field + `AlterRoleSetConfig` change. - */ - -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`role config GUC (pg${pgVersion})`, () => { - test( - "diff captures ALTER ROLE ... SET pgrst.db_aggregates_enabled", - withDbIsolated(pgVersion, async (db) => { - // Same role on both sides; branch has the pgrst GUC set. - const setup = ` - CREATE ROLE authenticator WITH NOLOGIN NOINHERIT; - `; - await db.main.query(setup); - await db.branch.query(setup); - await db.branch.query( - `ALTER ROLE authenticator SET pgrst.db_aggregates_enabled = 'true'`, - ); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - // biome-ignore lint/style/noNonNullAssertion: guarded above - const statements = flattenPlanStatements(result!.plan); - - const setStatements = statements.filter((s) => - s.includes("pgrst.db_aggregates_enabled"), - ); - expect(setStatements).toHaveLength(1); - expect(setStatements[0]).toBe( - "ALTER ROLE authenticator SET pgrst.db_aggregates_enabled TO true", - ); - - // Apply the plan and verify the catalog lines up. - const script = statements.join(";\n"); - await expect( - db.main.query(script.endsWith(";") ? script : `${script};`), - ).resolves.toBeDefined(); - - const replay = await createPlan(db.main, db.branch); - expect(replay).toBeNull(); - }), - ); - - test( - "diff emits RESET for removed setting and SET for added one", - withDbIsolated(pgVersion, async (db) => { - const setup = ` - CREATE ROLE api_role WITH NOLOGIN NOINHERIT; - `; - await db.main.query(setup); - await db.branch.query(setup); - // Main has statement_timeout; branch has lock_timeout instead. - await db.main.query(`ALTER ROLE api_role SET statement_timeout = '3s'`); - await db.branch.query(`ALTER ROLE api_role SET lock_timeout = '5s'`); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - // biome-ignore lint/style/noNonNullAssertion: guarded above - const statements = flattenPlanStatements(result!.plan); - - const resetStatements = statements.filter( - (s) => s === "ALTER ROLE api_role RESET statement_timeout", - ); - const setStatements = statements.filter( - (s) => s === "ALTER ROLE api_role SET lock_timeout TO '5s'", - ); - expect(resetStatements).toHaveLength(1); - expect(setStatements).toHaveLength(1); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/role-membership-dedup.test.ts b/packages/pg-delta/tests/integration/role-membership-dedup.test.ts deleted file mode 100644 index 90b19158a..000000000 --- a/packages/pg-delta/tests/integration/role-membership-dedup.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Integration tests for role membership deduplication and self-grant handling. - * - * In PostgreSQL 16+, pg_auth_members can have multiple rows for the same - * (roleid, member) pair with different grantors. This test verifies that - * the diff engine correctly deduplicates these memberships and does not - * produce duplicate GRANT statements. - * - * Additionally, PostgreSQL 17+ rejects GRANT ... WITH ADMIN OPTION when - * the grantee is the same as the grantor of the existing membership. - * Self-granted memberships (member === grantor) are auto-created by - * CREATE ROLE and must be skipped in diff output. - */ - -import { describe, expect, test } from "bun:test"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -/** Join plan statements into a runnable SQL script. */ -function buildScript(statements: string[]): string { - const joined = statements.join(";\n"); - return joined.endsWith(";") ? joined : `${joined};`; -} - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`role membership dedup (pg${pgVersion})`, () => { - // PG 16+ supports multiple grantors for the same role-member pair - if (pgVersion >= 16) { - test( - "no duplicate GRANT when membership has multiple grantors", - withDbIsolated(pgVersion, async (db) => { - // On the branch: create a role membership that will have two rows - // in pg_auth_members due to being granted by different grantors. - // - // 1. Create an admin role with CREATEROLE - // 2. Create a parent role and a child role - // 3. Grant the admin role membership of parent_role WITH ADMIN OPTION - // (so it can then grant it to others) - // 4. Have the superuser (postgres) grant the membership to child - // 5. Have the admin role also grant the membership to child - // This creates two pg_auth_members rows for the same (parent, child) pair. - await db.branch.query(` - CREATE ROLE admin_grantor WITH CREATEROLE; - CREATE ROLE parent_role; - CREATE ROLE child_role; - - -- Give admin_grantor the ability to grant parent_role - GRANT parent_role TO admin_grantor WITH ADMIN OPTION; - - -- First grant: by postgres (superuser/default) - GRANT parent_role TO child_role; - - -- Second grant: by admin_grantor (creates a second pg_auth_members row) - SET ROLE admin_grantor; - GRANT parent_role TO child_role; - RESET ROLE; - `); - - // Extract the branch catalog and verify the role has deduplicated members - const branchCatalog = await extractCatalog(db.branch); - const parentRole = Object.values(branchCatalog.roles).find( - (r) => r.name === "parent_role", - ); - expect(parentRole).toBeDefined(); - // child_role and admin_grantor are members, but child_role should not - // be duplicated despite having two grantors - const childMembers = parentRole?.members.filter( - (m) => m.member === "child_role", - ); - expect(childMembers).toHaveLength(1); - - // Now create a plan from empty main to branch with the roles - // The plan should contain exactly one GRANT for child_role -> parent_role - const result = await createPlan(db.main, db.branch); - expect(flattenPlanStatements(result!.plan)).toMatchInlineSnapshot(` - [ - "CREATE ROLE admin_grantor WITH CREATEROLE", - "CREATE ROLE child_role", - "CREATE ROLE parent_role", - "GRANT parent_role TO admin_grantor WITH ADMIN OPTION", - "GRANT parent_role TO child_role", - ] - `); - }), - ); - - test( - "no diff when both sides have same membership from different grantors", - withDbIsolated(pgVersion, async (db) => { - // Setup: same role structure on both main and branch - const setup = ` - CREATE ROLE admin_grantor WITH CREATEROLE; - CREATE ROLE parent_role; - CREATE ROLE child_role; - - -- Give admin_grantor the ability to grant parent_role - GRANT parent_role TO admin_grantor WITH ADMIN OPTION; - `; - - await db.main.query(setup); - await db.branch.query(setup); - - // Main: grant by postgres only - await db.main.query(` - GRANT parent_role TO child_role; - `); - - // Branch: grant by both postgres and admin_grantor - await db.branch.query(` - GRANT parent_role TO child_role; - SET ROLE admin_grantor; - GRANT parent_role TO child_role; - RESET ROLE; - `); - - // Plan should have no changes for the parent_role -> child_role - // membership because both sides have the same effective membership - // after deduplication. The plan may be null (no changes at all) or - // non-null with unrelated changes — either way, there should be no - // GRANT/REVOKE for parent_role TO/FROM child_role. - const result = await createPlan(db.main, db.branch); - expect(result).toBeNull(); - }), - ); - } - }); - - describe(`role self-grant skip (pg${pgVersion})`, () => { - test( - "GRANT role TO postgres WITH ADMIN OPTION is skipped for creator-granted membership", - withDbIsolated(pgVersion, async (db) => { - // Create a role on branch only. When postgres creates a role, PG - // automatically adds a pg_auth_members row where postgres is both - // the member and the grantor (with admin_option=true on PG 16+). - // The diff should NOT emit "GRANT developer TO postgres WITH ADMIN OPTION" - // because that would fail with: - // ERROR: ADMIN option cannot be granted back to your own grantor - await db.branch.query(` - CREATE ROLE developer; - `); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - // biome-ignore lint/style/noNonNullAssertion: guarded by expect above - const statements = flattenPlanStatements(result!.plan); - - // Should contain CREATE ROLE but NOT any GRANT to postgres - expect(statements).toContain("CREATE ROLE developer"); - const grantToPostgres = statements.filter((s) => - s.includes("GRANT developer TO postgres"), - ); - expect(grantToPostgres).toHaveLength(0); - - // Verify the plan can actually be applied without errors - // (this is the core of the bug: the SQL must run as postgres) - const script = buildScript(statements); - await expect(db.main.query(script)).resolves.toBeDefined(); - }), - ); - - test( - "GRANT role TO child_role works when child_role is not the grantor", - withDbIsolated(pgVersion, async (db) => { - // Normal case: granting a role to a different user should work fine - await db.branch.query(` - CREATE ROLE parent_role; - CREATE ROLE child_role; - GRANT parent_role TO child_role; - `); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - // biome-ignore lint/style/noNonNullAssertion: guarded by expect above - const statements = flattenPlanStatements(result!.plan); - - // Should contain both CREATE ROLEs and the GRANT - expect(statements).toContain("CREATE ROLE child_role"); - expect(statements).toContain("CREATE ROLE parent_role"); - const grantStatements = statements.filter((s) => - s.includes("GRANT parent_role TO child_role"), - ); - expect(grantStatements).toHaveLength(1); - - // Verify the plan can be applied - const script = buildScript(statements); - await expect(db.main.query(script)).resolves.toBeDefined(); - }), - ); - - test( - "role with admin option to non-self member works correctly", - withDbIsolated(pgVersion, async (db) => { - // Grant with admin option to a non-self member should be emitted - await db.branch.query(` - CREATE ROLE parent_role; - CREATE ROLE child_role; - GRANT parent_role TO child_role WITH ADMIN OPTION; - `); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - // biome-ignore lint/style/noNonNullAssertion: guarded by expect above - const statements = flattenPlanStatements(result!.plan); - - // Should contain GRANT WITH ADMIN OPTION - const grantStatements = statements.filter((s) => - s.includes("GRANT parent_role TO child_role WITH ADMIN OPTION"), - ); - expect(grantStatements).toHaveLength(1); - - // Verify the plan can be applied - const script = buildScript(statements); - await expect(db.main.query(script)).resolves.toBeDefined(); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/role-option.test.ts b/packages/pg-delta/tests/integration/role-option.test.ts deleted file mode 100644 index fd606ffb8..000000000 --- a/packages/pg-delta/tests/integration/role-option.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Integration tests for the role option in createPlan. - * Verifies that SET ROLE is properly applied on pool connections. - */ - -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`role option (pg${pgVersion})`, () => { - test( - "plan contains SET ROLE when role option is provided", - withDb(pgVersion, async (db) => { - // Setup: create a schema in branch only - await db.branch.query("CREATE SCHEMA test_schema"); - - // Create plan with role option - const result = await createPlan(db.main, db.branch, { - role: "test_role", - }); - - expect(result).not.toBeNull(); - expect(flattenPlanStatements(result!.plan)[0]).toBe( - 'SET ROLE "test_role"', - ); - expect(result?.plan.role).toBe("test_role"); - }), - ); - - test( - "extraction uses the specified role", - withDbIsolated(pgVersion, async (db) => { - // Create a role on both containers (isolated containers don't share roles) - await db.main.query(` - CREATE ROLE extraction_test_role WITH NOLOGIN; - CREATE SCHEMA test_schema; - GRANT USAGE ON SCHEMA test_schema TO extraction_test_role; - GRANT CREATE ON SCHEMA test_schema TO extraction_test_role; - `); - await db.branch.query(` - CREATE ROLE extraction_test_role WITH NOLOGIN; - CREATE SCHEMA test_schema; - GRANT USAGE ON SCHEMA test_schema TO extraction_test_role; - GRANT CREATE ON SCHEMA test_schema TO extraction_test_role; - `); - - // Create a table in branch as the test role - await db.branch.query(` - SET ROLE extraction_test_role; - CREATE TABLE test_schema.role_owned_table (id integer); - RESET ROLE; - `); - - // Create plan with role option - should see the table owned by the role - const result = await createPlan(db.main, db.branch, { - role: "extraction_test_role", - }); - - expect(result).not.toBeNull(); - // The plan should include creating the table - const createTableStatement = flattenPlanStatements(result!.plan).find( - (s) => s.includes("CREATE TABLE"), - ); - expect(createTableStatement).toBeDefined(); - expect(createTableStatement).toContain("test_schema"); - expect(createTableStatement).toContain("role_owned_table"); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/roundtrip.ts b/packages/pg-delta/tests/integration/roundtrip.ts deleted file mode 100644 index 89756c542..000000000 --- a/packages/pg-delta/tests/integration/roundtrip.ts +++ /dev/null @@ -1,596 +0,0 @@ -/** - * Test configuration and utilities for pg-delta integration tests. - */ - -import { expect } from "bun:test"; -import { inspect } from "node:util"; -import debug from "debug"; -import type { Pool } from "pg"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { type Catalog, extractCatalog } from "../../src/core/catalog.model.ts"; -import type { Change } from "../../src/core/change.types.ts"; -import { extractVersion } from "../../src/core/context.ts"; -import { applyDeclarativeSchema } from "../../src/core/declarative-apply/index.ts"; -import type { PgDepend } from "../../src/core/depend.ts"; -import { - type ExportOptions, - exportDeclarativeSchema, -} from "../../src/core/export/index.ts"; -import type { DeclarativeSchemaOutput } from "../../src/core/export/types.ts"; -import { - buildPlanScopeFingerprint, - hashStableIds, -} from "../../src/core/fingerprint.ts"; -import { - type Integration, - type ResolvedIntegration, - resolveIntegration, -} from "../../src/core/integrations/integration.types.ts"; -import { applyPlan } from "../../src/core/plan/apply.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { - POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG, - type PostgresVersion, -} from "../constants.ts"; -import { containerManager } from "../container-manager.js"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; -import type { Plan } from "../../src/core/plan/types.ts"; - -const debugTest = debug("pg-delta:test"); -const debugDependencies = debug("pg-delta:dependencies"); - -interface RoundtripTestOptions { - /** Pool for the main (source) database. */ - mainSession: Pool; - /** Pool for the branch (target) database. */ - branchSession: Pool; - /** Optional test name for identification. */ - name?: string; - /** SQL run on both databases before applying testSql to establish baseline schema. */ - initialSetup?: string; - /** SQL run on branch only; diff is generated from main to this state. */ - testSql?: string; - /** Human-readable description of the test. */ - description?: string; - /** Comparator to force a deterministic order of changes; when set, random sort is skipped. */ - sortChangesCallback?: (a: Change, b: Change) => number; - /** - * Terms that must appear in the generated SQL, or "same-as-test-sql" to match testSql. - * When defined, random sorting of changes is skipped to ensure deterministic order. - */ - expectedSqlTerms?: string[] | "same-as-test-sql"; - /** Optional custom assertion for generated SQL statements (e.g. inline snapshots). */ - assertSqlStatements?: (sqlStatements: string[]) => void; - /** Optional assertion on the generated plan (e.g. migration unit shape). */ - assertPlan?: (plan: Plan) => void; - /** Dependencies that must be present in the main catalog after the roundtrip. */ - expectedMainDependencies?: PgDepend[]; - /** Dependencies that must be present in the branch catalog. */ - expectedBranchDependencies?: PgDepend[]; - /** Changes in the order they should appear in the generated migration; validates dependency ordering. */ - expectedOperationOrder?: Change[]; - /** Integration used for filtering and SQL serialization (e.g. supabase). */ - integration?: Integration; -} - -export interface DeclarativeExportTestOptions { - /** Pool for the main (source) database. */ - mainSession: Pool; - /** Pool for the branch (target) database. */ - branchSession: Pool; - /** SQL run on both databases before applying testSql to establish baseline schema. */ - initialSetup?: string; - /** SQL run on branch only; declarative export is run from this state. */ - testSql?: string; - /** Integration used for filtering and SQL serialization (e.g. supabase). */ - integration?: Integration; - /** Additional options for declarative export (integration is set separately). */ - exportOptions?: Omit; -} - -/** - * Test that schema extraction, SQL generation, and re-execution produces - * functionally identical pg_catalog data. - * - * This validates the core roundtrip fidelity: - * 1. Extract catalog from main database (mainSession) - * 2. Extract catalog from branch database (branchSession) - * 3. Generate migration from main to branch - * 4. Apply migration to main database - * 5. Verify main and branch catalogs are now semantically identical - */ -export async function roundtripFidelityTest( - options: RoundtripTestOptions, -): Promise { - const { - mainSession, - branchSession, - initialSetup, - testSql, - expectedSqlTerms, - assertSqlStatements, - expectedMainDependencies, - expectedBranchDependencies, - expectedOperationOrder, - sortChangesCallback, - integration, - } = options; - // Silent warnings from PostgreSQL such as subscriptions created without a slot. - const sessionConfig = ["SET LOCAL client_min_messages = error"]; - // Set up initial schema in BOTH databases - if (initialSetup) { - await expect( - mainSession.query([...sessionConfig, initialSetup].join(";\n\n")), - ).resolves.toBeDefined(); - await expect( - branchSession.query([...sessionConfig, initialSetup].join(";\n\n")), - ).resolves.toBeDefined(); - } - - // Execute the test SQL in the BRANCH database only - if (testSql) { - await expect( - branchSession.query([...sessionConfig, testSql].join(";\n\n")), - ).resolves.toBeDefined(); - } - - // Extract catalogs from both databases - debugTest("mainCatalog: "); - const mainCatalog = await extractCatalog(mainSession); - debugTest("branchCatalog: "); - const branchCatalog = await extractCatalog(branchSession); - - if (expectedMainDependencies && expectedBranchDependencies) { - validateDependencies( - mainCatalog, - branchCatalog, - expectedMainDependencies, - expectedBranchDependencies, - ); - } - - // Generate plan using core workflow - const planResult = await createPlan(mainSession, branchSession, { - filter: integration?.filter, - serialize: integration?.serialize, - }); - if (!planResult) { - return; - } - - let { plan, sortedChanges } = planResult; - const resolvedIntegration = integration - ? resolveIntegration(integration) - : {}; - const integrationFilter = resolvedIntegration?.filter; - - // Optional pre-sort for deterministic tie-breaking in tests - if (sortChangesCallback) { - sortedChanges = [...sortedChanges].sort(sortChangesCallback); - } - - debugDependencies("\n==== Sorted Changes ===="); - for (let i = 0; i < sortedChanges.length; i++) { - const change = sortedChanges[i]; - debugDependencies( - "[%d] %s creates: %O requires: %O", - i, - change.constructor.name, - change.creates, - change.requires ?? [], - ); - } - debugDependencies("==== End Sorted Changes ====\n"); - - if (expectedOperationOrder) { - validateOperationOrder(sortedChanges, expectedOperationOrder); - } - - const hasRoutineChanges = sortedChanges.some( - (change) => - change.objectType === "procedure" || change.objectType === "aggregate", - ); - const { hash: targetFingerprint, stableIds } = buildPlanScopeFingerprint( - branchCatalog, - sortedChanges, - ); - const migrationSessionConfig = hasRoutineChanges - ? ["SET check_function_bodies = false"] - : []; - - const sqlStatements = flattenPlanStatements(plan); - const migrationScript = `${[...migrationSessionConfig, ...sqlStatements].join( - ";\n\n", - )};`; - - // Verify expected terms are the same as the generated SQL - if (expectedSqlTerms) { - if (expectedSqlTerms === "same-as-test-sql") { - // biome-ignore lint/style/noNonNullAssertion: testSql is guaranteed defined when expectedSqlTerms === "same-as-test-sql" - expect(migrationScript).toStrictEqual(testSql!); - } else { - expect(sqlStatements).toStrictEqual(expectedSqlTerms); - } - } - - if (assertSqlStatements) { - assertSqlStatements(sqlStatements); - } - - if (options.assertPlan) { - options.assertPlan(plan); - } - - debugTest("migrationScript: %s", migrationScript); - - // Apply migration using core apply - const applyResult = await applyPlan(plan, mainSession, branchSession, { - verifyPostApply: true, - }); - if (applyResult.status !== "applied") { - const prettyApplyResult = inspect(applyResult, { - depth: null, - colors: false, - compact: false, - breakLength: 120, - }); - throw new Error(`Apply failed:\n${prettyApplyResult}`, { - cause: applyResult, - }); - } - - const debugMainCatalogAfter = await extractCatalog(mainSession); - const postApplyFingerprint = hashStableIds(debugMainCatalogAfter, stableIds); - - if (applyResult.warnings?.length) { - console.error( - "[roundtrip] apply warnings: %o\n[targetFingerprint=%s postApplyFingerprint=%s]", - applyResult.warnings, - targetFingerprint, - postApplyFingerprint, - ); - } - - if (postApplyFingerprint !== targetFingerprint) { - const remainingChanges = diffCatalogs(debugMainCatalogAfter, branchCatalog); - const sortedRemaining = sortChanges( - { mainCatalog: debugMainCatalogAfter, branchCatalog }, - remainingChanges, - ); - const remainingSql = sortedRemaining.map((c) => c.serialize()).join(";\n"); - const remainingSummary = sortedRemaining.map((c) => ({ - change: c.constructor.name, - op: c.operation, - objectType: c.objectType, - scope: (c as { scope?: string }).scope ?? "object", - creates: c.creates, - drops: c.drops, - requires: c.requires, - })); - console.error( - "[roundtrip] fingerprint mismatch\n target=%s\n post=%s\n remainingSummary=%o\n remainingSql=%s", - targetFingerprint, - postApplyFingerprint, - remainingSummary, - remainingSql, - ); - } - - expect(postApplyFingerprint).toStrictEqual(targetFingerprint); - expect(applyResult.warnings ?? []).toEqual([]); - - await verifyNoRemainingChanges( - mainSession, - branchCatalog, - integrationFilter, - migrationScript, - ); -} - -export async function testDeclarativeExport( - options: DeclarativeExportTestOptions, -): Promise { - const { - mainSession, - branchSession, - initialSetup, - testSql, - integration, - exportOptions, - } = options; - // Silent warnings from PostgreSQL such as subscriptions created without a slot. - const sessionConfig = ["SET LOCAL client_min_messages = error"]; - - if (initialSetup) { - await expect( - mainSession.query([...sessionConfig, initialSetup].join(";\n\n")), - ).resolves.toBeDefined(); - await expect( - branchSession.query([...sessionConfig, initialSetup].join(";\n\n")), - ).resolves.toBeDefined(); - } - - if (testSql) { - await expect( - branchSession.query([...sessionConfig, testSql].join(";\n\n")), - ).resolves.toBeDefined(); - } - - // Use createPlan to get the plan result, then export declarative schema - const planResult = await createPlan(mainSession, branchSession, { - filter: integration?.filter, - serialize: integration?.serialize, - }); - - if (!planResult) { - throw new Error("No changes detected - cannot test declarative export"); - } - - const { sortedChanges, ctx } = planResult; - const { branchCatalog } = ctx; - const resolvedIntegration = integration - ? resolveIntegration(integration) - : {}; - const integrationFilter = resolvedIntegration?.filter; - - const output = exportDeclarativeSchema(planResult, { - integration, - ...exportOptions, - }); - - expect(output.version).toBe(1); - expect(output.mode).toBe("declarative"); - expect(output.files).toBeInstanceOf(Array); - expect(output.source.fingerprint).toBeTruthy(); - expect(output.target.fingerprint).toBeTruthy(); - - const pgVersion = await getPostgresMajorVersion(mainSession); - const { main: testPool, cleanup } = - await containerManager.getDatabasePair(pgVersion); - - try { - await testPool.query("SET client_min_messages = error"); - - if (initialSetup) { - await expect( - testPool.query([...sessionConfig, initialSetup].join(";\n\n")), - ).resolves.toBeDefined(); - } - - const hasRoutineChanges = sortedChanges.some( - (change) => - change.objectType === "procedure" || change.objectType === "aggregate", - ); - if (hasRoutineChanges) { - await expect( - testPool.query("SET check_function_bodies = false"), - ).resolves.toBeDefined(); - } - - // Use declarative-apply to sort the output files and apply them to the database - const applyResult = await applyDeclarativeSchema({ - content: output.files.map((file) => ({ - filePath: file.path, - sql: file.sql, - })), - pool: testPool, - disableCheckFunctionBodies: true, - }); - if (applyResult.apply.status !== "success") { - throw new Error("Declarative apply failed", { cause: applyResult }); - } - - const finalCatalog = await extractCatalog(testPool); - const exportChanges = sortedChanges; - const { hash: finalFingerprint } = buildPlanScopeFingerprint( - finalCatalog, - exportChanges, - ); - - if (finalFingerprint !== output.target.fingerprint) { - const remainingChanges = diffCatalogs(finalCatalog, branchCatalog); - const remainingFiltered = integrationFilter - ? remainingChanges.filter((change) => integrationFilter(change)) - : remainingChanges; - const sortedRemaining = sortChanges( - { mainCatalog: finalCatalog, branchCatalog }, - remainingFiltered, - ); - const remainingSql = sortedRemaining - .map((c) => c.serialize()) - .join(";\n"); - const remainingSummary = sortedRemaining.map((c) => ({ - change: c.constructor.name, - op: c.operation, - objectType: c.objectType, - scope: (c as { scope?: string }).scope ?? "object", - creates: c.creates, - drops: c.drops, - requires: c.requires, - })); - console.error( - "[declarative-export] fingerprint mismatch\n target=%s\n post=%s\n remainingSummary=%o\n remainingSql=%s", - output.target.fingerprint, - finalFingerprint, - remainingSummary, - remainingSql, - ); - } - - expect(finalFingerprint).toStrictEqual(output.target.fingerprint); - } finally { - await cleanup(); - } - - return output; -} - -async function verifyNoRemainingChanges( - mainSession: Pool, - branchCatalog: Catalog, - integrationFilter: ResolvedIntegration["filter"] | undefined, - migrationScript: string, -): Promise { - debugTest("mainCatalogAfter: "); - const mainCatalogAfter = await extractCatalog(mainSession); - - // Verify semantic equality by diffing the catalogs again - // This ensures the migration produced a database state identical to the target - const changesAfter = diffCatalogs(mainCatalogAfter, branchCatalog); - - const filteredChangesAfter = integrationFilter - ? changesAfter.filter((change) => integrationFilter(change)) - : changesAfter; - - if (filteredChangesAfter.length === 0) { - return; - } - - // Sort the remaining changes for better debugging - const sortedChangesAfter = sortChanges( - { mainCatalog: mainCatalogAfter, branchCatalog }, - filteredChangesAfter, - ); - - const remainingSqlStatements = sortedChangesAfter.map((change) => - change.serialize(), - ); - const remainingMigrationScript = remainingSqlStatements.join(";\n\n"); - - // Build detailed error message - const changeDetails = sortedChangesAfter.map((change, idx) => { - const parts = [ - `${idx + 1}. ${change.constructor.name}`, - ` Operation: ${change.operation}`, - ` Object Type: ${change.objectType}`, - ` Scope: ${change.scope || "object"}`, - ]; - - if (change.creates.length > 0) { - parts.push(` Creates: ${change.creates.join(", ")}`); - } - if (change.drops.length > 0) { - parts.push(` Drops: ${change.drops.join(", ")}`); - } - if (change.requires.length > 0) { - parts.push(` Requires: ${change.requires.join(", ")}`); - } - - return parts.join("\n"); - }); - - const errorMessage = [ - `Migration verification failed: Found ${changesAfter.length} remaining changes after migration`, - "", - "=== Remaining Changes ===", - ...changeDetails, - "", - "=== SQL for Remaining Changes ===", - remainingMigrationScript || "(no SQL generated)", - "", - "=== Original Migration Script ===", - migrationScript || "(no migration script)", - ].join("\n"); - - throw new Error(errorMessage); -} - -async function getPostgresMajorVersion( - session: Pool, -): Promise { - const versionNum = await extractVersion(session); - const major = Math.floor(versionNum / 10000) as PostgresVersion; - if (!POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[major]) { - throw new Error( - `Unsupported PostgreSQL version: ${versionNum} (major=${major})`, - ); - } - return major; -} - -function getDependencyStableId(depend: PgDepend): string { - return `${depend.dependent_stable_id} -> ${depend.referenced_stable_id} :: ${depend.deptype}`; -} - -function validateDependencies( - mainCatalog: Catalog, - branchCatalog: Catalog, - expectedMainDependencies: PgDepend[], - expectedBranchDependencies: PgDepend[], -) { - const mainDependencies = new Set( - mainCatalog.depends.reduce((acc, depend) => { - if ( - !depend.dependent_stable_id.startsWith("unknown") && - !depend.referenced_stable_id.startsWith("unknown") - ) { - acc.add(getDependencyStableId(depend)); - } - return acc; - }, new Set()), - ); - const branchDependencies = new Set( - branchCatalog.depends.reduce((acc, depend) => { - if ( - !depend.dependent_stable_id.startsWith("unknown") && - !depend.referenced_stable_id.startsWith("unknown") - ) { - acc.add(getDependencyStableId(depend)); - } - return acc; - }, new Set()), - ); - - const filteredMainDeps = Array.from(mainDependencies).filter( - (dep) => - !dep.includes("pg_") && - !dep.includes("information_schema") && - !dep.includes("pg_toast") && - !dep.includes("storage") && - !dep.includes("auth") && - !dep.includes("secrets") && - !dep.includes("vault") && - !dep.includes("extensions") && - !dep.includes("realtime") && - !dep.includes("graphql") && - !dep.includes("defaultAcl"), - ); - const filteredBranchDeps = Array.from(branchDependencies).filter( - (dep) => - !dep.includes("pg_") && - !dep.includes("information_schema") && - !dep.includes("pg_toast") && - !dep.includes("storage") && - !dep.includes("auth") && - !dep.includes("secrets") && - !dep.includes("vault") && - !dep.includes("extensions") && - !dep.includes("realtime") && - !dep.includes("graphql") && - !dep.includes("defaultAcl"), - ); - debugTest("mainDependencies: %O", filteredMainDeps); - debugTest("branchDependencies: %O", filteredBranchDeps); - - // Extract dependencies from main catalog - const expectedMainSet = new Set( - expectedMainDependencies.map(getDependencyStableId), - ); - const expectedBranchSet = new Set( - expectedBranchDependencies.map(getDependencyStableId), - ); - // Validate main dependencies - const mainMissing = expectedMainSet.difference(mainDependencies); - const branchMissing = expectedBranchSet.difference(branchDependencies); - - expect(mainMissing).toEqual(new Set()); - expect(branchMissing).toEqual(new Set()); -} - -function validateOperationOrder( - changes: Change[], - expectedOperationOrder: Change[], -) { - expect(changes).toStrictEqual(expectedOperationOrder); -} diff --git a/packages/pg-delta/tests/integration/rule-operations.test.ts b/packages/pg-delta/tests/integration/rule-operations.test.ts deleted file mode 100644 index 817f9a571..000000000 --- a/packages/pg-delta/tests/integration/rule-operations.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import type { Change } from "../../src/core/change.types.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`rule operations (pg${pgVersion})`, () => { - test( - "create rule", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - balance numeric NOT NULL DEFAULT 0 - ); - `, - testSql: dedent` - CREATE RULE prevent_negative_balance AS - ON INSERT TO test_schema.accounts - WHERE NEW.balance < 0 - DO INSTEAD NOTHING; - `, - }); - }), - ); - - test( - "drop rule", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - balance numeric NOT NULL DEFAULT 0 - ); - CREATE RULE prevent_negative_balance AS - ON INSERT TO test_schema.accounts - WHERE NEW.balance < 0 - DO INSTEAD NOTHING; - `, - testSql: `DROP RULE prevent_negative_balance ON test_schema.accounts;`, - }); - }), - ); - - test( - "replace rule definition", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - balance numeric NOT NULL DEFAULT 0 - ); - CREATE TABLE test_schema.rule_events ( - message text NOT NULL, - created_at timestamptz DEFAULT now() - ); - CREATE RULE prevent_negative_balance AS - ON INSERT TO test_schema.accounts - WHERE NEW.balance < 0 - DO INSTEAD NOTHING; - `, - testSql: dedent` - CREATE OR REPLACE RULE prevent_negative_balance AS - ON INSERT TO test_schema.accounts - WHERE NEW.balance < 0 - DO ALSO INSERT INTO test_schema.rule_events (message) - VALUES ('negative balance attempt detected'); - `, - }); - }), - ); - - test( - "rule comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - balance numeric NOT NULL DEFAULT 0 - ); - CREATE RULE prevent_negative_balance AS - ON INSERT TO test_schema.accounts - WHERE NEW.balance < 0 - DO INSTEAD NOTHING; - `, - testSql: `COMMENT ON RULE prevent_negative_balance ON test_schema.accounts IS 'prevent inserting negative balances';`, - }); - }), - ); - - test( - "rule enabled state", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - balance numeric NOT NULL DEFAULT 0 - ); - CREATE RULE prevent_negative_balance AS - ON INSERT TO test_schema.accounts - WHERE NEW.balance < 0 - DO INSTEAD NOTHING; - `, - testSql: `ALTER TABLE test_schema.accounts DISABLE RULE prevent_negative_balance;`, - }); - }), - ); - - test( - "rule enable always state", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - balance numeric NOT NULL DEFAULT 0 - ); - CREATE RULE prevent_negative_balance AS - ON INSERT TO test_schema.accounts - WHERE NEW.balance < 0 - DO INSTEAD NOTHING; - ALTER TABLE test_schema.accounts DISABLE RULE prevent_negative_balance; - `, - testSql: `ALTER TABLE test_schema.accounts ENABLE ALWAYS RULE prevent_negative_balance;`, - }); - }), - ); - - test( - "rule creation depends on newly added column", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - note text - ); - `, - testSql: dedent` - ALTER TABLE test_schema.accounts - ADD COLUMN flagged boolean; - - CREATE RULE prevent_flagged_insert AS - ON INSERT TO test_schema.accounts - WHERE NEW.flagged - DO INSTEAD NOTHING; - `, - sortChangesCallback: (a, b) => { - // force create rule before alter table to test that we track the dependency rule -> column - const priority = (change: Change) => { - if ( - change.objectType === "rule" && - change.operation === "create" - ) { - return 0; - } - if ( - change.objectType === "table" && - change.operation === "alter" - ) { - return 1; - } - return 2; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/security-label-filter.test.ts b/packages/pg-delta/tests/integration/security-label-filter.test.ts deleted file mode 100644 index 223714d97..000000000 --- a/packages/pg-delta/tests/integration/security-label-filter.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { shouldSkipDummySeclabelBuild } from "../postgres-alpine.ts"; -import { withDb } from "../utils.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -const DUMMY_PROVIDER_SETUP = `CREATE EXTENSION IF NOT EXISTS dummy_seclabel;`; - -const SKIP_SECLABEL_TESTS = shouldSkipDummySeclabelBuild(); - -for (const pgVersion of POSTGRES_VERSIONS) { - describe.skipIf(SKIP_SECLABEL_TESTS)( - `security label filter DSL (pg${pgVersion})`, - () => { - test( - "excludes all security_label changes when scope is negated", - withDb(pgVersion, async (db) => { - await Promise.all([ - db.main.query(DUMMY_PROVIDER_SETUP), - db.branch.query(DUMMY_PROVIDER_SETUP), - ]); - await Promise.all([ - db.main.query(`CREATE SCHEMA labeled;`), - db.branch.query(` - CREATE SCHEMA labeled; - SECURITY LABEL FOR dummy ON SCHEMA labeled IS 'classified'; - `), - ]); - - const result = await createPlan(db.main, db.branch, { - filter: { not: { scope: "security_label" } }, - }); - - const sql = result - ? flattenPlanStatements(result.plan).join(";\n") - : ""; - expect(sql).not.toContain("SECURITY LABEL"); - }), - ); - - test( - "provider filter excludes only matching provider", - withDb(pgVersion, async (db) => { - await Promise.all([ - db.main.query(DUMMY_PROVIDER_SETUP), - db.branch.query(DUMMY_PROVIDER_SETUP), - ]); - await Promise.all([ - db.main.query(`CREATE SCHEMA labeled_provider_filter;`), - db.branch.query(` - CREATE SCHEMA labeled_provider_filter; - SECURITY LABEL FOR dummy ON SCHEMA labeled_provider_filter IS 'classified'; - `), - ]); - - const result = await createPlan(db.main, db.branch, { - filter: { not: { scope: "security_label", provider: "dummy" } }, - }); - - const sql = result - ? flattenPlanStatements(result.plan).join(";\n") - : ""; - expect(sql).not.toContain("SECURITY LABEL FOR dummy"); - }), - ); - }, - ); -} diff --git a/packages/pg-delta/tests/integration/security-label-operations.test.ts b/packages/pg-delta/tests/integration/security-label-operations.test.ts deleted file mode 100644 index a8cfe3cec..000000000 --- a/packages/pg-delta/tests/integration/security-label-operations.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { describe, test } from "bun:test"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { shouldSkipDummySeclabelBuild } from "../postgres-alpine.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -/** - * Security-label integration tests use PostgreSQL's `dummy_seclabel` contrib - * module, which registers the "dummy" provider. It ships with both the - * official alpine images and the Supabase PostgreSQL images used in CI. - * - * When the sandbox escape hatch (`PGDELTA_SKIP_DUMMY_SECLABEL_BUILD`) is set, - * `buildPostgresTestImage` falls back to the stock postgres-alpine image, - * which does not ship dummy_seclabel — so this whole file skips. Coverage - * is preserved in CI, where the prebuilt `pg-delta-test:*` image is used. - */ -const DUMMY_PROVIDER_SETUP = `CREATE EXTENSION IF NOT EXISTS dummy_seclabel;`; - -const SKIP_SECLABEL_TESTS = shouldSkipDummySeclabelBuild(); - -for (const pgVersion of POSTGRES_VERSIONS) { - describe.skipIf(SKIP_SECLABEL_TESTS)( - `security labels on tables and columns (pg${pgVersion})`, - () => { - test( - "label on new table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: DUMMY_PROVIDER_SETUP, - testSql: ` - CREATE TABLE public.t1 (id integer PRIMARY KEY); - SECURITY LABEL FOR dummy ON TABLE public.t1 IS 'classified'; - `, - }); - }), - ); - - test( - "label on column", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE TABLE public.t1 (id integer PRIMARY KEY, email text); - `, - testSql: ` - SECURITY LABEL FOR dummy ON COLUMN public.t1.email IS 'classified'; - `, - }); - }), - ); - - test( - "change table + column labels together", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE TABLE public.t1 (id integer PRIMARY KEY, email text); - SECURITY LABEL FOR dummy ON TABLE public.t1 IS 'secret'; - SECURITY LABEL FOR dummy ON COLUMN public.t1.email IS 'secret'; - `, - testSql: ` - SECURITY LABEL FOR dummy ON TABLE public.t1 IS 'classified'; - SECURITY LABEL FOR dummy ON COLUMN public.t1.email IS 'unclassified'; - `, - }); - }), - ); - - test( - "drop column label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE TABLE public.t1 (id integer PRIMARY KEY, email text); - SECURITY LABEL FOR dummy ON COLUMN public.t1.email IS 'secret'; - `, - testSql: ` - SECURITY LABEL FOR dummy ON COLUMN public.t1.email IS NULL; - `, - }); - }), - ); - }, - ); - - describe.skipIf(SKIP_SECLABEL_TESTS)( - `security labels on other object types (pg${pgVersion})`, - () => { - test( - "view label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE TABLE public.base (id integer); - CREATE VIEW public.v AS SELECT id FROM public.base; - `, - testSql: ` - SECURITY LABEL FOR dummy ON VIEW public.v IS 'classified'; - `, - }); - }), - ); - - test( - "materialized view label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE TABLE public.base (id integer); - CREATE MATERIALIZED VIEW public.mv AS SELECT id FROM public.base; - `, - testSql: ` - SECURITY LABEL FOR dummy ON MATERIALIZED VIEW public.mv IS 'classified'; - `, - }); - }), - ); - - test( - "sequence label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE SEQUENCE public.s1; - `, - testSql: ` - SECURITY LABEL FOR dummy ON SEQUENCE public.s1 IS 'classified'; - `, - }); - }), - ); - - test( - "domain label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE DOMAIN public.non_empty_text AS text CHECK (VALUE <> ''); - `, - testSql: ` - SECURITY LABEL FOR dummy ON DOMAIN public.non_empty_text IS 'classified'; - `, - }); - }), - ); - - test( - "enum (TYPE) label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE TYPE public.status AS ENUM ('active', 'inactive'); - `, - testSql: ` - SECURITY LABEL FOR dummy ON TYPE public.status IS 'classified'; - `, - }); - }), - ); - - test( - "composite TYPE label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE TYPE public.full_name AS (first text, last text); - `, - testSql: ` - SECURITY LABEL FOR dummy ON TYPE public.full_name IS 'classified'; - `, - }); - }), - ); - - test( - "function label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE FUNCTION public.noop() RETURNS integer AS $$ SELECT 1 $$ LANGUAGE sql; - `, - testSql: ` - SECURITY LABEL FOR dummy ON FUNCTION public.noop() IS 'classified'; - `, - }); - }), - ); - - test( - "role label (shared catalog)", - withDbIsolated(pgVersion, async (db) => { - // Roles and role security labels are cluster-wide, so this test needs - // full container isolation instead of withDb's database-only isolation. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_roles WHERE rolname = 'test_role_with_label' - ) THEN - CREATE ROLE test_role_with_label; - END IF; - END - $$; - `, - testSql: ` - SECURITY LABEL FOR dummy ON ROLE test_role_with_label IS 'classified'; - `, - expectedSqlTerms: [ - "SECURITY LABEL FOR dummy ON ROLE test_role_with_label IS 'classified'", - ], - }); - }), - ); - }, - ); - - describe.skipIf(SKIP_SECLABEL_TESTS)( - `security labels on schemas (pg${pgVersion})`, - () => { - test( - "add label to new schema", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: DUMMY_PROVIDER_SETUP, - testSql: ` - CREATE SCHEMA labeled; - SECURITY LABEL FOR dummy ON SCHEMA labeled IS 'classified'; - `, - }); - }), - ); - - test( - "add label to existing schema", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE SCHEMA labeled; - `, - testSql: ` - SECURITY LABEL FOR dummy ON SCHEMA labeled IS 'classified'; - `, - }); - }), - ); - - test( - "change label value", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE SCHEMA labeled; - SECURITY LABEL FOR dummy ON SCHEMA labeled IS 'secret'; - `, - testSql: ` - SECURITY LABEL FOR dummy ON SCHEMA labeled IS 'classified'; - `, - }); - }), - ); - - test( - "drop label", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - ${DUMMY_PROVIDER_SETUP} - CREATE SCHEMA labeled; - SECURITY LABEL FOR dummy ON SCHEMA labeled IS 'secret'; - `, - testSql: ` - SECURITY LABEL FOR dummy ON SCHEMA labeled IS NULL; - `, - }); - }), - ); - }, - ); -} diff --git a/packages/pg-delta/tests/integration/sensitive-and-env-dependent-handling.test.ts b/packages/pg-delta/tests/integration/sensitive-and-env-dependent-handling.test.ts deleted file mode 100644 index 99f83689f..000000000 --- a/packages/pg-delta/tests/integration/sensitive-and-env-dependent-handling.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Integration tests for sensitive information and environment-dependent value handling. - * - * This file covers two related concerns: - * 1. Masking/Placeholders: Sensitive values are replaced with placeholders in CREATE statements - * 2. Diff Filtering: Environment-dependent value changes are ignored during diff (SET actions filtered) - */ - -import { describe, expect, test } from "bun:test"; -import { sql } from "@ts-safeql/sql-tag"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`sensitive and env-dependent handling (pg${pgVersion})`, () => { - describe("masking and placeholders (CREATE operations)", () => { - test( - "role with LOGIN generates password warning", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: `CREATE ROLE test_login_role WITH LOGIN;`, - expectedSqlTerms: ["CREATE ROLE test_login_role WITH LOGIN"], - }); - }), - ); - - test( - "role without LOGIN does not generate password warning", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: `CREATE ROLE test_no_login_role WITH NOLOGIN;`, - expectedSqlTerms: ["CREATE ROLE test_no_login_role"], - }); - }), - ); - - test( - "subscription with password in conninfo is masked", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: `CREATE PUBLICATION sub_sensitive_pub FOR ALL TABLES;`, - testSql: ` - CREATE SUBSCRIPTION sub_sensitive - CONNECTION 'dbname=${mainDbName} password=secret123' - PUBLICATION sub_sensitive_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - `, - expectedSqlTerms: [ - `CREATE SUBSCRIPTION sub_sensitive CONNECTION 'host=__CONN_HOST__ port=__CONN_PORT__ dbname=__CONN_DBNAME__ user=__CONN_USER__ password=__CONN_PASSWORD__' PUBLICATION sub_sensitive_pub WITH (enabled = false, slot_name = NONE${ - pgVersion >= 18 ? ", streaming = 'parallel'" : "" - }, create_slot = false, connect = false)`, - ], - }); - }), - ); - - test( - "server with sensitive options are redacted but safe options roundtrip", - withDb(pgVersion, async (db) => { - // Note: postgres_fdw doesn't accept password/user in server options, - // so we test with a custom FDW that accepts arbitrary options - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - testSql: ` - CREATE FOREIGN DATA WRAPPER test_sensitive_fdw; - CREATE SERVER test_sensitive_server2 - FOREIGN DATA WRAPPER test_sensitive_fdw - OPTIONS (password 'secret123', user 'testuser', host 'localhost'); - `, - expectedSqlTerms: [ - "CREATE FOREIGN DATA WRAPPER test_sensitive_fdw NO HANDLER NO VALIDATOR", - "CREATE SERVER test_sensitive_server2 FOREIGN DATA WRAPPER test_sensitive_fdw OPTIONS (password '__OPTION_PASSWORD__', user 'testuser', host 'localhost')", - ], - assertSqlStatements: (sqlStatements) => { - const joined = sqlStatements.join("\n"); - expect(joined).not.toContain("secret123"); - }, - }); - }), - ); - - test( - "user mapping with sensitive options are redacted but safe options roundtrip", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE EXTENSION IF NOT EXISTS postgres_fdw; - `, - testSql: ` - CREATE SERVER test_um_server - FOREIGN DATA WRAPPER postgres_fdw - OPTIONS (host 'localhost'); - CREATE USER MAPPING FOR CURRENT_USER - SERVER test_um_server - OPTIONS (user 'testuser', password 'secret456'); - `, - expectedSqlTerms: [ - "CREATE SERVER test_um_server FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host 'localhost')", - "CREATE USER MAPPING FOR postgres SERVER test_um_server OPTIONS (user 'testuser', password '__OPTION_PASSWORD__')", - ], - assertSqlStatements: (sqlStatements) => { - const joined = sqlStatements.join("\n"); - expect(joined).not.toContain("secret456"); - }, - }); - }), - ); - }); - - describe("diff filtering (ALTER operations)", () => { - test( - "alter role password does not generate ALTER statement", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE ROLE test_password_role WITH LOGIN; - `, - testSql: ` - ALTER ROLE test_password_role PASSWORD 'newpassword123'; - `, - // Password changes are environment-dependent and should be ignored during diff - expectedSqlTerms: [], - }); - }), - ); - - test( - "alter subscription connection with password is ignored", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE PUBLICATION sub_alter_sensitive_pub FOR ALL TABLES; - CREATE SUBSCRIPTION sub_alter_sensitive - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_alter_sensitive_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - `, - testSql: ` - ALTER SUBSCRIPTION sub_alter_sensitive - CONNECTION 'dbname=${mainDbName} password=newsecret'; - `, - // Conninfo changes are environment-dependent and should be ignored during diff - expectedSqlTerms: [], - }); - }), - ); - - test( - "subscription: changing conninfo does not generate ALTER", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE PUBLICATION sub_env_pub FOR ALL TABLES; - CREATE SUBSCRIPTION sub_env - CONNECTION 'dbname=${mainDbName} host=prod.example.com port=5432' - PUBLICATION sub_env_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - `, - testSql: ` - ALTER SUBSCRIPTION sub_env - CONNECTION 'dbname=${mainDbName} host=dev.example.com port=5433'; - `, - // Conninfo changes are environment-dependent and should be ignored - expectedSqlTerms: [], - }); - }), - ); - - test( - "subscription: changing non-conninfo properties still generates ALTER", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE PUBLICATION sub_env_pub FOR ALL TABLES; - CREATE SUBSCRIPTION sub_env - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_env_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - `, - testSql: ` - ALTER SUBSCRIPTION sub_env SET (binary = true); - `, - expectedSqlTerms: [ - "ALTER SUBSCRIPTION sub_env SET (binary = true)", - ], - }); - }), - ); - - test( - "server: SET option changes for non-sensitive options generate ALTER", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_env_fdw; - CREATE SERVER test_env_server - FOREIGN DATA WRAPPER test_env_fdw - OPTIONS (host 'prod.example.com', port '5432', dbname 'prod_db', fetch_size '100'); - `, - testSql: ` - ALTER SERVER test_env_server OPTIONS ( - SET host 'dev.example.com', - SET port '5433', - SET dbname 'dev_db', - SET fetch_size '200' - ); - `, - // Non-sensitive options roundtrip with their real values now. - expectedSqlTerms: [ - "ALTER SERVER test_env_server OPTIONS (SET host 'dev.example.com', SET port '5433', SET dbname 'dev_db', SET fetch_size '200')", - ], - }); - }), - ); - - test( - "server: adding options generates ALTER (ADD not filtered)", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE FOREIGN DATA WRAPPER test_env_fdw; - CREATE SERVER test_env_server - FOREIGN DATA WRAPPER test_env_fdw - OPTIONS (fetch_size '100'); - `, - testSql: ` - ALTER SERVER test_env_server OPTIONS ( - ADD host 'dev.example.com', - ADD port '5433' - ); - `, - expectedSqlTerms: [ - "ALTER SERVER test_env_server OPTIONS (ADD host 'dev.example.com', ADD port '5433')", - ], - }); - }), - ); - - test( - "user mapping: SET on password is suppressed (redaction makes values equal); SET on non-secret options emits ALTER", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE EXTENSION IF NOT EXISTS postgres_fdw; - CREATE SERVER test_um_server - FOREIGN DATA WRAPPER postgres_fdw - OPTIONS (host 'localhost'); - CREATE USER MAPPING FOR CURRENT_USER - SERVER test_um_server - OPTIONS (user 'prod_user', password 'prod_pass'); - `, - testSql: ` - ALTER USER MAPPING FOR CURRENT_USER - SERVER test_um_server - OPTIONS ( - SET user 'dev_user', - SET password 'dev_pass' - ); - `, - // Both passwords redact to the same placeholder so diff sees no - // change for password and skips it; non-secret options diff normally. - expectedSqlTerms: [ - "ALTER USER MAPPING FOR postgres SERVER test_um_server OPTIONS (SET user 'dev_user')", - ], - assertSqlStatements: (sqlStatements) => { - const joined = sqlStatements.join("\n"); - expect(joined).not.toContain("dev_pass"); - expect(joined).not.toContain("prod_pass"); - }, - }); - }), - ); - }); - }); -} diff --git a/packages/pg-delta/tests/integration/sequence-operations.test.ts b/packages/pg-delta/tests/integration/sequence-operations.test.ts deleted file mode 100644 index 9519f00b5..000000000 --- a/packages/pg-delta/tests/integration/sequence-operations.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * Integration tests for PostgreSQL sequence operations. - */ - -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`sequence operations (pg${pgVersion})`, () => { - test( - "create basic sequence", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: "CREATE SEQUENCE test_schema.test_seq;", - }); - }), - ); - - test( - "create sequence with options", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE SEQUENCE test_schema.custom_seq - AS integer - INCREMENT BY 2 - MINVALUE 10 - MAXVALUE 1000 - START WITH 10 - CACHE 5 - CYCLE; - `, - }); - }), - ); - - test( - "drop sequence", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE SEQUENCE test_schema.test_seq; - `, - testSql: "DROP SEQUENCE test_schema.test_seq;", - }); - }), - ); - - test( - "create table with serial column (sequence dependency)", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users ( - id SERIAL PRIMARY KEY, - name TEXT - ); - `, - }); - }), - ); - - test( - "alter sequence properties", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE SEQUENCE test_schema.test_seq INCREMENT BY 1 CACHE 1; - `, - testSql: ` - ALTER SEQUENCE test_schema.test_seq INCREMENT BY 5 CACHE 10; - `, - }); - }), - ); - - test( - "sequence comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE SEQUENCE test_schema.seq1; - `, - testSql: ` - COMMENT ON SEQUENCE test_schema.seq1 IS 'test sequence comment'; - `, - }); - }), - ); - - test( - "drop table with owned sequence (skips DROP SEQUENCE)", - withDb(pgVersion, async (db) => { - // This test verifies that the diff tool correctly skips generating DROP SEQUENCE - // when a sequence is owned by a table that's being dropped. - // - // Scenario: - // 1. Sequence is owned by a table column - // 2. Table uses the sequence in a default (nextval) - // 3. Table is dropped - // - // When PostgreSQL drops a table that owns a sequence, it automatically drops - // the sequence as well. The diff tool should detect this and skip generating - // DROP SEQUENCE to avoid migration errors (sequence doesn't exist). - // - // Expected: Only DROP TABLE is generated (no DROP SEQUENCE) - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE SEQUENCE test_schema.user_id_seq; - CREATE TABLE test_schema.users ( - id bigint PRIMARY KEY DEFAULT nextval('test_schema.user_id_seq') - ); - ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; - `, - testSql: ` - DROP TABLE test_schema.users; - `, - // Validate that only DROP TABLE is generated - // The sequence is owned by the table, so PostgreSQL auto-drops it when the table is dropped. - // The diff tool correctly skips generating DROP SEQUENCE to avoid errors. - expectedSqlTerms: ["DROP TABLE test_schema.users"], - }); - }), - ); - - test( - "alter owned sequence data_type in place keeps OWNED BY and column default", - withDb(pgVersion, async (db) => { - // Previously this scenario emitted DROP SEQUENCE CASCADE + - // CREATE SEQUENCE + ALTER SEQUENCE OWNED BY + restore the - // column default. That path silently reset `last_value` to the - // START WITH value (data-loss bug) and produced a CycleError - // when the owning column's table survived. The diff now emits - // a single ALTER SEQUENCE ... AS bigint, which preserves the - // sequence's last_value, OWNED BY relationship, and the - // column's DEFAULT reference automatically. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE SEQUENCE test_schema.user_id_seq AS integer; - CREATE TABLE test_schema.users ( - id bigint PRIMARY KEY DEFAULT nextval('test_schema.user_id_seq'::regclass) - ); - ALTER SEQUENCE test_schema.user_id_seq OWNED BY test_schema.users.id; - `, - testSql: ` - ALTER SEQUENCE test_schema.user_id_seq AS bigint; - `, - expectedSqlTerms: [ - // `AS bigint` widens the implicit MAXVALUE from integer's - // 2^31-1 to bigint's 2^63-1; the diff emits `NO MAXVALUE` - // because the new bound equals bigint's default. - "ALTER SEQUENCE test_schema.user_id_seq AS bigint NO MAXVALUE", - ], - }); - }), - ); - - test( - "drop sequence referenced by column default", - withDb(pgVersion, async (db) => { - // Regression for https://github.com/supabase/pg-toolbelt/issues/230 - // The column default `nextval('test_schema.my_seq'::regclass)` keeps a - // pg_depend edge from the column to the sequence. Dropping the - // sequence requires the default to be removed first; otherwise - // PostgreSQL aborts the migration with error 2BP01. - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE SEQUENCE test_schema.my_seq START 1000; - CREATE TABLE test_schema.items ( - id integer PRIMARY KEY DEFAULT nextval('test_schema.my_seq'::regclass), - name text - ); - `, - testSql: ` - ALTER TABLE test_schema.items ALTER COLUMN id DROP DEFAULT; - DROP SEQUENCE test_schema.my_seq; - `, - }); - }), - ); - - test( - "create table with GENERATED ALWAYS AS IDENTITY column", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.identity_always ( - id int GENERATED ALWAYS AS IDENTITY, - name text - ); - `, - }); - }), - ); - - test( - "create table with GENERATED BY DEFAULT AS IDENTITY column", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.identity_by_default ( - id int GENERATED BY DEFAULT AS IDENTITY, - name text - ); - `, - }); - }), - ); - - test( - "serial and identity transition diffs", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - - CREATE TABLE test_schema.items ( - c1 int NOT NULL, - c2 serial, - c3 int GENERATED ALWAYS AS IDENTITY - ); - `, - testSql: ` - CREATE SEQUENCE test_schema.items_c1_seq OWNED BY test_schema.items.c1; - ALTER TABLE test_schema.items ALTER COLUMN c1 SET DEFAULT nextval('test_schema.items_c1_seq'::regclass); - ALTER TABLE test_schema.items ALTER COLUMN c2 DROP DEFAULT; - DROP SEQUENCE test_schema.items_c2_seq; - ALTER TABLE test_schema.items ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; - ALTER TABLE test_schema.items ALTER COLUMN c3 SET GENERATED BY DEFAULT; - `, - expectedSqlTerms: [ - // DROP DEFAULT is routed to the drop phase so it releases the - // pg_depend edge to items_c2_seq before the sequence drop runs. - "ALTER TABLE test_schema.items ALTER COLUMN c2 DROP DEFAULT", - "DROP SEQUENCE test_schema.items_c2_seq CASCADE", - "CREATE SEQUENCE test_schema.items_c1_seq", - "ALTER SEQUENCE test_schema.items_c1_seq OWNED BY test_schema.items.c1", - "ALTER TABLE test_schema.items ALTER COLUMN c1 SET DEFAULT nextval('test_schema.items_c1_seq'::regclass)", - "ALTER TABLE test_schema.items ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY", - "ALTER TABLE test_schema.items ALTER COLUMN c3 SET GENERATED BY DEFAULT", - ], - }); - }), - ); - - test( - "alter sequence data_type emits ALTER ... AS, not DROP+CREATE", - withDb(pgVersion, async (db) => { - // Sequence whose only diff is `data_type: integer → bigint` must - // be altered in place, not replaced. The previous Drop+Create - // path silently reset `last_value` to the START WITH value - // (data-loss bug; see Sentry SUPABASE-API-7RS) and produced a - // DropSequence ↔ DropTable cycle when a surviving column had - // DEFAULT nextval(seq). - await db.main.query("CREATE SEQUENCE public.shrink_seq AS integer"); - await db.branch.query("CREATE SEQUENCE public.shrink_seq AS bigint"); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected plan result"); - const sql = flattenPlanStatements(result.plan).join("\n"); - expect(sql).toContain("ALTER SEQUENCE public.shrink_seq AS bigint"); - expect(sql).not.toContain("DROP SEQUENCE"); - }), - ); - - test( - "shrink sequence type with last_value over new range generates plan that PG rejects at apply", - withDb(pgVersion, async (db) => { - // Pin the row-3 behavior from the data_type fix design matrix: - // shrinking from bigint to integer when last_value exceeds - // 2^31-1 must produce a plan (no CycleError, no Drop+Create - // path), and PG must refuse the migration at apply time - // because `last_value` is out of range. This is the desired - // behavior — a clear apply-time failure beats the previous - // silent data corruption (Drop+Create reset last_value to 1 - // and the next nextval would collide with existing rows). - await db.main.query( - [ - "CREATE SEQUENCE public.shrink_seq AS bigint", - // Push last_value above integer's max (2^31 - 1 = 2147483647). - "SELECT setval('public.shrink_seq', 3000000000)", - ].join(";\n"), - ); - await db.branch.query( - "CREATE SEQUENCE public.shrink_seq AS integer MAXVALUE 2147483647", - ); - - // Plan generation must succeed — no CycleError, no fallback - // to Drop+Create. - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected plan result"); - const sql = flattenPlanStatements(result.plan).join("\n"); - expect(sql).toContain("ALTER SEQUENCE public.shrink_seq AS integer"); - expect(sql).not.toContain("DROP SEQUENCE"); - - // Applying the plan against main must fail because the - // sequence's existing last_value (3_000_000_000) overflows the - // new integer range. Run each statement directly so the - // expected PG error surfaces (applyPlan would also fail; this - // form is just clearer about what we're asserting). - let applyError: unknown; - try { - for (const statement of flattenPlanStatements(result.plan)) { - await db.main.query(statement); - } - } catch (err) { - applyError = err; - } - expect(applyError).toBeInstanceOf(Error); - // PG reports the overflow with one of these phrasings depending - // on which clause it evaluates first ("AS integer" narrowing the - // implicit MAXVALUE, or an explicit MAXVALUE / RESTART). Any of - // them is the correct user-facing failure. - expect(String(applyError)).toMatch( - /out of range|maximum value|cannot be greater than MAXVALUE/i, - ); - }), - ); - - test( - "identity to serial transition diffs", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.identity_to_serial ( - id int GENERATED ALWAYS AS IDENTITY - ); - `, - testSql: ` - ALTER TABLE test_schema.identity_to_serial ALTER COLUMN id DROP IDENTITY; - CREATE SEQUENCE test_schema.identity_to_serial_id_serial_seq OWNED BY test_schema.identity_to_serial.id; - ALTER TABLE test_schema.identity_to_serial ALTER COLUMN id SET DEFAULT nextval('test_schema.identity_to_serial_id_serial_seq'::regclass); - `, - expectedSqlTerms: [ - // DROP IDENTITY is routed to the drop phase so it tears down - // the implicit identity sequence before the new sequence and - // matching default are introduced. - "ALTER TABLE test_schema.identity_to_serial ALTER COLUMN id DROP IDENTITY", - "CREATE SEQUENCE test_schema.identity_to_serial_id_serial_seq", - "ALTER SEQUENCE test_schema.identity_to_serial_id_serial_seq OWNED BY test_schema.identity_to_serial.id", - "ALTER TABLE test_schema.identity_to_serial ALTER COLUMN id SET DEFAULT nextval('test_schema.identity_to_serial_id_serial_seq'::regclass)", - ], - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/ssl-operations.test.ts b/packages/pg-delta/tests/integration/ssl-operations.test.ts deleted file mode 100644 index 710349a89..000000000 --- a/packages/pg-delta/tests/integration/ssl-operations.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** - * Integration tests for SSL/TLS connection support. - */ - -import { describe, expect, it } from "bun:test"; -import { readFile } from "node:fs/promises"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { createPool } from "../../src/core/postgres-config.ts"; -import { - POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG, - POSTGRES_VERSIONS, -} from "../constants.ts"; -import { PostgresSslContainer } from "../postgres-ssl.ts"; -import { - generateSslCertificates, - type SslCertificateOptions, -} from "../ssl-utils.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -const SSL_POSTGRES_VERSIONS = POSTGRES_VERSIONS.filter( - (pgVersion) => pgVersion !== 18, -); -// PostgreSQL 18 currently causes node-pg to emit "Connection terminated unexpectedly" -// during sslmode=require fixture teardown in this focused SSL suite. -// Keep pg18 covered by the broader integration matrix while leaving this -// file on the versions that complete reliably in CI/local runs. - -for (const pgVersion of SSL_POSTGRES_VERSIONS) { - describe(`SSL operations (pg${pgVersion})`, () => { - it( - "should connect with sslmode=require", - async () => { - const certificates = await generateSslCertificates(); - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresSslContainer( - image, - certificates, - ).start(); - - try { - const sourceUrl = `${container.getConnectionUri()}?sslmode=require`; - const targetUrl = `${container.getConnectionUri()}?sslmode=require`; - - // Should not throw - SSL connection should work - const result = await createPlan(sourceUrl, targetUrl); - expect(result).toBeNull(); // No changes expected for identical databases - } finally { - await container.stop(); - await certificates.cleanup(); - } - }, - { retry: 3 }, - ); - - it( - "should connect with sslmode=verify-ca using CA certificate file", - async () => { - const certificates = await generateSslCertificates(); - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresSslContainer( - image, - certificates, - ).start(); - - try { - const sourceUrl = `${container.getConnectionUri()}?sslmode=verify-ca&sslrootcert=${certificates.caCert}`; - const targetUrl = `${container.getConnectionUri()}?sslmode=require`; - - // Should not throw - SSL connection with CA verification should work - const result = await createPlan(sourceUrl, targetUrl); - expect(result).toBeNull(); // No changes expected for identical databases - } finally { - await container.stop(); - await certificates.cleanup(); - } - }, - { retry: 3 }, - ); - - it( - "should connect with sslmode=verify-ca using CA certificate from environment variable", - async () => { - const certificates = await generateSslCertificates(); - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresSslContainer( - image, - certificates, - ).start(); - - try { - const caContent = await readFile(certificates.caCert, "utf-8"); - process.env.PGDELTA_SOURCE_SSLROOTCERT = caContent; - - const sourceUrl = `${container.getConnectionUri()}?sslmode=verify-ca`; - const targetUrl = `${container.getConnectionUri()}?sslmode=require`; - - // Should not throw - SSL connection with CA from env var should work - const result = await createPlan(sourceUrl, targetUrl); - expect(result).toBeNull(); // No changes expected for identical databases - } finally { - delete process.env.PGDELTA_SOURCE_SSLROOTCERT; - await container.stop(); - await certificates.cleanup(); - } - }, - { retry: 3 }, - ); - - it( - "should fail to connect without SSL when server requires SSL", - async () => { - const certificates = await generateSslCertificates(); - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresSslContainer( - image, - certificates, - ).start(); - - try { - const sourceUrl = container.getConnectionUri(); // No sslmode parameter - should fail - const targetUrl = `${container.getConnectionUri()}?sslmode=require`; // Target needs SSL too - - // Should throw - server requires SSL but client doesn't use it - await expect(createPlan(sourceUrl, targetUrl)).rejects.toThrow(); - } finally { - await container.stop(); - await certificates.cleanup(); - } - }, - { retry: 3 }, - ); - - it( - "should detect schema differences over SSL connection", - async () => { - const certificates = await generateSslCertificates(); - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresSslContainer( - image, - certificates, - ).start(); - - try { - // Use pg Pool instead of container.exec() which hangs under Bun. - // SSL container requires SSL for TCP connections. Use rejectUnauthorized: false - // since the container uses self-signed certs (this is test setup, not the SUT). - const sslOpts = { ssl: { rejectUnauthorized: false } }; - const adminPool = createPool(container.getConnectionUri(), sslOpts); - - // Create a test database - await adminPool.query( - `CREATE DATABASE "test_db" OWNER "${container.getUsername()}"`, - ); - - // Create a table in the test database via pg Pool - const testDbPool = createPool( - container.getConnectionUriForDatabase("test_db"), - sslOpts, - ); - await testDbPool.query("CREATE TABLE test_table (id integer)"); - await testDbPool.end(); - await adminPool.end(); - - const sourceUrl = `${container.getConnectionUriForDatabase("test_db")}?sslmode=require`; - const targetUrl = `${container.getConnectionUriForDatabase("postgres")}?sslmode=require`; - - // Should detect the difference - const planResult = await createPlan(sourceUrl, targetUrl); - expect(planResult).not.toBeNull(); - expect( - flattenPlanStatements(planResult!.plan).length, - ).toBeGreaterThan(0); - } finally { - await container.stop(); - await certificates.cleanup(); - } - }, - { retry: 3 }, - ); - - /** - * Test for issue: verify-ca mode incorrectly verifies hostname. - * - * User report: "x509: certificate is not standards compliant" error when using - * verify-ca with certificates that have hostname mismatches. - * - * PostgreSQL's sslmode=verify-ca should ONLY verify the certificate chain (CA), - * NOT the hostname. This test uses a certificate with a different hostname - * (wronghost.example.com instead of localhost) to verify that verify-ca - * accepts it as long as the CA is trusted. - * - * Current behavior: FAILS with hostname verification error - * Expected behavior: PASSES because verify-ca should not check hostname - */ - it( - "should connect with sslmode=verify-ca when hostname does not match (CA-only verification)", - async () => { - // Generate certificates with a different hostname that won't match localhost - const certOptions: SslCertificateOptions = { - serverCN: "wronghost.example.com", - serverSAN: ["DNS:wronghost.example.com"], - }; - const certificates = await generateSslCertificates(certOptions); - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresSslContainer( - image, - certificates, - ).start(); - - try { - // Use verify-ca with CA certificate - should work because verify-ca - // only verifies the CA chain, not the hostname - const sourceUrl = `${container.getConnectionUri()}?sslmode=verify-ca&sslrootcert=${certificates.caCert}`; - const targetUrl = `${container.getConnectionUri()}?sslmode=verify-ca&sslrootcert=${certificates.caCert}`; - - // This should NOT throw - verify-ca should accept a certificate signed by - // the trusted CA regardless of hostname mismatch - const result = await createPlan(sourceUrl, targetUrl); - expect(result).toBeNull(); // No changes expected for identical databases - } finally { - await container.stop(); - await certificates.cleanup(); - } - }, - { retry: 3 }, - ); - - /** - * Test for libpq compatibility: sslmode=require with CA cert should verify CA. - * - * From PostgreSQL docs: "For backwards compatibility with earlier versions of - * PostgreSQL, if a root CA file exists, the behavior of sslmode=require will - * be the same as that of verify-ca." - * - * This test verifies that when sslmode=require is used WITH a CA certificate, - * it actually VERIFIES the CA chain. We use a DIFFERENT CA (not the one that - * signed the server cert) to prove that CA verification is happening. - * - * Current behavior: PASSES (incorrectly) - CA cert is ignored, any cert accepted - * Expected behavior: FAILS - should reject because CA doesn't match - */ - it( - "should reject connection with sslmode=require and wrong CA cert (libpq compatibility - CA must be verified)", - async () => { - // Generate server certificates with one CA - const serverCerts = await generateSslCertificates(); - // Generate a DIFFERENT CA that didn't sign the server cert - const wrongCaCerts = await generateSslCertificates(); - - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresSslContainer( - image, - serverCerts, - ).start(); - - try { - // Use require with WRONG CA certificate - should fail because - // require+CA should verify the CA chain (like verify-ca) - const sourceUrl = `${container.getConnectionUri()}?sslmode=require&sslrootcert=${wrongCaCerts.caCert}`; - const targetUrl = `${container.getConnectionUri()}?sslmode=require&sslrootcert=${wrongCaCerts.caCert}`; - - // This SHOULD throw - the CA doesn't match the server's certificate - // If it passes, it means CA verification is NOT happening (current bug) - await expect(createPlan(sourceUrl, targetUrl)).rejects.toThrow(); - } finally { - await container.stop(); - await serverCerts.cleanup(); - await wrongCaCerts.cleanup(); - } - }, - { retry: 3 }, - ); - - /** - * Test for issue: verify-full mode should verify hostname. - * - * PostgreSQL's sslmode=verify-full should verify BOTH the certificate chain - * AND the hostname. This test confirms that verify-full correctly rejects - * certificates where the hostname doesn't match. - */ - it( - "should reject connection with sslmode=verify-full when hostname does not match", - async () => { - // Generate certificates with a different hostname that won't match localhost - const certOptions: SslCertificateOptions = { - serverCN: "wronghost.example.com", - serverSAN: ["DNS:wronghost.example.com"], - }; - const certificates = await generateSslCertificates(certOptions); - const image = `postgres:${POSTGRES_VERSION_TO_ALPINE_POSTGRES_TAG[pgVersion]}`; - const container = await new PostgresSslContainer( - image, - certificates, - ).start(); - - try { - // Use verify-full with CA certificate - should fail because verify-full - // requires hostname to match - const sourceUrl = `${container.getConnectionUri()}?sslmode=verify-full&sslrootcert=${certificates.caCert}`; - const targetUrl = `${container.getConnectionUri()}?sslmode=verify-full&sslrootcert=${certificates.caCert}`; - - // This SHOULD throw because verify-full requires hostname match - await expect(createPlan(sourceUrl, targetUrl)).rejects.toThrow(); - } finally { - await container.stop(); - await certificates.cleanup(); - } - }, - { retry: 3 }, - ); - }); -} diff --git a/packages/pg-delta/tests/integration/subscription-operations.test.ts b/packages/pg-delta/tests/integration/subscription-operations.test.ts deleted file mode 100644 index 0581daa8a..000000000 --- a/packages/pg-delta/tests/integration/subscription-operations.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { sql } from "@ts-safeql/sql-tag"; -import dedent from "dedent"; -import type { Change } from "../../src/core/change.types.ts"; -import { applyPlan } from "../../src/core/plan/apply.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`subscription operations (pg${pgVersion})`, () => { - test( - "create subscription without connecting", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE PUBLICATION sub_create_pub FOR ALL TABLES; - `, - testSql: ` - CREATE SUBSCRIPTION sub_create - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_create_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - `, - }); - }), - ); - - test( - "alter subscription configuration", - withDbIsolated(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE PUBLICATION sub_alter_pub FOR ALL TABLES; - CREATE PUBLICATION sub_alter_pub2 FOR ALL TABLES; - CREATE ROLE sub_owner SUPERUSER; - CREATE SUBSCRIPTION sub_alter - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_alter_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - `, - testSql: ` - ALTER SUBSCRIPTION sub_alter - CONNECTION 'dbname=postgres application_name=sub_alter'; - - ALTER SUBSCRIPTION sub_alter - SET PUBLICATION sub_alter_pub, sub_alter_pub2 WITH (refresh = false); - - ALTER SUBSCRIPTION sub_alter SET ( - slot_name = 'sub_alter_slot', - binary = true, - streaming = ${pgVersion >= 17 ? "'parallel'" : "true"}, - synchronous_commit = 'local', - disable_on_error = true${ - pgVersion >= 16 ? ", password_required = false" : "" - }${pgVersion >= 17 ? ", run_as_owner = true" : ""}${ - pgVersion >= 17 ? ", origin = 'none'" : "" - } - ); - - COMMENT ON SUBSCRIPTION sub_alter IS 'subscription metadata'; - ALTER SUBSCRIPTION sub_alter OWNER TO sub_owner; - `, - }); - }), - ); - - test( - "drop subscription", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE PUBLICATION sub_drop_pub FOR ALL TABLES; - CREATE SUBSCRIPTION sub_drop - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_drop_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - `, - testSql: `DROP SUBSCRIPTION sub_drop;`, - }); - }), - ); - - test( - "subscription comment creation", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE PUBLICATION sub_comment_pub FOR ALL TABLES; - CREATE SUBSCRIPTION sub_comment - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_comment_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - `, - testSql: `COMMENT ON SUBSCRIPTION sub_comment IS 'subscription comment';`, - }); - }), - ); - - test( - "subscription comment removal", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE PUBLICATION sub_comment_drop_pub FOR ALL TABLES; - CREATE SUBSCRIPTION sub_comment_drop - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_comment_drop_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - COMMENT ON SUBSCRIPTION sub_comment_drop IS 'subscription comment'; - `, - testSql: `COMMENT ON SUBSCRIPTION sub_comment_drop IS NULL;`, - }); - }), - ); - - test( - "subscription comment creation depends on subscription create order", - withDb(pgVersion, async (db) => { - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE PUBLICATION sub_comment_dependency_pub FOR ALL TABLES; - `, - testSql: dedent` - CREATE SUBSCRIPTION sub_comment_dependency - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_comment_dependency_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = NONE - ); - - COMMENT ON SUBSCRIPTION sub_comment_dependency IS 'dependency check'; - `, - sortChangesCallback: (a: Change, b: Change) => { - // Force the comment create to sort ahead of the subscription create to prove the sorter fixes the order. - const priority = (change: Change) => { - if ( - change.objectType === "subscription" && - change.scope === "comment" && - change.operation === "create" - ) { - return 0; - } - if ( - change.objectType === "subscription" && - change.scope === "object" && - change.operation === "create" - ) { - return 1; - } - return 2; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - - test( - "creates a subscription reusing an existing replication slot inside a transaction", - withDbIsolated(pgVersion, async (db) => { - // A subscription whose replication slot actually exists must - // serialize with create_slot = false so the slot is reused instead of - // recreated. That form keeps the connect = true default and is - // accepted inside a transaction block (PostgreSQL's 25001 gate is on - // create_slot = true), so the plan stays fully transactional. - // Extraction redacts conninfo to the __CONN_*__ placeholder (it - // carries credentials), so the apply leg substitutes real connection - // values first — exactly what a user does — and executes the result - // inside an explicit transaction against the same cluster, which only - // works because no slot is created as part of the command. - const { - rows: [{ name: branchDbName }], - } = await db.branch.query<{ name: string }>( - sql`select current_database() as name`, - ); - await db.branch.query( - "CREATE PUBLICATION sub_with_slot_pub FOR ALL TABLES", - ); - await db.branch.query( - sql`select pg_create_logical_replication_slot('sub_existing_slot', 'pgoutput')`, - ); - await db.branch.query(dedent` - CREATE SUBSCRIPTION sub_with_slot - CONNECTION 'dbname=${branchDbName}' - PUBLICATION sub_with_slot_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = 'sub_existing_slot' - ); - `); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - - expect(result.plan.units).toHaveLength(1); - const [unit] = result.plan.units; - expect(unit.transactionMode).toBe("transactional"); - const createStatement = unit.statements.find((statement) => - statement.startsWith("CREATE SUBSCRIPTION sub_with_slot"), - ); - expect(createStatement).toBeDefined(); - if (!createStatement) throw new Error("expected create statement"); - expect(createStatement).toContain("create_slot = false"); - // connect must stay at its default (true) so the existing slot is - // looked up on the publisher rather than skipped. - expect(createStatement).not.toContain("connect = false"); - expect(createStatement).toContain("slot_name = 'sub_existing_slot'"); - - const executable = createStatement.replace( - /CONNECTION '[^']*'/, - `CONNECTION 'dbname=${branchDbName}'`, - ); - const client = await db.main.connect(); - try { - await client.query("BEGIN"); - await client.query(executable); - await client.query("COMMIT"); - } catch (error) { - await client.query("ROLLBACK").catch(() => {}); - throw error; - } finally { - client.release(); - } - - const { rows: subscriptions } = await db.main.query( - sql` - select 1 - from pg_subscription s - join pg_database d on d.oid = s.subdbid - where s.subname = 'sub_with_slot' - and d.datname = current_database() - `, - ); - expect(subscriptions).toHaveLength(1); - }), - ); - - test( - "drops a subscription with an associated replication slot outside a transaction block", - withDbIsolated(pgVersion, async (db) => { - // DROP SUBSCRIPTION must connect to the publisher to drop the remote - // slot, and PostgreSQL rejects it inside a transaction block (25001) - // whenever a slot is associated. The extra table guarantees the plan - // has more than one statement, so a naive single-script/explicit - // transaction apply would fail. - const { - rows: [{ name: mainDbName }], - } = await db.main.query<{ name: string }>( - sql`select current_database() as name`, - ); - await db.main.query( - sql`select pg_create_logical_replication_slot('sub_drop_slot', 'pgoutput')`, - ); - await db.main.query(dedent` - CREATE SUBSCRIPTION sub_drop_with_slot - CONNECTION 'dbname=${mainDbName}' - PUBLICATION sub_drop_pub - WITH ( - connect = false, - create_slot = false, - enabled = false, - slot_name = 'sub_drop_slot' - ); - `); - await db.main.query("CREATE TABLE public.drop_me (id integer)"); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected result"); - - const dropUnit = result.plan.units.find((unit) => - unit.statements.some((statement) => - statement.startsWith("DROP SUBSCRIPTION sub_drop_with_slot"), - ), - ); - expect(dropUnit).toBeDefined(); - expect(dropUnit?.transactionMode).toBe("none"); - expect(dropUnit?.statements).toHaveLength(1); - - const applied = await applyPlan(result.plan, db.main, db.branch); - expect(applied.status).toBe("applied"); - if (applied.status !== "applied") throw new Error("expected applied"); - expect(applied.warnings).toBeUndefined(); - - const after = await createPlan(db.main, db.branch); - expect(after).toBeNull(); - - // DROP SUBSCRIPTION also dropped the slot it connected for. - const { rows: slots } = await db.main.query( - sql`select 1 from pg_replication_slots where slot_name = 'sub_drop_slot'`, - ); - expect(slots).toHaveLength(0); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/supabase-all-extensions-roundtrip.test.ts b/packages/pg-delta/tests/integration/supabase-all-extensions-roundtrip.test.ts deleted file mode 100644 index 3a9df107f..000000000 --- a/packages/pg-delta/tests/integration/supabase-all-extensions-roundtrip.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { applyDeclarativeSchema } from "../../src/core/declarative-apply/index.ts"; -import { exportDeclarativeSchema } from "../../src/core/export/index.ts"; -import { compileFilterDSL } from "../../src/core/integrations/filter/dsl.ts"; -import { compileSerializeDSL } from "../../src/core/integrations/serialize/dsl.ts"; -import { supabase as supabaseIntegration } from "../../src/core/integrations/supabase.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { sortChanges } from "../../src/core/sort/sort-changes.ts"; -import { SUPABASE_POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbSupabaseIsolated } from "../utils.ts"; - -// Only extensions whose control file pins a non-default target schema can -// trip the issue #222 bug: `CREATE EXTENSION foo WITH SCHEMA ` fails when -// does not exist on main. Relocatable / `public`-default extensions -// always resolve against public, which always exists, and extensions pinned -// to `pg_catalog` (plpgsql) use a built-in schema that also always exists. -// Installing only the pinned-schema subset keeps the test CI-friendly while -// still exercising every code path the fix touches. -const PINNED_SCHEMA_EXTENSION_QUERY = ` - SELECT v.name - FROM pg_available_extension_versions v - JOIN pg_available_extensions a ON a.name = v.name - WHERE v.version = a.default_version - AND v.schema IS NOT NULL - AND v.schema NOT IN ('public', 'pg_catalog') - ORDER BY v.name -`; - -for (const pgVersion of SUPABASE_POSTGRES_VERSIONS) { - describe(`supabase extension declarative roundtrip (pg${pgVersion})`, () => { - // Canary test: skipped in CI because spinning up every pinned-schema - // extension across all supabase images is too slow for the default - // pipeline. Run it locally whenever we bump the supabase/postgres image - // versions to catch regressions in the WITH SCHEMA roundtrip path. - test.skipIf(Boolean(process.env.CI))( - "every pinned-schema extension reapplies cleanly via the supabase integration", - withDbSupabaseIsolated(pgVersion, async (db) => { - const available = await db.branch.query<{ name: string }>( - PINNED_SCHEMA_EXTENSION_QUERY, - ); - - for (const row of available.rows) { - await db.branch.query( - `CREATE EXTENSION IF NOT EXISTS "${row.name}" CASCADE`, - ); - } - - // Drop every extension pre-installed on main (by the supabase/postgres - // image itself or by the base-init fixture) whose target schema is - // non-public and pinned, so the roundtrip has to emit CREATE EXTENSION - // against an empty target. Without this, image-installed extensions - // like pg_graphql (graphql) / supabase_vault (vault) never exercise - // the WITH SCHEMA code path where the issue #222 bug lives. - const pinned = new Set(available.rows.map((row) => row.name)); - const preInstalled = await db.main.query<{ name: string }>( - `SELECT extname AS name FROM pg_extension`, - ); - for (const row of preInstalled.rows) { - if (pinned.has(row.name)) { - await db.main.query( - `DROP EXTENSION IF EXISTS "${row.name}" CASCADE`, - ); - } - } - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const compiledFilter = compileFilterDSL(supabaseIntegration.filter); - const compiledSerialize = compileSerializeDSL( - supabaseIntegration.serialize, - ); - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - skipDefaultPrivilegeSubtraction: true, - }); - - if (!planResult) { - throw new Error( - "createPlan returned null -- no changes detected between baseline and branch", - ); - } - - const output = exportDeclarativeSchema(planResult, { - integration: { serialize: compiledSerialize }, - }); - - const applyResult = await applyDeclarativeSchema({ - content: output.files.map((file) => ({ - filePath: file.path, - sql: file.sql, - })), - pool: db.main, - disableCheckFunctionBodies: true, - validateFunctionBodies: false, - }); - - if (applyResult.apply.status !== "success") { - console.error( - `[supabase-extension-roundtrip pg${pgVersion}] declarative apply ${applyResult.apply.status}:\n` + - JSON.stringify(applyResult.apply, null, 2), - ); - throw new Error( - `Declarative apply failed (${applyResult.apply.status})`, - { cause: applyResult }, - ); - } - - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const allChanges = diffCatalogs(mainCatalog, branchCatalog); - const remainingChanges = allChanges.filter(compiledFilter); - - if (remainingChanges.length > 0) { - const sorted = sortChanges( - { mainCatalog, branchCatalog }, - remainingChanges, - ); - const remainingSql = sorted - .map((change) => change.serialize()) - .join(";\n"); - console.error( - `[supabase-extension-roundtrip pg${pgVersion}] ${remainingChanges.length} remaining change(s) after roundtrip:\n${remainingSql}`, - ); - } - - expect(remainingChanges).toHaveLength(0); - }), - 5 * 60 * 1000, - ); - }); -} diff --git a/packages/pg-delta/tests/integration/supabase-base-init.test.ts b/packages/pg-delta/tests/integration/supabase-base-init.test.ts deleted file mode 100644 index 73a98f116..000000000 --- a/packages/pg-delta/tests/integration/supabase-base-init.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { SUPABASE_POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbSupabaseIsolated } from "../utils.ts"; - -for (const pgVersion of SUPABASE_POSTGRES_VERSIONS) { - describe(`supabase base init baseline (pg${pgVersion})`, () => { - test( - "replays the full-stack base init before test code runs", - withDbSupabaseIsolated(pgVersion, async (db) => { - // `storage.buckets` does not exist on the raw image alone. This is a - // cheap end-to-end smoke test that proves the generated base-init SQL - // ran before the test body and exposed service-managed Supabase tables. - await db.main.query(` - INSERT INTO storage.buckets (id, name) - VALUES ('avatars', 'avatars'); - `); - - const result = await db.main.query(` - SELECT id, name - FROM storage.buckets - WHERE id = 'avatars'; - `); - - expect(result.rows).toMatchInlineSnapshot(` - [ - { - "id": "avatars", - "name": "avatars", - }, - ] - `); - }), - 120_000, - ); - }); -} diff --git a/packages/pg-delta/tests/integration/supabase-dsl-e2e.test.ts b/packages/pg-delta/tests/integration/supabase-dsl-e2e.test.ts deleted file mode 100644 index e76762703..000000000 --- a/packages/pg-delta/tests/integration/supabase-dsl-e2e.test.ts +++ /dev/null @@ -1,457 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import dedent from "dedent"; -import { compileFilterDSL } from "../../src/core/integrations/filter/dsl.ts"; -import { compileSerializeDSL } from "../../src/core/integrations/serialize/dsl.ts"; -import { supabase as supabaseIntegration } from "../../src/core/integrations/supabase.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { withDbSupabaseIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -const pgVersion = 17; - -const installPgNetSql = dedent` - CREATE EXTENSION IF NOT EXISTS pg_net; -`; - -const dropPgNetSql = "DROP EXTENSION pg_net;"; - -describe(`supabase integration e2e (pg${pgVersion})`, () => { - test( - "captures user-defined triggers attached to auth.users", - withDbSupabaseIsolated(pgVersion, async (db) => { - // Regression for https://github.com/supabase/pg-toolbelt/issues/254 — - // a user-attached trigger on `auth.users` (calling a function in - // `public`) was being filtered out by the Supabase managed-schema - // exclusion. The whole `auth` schema is on the deny list, but the - // trigger function lives in `public`, which is the user-defined - // signal the filter should respect. - // - // Run the SQL as `postgres` to mirror what `supabase db diff` does - // — the test container connects as `supabase_admin`, but the CLI - // (and migrations) operate as `postgres`, so functions created - // through the normal path are owned by `postgres` rather than - // `supabase_admin`. - await db.branch.query(dedent` - SET ROLE postgres; - - CREATE FUNCTION public.handle_new_user() - RETURNS trigger - LANGUAGE plpgsql - AS $$ BEGIN RETURN NEW; END $$; - - CREATE TRIGGER on_auth_user_created - AFTER INSERT ON auth.users - FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); - - RESET ROLE; - `); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - expect(flattenPlanStatements(planResult!.plan)).toMatchInlineSnapshot(` - [ - "SET check_function_bodies = false", - - "CREATE FUNCTION public.handle_new_user() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ BEGIN RETURN NEW; END $function$" - , - "CREATE TRIGGER on_auth_user_created AFTER INSERT ON users FOR EACH ROW EXECUTE FUNCTION handle_new_user()", - "ALTER FUNCTION public.handle_new_user() OWNER TO postgres", - ] - `); - }), - 120_000, - ); - - test( - "captures pg_net extension drops in createPlan", - withDbSupabaseIsolated(pgVersion, async (db) => { - await db.main.query(installPgNetSql); - await db.branch.query(installPgNetSql); - await db.branch.query(dropPgNetSql); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - expect(planResult).not.toBeNull(); - expect(flattenPlanStatements(planResult!.plan)).toMatchInlineSnapshot(` - [ - "DROP EXTENSION pg_net", - ] - `); - }), - 120_000, - ); - - test( - "roundtrips pg_net extension drops through the supabase integration", - withDbSupabaseIsolated(pgVersion, async (db) => { - await db.main.query(installPgNetSql); - await db.branch.query(installPgNetSql); - await db.branch.query(dropPgNetSql); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - expect(planResult).not.toBeNull(); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - integration: { - filter: compileFilterDSL(supabaseIntegration.filter), - serialize: compileSerializeDSL(supabaseIntegration.serialize), - }, - assertSqlStatements: (sqlStatements) => { - expect(sqlStatements).toMatchInlineSnapshot(` - [ - "DROP EXTENSION pg_net", - ] - `); - }, - }); - }), - 120_000, - ); - - // Regression for CLI-1470: Wasm-based foreign data wrappers (e.g. - // `clerk`, `clerk_oauth`) wire their handler/validator through the - // `extensions.*` schema. Supabase Cloud provisions them as - // `supabase_admin`, but local Docker images do not have an equivalent - // pre-step, so `supabase db reset` cannot replay - // `CREATE FOREIGN DATA WRAPPER`. The Supabase integration filter must - // suppress these FDW changes regardless of who owns the wrapper at - // diff time. - test( - "suppresses CREATE FOREIGN DATA WRAPPER backed by extensions.* handler", - withDbSupabaseIsolated(pgVersion, async (db) => { - await db.branch.query(dedent` - CREATE EXTENSION IF NOT EXISTS postgres_fdw SCHEMA extensions; - CREATE FOREIGN DATA WRAPPER wasm_lookalike - HANDLER extensions.postgres_fdw_handler - VALIDATOR extensions.postgres_fdw_validator; - `); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - // postgres_fdw is allow-listed for CREATE EXTENSION; the only - // expected output is the extension itself. No - // `CREATE FOREIGN DATA WRAPPER` for the Wasm-lookalike wrapper - // should appear, since it depends on a handler that lives in the - // managed `extensions` schema. - const statements = planResult - ? flattenPlanStatements(planResult.plan) - : []; - const fdwStatements = statements.filter((stmt) => - stmt.includes("FOREIGN DATA WRAPPER"), - ); - expect(fdwStatements).toStrictEqual([]); - }), - 120_000, - ); - - // Follow-up to CLI-1470: suppress SERVER / FOREIGN TABLE / USER MAPPING - // that depend on Supabase Wasm FDWs, whose handler/validator are the - // `extensions.wasm_fdw_handler` / `extensions.wasm_fdw_validator` functions - // shipped by the `wrappers` extension. Without this, `db pull` emits - // `CREATE SERVER ... FOREIGN DATA WRAPPER clerk_oauth` while the wrapper DDL - // is suppressed, and local `supabase db reset` fails with - // `foreign-data wrapper "clerk_oauth" does not exist`. - // - // The `wrappers` extension (and the Wasm runtime it needs) is not present - // in the local image, so we fabricate handler/validator functions with the - // exact `wasm_fdw_*` names the integration filter keys on, backed by - // `postgres_fdw`'s C symbols. This reproduces the catalog shape of a real - // Wasm wrapper without the runtime. - // - // The dependents are created under `SET ROLE postgres` so they are owned by - // `postgres`, not the `supabase_admin` connection role. That forces the - // suppression to come from the Wasm handler/validator rule rather than the - // `*/owner` deny list — otherwise the test would pass even if the - // wasm-specific rule were broken. - test( - "suppresses Wasm FDW server, foreign table, and user mapping dependents", - withDbSupabaseIsolated(pgVersion, async (db) => { - await db.branch.query(dedent` - CREATE EXTENSION IF NOT EXISTS postgres_fdw SCHEMA extensions; - CREATE FUNCTION extensions.wasm_fdw_handler() - RETURNS fdw_handler - LANGUAGE c AS '$libdir/postgres_fdw', 'postgres_fdw_handler'; - CREATE FUNCTION extensions.wasm_fdw_validator(text[], oid) - RETURNS void - LANGUAGE c AS '$libdir/postgres_fdw', 'postgres_fdw_validator'; - CREATE FOREIGN DATA WRAPPER clerk_oauth - HANDLER extensions.wasm_fdw_handler - VALIDATOR extensions.wasm_fdw_validator; - GRANT USAGE ON FOREIGN DATA WRAPPER clerk_oauth TO postgres; - SET ROLE postgres; - CREATE SERVER wasm_server FOREIGN DATA WRAPPER clerk_oauth; - CREATE SCHEMA wasm_fdw_test; - CREATE FOREIGN TABLE wasm_fdw_test.remote_row (id integer) - SERVER wasm_server - OPTIONS (schema_name 'public', table_name 'remote_row'); - CREATE USER MAPPING FOR postgres SERVER wasm_server - OPTIONS (user 'remote', password 'secret'); - RESET ROLE; - `); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - const statements = planResult - ? flattenPlanStatements(planResult.plan) - : []; - const wasmDependentStatements = statements.filter( - (stmt) => - /\bCREATE\s+SERVER\s+wasm_server\b/i.test(stmt) || - /\bCREATE\s+FOREIGN\s+TABLE\b[^;]*\bwasm_fdw_test\.remote_row\b/i.test( - stmt, - ) || - /\bCREATE\s+USER\s+MAPPING\b[^;]*\bSERVER\s+wasm_server\b/i.test( - stmt, - ), - ); - expect(wasmDependentStatements).toStrictEqual([]); - }), - 120_000, - ); - - // Counterpart to the Wasm suppression above: `postgres_fdw` installs its - // handler/validator into `extensions` on Supabase too, but the contrib FDW - // IS available in the local image, so a user-created `postgres_fdw` server - // (plus its foreign table and user mapping) must still be emitted — keying - // suppression on the bare `extensions.*` namespace would wrongly drop them. - test( - "preserves user-owned postgres_fdw server, foreign table, and user mapping", - withDbSupabaseIsolated(pgVersion, async (db) => { - // Owned by `postgres` (via SET ROLE) so the `*/owner` deny list does not - // drop them — the only thing that could suppress these is the Wasm - // handler rule, which must NOT match `extensions.postgres_fdw_handler`. - await db.branch.query(dedent` - CREATE EXTENSION IF NOT EXISTS postgres_fdw SCHEMA extensions; - SET ROLE postgres; - CREATE SERVER user_pg_server - FOREIGN DATA WRAPPER postgres_fdw - OPTIONS (host 'remote', dbname 'remote_db'); - CREATE SCHEMA user_fdw_test; - CREATE FOREIGN TABLE user_fdw_test.remote_row (id integer) - SERVER user_pg_server - OPTIONS (schema_name 'public', table_name 'remote_row'); - CREATE USER MAPPING FOR postgres SERVER user_pg_server - OPTIONS (user 'remote', password 'secret'); - RESET ROLE; - `); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - const statements = planResult - ? flattenPlanStatements(planResult.plan) - : []; - const hasServer = statements.some((stmt) => - /\bCREATE\s+SERVER\s+user_pg_server\b/i.test(stmt), - ); - const hasForeignTable = statements.some((stmt) => - /\bCREATE\s+FOREIGN\s+TABLE\b[^;]*\buser_fdw_test\.remote_row\b/i.test( - stmt, - ), - ); - const hasUserMapping = statements.some((stmt) => - /\bCREATE\s+USER\s+MAPPING\b[^;]*\bSERVER\s+user_pg_server\b/i.test( - stmt, - ), - ); - expect({ hasServer, hasForeignTable, hasUserMapping }).toStrictEqual({ - hasServer: true, - hasForeignTable: true, - hasUserMapping: true, - }); - }), - 120_000, - ); - - // Regression for CLI-1469. `GRANT`/`REVOKE ... ON FOREIGN DATA WRAPPER` - // requires superuser. On Supabase Cloud `postgres` has the elevated - // rights; the local Docker image does not, so `supabase db reset` - // aborts with `permission denied for foreign-data wrapper`. The - // existing `*/owner` rule drops FDW ACL owned by `supabase_admin`; - // this test pins the post-restore case where `pg_dump` rewrites - // OWNER TO `postgres` and the owner gate no longer matches. - test( - "suppresses GRANT/REVOKE on FOREIGN DATA WRAPPER even when owned by postgres", - withDbSupabaseIsolated(pgVersion, async (db) => { - await db.main.query(dedent` - CREATE ROLE fdw_user; - CREATE FOREIGN DATA WRAPPER user_fdw; - GRANT ALL ON FOREIGN DATA WRAPPER user_fdw TO fdw_user; - `); - await db.branch.query(dedent` - CREATE ROLE fdw_user; - CREATE FOREIGN DATA WRAPPER user_fdw; - `); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - const statements = planResult - ? flattenPlanStatements(planResult.plan) - : []; - const fdwAclStatements = statements.filter((stmt) => - /\b(?:GRANT|REVOKE)\b[^;]*\bON\b[^;]*\bFOREIGN\s+DATA\s+WRAPPER\b/.test( - stmt, - ), - ); - expect(fdwAclStatements).toStrictEqual([]); - }), - 120_000, - ); - - // Companion to the rule above: user-owned FOREIGN SERVER ACL must - // still roundtrip. Server GRANT/REVOKE doesn't require superuser, so - // a user-created server (e.g. a `dblink`/`postgres_fdw` server - // pointing to a peer DB) is genuinely user-declarative state and - // should not be swept up by the FDW ACL suppression. - test( - "preserves GRANT on user-owned FOREIGN SERVER", - withDbSupabaseIsolated(pgVersion, async (db) => { - await db.main.query(dedent` - CREATE EXTENSION IF NOT EXISTS postgres_fdw SCHEMA extensions; - CREATE ROLE server_user; - SET ROLE postgres; - CREATE SERVER user_server FOREIGN DATA WRAPPER postgres_fdw; - RESET ROLE; - `); - await db.branch.query(dedent` - CREATE EXTENSION IF NOT EXISTS postgres_fdw SCHEMA extensions; - CREATE ROLE server_user; - SET ROLE postgres; - CREATE SERVER user_server FOREIGN DATA WRAPPER postgres_fdw; - GRANT USAGE ON FOREIGN SERVER user_server TO server_user; - RESET ROLE; - `); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - const statements = planResult - ? flattenPlanStatements(planResult.plan) - : []; - // pg-delta serializes server ACL with the `ON SERVER` shorthand - // rather than `ON FOREIGN SERVER` (both are equivalent in PG) and - // collapses a complete privilege set to `ALL`. - expect(statements).toContain( - "GRANT ALL ON SERVER user_server TO server_user", - ); - }), - 120_000, - ); - - // Regression for the pgmq-1.4.4 cloud projects (real-world: this - // bug fired during `supabase db pull --diff-engine pg-delta` against - // a project with several pgmq queues, where every `pgmq.q_*` / - // `pgmq.a_*` table was missing the `pg_depend deptype='e'` link to - // the pgmq extension). The trigger extractor's principled filter - // (`extension_table_oids` in trigger.model.ts) drops user triggers - // on tables that carry that link, so on a healthy pgmq install the - // bug never surfaces; we simulate the stale-cloud state by deleting - // the link directly, which forces the same code path the cloud - // project exercises and pins the supabase-filter-level fallback. - test( - "suppresses user triggers on pgmq queue tables when pg_depend link is missing", - withDbSupabaseIsolated(pgVersion, async (db) => { - await db.branch.query(dedent` - CREATE EXTENSION pgmq; - SELECT pgmq.create('processed_milestones_queue'); - - DELETE FROM pg_depend - WHERE objid = 'pgmq.q_processed_milestones_queue'::regclass - AND refclassid = 'pg_extension'::regclass - AND deptype = 'e'; - DELETE FROM pg_depend - WHERE objid = 'pgmq.a_processed_milestones_queue'::regclass - AND refclassid = 'pg_extension'::regclass - AND deptype = 'e'; - - CREATE FUNCTION public.move_data_from_queue() RETURNS trigger - LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; - - CREATE TRIGGER after_insert_processed_milestones_queue - AFTER INSERT ON pgmq.q_processed_milestones_queue - FOR EACH ROW EXECUTE FUNCTION public.move_data_from_queue(); - `); - - if (!supabaseIntegration.filter || !supabaseIntegration.serialize) { - throw new Error("supabase integration missing filter or serialize"); - } - - const planResult = await createPlan(db.main, db.branch, { - filter: supabaseIntegration.filter, - serialize: supabaseIntegration.serialize, - }); - - const statements = planResult - ? flattenPlanStatements(planResult.plan) - : []; - const queueTriggerStatements = statements.filter((stmt) => - /\bCREATE\s+TRIGGER\b[^;]*\bON\s+pgmq\.q_processed_milestones_queue\b/i.test( - stmt, - ), - ); - expect(queueTriggerStatements).toStrictEqual([]); - }), - 120_000, - ); -}); diff --git a/packages/pg-delta/tests/integration/table-function-circular-dependency.test.ts b/packages/pg-delta/tests/integration/table-function-circular-dependency.test.ts deleted file mode 100644 index 1ff8c087e..000000000 --- a/packages/pg-delta/tests/integration/table-function-circular-dependency.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Integration tests for handling circular dependencies between tables and functions. - * - * This verifies the fix for the issue where functions with RETURNS SETOF table_name - * need tables to be created first, while tables with defaults using functions need - * functions to be created first. - */ - -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`table-function circular dependency (pg${pgVersion})`, () => { - test( - "function with RETURNS SETOF table", - withDb(pgVersion, async (db) => { - // This tests the case where a function references a table in its return type - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - -- Create the table first - CREATE TABLE test_schema.items ( - id bigserial PRIMARY KEY, - name text NOT NULL, - price numeric(10,2) - ); - - -- Create a function that returns SETOF the table - -- This requires the table to exist for the return type validation - CREATE FUNCTION test_schema.get_expensive_items() - RETURNS SETOF test_schema.items - LANGUAGE sql - STABLE - AS $function$ - SELECT * FROM test_schema.items WHERE price > 100 - $function$; - `, - }); - }), - ); - - test( - "table with function-based default and function with RETURNS SETOF", - withDb(pgVersion, async (db) => { - // This tests both circular dependency cases: - // 1. Function depends on table (RETURNS SETOF) - // 2. Table depends on function (DEFAULT) - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - -- Create a helper function first - CREATE FUNCTION test_schema.next_order_number() - RETURNS integer - LANGUAGE plpgsql - VOLATILE - AS $function$ - BEGIN - RETURN (SELECT coalesce(max(order_number), 0) + 1 FROM test_schema.orders); - END; - $function$; - - -- Create table with function-based default - -- This table depends on the function - CREATE TABLE test_schema.orders ( - id bigserial PRIMARY KEY, - order_number integer DEFAULT test_schema.next_order_number(), - total_amount numeric(10,2), - created_at timestamp DEFAULT now() - ); - - -- Create a function that returns SETOF the table - -- This function depends on the table - CREATE FUNCTION test_schema.get_recent_orders() - RETURNS SETOF test_schema.orders - LANGUAGE sql - STABLE - AS $function$ - SELECT * FROM test_schema.orders - WHERE created_at > now() - interval '7 days' - ORDER BY created_at DESC - $function$; - `, - }); - }), - ); - - test( - "complex circular dependencies with multiple tables and functions", - withDb(pgVersion, async (db) => { - // This tests a more complex scenario with multiple inter-dependent objects - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - -- Create initial function - CREATE FUNCTION test_schema.generate_id() - RETURNS bigint - LANGUAGE sql - VOLATILE - AS $function$SELECT floor(random() * 1000000)::bigint$function$; - - -- Create first table with function default - CREATE TABLE test_schema.customers ( - id bigint PRIMARY KEY DEFAULT test_schema.generate_id(), - email text NOT NULL, - name text - ); - - -- Create second table with function default - CREATE TABLE test_schema.products ( - id bigint PRIMARY KEY DEFAULT test_schema.generate_id(), - title text NOT NULL, - price numeric(10,2) - ); - - -- Create function returning first table - CREATE FUNCTION test_schema.get_customers_by_email(search_email text) - RETURNS SETOF test_schema.customers - LANGUAGE sql - STABLE - AS $function$ - SELECT * FROM test_schema.customers WHERE email = search_email - $function$; - - -- Create function returning second table - CREATE FUNCTION test_schema.get_products_by_price(max_price numeric) - RETURNS SETOF test_schema.products - LANGUAGE sql - STABLE - AS $function$ - SELECT * FROM test_schema.products WHERE price <= max_price - $function$; - - -- Create another function that uses both tables - CREATE FUNCTION test_schema.get_customer_count() - RETURNS bigint - LANGUAGE sql - STABLE - AS $function$SELECT count(*) FROM test_schema.customers$function$; - `, - }); - }), - ); - - test( - "materialized view with function returning table", - withDb(pgVersion, async (db) => { - // Test that functions returning tables work with materialized views too - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE TABLE test_schema.transactions ( - id bigserial PRIMARY KEY, - amount numeric(10,2), - status text - ); - - CREATE FUNCTION test_schema.get_transactions_by_status(search_status text) - RETURNS SETOF test_schema.transactions - LANGUAGE sql - STABLE - AS $function$ - SELECT * FROM test_schema.transactions WHERE status = search_status - $function$; - - CREATE MATERIALIZED VIEW test_schema.transaction_summary AS - SELECT status, count(*) as count, sum(amount) as total - FROM test_schema.transactions - GROUP BY status; - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/table-function-dependency-ordering.test.ts b/packages/pg-delta/tests/integration/table-function-dependency-ordering.test.ts deleted file mode 100644 index c2d653a49..000000000 --- a/packages/pg-delta/tests/integration/table-function-dependency-ordering.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Integration tests for table-function dependency ordering. - * - * These tests specifically verify that the ordering fix works correctly: - * 1. Functions with RETURNS SETOF need tables to exist first - * 2. Tables with function-based defaults need functions to exist first (handled by refinement) - */ - -import { describe, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`table-function dependency ordering (pg${pgVersion})`, () => { - test( - "verify tables created before functions with RETURNS SETOF", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE TABLE test_schema.users ( - id bigserial PRIMARY KEY, - email text UNIQUE - ); - - CREATE FUNCTION test_schema.get_users() - RETURNS SETOF test_schema.users - LANGUAGE sql - STABLE - AS $function$SELECT * FROM test_schema.users$function$; - `, - }); - }), - ); - - test( - "verify function-based defaults work via refinement", - withDb(pgVersion, async (db) => { - // This tests the refinement pass which reorders when table depends on function - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE FUNCTION test_schema.serial_counter() - RETURNS integer - LANGUAGE plpgsql - VOLATILE - AS $function$ - BEGIN - RETURN nextval('test_schema.counter_seq'::regclass); - END; - $function$; - - CREATE SEQUENCE test_schema.counter_seq; - - CREATE TABLE test_schema.event_log ( - id integer PRIMARY KEY DEFAULT test_schema.serial_counter(), - message text - ); - `, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/table-operations.test.ts b/packages/pg-delta/tests/integration/table-operations.test.ts deleted file mode 100644 index 60f1818d1..000000000 --- a/packages/pg-delta/tests/integration/table-operations.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Integration tests for PostgreSQL table operations. - */ - -import { describe, expect, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`table operations (pg${pgVersion})`, () => { - test( - "simple table with columns", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users ( - id integer, - name text NOT NULL, - email text - ); - `, - }); - }), - ); - - test( - "table with constraints", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.constrained_table ( - id integer, - name text NOT NULL, - email text, - age integer - ); - `, - }); - }), - ); - - test( - "multiple tables", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users ( - id integer, - name text NOT NULL - ); - - CREATE TABLE test_schema.posts ( - id integer, - title text NOT NULL, - content text - ); - `, - }); - }), - ); - - test( - "table with various types", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.type_test ( - col_int integer, - col_bigint bigint, - col_text text, - col_varchar varchar(50), - col_boolean boolean, - col_timestamp timestamp, - col_numeric numeric(10,2), - col_uuid uuid - ); - `, - }); - }), - ); - - test( - "table in public schema", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: ` - CREATE TABLE public.simple_table ( - id integer, - name text - ); - `, - }); - }), - ); - - test( - "empty table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.empty_table (); - `, - }); - }), - ); - - test( - "tables in multiple schemas", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA schema_a; - CREATE SCHEMA schema_b; - `, - testSql: ` - CREATE TABLE schema_a.table_a ( - id integer, - name text - ); - - CREATE TABLE schema_b.table_b ( - id integer, - description text - ); - `, - }); - }), - ); - - test( - "partitioned table RANGE", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: `CREATE SCHEMA test_schema;`, - testSql: ` - CREATE TABLE test_schema.events ( - created_at timestamp without time zone NOT NULL, - payload text - ) PARTITION BY RANGE (created_at); - - CREATE TABLE test_schema.events_2024 PARTITION OF test_schema.events - FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); - - CREATE TABLE test_schema.events_2025 PARTITION OF test_schema.events - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - `, - }); - }), - ); - - test( - "attach partition", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.events ( - created_at timestamp without time zone NOT NULL, - payload text - ) PARTITION BY RANGE (created_at); - - CREATE TABLE test_schema.events_2025 ( - created_at timestamp without time zone NOT NULL, - payload text - ); - `, - testSql: ` - ALTER TABLE test_schema.events - ATTACH PARTITION test_schema.events_2025 - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - `, - }); - }), - ); - - test( - "detach partition", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.events ( - created_at timestamp without time zone NOT NULL, - payload text - ) PARTITION BY RANGE (created_at); - - CREATE TABLE test_schema.events_2025 PARTITION OF test_schema.events - FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); - `, - testSql: ` - ALTER TABLE test_schema.events - DETACH PARTITION test_schema.events_2025; - `, - }); - }), - ); - - test( - "table comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.events ( - id integer, - created_at timestamp without time zone NOT NULL, - payload text - ); - `, - testSql: ` - ALTER TABLE test_schema.events ADD CONSTRAINT events_pkey PRIMARY KEY (id); - ALTER TABLE test_schema.events ADD COLUMN description text; - COMMENT ON TABLE test_schema.events IS 'This is a test table'; - COMMENT ON COLUMN test_schema.events.created_at IS 'This is a created_at column'; - COMMENT ON CONSTRAINT events_pkey ON test_schema.events IS 'This is a test constraint'; - COMMENT ON COLUMN test_schema.events.description IS 'This is a description column'; - `, - }); - }), - ); - - test( - "replace table via enum dependency does not emit standalone drop/create for PK-owned index", - withDb(pgVersion, async (db) => { - // Regression guard for the index arm in expandReplaceDependencies. - // When an enum change forces DropCompositeType+CreateCompositeType-style - // replacement, the expander promotes every table with a column of that - // enum to a drop+create pair. The dependent PK index must be left to - // AlterTableAddConstraint inside the CreateTable branch; emitting a - // standalone DROP INDEX for a constraint-owned index fails with - // "cannot drop index ... because constraint ... requires it". - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA pk_regression; - CREATE TYPE pk_regression.status AS ENUM ('draft', 'published', 'archived'); - CREATE TABLE pk_regression.posts ( - id integer PRIMARY KEY, - title text NOT NULL, - status pk_regression.status NOT NULL DEFAULT 'draft' - ); - CREATE VIEW pk_regression.published_posts AS - SELECT id, title FROM pk_regression.posts - WHERE status = 'published'; - `, - testSql: dedent` - DROP VIEW pk_regression.published_posts; - ALTER TABLE pk_regression.posts ALTER COLUMN status DROP DEFAULT; - DROP TABLE pk_regression.posts; - DROP TYPE pk_regression.status; - CREATE TYPE pk_regression.status AS ENUM ('draft', 'published'); - CREATE TABLE pk_regression.posts ( - id integer PRIMARY KEY, - title text NOT NULL, - status pk_regression.status NOT NULL DEFAULT 'draft' - ); - CREATE VIEW pk_regression.published_posts AS - SELECT id, title FROM pk_regression.posts - WHERE status = 'published'; - `, - assertSqlStatements: (statements) => { - for (const stmt of statements) { - expect(stmt).not.toMatch( - /^DROP INDEX\s+pk_regression\.posts_pkey\b/i, - ); - expect(stmt).not.toMatch( - /^CREATE UNIQUE INDEX\s+posts_pkey\s+ON\s+pk_regression\.posts\b/i, - ); - } - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/trigger-operations.test.ts b/packages/pg-delta/tests/integration/trigger-operations.test.ts deleted file mode 100644 index 6ce954c6e..000000000 --- a/packages/pg-delta/tests/integration/trigger-operations.test.ts +++ /dev/null @@ -1,921 +0,0 @@ -/** - * Integration tests for PostgreSQL trigger operations. - */ - -import { describe, expect, test } from "bun:test"; -import dedent from "dedent"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`trigger operations (pg${pgVersion})`, () => { - test( - "INSTEAD OF triggers on views are diffed and ordered after view creation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: dedent` - CREATE TABLE test_schema.users ( - id integer PRIMARY KEY, - email text NOT NULL - ); - - CREATE VIEW test_schema.user_emails AS - SELECT id, email FROM test_schema.users; - - CREATE OR REPLACE FUNCTION test_schema.insert_user_email() - RETURNS trigger LANGUAGE plpgsql AS $$ - BEGIN - INSERT INTO test_schema.users (id, email) VALUES (NEW.id, NEW.email); - RETURN NEW; - END; - $$; - - CREATE OR REPLACE FUNCTION test_schema.update_user_email() - RETURNS trigger LANGUAGE plpgsql AS $$ - BEGIN - UPDATE test_schema.users SET email = NEW.email WHERE id = OLD.id; - RETURN NEW; - END; - $$; - - CREATE TRIGGER user_emails_insert - INSTEAD OF INSERT ON test_schema.user_emails - FOR EACH ROW - EXECUTE FUNCTION test_schema.insert_user_email(); - - CREATE TRIGGER user_emails_update - INSTEAD OF UPDATE ON test_schema.user_emails - FOR EACH ROW - EXECUTE FUNCTION test_schema.update_user_email(); - `, - assertSqlStatements: (statements) => { - expect(statements).toMatchInlineSnapshot(` - [ - "SET check_function_bodies = false", - - "CREATE FUNCTION test_schema.insert_user_email() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - BEGIN - INSERT INTO test_schema.users (id, email) VALUES (NEW.id, NEW.email); - RETURN NEW; - END; - $function$" - , - - "CREATE FUNCTION test_schema.update_user_email() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - BEGIN - UPDATE test_schema.users SET email = NEW.email WHERE id = OLD.id; - RETURN NEW; - END; - $function$" - , - "CREATE TABLE test_schema.users (id integer NOT NULL, email text NOT NULL)", - "ALTER TABLE test_schema.users ADD CONSTRAINT users_pkey PRIMARY KEY (id)", - - "CREATE VIEW test_schema.user_emails AS SELECT ${pgVersion === 15 ? "users." : ""}id, - ${pgVersion === 15 ? "users." : ""}email - FROM test_schema.users" - , - "CREATE TRIGGER user_emails_insert INSTEAD OF INSERT ON test_schema.user_emails FOR EACH ROW EXECUTE FUNCTION test_schema.insert_user_email()", - "CREATE TRIGGER user_emails_update INSTEAD OF UPDATE ON test_schema.user_emails FOR EACH ROW EXECUTE FUNCTION test_schema.update_user_email()", - ] - `); - }, - }); - }), - ); - - test( - "simple trigger creation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id serial PRIMARY KEY, - name text NOT NULL, - updated_at timestamp DEFAULT now() - ); - CREATE FUNCTION test_schema.update_timestamp() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - NEW.updated_at = now(); - RETURN NEW; - END; - $$; - `, - testSql: ` - CREATE TRIGGER update_timestamp_trigger - BEFORE UPDATE ON test_schema.users - FOR EACH ROW - EXECUTE FUNCTION test_schema.update_timestamp(); - `, - }); - }), - ); - - // Regression: CLI-1820. A trigger whose name must be double-quoted (it - // contains a dash) had its formatted DDL mangled — the SQL formatter - // dropped the "AFTER INSERT ON ..." event/table clause, producing invalid - // migration SQL. The generated statement must keep the full clause. - test( - "trigger with a double-quoted (dashed) name keeps its event/table clause", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.t1 ( - id serial PRIMARY KEY, - name text NOT NULL - ); - CREATE FUNCTION test_schema.notify_change() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RETURN NEW; - END; - $$; - `, - testSql: ` - CREATE TRIGGER "new-webhook-with-dashed-name" - AFTER INSERT ON test_schema.t1 - FOR EACH ROW - EXECUTE FUNCTION test_schema.notify_change(); - `, - assertSqlStatements: (statements) => { - const triggerStatement = statements.find((statement) => - statement.includes('"new-webhook-with-dashed-name"'), - ); - expect(triggerStatement).toContain( - "AFTER INSERT ON test_schema.t1", - ); - }, - }); - }), - ); - - test( - "multi-event trigger", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.audit_log ( - id serial PRIMARY KEY, - table_name text, - operation text, - old_data jsonb, - new_data jsonb, - changed_at timestamp DEFAULT now() - ); - CREATE TABLE test_schema.sensitive_data ( - id serial PRIMARY KEY, - secret_value text - ); - CREATE FUNCTION test_schema.audit_changes() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO test_schema.audit_log (table_name, operation, old_data) - VALUES (TG_TABLE_NAME, TG_OP, row_to_json(OLD)); - RETURN OLD; - ELSE - INSERT INTO test_schema.audit_log (table_name, operation, new_data) - VALUES (TG_TABLE_NAME, TG_OP, row_to_json(NEW)); - RETURN NEW; - END IF; - END; - $$; - `, - testSql: - "CREATE TRIGGER audit_trigger AFTER INSERT OR DELETE OR UPDATE ON test_schema.sensitive_data FOR EACH ROW EXECUTE FUNCTION test_schema.audit_changes();", - }); - }), - ); - - test( - "multi-event trigger preserves UPDATE OF column list", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - - CREATE TABLE test_schema.user_account ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - email text NOT NULL, - verified boolean NOT NULL DEFAULT false - ); - - CREATE OR REPLACE FUNCTION test_schema.user_account_encrypt_secret_email() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - NEW.email := 'enc:' || NEW.email; - RETURN NEW; - END; - $$; - `, - testSql: dedent` - CREATE OR REPLACE TRIGGER user_account_encrypt_secret_trigger_email - BEFORE INSERT OR UPDATE OF email ON test_schema.user_account - FOR EACH ROW - EXECUTE FUNCTION test_schema.user_account_encrypt_secret_email(); - `, - assertSqlStatements: (statements) => { - expect(statements).toMatchInlineSnapshot(` - [ - "CREATE TRIGGER user_account_encrypt_secret_trigger_email BEFORE INSERT OR UPDATE OF email ON test_schema.user_account FOR EACH ROW EXECUTE FUNCTION test_schema.user_account_encrypt_secret_email()", - ] - `); - expect(statements[0]).not.toContain( - "BEFORE INSERT OR UPDATE ON test_schema.user_account", - ); - }, - }); - }), - ); - - test( - "constraint trigger creation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - amount integer NOT NULL, - limit_amount integer NOT NULL - ); - CREATE FUNCTION test_schema.enforce_amount_limit() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF NEW.amount > NEW.limit_amount THEN - RAISE EXCEPTION 'amount exceeds limit'; - END IF; - RETURN NEW; - END; - $$; - `, - testSql: dedent` - CREATE CONSTRAINT TRIGGER enforce_amount_limit_trigger - AFTER INSERT OR UPDATE ON test_schema.accounts - DEFERRABLE INITIALLY IMMEDIATE - FOR EACH ROW - EXECUTE FUNCTION test_schema.enforce_amount_limit(); - `, - }); - }), - ); - - test( - "constraint trigger update", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.roles ( - id serial PRIMARY KEY, - organization_id integer NOT NULL, - project_ids integer[] NOT NULL - ); - CREATE FUNCTION test_schema.role_and_project_ids_belong_to_org() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF EXISTS ( - SELECT 1 - FROM unnest(NEW.project_ids) project_id - ) THEN - -- no-op: keep this function lightweight for the test - NULL; - END IF; - RETURN NULL; - END; - $$; - CREATE CONSTRAINT TRIGGER role_and_project_ids_belong_to_org - AFTER INSERT OR UPDATE ON test_schema.roles - FOR EACH ROW - EXECUTE FUNCTION test_schema.role_and_project_ids_belong_to_org(); - `, - testSql: dedent` - DROP TRIGGER role_and_project_ids_belong_to_org ON test_schema.roles; - - CREATE CONSTRAINT TRIGGER role_and_project_ids_belong_to_org - AFTER INSERT OR UPDATE ON test_schema.roles - DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW - EXECUTE FUNCTION test_schema.role_and_project_ids_belong_to_org(); - `, - }); - }), - ); - - test( - "constraint trigger deletion", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.orders ( - id serial PRIMARY KEY, - amount integer NOT NULL - ); - CREATE FUNCTION test_schema.enforce_order_amount() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF NEW.amount < 0 THEN - RAISE EXCEPTION 'amount must be >= 0'; - END IF; - RETURN NULL; - END; - $$; - CREATE CONSTRAINT TRIGGER enforce_order_amount - AFTER INSERT OR UPDATE ON test_schema.orders - FOR EACH ROW - EXECUTE FUNCTION test_schema.enforce_order_amount(); - `, - testSql: ` - DROP TRIGGER enforce_order_amount ON test_schema.orders; - `, - }); - }), - ); - - test( - "constraint trigger comment alteration", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.accounts ( - id serial PRIMARY KEY, - balance integer NOT NULL - ); - CREATE FUNCTION test_schema.guard_balance() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF NEW.balance < 0 THEN - RAISE EXCEPTION 'balance must be >= 0'; - END IF; - RETURN NULL; - END; - $$; - CREATE CONSTRAINT TRIGGER guard_balance - AFTER INSERT OR UPDATE ON test_schema.accounts - FOR EACH ROW - EXECUTE FUNCTION test_schema.guard_balance(); - `, - testSql: ` - COMMENT ON TRIGGER guard_balance ON test_schema.accounts IS 'constraint trigger comment'; - `, - }); - }), - ); - - test( - "conditional trigger with WHEN clause", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.products ( - id serial PRIMARY KEY, - name text NOT NULL, - price numeric(10,2), - category text - ); - CREATE FUNCTION test_schema.log_price_changes() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RAISE NOTICE 'Price changed for product %: % -> %', NEW.name, OLD.price, NEW.price; - RETURN NEW; - END; - $$; - `, - testSql: ` - CREATE TRIGGER price_change_trigger - AFTER UPDATE ON test_schema.products - FOR EACH ROW - WHEN (OLD.price IS DISTINCT FROM NEW.price) - EXECUTE FUNCTION test_schema.log_price_changes(); - `, - }); - }), - ); - - test( - "trigger dropping", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.test_table ( - id serial PRIMARY KEY, - value text - ); - CREATE FUNCTION test_schema.test_trigger_func() - RETURNS trigger - LANGUAGE plpgsql - AS 'BEGIN RETURN NEW; END;'; - CREATE TRIGGER old_trigger - BEFORE INSERT ON test_schema.test_table - FOR EACH ROW - EXECUTE FUNCTION test_schema.test_trigger_func(); - `, - testSql: `DROP TRIGGER old_trigger ON test_schema.test_table;`, - }); - }), - ); - - test( - "trigger replacement (modification)", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id serial PRIMARY KEY, - email text UNIQUE, - created_at timestamp DEFAULT now() - ); - CREATE FUNCTION test_schema.validate_email() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF NEW.email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$' THEN - RAISE EXCEPTION 'Invalid email format'; - END IF; - RETURN NEW; - END; - $$; - CREATE TRIGGER email_validation_trigger - BEFORE INSERT ON test_schema.users - FOR EACH ROW - EXECUTE FUNCTION test_schema.validate_email(); - `, - testSql: dedent` - CREATE OR REPLACE FUNCTION test_schema.validate_email() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - BEGIN - -- Updated validation logic - IF NEW.email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$' THEN - RAISE EXCEPTION 'Invalid email format: %', NEW.email; - END IF; - -- Additional validation - IF length(NEW.email) > 255 THEN - RAISE EXCEPTION 'Email too long'; - END IF; - RETURN NEW; - END; - $function$; - - DROP TRIGGER email_validation_trigger ON test_schema.users; - - CREATE TRIGGER email_validation_trigger - BEFORE INSERT OR UPDATE ON test_schema.users - FOR EACH ROW - EXECUTE FUNCTION test_schema.validate_email(); - `, - }); - }), - ); - - test( - "trigger after function dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema", - testSql: dedent` - CREATE TABLE test_schema.events ( - id serial PRIMARY KEY, - event_type text, - occurred_at timestamp DEFAULT now() - ); - - CREATE FUNCTION test_schema.notify_event() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - BEGIN - PERFORM pg_notify('event_occurred', NEW.event_type); - RETURN NEW; - END; - $function$; - - CREATE TRIGGER event_notification_trigger - AFTER INSERT ON test_schema.events - FOR EACH ROW - EXECUTE FUNCTION test_schema.notify_event(); - `, - }); - }), - ); - - test( - "drop trigger before dropping trigger function", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.foo (id integer PRIMARY KEY); - CREATE FUNCTION test_schema.bar() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RETURN NULL; - END; - $$; - CREATE TRIGGER foo_insert - BEFORE INSERT ON test_schema.foo - FOR EACH ROW - EXECUTE FUNCTION test_schema.bar(); - `, - testSql: dedent` - DROP TRIGGER foo_insert ON test_schema.foo; - DROP FUNCTION test_schema.bar(); - `, - }); - }), - ); - - test( - "drop all triggers before dropping shared trigger function", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.foo (id integer PRIMARY KEY); - CREATE TABLE test_schema.bar (id integer PRIMARY KEY); - CREATE FUNCTION test_schema.shared_trigger_fn() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RETURN NEW; - END; - $$; - CREATE TRIGGER foo_insert - BEFORE INSERT ON test_schema.foo - FOR EACH ROW - EXECUTE FUNCTION test_schema.shared_trigger_fn(); - CREATE TRIGGER bar_insert - BEFORE INSERT ON test_schema.bar - FOR EACH ROW - EXECUTE FUNCTION test_schema.shared_trigger_fn(); - `, - testSql: dedent` - DROP TRIGGER foo_insert ON test_schema.foo; - DROP TRIGGER bar_insert ON test_schema.bar; - DROP FUNCTION test_schema.shared_trigger_fn(); - `, - }); - }), - ); - - test( - "trigger semantic equality", - withDb(pgVersion, async (db) => { - // Setup: Create a trigger in both databases - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: `CREATE SCHEMA test_schema - CREATE TABLE test_schema.test_table ( - id serial PRIMARY KEY, - value text - ); - CREATE FUNCTION test_schema.test_func() - RETURNS trigger - LANGUAGE plpgsql - AS 'BEGIN RETURN NEW; END;'; - CREATE TRIGGER test_trigger - BEFORE INSERT ON test_schema.test_table - FOR EACH ROW - EXECUTE FUNCTION test_schema.test_func();`, - expectedSqlTerms: [], - }); - }), - ); - - test( - "trigger with dependencies roundtrip", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema", - testSql: dedent` - CREATE TABLE test_schema.orders ( - id serial PRIMARY KEY, - customer_id integer NOT NULL, - total_amount numeric(10,2), - status text DEFAULT 'pending', - created_at timestamp DEFAULT now(), - updated_at timestamp DEFAULT now() - ); - - CREATE TABLE test_schema.order_audit ( - id serial PRIMARY KEY, - order_id integer, - old_status text, - new_status text, - changed_at timestamp DEFAULT now() - ); - - CREATE FUNCTION test_schema.audit_order_status() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - BEGIN - IF OLD.status IS DISTINCT FROM NEW.status THEN - INSERT INTO test_schema.order_audit (order_id, old_status, new_status) - VALUES (NEW.id, OLD.status, NEW.status); - END IF; - RETURN NEW; - END; - $function$; - - CREATE FUNCTION test_schema.update_order_timestamp() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - BEGIN - NEW.updated_at = now(); - RETURN NEW; - END; - $function$; - - CREATE TRIGGER order_status_audit_trigger - AFTER UPDATE ON test_schema.orders - FOR EACH ROW - WHEN (OLD.status IS DISTINCT FROM NEW.status) - EXECUTE FUNCTION test_schema.audit_order_status(); - - CREATE TRIGGER order_timestamp_trigger - BEFORE UPDATE ON test_schema.orders - FOR EACH ROW - EXECUTE FUNCTION test_schema.update_order_timestamp(); - `, - }); - }), - ); - - test( - "trigger comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: dedent` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.logs ( - id serial PRIMARY KEY, - msg text, - created_at timestamp DEFAULT now() - ); - CREATE FUNCTION test_schema.log_insert() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - RETURN NEW; - END; - $$; - CREATE TRIGGER logs_insert_trigger - BEFORE INSERT ON test_schema.logs - FOR EACH ROW - EXECUTE FUNCTION test_schema.log_insert(); - `, - testSql: ` - COMMENT ON TRIGGER logs_insert_trigger ON test_schema.logs IS 'logs insert trigger'; - `, - }); - }), - ); - - // Assert that https://github.com/djrobstep/migra/issues/159 is working - test( - "hasura event trigger function introspection", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "", - testSql: dedent` - CREATE SCHEMA IF NOT EXISTS hdb_catalog; - CREATE SCHEMA IF NOT EXISTS hdb_views; - - -- Minimal stub for Hasura's event log insertion function - CREATE OR REPLACE FUNCTION hdb_catalog.insert_event_log( - schema_name text, - table_name text, - trigger_name text, - op text, - data json - ) RETURNS void - LANGUAGE plpgsql - AS $fn$ - BEGIN - PERFORM 1; - END; - $fn$; - - CREATE FUNCTION hdb_views."notify_hasura_my_event_trigger_name_I"() RETURNS trigger - LANGUAGE plpgsql - AS $$ - DECLARE - _old record; - _new record; - _data json; - BEGIN - IF TG_OP = 'UPDATE' THEN - _old := row(OLD ); - _new := row(NEW ); - ELSE - /* initialize _old and _new with dummy values for INSERT and UPDATE events*/ - _old := row((select 1)); - _new := row((select 1)); - END IF; - _data := json_build_object( - 'old', NULL, - 'new', row_to_json(NEW ) - ); - BEGIN - IF (TG_OP <> 'UPDATE') OR (_old <> _new) THEN - PERFORM hdb_catalog.insert_event_log(CAST(TG_TABLE_SCHEMA AS text), CAST(TG_TABLE_NAME AS text), CAST('my_event_trigger_name' AS text), TG_OP, _data); - END IF; - EXCEPTION WHEN undefined_function THEN - IF (TG_OP <> 'UPDATE') OR (_old *<> _new) THEN - PERFORM hdb_catalog.insert_event_log(CAST(TG_TABLE_SCHEMA AS text), CAST(TG_TABLE_NAME AS text), CAST('my_event_trigger_name' AS text), TG_OP, _data); - END IF; - END; - - RETURN NULL; - END; - $$; - `, - }); - }), - - // Test with a table, with a two columns, and a trigger that use the two in it's WHEN clause - // Then one of the table column is dropped or when a column is added, what happen to the trigger ? - // Eg: a trigger like so: - // CREATE OR REPLACE FUNCTION post_activity_func() - // RETURNS TRIGGER - // AS - // $$ - // BEGIN - // IF TG_OP = 'UPDATE' AND (NOT NEW.draft OR (NOT OLD.draft AND NEW.draft)) -- (publish) OR (publish to draft) - // THEN - // INSERT INTO post_activity ( - // id, context, creation_date, data, last_published_date, draft, growth, internet_link, title, - // todo, votes_average, brand_id, category_id, country_id, localisation_id, outcome_id, user_id, - // ability_id, strategy_id, opportunity_id, created_by, created_date, last_modified_by, - // last_modified_date, operation - // ) VALUES (OLD.*, 'UPDATE') ON CONFLICT ON CONSTRAINT post_activity_pkey DO NOTHING; - - // RETURN NEW; - // ELSIF TG_OP = 'DELETE' THEN - // INSERT INTO post_activity ( - // id, context, creation_date, data, last_published_date, draft, growth, internet_link, title, - // todo, votes_average, brand_id, category_id, country_id, localisation_id, outcome_id, user_id, - // ability_id, strategy_id, opportunity_id, created_by, created_date, last_modified_by, - // last_modified_date, operation - // ) VALUES (OLD.*, 'DELETE') ON CONFLICT ON CONSTRAINT post_activity_pkey DO NOTHING; - // RETURN OLD; - // ELSIF TG_OP = 'UPDATE' THEN - // RETURN NEW; - // END IF; - // END; - // $$ - // LANGUAGE plpgsql; - // Then table is altered in the diff with something like this: - // alter table post_activity add removed boolean default false; - // If the trigger ins't updated to this, it will cause an error (probably): - // CREATE OR REPLACE FUNCTION post_activity_func() - // RETURNS TRIGGER - // AS - // $$ - // BEGIN - // IF TG_OP = 'UPDATE' AND (NOT NEW.draft OR (NOT OLD.draft AND NEW.draft)) -- (publish) OR (publish to draft) - // THEN - // INSERT INTO post_activity ( - // id, context, creation_date, data, last_published_date, draft, growth, internet_link, title, - // todo, votes_average, brand_id, category_id, country_id, localisation_id, outcome_id, user_id, - // ability_id, strategy_id, opportunity_id, created_by, created_date, last_modified_by, - // last_modified_date, removed, operation - // ) VALUES (OLD.*, 'UPDATE') ON CONFLICT ON CONSTRAINT post_activity_pkey DO NOTHING; - - // RETURN NEW; - // ELSIF TG_OP = 'DELETE' THEN - // INSERT INTO post_activity ( - // id, context, creation_date, data, last_published_date, draft, growth, internet_link, title, - // todo, votes_average, brand_id, category_id, country_id, localisation_id, outcome_id, user_id, - // ability_id, strategy_id, opportunity_id, created_by, created_date, last_modified_by, - // last_modified_date, removed, operation - // ) VALUES (OLD.*, 'DELETE') ON CONFLICT ON CONSTRAINT post_activity_pkey DO NOTHING; - // RETURN OLD; - // ELSIF TG_OP = 'UPDATE' THEN - // RETURN NEW; - // END IF; - // END; - // $$ - // LANGUAGE plpgsql; - - // Another test case, with tables with depending view that would cause an error if we use a `CREATE OR REPLACE` on the view rather than a DROP + CREATE: - // When a table gains a new column and a dependent view uses SELECT t.*, pgschema emits CREATE OR REPLACE VIEW which fails because PostgreSQL cannot rename existing view columns. - - // Reproduction - // Given: - - // CREATE TABLE item ( - // id uuid PRIMARY KEY, - // title text, - // status text - // ); - - // CREATE VIEW item_extended AS - // SELECT i.*, c.name AS category_name - // FROM item i JOIN category c ON ...; - // Now add a column to the table: - - // -- In desired state SQL: - // CREATE TABLE item ( - // id uuid PRIMARY KEY, - // title text, - // status text, - // new_col text -- added - // ); - // pgschema detects item_extended needs updating (because i.* now includes new_col) and emits: - - // CREATE OR REPLACE VIEW item_extended AS - // SELECT i.*, c.name AS category_name FROM ...; - // This fails with: - - // ERROR: cannot change name of view column "category_name" to "new_col" (SQLSTATE 42P16) - // The i.* expansion now includes new_col before category_name, shifting column positions. PostgreSQL's CREATE OR REPLACE VIEW does not allow renaming existing columns. - - // Expected behavior - // When a view's column set changes (not just the query body), pgschema should DROP VIEW + CREATE VIEW instead of CREATE OR REPLACE VIEW. This may require cascading drops for dependent views, which should be recreated afterward. - - // Impact - // This blocks routine schema changes (adding columns to core tables) from being applied automatically. Any table referenced by views using SELECT * is affected. - ); - }); -} diff --git a/packages/pg-delta/tests/integration/trigger-update-of-column-numbers.test.ts b/packages/pg-delta/tests/integration/trigger-update-of-column-numbers.test.ts deleted file mode 100644 index 0880fa223..000000000 --- a/packages/pg-delta/tests/integration/trigger-update-of-column-numbers.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Integration test: minimal reproduction of the "CREATE OR REPLACE TRIGGER" - * infinite diff loop. - * - * Bug summary: - * `Trigger.column_numbers` stores the raw `pg_trigger.tgattr` int2vector, - * i.e. the physical `attnum` of each column referenced by `CREATE TRIGGER - * ... UPDATE OF col_a, col_b, ...`. When the same trigger is created on - * logically-identical tables whose physical column layout differs (because - * one was built with `CREATE TABLE ... (everything)` and the other grew via - * `CREATE TABLE + ALTER TABLE DROP/ADD COLUMN`), the `tgattr` vectors - * disagree even though the column NAMES in the trigger definition match. - * - * `pg_get_triggerdef()` renders column names (not attnums), so the - * emitted `CREATE OR REPLACE TRIGGER ...` SQL is functionally correct and - * identical on both sides. However, the catalog diff compares `tgattr` - * through `deepEqual` in `NON_ALTERABLE_FIELDS` and always flags the - * trigger as needing a `ReplaceTrigger`. Because `CREATE OR REPLACE - * TRIGGER` does not renumber the underlying table's columns, re-applying - * the emitted SQL never converges -- every subsequent sync reports the - * same phantom change. - * - * This test reproduces the behavior with the smallest possible setup so the - * regression signal is easy to interpret. - */ - -import { describe, expect, test } from "bun:test"; -import { diffCatalogs } from "../../src/core/catalog.diff.ts"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import { ReplaceTrigger } from "../../src/core/objects/trigger/changes/trigger.alter.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDbIsolated } from "../utils.ts"; - -const TRIGGER_FUNCTION_SQL = ` - CREATE FUNCTION public.trg_fn() RETURNS trigger - LANGUAGE plpgsql AS $$ - BEGIN - RETURN NEW; - END; - $$; -`; - -const TRIGGER_SQL = ` - CREATE TRIGGER trg - BEFORE UPDATE OF a, b, d - ON public.t - FOR EACH ROW - EXECUTE FUNCTION public.trg_fn(); -`; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`trigger UPDATE OF column-number diff loop (pg${pgVersion})`, () => { - test( - "same-named columns on tables with different physical attnums must not produce a trigger diff", - withDbIsolated(pgVersion, async (db) => { - // main: built with a single CREATE TABLE -- columns a, b, d get - // consecutive attnums 1, 2, 3. - await db.main.query(` - CREATE TABLE public.t ( - a int, - b int, - d int - ); - `); - await db.main.query(TRIGGER_FUNCTION_SQL); - await db.main.query(TRIGGER_SQL); - - // branch: same logical columns, but grown via ALTER TABLE so that the - // physical attnums of a, b, d differ from main (in particular, b was - // dropped and re-added, and d was added after c was dropped -- so - // tgattr on branch will contain sparse, larger attnums). - await db.branch.query(` - CREATE TABLE public.t ( - a int, - b int, - c int - ); - ALTER TABLE public.t DROP COLUMN b; - ALTER TABLE public.t DROP COLUMN c; - ALTER TABLE public.t ADD COLUMN b int; - ALTER TABLE public.t ADD COLUMN d int; - `); - await db.branch.query(TRIGGER_FUNCTION_SQL); - await db.branch.query(TRIGGER_SQL); - - // Sanity check: the two trigger definitions as rendered by - // pg_get_triggerdef() should be identical (column NAMES match). This - // confirms the emitted SQL is semantically equivalent -- only the - // physical attnums differ. - const mainDef = await db.main.query<{ def: string }>( - `SELECT pg_get_triggerdef(oid) AS def FROM pg_trigger WHERE tgname = 'trg' AND NOT tgisinternal`, - ); - const branchDef = await db.branch.query<{ def: string }>( - `SELECT pg_get_triggerdef(oid) AS def FROM pg_trigger WHERE tgname = 'trg' AND NOT tgisinternal`, - ); - expect(mainDef.rows[0].def).toBe(branchDef.rows[0].def); - - // Extract catalogs and diff. With the current bug, the diff emits a - // ReplaceTrigger because `column_numbers` (tgattr) differs between - // main and branch even though the logical trigger is identical. - const mainCatalog = await extractCatalog(db.main); - const branchCatalog = await extractCatalog(db.branch); - const firstChanges = diffCatalogs(mainCatalog, branchCatalog); - const firstTriggerReplaces = firstChanges.filter( - (c): c is ReplaceTrigger => c instanceof ReplaceTrigger, - ); - - if (firstTriggerReplaces.length > 0) { - console.error( - `[trigger-update-of-column-numbers] first-pass spurious ReplaceTrigger:\n${firstTriggerReplaces - .map((c) => c.serialize()) - .join(";\n")}`, - ); - } - - // Expected behavior: zero trigger diffs because the triggers are - // logically identical. With the current bug this assertion fails. - expect(firstTriggerReplaces).toHaveLength(0); - - // Second part of the bug: even if we apply the (semantically - // identical) CREATE OR REPLACE TRIGGER SQL that the diff emits, the - // next sync still reports the same phantom change because - // CREATE OR REPLACE TRIGGER does not move the table's column - // attnums. Demonstrate the non-converging loop. - // - // Force-run a ReplaceTrigger against main (even if the first check - // passed we still want to confirm idempotency under the worst case) - // to guarantee this branch is exercised independently of the fix. - const branchTrigger = Object.values(branchCatalog.triggers)[0]; - if (!branchTrigger) { - throw new Error( - "expected a trigger on branch for the non-convergence check", - ); - } - const replace = new ReplaceTrigger({ trigger: branchTrigger }); - await db.main.query(replace.serialize()); - - const mainCatalogAfter = await extractCatalog(db.main); - const secondChanges = diffCatalogs(mainCatalogAfter, branchCatalog); - const secondTriggerReplaces = secondChanges.filter( - (c): c is ReplaceTrigger => c instanceof ReplaceTrigger, - ); - - if (secondTriggerReplaces.length > 0) { - console.error( - `[trigger-update-of-column-numbers] second-pass non-convergence:\n${secondTriggerReplaces - .map((c) => c.serialize()) - .join(";\n")}`, - ); - } - - expect(secondTriggerReplaces).toHaveLength(0); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/type-operations.test.ts b/packages/pg-delta/tests/integration/type-operations.test.ts deleted file mode 100644 index 145aeebca..000000000 --- a/packages/pg-delta/tests/integration/type-operations.test.ts +++ /dev/null @@ -1,709 +0,0 @@ -/** - * Integration tests for PostgreSQL type operations. - */ - -import { describe, expect, test } from "bun:test"; -import dedent from "dedent"; -import { extractCatalog } from "../../src/core/catalog.model.ts"; -import type { Change } from "../../src/core/change.types.ts"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`type operations (pg${pgVersion})`, () => { - test( - "create enum type", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TYPE test_schema.mood AS ENUM ('sad', 'ok', 'happy'); - `, - }); - }), - ); - test( - "add enum value before setting default to the new value", - withDb(pgVersion, async (db) => { - const initialSetup = ` - CREATE TYPE public.user_role AS ENUM ('admin', 'user'); - - CREATE TABLE public.profiles ( - id integer PRIMARY KEY, - role public.user_role DEFAULT 'user' - ); - `; - await db.main.query(initialSetup); - await db.branch.query(initialSetup); - // The branch setup itself needs two separate queries: using the new - // value in the same implicit transaction would hit 55P04 — the exact - // behavior the generated plan has to avoid. - await db.branch.query("ALTER TYPE public.user_role ADD VALUE 'store'"); - await db.branch.query( - "ALTER TABLE public.profiles ALTER COLUMN role SET DEFAULT 'store'", - ); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - assertPlan: (plan) => { - expect(plan.units).toHaveLength(2); - expect(plan.units[1].reason).toBe("enum_value_visibility"); - }, - }); - }), - ); - - test( - "add enum value before adding check constraint that references it", - withDb(pgVersion, async (db) => { - const initialSetup = ` - CREATE TYPE public.order_status AS ENUM ('pending', 'shipped'); - - CREATE TABLE public.orders ( - id integer PRIMARY KEY, - status public.order_status DEFAULT 'pending' - ); - `; - await db.main.query(initialSetup); - await db.branch.query(initialSetup); - await db.branch.query( - "ALTER TYPE public.order_status ADD VALUE 'delivered'", - ); - await db.branch.query(` - ALTER TABLE public.orders - ADD CONSTRAINT status_check - CHECK (status IN ('pending', 'shipped', 'delivered')) - `); - - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - assertPlan: (plan) => { - expect(plan.units).toHaveLength(2); - expect(plan.units[1].reason).toBe("enum_value_visibility"); - }, - }); - }), - ); - - test( - "create domain type with constraint", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE DOMAIN test_schema.positive_int AS INTEGER CHECK (VALUE > 0); - `, - }); - }), - ); - test( - "domain CHECK function dependencies are ordered before domains", - withDb(pgVersion, async (db) => { - const schemaSql = "CREATE SCHEMA test_schema;"; - const testSql = dedent` - CREATE FUNCTION test_schema.check_prefix(val text, prefix text) - RETURNS boolean - LANGUAGE sql - IMMUTABLE - AS $function$ - SELECT starts_with(val, prefix) - $function$; - - CREATE DOMAIN test_schema.user_id AS text - CHECK (test_schema.check_prefix(VALUE, 'user_')); - - CREATE DOMAIN test_schema.org_id AS text - CHECK (test_schema.check_prefix(VALUE, 'org_')); - `; - - await db.main.query(schemaSql); - await db.branch.query(schemaSql); - await db.branch.query(testSql); - - const planResult = await createPlan(db.main, db.branch); - expect(planResult).toBeDefined(); - if (!planResult) { - throw new Error("Expected planResult to be defined"); - } - - const statements = flattenPlanStatements(planResult.plan); - const checkPrefixCreateIndex = statements.findIndex((statement) => - statement.includes("CREATE FUNCTION test_schema.check_prefix("), - ); - const userDomainCreateIndex = statements.findIndex((statement) => - statement.includes("CREATE DOMAIN test_schema.user_id"), - ); - const orgDomainCreateIndex = statements.findIndex((statement) => - statement.includes("CREATE DOMAIN test_schema.org_id"), - ); - - expect(checkPrefixCreateIndex).toBeGreaterThanOrEqual(0); - expect(userDomainCreateIndex).toBeGreaterThanOrEqual(0); - expect(orgDomainCreateIndex).toBeGreaterThanOrEqual(0); - expect(checkPrefixCreateIndex).toBeLessThan(userDomainCreateIndex); - expect(checkPrefixCreateIndex).toBeLessThan(orgDomainCreateIndex); - - const branchCatalog = await extractCatalog(db.branch); - const hasUserDomainDependency = branchCatalog.depends.some( - (depend) => - depend.dependent_stable_id.startsWith( - "constraint:test_schema.user_id.", - ) && depend.referenced_stable_id.includes("check_prefix("), - ); - const hasOrgDomainDependency = branchCatalog.depends.some( - (depend) => - depend.dependent_stable_id.startsWith( - "constraint:test_schema.org_id.", - ) && depend.referenced_stable_id.includes("check_prefix("), - ); - - expect(hasUserDomainDependency).toBe(true); - expect(hasOrgDomainDependency).toBe(true); - }), - ); - test( - "create composite type", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TYPE test_schema.address AS ( - street VARCHAR(90), - city VARCHAR(90), - state VARCHAR(2) - ); - `, - }); - }), - ); - test( - "domain CHECK dependency coexists with function using the domain type", - withDb(pgVersion, async (db) => { - const schemaSql = "CREATE SCHEMA test_schema;"; - const testSql = dedent` - CREATE FUNCTION test_schema.check_prefix(val text, prefix text) - RETURNS boolean - LANGUAGE sql - IMMUTABLE - AS $function$ - SELECT starts_with(val, prefix) - $function$; - - CREATE DOMAIN test_schema.user_id AS text - CHECK (test_schema.check_prefix(VALUE, 'user_')); - - CREATE FUNCTION test_schema.normalize_user_id(input test_schema.user_id) - RETURNS text - LANGUAGE sql - IMMUTABLE - AS $function$ - SELECT lower(input::text) - $function$; - `; - - await db.main.query(schemaSql); - await db.branch.query(schemaSql); - await db.branch.query(testSql); - - const planResult = await createPlan(db.main, db.branch); - expect(planResult).toBeDefined(); - if (!planResult) { - throw new Error("Expected planResult to be defined"); - } - - const statements = flattenPlanStatements(planResult.plan); - const checkPrefixCreateIndex = statements.findIndex((statement) => - statement.includes("CREATE FUNCTION test_schema.check_prefix("), - ); - const domainCreateIndex = statements.findIndex((statement) => - statement.includes("CREATE DOMAIN test_schema.user_id"), - ); - const normalizeCreateIndex = statements.findIndex((statement) => - statement.includes("CREATE FUNCTION test_schema.normalize_user_id("), - ); - - expect(checkPrefixCreateIndex).toBeGreaterThanOrEqual(0); - expect(domainCreateIndex).toBeGreaterThanOrEqual(0); - expect(normalizeCreateIndex).toBeGreaterThanOrEqual(0); - expect(checkPrefixCreateIndex).toBeLessThan(domainCreateIndex); - expect(domainCreateIndex).toBeLessThan(normalizeCreateIndex); - }), - ); - test( - "create range type", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TYPE test_schema.floatrange AS RANGE (subtype = float8); - `, - }); - }), - ); - test( - "drop enum type", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: - "CREATE SCHEMA test_schema; CREATE TYPE test_schema.old_mood AS ENUM ('sad', 'happy');", - testSql: ` - DROP TYPE test_schema.old_mood; - `, - }); - }), - ); - test( - "replace enum type (modify values)", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: - "CREATE SCHEMA test_schema; CREATE TYPE test_schema.status AS ENUM ('pending', 'approved');", - testSql: ` - DROP TYPE test_schema.status; - CREATE TYPE test_schema.status AS ENUM ('pending', 'approved', 'rejected'); - `, - }); - }), - ); - test( - "replace domain type (modify constraint)", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: - "CREATE SCHEMA test_schema; CREATE DOMAIN test_schema.valid_int AS INTEGER CHECK (VALUE > 0);", - testSql: ` - DROP DOMAIN test_schema.valid_int; - CREATE DOMAIN test_schema.valid_int AS INTEGER CHECK (VALUE >= 0 AND VALUE <= 100); - `, - }); - }), - ); - - test( - "enum type with table dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "enum-table-dependency", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TYPE test_schema.user_status AS ENUM ('active', 'inactive', 'pending'); - - CREATE TABLE test_schema.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - status test_schema.user_status DEFAULT 'pending' - ); - `, - }); - }), - ); - - test( - "domain type with table dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "domain-table-dependency", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE DOMAIN test_schema.email AS TEXT CHECK (VALUE ~ '^[^@]+@[^@]+\\.[^@]+$'); - - CREATE TABLE test_schema.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - email_address test_schema.email - ); - `, - }); - }), - ); - - test( - "composite type with table dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "composite-table-dependency", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TYPE test_schema.address AS ( - street TEXT, - city TEXT, - zip_code TEXT - ); - - CREATE TABLE test_schema.customers ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - billing_address test_schema.address, - shipping_address test_schema.address - ); - `, - }); - }), - ); - - test( - "multiple types complex dependencies", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "multiple-types-complex-dependencies", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA commerce;", - testSql: ` - -- Create base types - CREATE TYPE commerce.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled'); - CREATE DOMAIN commerce.price AS DECIMAL(10,2) CHECK (VALUE >= 0); - - -- Create composite type using domain - CREATE TYPE commerce.product_info AS ( - name TEXT, - description TEXT, - unit_price commerce.price - ); - - -- Create tables using all types - CREATE TABLE commerce.products ( - id INTEGER PRIMARY KEY, - info commerce.product_info, - category TEXT - ); - - CREATE TABLE commerce.orders ( - id INTEGER PRIMARY KEY, - status commerce.order_status DEFAULT 'pending', - total_amount commerce.price - ); - `, - }); - }), - ); - - test( - "type cascade drop with dependent table", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "type-cascade-drop-dependent-table", - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TYPE test_schema.priority AS ENUM ('low', 'medium', 'high'); - CREATE TABLE test_schema.tasks ( - id INTEGER PRIMARY KEY, - title TEXT, - priority test_schema.priority DEFAULT 'medium' - ); - `, - testSql: ` - DROP TABLE test_schema.tasks; - DROP TYPE test_schema.priority; - `, - }); - }), - ); - - test( - "type name with special characters", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "type-name-special-characters", - mainSession: db.main, - branchSession: db.branch, - initialSetup: 'CREATE SCHEMA "test-schema";', - testSql: ` - CREATE TYPE "test-schema"."user-status" AS ENUM ('active', 'in-active'); - CREATE DOMAIN "test-schema"."positive-number" AS INTEGER CHECK (VALUE > 0); - `, - }); - }), - ); - - test( - "materialized view with enum dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "materialized-view-enum-dependency", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA analytics;", - testSql: dedent` - CREATE TYPE analytics.status AS ENUM ('active', 'inactive', 'pending'); - - CREATE TABLE analytics.users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - status analytics.status DEFAULT 'pending' - ); - - CREATE MATERIALIZED VIEW analytics.user_status_summary AS - SELECT - status, - COUNT(*) as count - FROM analytics.users - GROUP BY status; - `, - }); - }), - ); - - test( - "materialized view with domain dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "materialized-view-domain-dependency", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA financial;", - testSql: dedent` - CREATE DOMAIN financial.currency AS DECIMAL(10,2) CHECK (VALUE >= 0); - - CREATE TABLE financial.transactions ( - id INTEGER PRIMARY KEY, - amount financial.currency NOT NULL, - description TEXT - ); - - CREATE MATERIALIZED VIEW financial.transaction_summary AS - SELECT - SUM(amount) as total_amount, - COUNT(*) as transaction_count - FROM financial.transactions - WHERE amount > 0; - `, - }); - }), - ); - - test( - "materialized view with composite type dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "materialized-view-composite-dependency", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA inventory;", - testSql: dedent` - CREATE TYPE inventory.address AS ( - street TEXT, - city TEXT, - zip_code TEXT - ); - - CREATE TABLE inventory.warehouses ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - location inventory.address - ); - - CREATE MATERIALIZED VIEW inventory.warehouse_locations AS - SELECT - name, - (location).city as city, - (location).zip_code as zip_code - FROM inventory.warehouses - WHERE (location).city IS NOT NULL; - `, - }); - }), - ); - - test( - "complex mixed dependencies with materialized views", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "complex-mixed-dependencies-materialized-views", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA ecommerce;", - testSql: dedent` - -- Create types - CREATE TYPE ecommerce.order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered'); - CREATE DOMAIN ecommerce.price AS DECIMAL(10,2) CHECK (VALUE >= 0); - CREATE TYPE ecommerce.product_info AS ( - name TEXT, - description TEXT, - base_price ecommerce.price - ); - - -- Create tables using the types - CREATE TABLE ecommerce.products ( - id INTEGER PRIMARY KEY, - info ecommerce.product_info NOT NULL, - category TEXT - ); - - CREATE TABLE ecommerce.orders ( - id INTEGER PRIMARY KEY, - status ecommerce.order_status DEFAULT 'pending', - final_price ecommerce.price NOT NULL - ); - - -- Create materialized views that depend on the tables and types - CREATE MATERIALIZED VIEW ecommerce.product_pricing AS - SELECT - id, - (info).name as product_name, - (info).base_price as base_price, - category - FROM ecommerce.products - WHERE (info).base_price > 0; - - CREATE MATERIALIZED VIEW ecommerce.order_summary AS - SELECT - status, - COUNT(*) as order_count, - AVG(final_price) as avg_price - FROM ecommerce.orders - GROUP BY status; - `, - }); - }), - ); - - test( - "drop type with materialized view dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "drop-type-materialized-view-dependency", - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA reporting; - CREATE TYPE reporting.priority AS ENUM ('low', 'medium', 'high'); - CREATE TABLE reporting.tasks ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - priority reporting.priority DEFAULT 'medium' - ); - CREATE MATERIALIZED VIEW reporting.priority_stats AS - SELECT - priority, - COUNT(*) as task_count - FROM reporting.tasks - GROUP BY priority; - `, - testSql: ` - DROP MATERIALIZED VIEW reporting.priority_stats; - DROP TABLE reporting.tasks; - DROP TYPE reporting.priority; - `, - }); - }), - ); - - test( - "materialized view with range type dependency", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - name: "materialized-view-range-dependency", - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA scheduling;", - testSql: dedent` - CREATE TYPE scheduling.time_range AS RANGE (subtype = timestamp); - - CREATE TABLE scheduling.events ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - time_slot scheduling.time_range - ); - - CREATE MATERIALIZED VIEW scheduling.event_durations AS - SELECT - name, - EXTRACT(EPOCH FROM (upper(time_slot) - lower(time_slot))) / 3600 as duration_hours - FROM scheduling.events - WHERE time_slot IS NOT NULL; - `, - }); - }), - ); - - test( - "type comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TYPE test_schema.mood AS ENUM ('sad', 'ok', 'happy'); - CREATE DOMAIN test_schema.positive_int AS INTEGER CHECK (VALUE > 0); - CREATE TYPE test_schema.address AS ( - street TEXT, - city TEXT - ); - - COMMENT ON TYPE test_schema.mood IS 'mood type'; - COMMENT ON DOMAIN test_schema.positive_int IS 'positive integer domain'; - COMMENT ON TYPE test_schema.address IS 'address composite type'; - `, - sortChangesCallback: (a, b) => { - const priority = (change: Change) => { - if ( - change.objectType === "domain" && - change.scope === "comment" - ) { - return 0; - } - if (change.objectType === "enum" && change.scope === "comment") { - return 1; - } - if ( - change.objectType === "composite_type" && - change.scope === "comment" - ) { - return 2; - } - if ( - change.objectType === "domain" && - change.operation === "create" - ) { - return 3; - } - if ( - change.objectType === "enum" && - change.operation === "create" - ) { - return 4; - } - if ( - change.objectType === "composite_type" && - change.operation === "create" - ) { - return 5; - } - return 6; - }; - return priority(a) - priority(b); - }, - }); - }), - ); - }); -} diff --git a/packages/pg-delta/tests/integration/view-operations.test.ts b/packages/pg-delta/tests/integration/view-operations.test.ts deleted file mode 100644 index 7bf5962a0..000000000 --- a/packages/pg-delta/tests/integration/view-operations.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Integration tests for PostgreSQL view operations. - */ - -import { describe, expect, test } from "bun:test"; -import { createPlan } from "../../src/core/plan/create.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; -import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb, withDbIsolated } from "../utils.ts"; -import { roundtripFidelityTest } from "./roundtrip.ts"; - -for (const pgVersion of POSTGRES_VERSIONS) { - describe(`view operations (pg${pgVersion})`, () => { - test( - "simple view creation", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users ( - id integer, - name text, - email text - ); - - CREATE VIEW test_schema.active_users AS - SELECT id, name, email - FROM test_schema.users - WHERE email IS NOT NULL; - `, - }); - }), - ); - - test( - "nested view dependencies - 3 levels deep", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.users ( - id integer, - name text, - email text, - created_at timestamp DEFAULT NOW() - ); - - CREATE TABLE test_schema.orders ( - id integer, - user_id integer, - amount decimal(10,2), - created_at timestamp DEFAULT NOW() - ); - - -- Level 1: Views directly on tables - CREATE VIEW test_schema.recent_users AS - SELECT id, name, email, created_at - FROM test_schema.users - WHERE created_at > NOW() - INTERVAL '30 days'; - - CREATE VIEW test_schema.high_value_orders AS - SELECT id, user_id, amount, created_at - FROM test_schema.orders - WHERE amount > 100; - - -- Level 2: Views on other views - CREATE VIEW test_schema.recent_big_spenders AS - SELECT u.id, u.name, u.email, COUNT(o.id) as order_count, SUM(o.amount) as total_spent - FROM test_schema.recent_users u - JOIN test_schema.high_value_orders o ON u.id = o.user_id - GROUP BY u.id, u.name, u.email; - - -- Level 3: Views on views of views - CREATE VIEW test_schema.top_customers AS - SELECT id, name, email, total_spent - FROM test_schema.recent_big_spenders - WHERE total_spent > 1000 - ORDER BY total_spent DESC - LIMIT 10; - `, - }); - }), - ); - - test( - "view replacement with dependency changes", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - - CREATE TABLE test_schema.users ( - id integer, - name text, - status text - ); - - CREATE TABLE test_schema.profiles ( - user_id integer, - bio text, - avatar_url text - ); - - CREATE VIEW test_schema.user_summary AS - SELECT id, name, status - FROM test_schema.users; - `, - testSql: ` - -- Replace view to include profile data (new dependency) - CREATE OR REPLACE VIEW test_schema.user_summary AS - SELECT u.id, u.name, u.status, p.bio, p.avatar_url - FROM test_schema.users u - LEFT JOIN test_schema.profiles p ON u.id = p.user_id; - `, - }); - }), - ); - - test( - "recreates select-star view when base table columns change", - withDb(pgVersion, async (db) => { - const initialSetup = ` - CREATE SCHEMA test_schema; - - CREATE TABLE test_schema.items ( - id serial PRIMARY KEY, - title text NOT NULL, - status text DEFAULT 'active' - ); - - CREATE VIEW test_schema.item_details AS - SELECT i.* FROM test_schema.items i; - `; - - const testSql = ` - ALTER TABLE test_schema.items ADD COLUMN priority int DEFAULT 0; - - DROP VIEW test_schema.item_details; - CREATE VIEW test_schema.item_details AS - SELECT i.* FROM test_schema.items i; - `; - - await db.main.query(initialSetup); - await db.branch.query(initialSetup); - await db.branch.query(testSql); - - const result = await createPlan(db.main, db.branch); - expect(result).not.toBeNull(); - if (!result) throw new Error("expected plan result"); - - const statements = flattenPlanStatements(result.plan); - const dropViewIndex = statements.findIndex((statement) => - statement.startsWith("DROP VIEW test_schema.item_details"), - ); - const alterTableIndex = statements.findIndex((statement) => - statement.startsWith( - "ALTER TABLE test_schema.items ADD COLUMN priority integer DEFAULT 0", - ), - ); - const createViewIndex = statements.findIndex((statement) => - statement.startsWith("CREATE VIEW test_schema.item_details AS"), - ); - - expect(dropViewIndex).toBeGreaterThanOrEqual(0); - expect(alterTableIndex).toBeGreaterThanOrEqual(0); - expect(createViewIndex).toBeGreaterThanOrEqual(0); - expect(dropViewIndex).toBeLessThan(alterTableIndex); - expect(alterTableIndex).toBeLessThan(createViewIndex); - expect( - statements.some((statement) => - statement.startsWith( - "CREATE OR REPLACE VIEW test_schema.item_details AS", - ), - ), - ).toBe(false); - }), - ); - - test( - "complex view dependencies with multiple joins", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA analytics;", - testSql: ` - CREATE TABLE analytics.customers ( - id integer, - name text, - region text, - tier text - ); - - CREATE TABLE analytics.products ( - id integer, - name text, - category text, - price decimal(10,2) - ); - - CREATE TABLE analytics.sales ( - id integer, - customer_id integer, - product_id integer, - quantity integer, - sale_date date - ); - - -- Base analytical views - CREATE VIEW analytics.customer_stats AS - SELECT - c.id, - c.name, - c.region, - c.tier, - COUNT(s.id) as total_orders, - SUM(s.quantity * p.price) as total_revenue - FROM analytics.customers c - LEFT JOIN analytics.sales s ON c.id = s.customer_id - LEFT JOIN analytics.products p ON s.product_id = p.id - GROUP BY c.id, c.name, c.region, c.tier; - - CREATE VIEW analytics.product_performance AS - SELECT - p.id, - p.name, - p.category, - p.price, - COUNT(s.id) as units_sold, - SUM(s.quantity) as total_quantity - FROM analytics.products p - LEFT JOIN analytics.sales s ON p.id = s.product_id - GROUP BY p.id, p.name, p.category, p.price; - - -- Higher-level analytics view depending on both above views - CREATE VIEW analytics.business_summary AS - SELECT - 'customers' as metric_type, - COUNT(*) as count, - AVG(total_revenue) as avg_value - FROM analytics.customer_stats - WHERE total_revenue > 0 - - UNION ALL - - SELECT - 'products' as metric_type, - COUNT(*) as count, - AVG(price) as avg_value - FROM analytics.product_performance - WHERE units_sold > 0; - `, - }); - }), - ); - - test( - "valid recursive patterns are not flagged as cycles", - withDb(pgVersion, async (db) => { - // Test case: Valid recursive CTE pattern that should NOT be flagged as a cycle - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: "CREATE SCHEMA test_schema;", - testSql: ` - CREATE TABLE test_schema.employees ( - id integer, - name text, - manager_id integer - ); - - -- This is a valid recursive pattern using CTE, not a cycle - CREATE VIEW test_schema.employee_hierarchy AS - WITH RECURSIVE hierarchy AS ( - SELECT id, name, manager_id, 0 as level - FROM test_schema.employees - WHERE manager_id IS NULL - - UNION ALL - - SELECT e.id, e.name, e.manager_id, h.level + 1 - FROM test_schema.employees e - JOIN hierarchy h ON e.manager_id = h.id - ) - SELECT * FROM hierarchy; - `, - }); - }), - ); - - test( - "view comments", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer, - name text - ); - CREATE VIEW test_schema.user_names AS SELECT id, name FROM test_schema.users; - `, - testSql: ` - COMMENT ON VIEW test_schema.user_names IS 'users names view'; - `, - }); - }), - ); - - test( - "view with options", - withDb(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer, - name text - ); - CREATE VIEW test_schema.alter_options WITH (security_barrier = TRUE) AS SELECT id, name FROM test_schema.users; - CREATE VIEW test_schema.reset_options WITH (security_invoker = TRUE) AS SELECT id, name FROM test_schema.users; - `, - testSql: ` - ALTER VIEW test_schema.alter_options SET (security_invoker = TRUE, security_barrier = FALSE); - CREATE VIEW test_schema.create_with_options WITH (security_invoker = TRUE) AS SELECT id, name FROM test_schema.users; - ALTER VIEW test_schema.reset_options RESET (security_invoker); - `, - }); - }), - ); - - test( - "view owner change", - withDbIsolated(pgVersion, async (db) => { - await roundtripFidelityTest({ - mainSession: db.main, - branchSession: db.branch, - initialSetup: ` - CREATE ROLE view_previous_owner WITH LOGIN; - CREATE SCHEMA test_schema; - CREATE TABLE test_schema.users ( - id integer, - name text - ); - CREATE VIEW test_schema.owned_view AS SELECT id, name FROM test_schema.users; - ALTER VIEW test_schema.owned_view OWNER TO view_previous_owner; - `, - testSql: ` - CREATE ROLE view_new_owner WITH LOGIN; - ALTER VIEW test_schema.owned_view OWNER TO view_new_owner; - `, - }); - }), - ); - }); -} -// CASCADE operations are intentionally not supported as dependency resolution -// handles proper ordering of DROP operations automatically -// NOTE: View cycles can occur in PostgreSQL through recursive CTEs or complex dependency patterns. -// For example: -// - View A references View B in a subquery -// - View B references View A in a different context -// - Both views exist but create a logical circular dependency -// -// PostgreSQL itself prevents direct cycles during view creation, but complex patterns -// involving multiple views, functions, and recursive CTEs can create scenarios where -// dependency resolution becomes challenging. -// -// TODO: Add integration tests for view cycle detection once cycle detection is implemented -// in the dependency resolution system. These tests should verify that: -// 1. Obvious cycles are detected and reported -// 2. Complex multi-level cycles are identified -// 3. False positives (valid recursive patterns) are not flagged as cycles -// 4. Proper error messages guide users on how to resolve cycles diff --git a/packages/pg-delta/tests/load-lenient-function-bodies.test.ts b/packages/pg-delta/tests/load-lenient-function-bodies.test.ts new file mode 100644 index 000000000..f3427e98f --- /dev/null +++ b/packages/pg-delta/tests/load-lenient-function-bodies.test.ts @@ -0,0 +1,136 @@ +/** + * Regression coverage: a USER routine whose body fails the post-load + * `check_function_bodies = on` re-validation must NOT be a fatal load error by + * default. Postgres itself accepts the function under `check_function_bodies = + * off` (which pg-delta's own apply executor emits in every plan preamble, + * `src/plan/plan.ts`), so refusing to READ back a function pg-delta would + * happily WRITE is an asymmetry that blocks round-tripping any real schema that + * relies on check-off (legacy forward refs, tolerated casts, …). + * + * The loader now classifies a phase-2 body-validation failure three ways: + * 1. seeded/reference-only routine, unchanged → WARNING, distinct code + * `invalid_seeded_routine_body` (covered in load-seeded-schema-validation). + * 2. routine in a seeded schema but NOT an unchanged seed → FATAL, code + * `invalid_routine_body` (Codex #329 hardening — covered there too). + * 3. USER routine (schema not seeded) → WARNING by default (code + * `invalid_routine_body`), FATAL only under `strictFunctionBodies: true`. + * + * This file covers class 3 (the mission) and the strict opt-in, plus an + * end-to-end export → apply round-trip against a fresh database with default + * (lenient) settings. + * + * Stock alpine image; Docker required. + */ +import { describe, expect, test } from "bun:test"; +import { extract } from "../src/extract/extract.ts"; +import { exportSqlFiles } from "../src/frontends/export-sql-files.ts"; +import { + loadSqlFiles, + ShadowLoadError, +} from "../src/frontends/load-sql-files.ts"; +import { createTestDb, sharedCluster } from "./containers.ts"; + +async function captureError(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} + +// A user-schema function that loads fine under check-off but fails strict +// re-validation (references a table that does not exist). `public` is NOT a +// seeded schema, so this is class 3. +const LEGACY_FN_SQL = + "CREATE FUNCTION public.legacy() RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent';"; + +describe("loadSqlFiles — lenient user-routine body validation", () => { + test("a user routine that fails strict re-lint loads with a WARNING by default", async () => { + const shadow = await createTestDb("lenientdefault"); + try { + const result = await loadSqlFiles( + [{ name: "01_fn.sql", sql: LEGACY_FN_SQL }], + shadow.pool, + ); + + const warning = result.diagnostics.find( + (d) => d.code === "invalid_routine_body", + ); + expect(warning).toBeDefined(); + expect(warning?.severity).toBe("warning"); + expect(warning?.message).toContain("public.legacy:"); + expect(warning?.message).toContain("nonexistent"); + + // the function is present in the loaded fact base (it was created). + expect( + result.factBase.has({ + kind: "function", + schema: "public", + name: "legacy", + args: [], + }), + ).toBe(true); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("strictFunctionBodies: true restores the fatal gate for a user routine", async () => { + const shadow = await createTestDb("lenientstrict"); + try { + const err = await captureError( + loadSqlFiles([{ name: "01_fn.sql", sql: LEGACY_FN_SQL }], shadow.pool, { + strictFunctionBodies: true, + }), + ); + expect(err).toBeInstanceOf(ShadowLoadError); + const detail = (err as ShadowLoadError).details.find( + (d) => d.code === "invalid_routine_body", + ); + expect(detail).toBeDefined(); + expect(detail?.severity).toBe("error"); + expect(detail?.message).toContain("public.legacy:"); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("export → load round-trip of a check-off function succeeds with default (lenient) settings and preserves the def", async () => { + const cluster = await sharedCluster(); + const src = await cluster.createDb("lenient_rt_src"); + const shadow = await cluster.createDb("lenient_rt_shadow"); + try { + // Author a function on the SOURCE the way Postgres allows it: with + // check_function_bodies OFF, so a body that fails strict re-validation is + // accepted. Extraction just reads catalogs, so the source captures fine. + await src.pool.query( + `SET check_function_bodies = off; + CREATE FUNCTION public.legacy() RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent';`, + ); + const srcExtract = await extract(src.pool); + const fb = srcExtract.factBase; + const srcDef = ( + await src.pool.query( + `SELECT pg_get_functiondef('public.legacy()'::regprocedure) AS def`, + ) + ).rows[0] as { def: string }; + + // export → apply against a FRESH database with DEFAULT (lenient) settings. + const files = exportSqlFiles(fb, { layout: "by-object" }).filter( + (f) => !/cluster[_/]roles/.test(f.name), + ); + const loaded = await loadSqlFiles(files, shadow.pool); + + // fidelity: the loaded fact base hash-matches the source, AND the target's + // re-extracted function definition is byte-identical. + expect(loaded.factBase.rootHash).toBe(fb.rootHash); + const targetDef = ( + await shadow.pool.query( + `SELECT pg_get_functiondef('public.legacy()'::regprocedure) AS def`, + ) + ).rows[0] as { def: string }; + expect(targetDef.def).toBe(srcDef.def); + } finally { + await Promise.all([src.drop(), shadow.drop()]); + } + }, 120_000); +}); diff --git a/packages/pg-delta/tests/load-seeded-schema-validation.test.ts b/packages/pg-delta/tests/load-seeded-schema-validation.test.ts new file mode 100644 index 000000000..0f3773b36 --- /dev/null +++ b/packages/pg-delta/tests/load-seeded-schema-validation.test.ts @@ -0,0 +1,296 @@ +/** + * Regression coverage for Unit B + Codex #329 (comment 3573438706): body + * validation leniency in seeded schemas must be scoped to the routines the + * Phase 2b seed ACTUALLY created (by full overload-safe identity, and only + * while the body is unchanged) — NOT to every routine that happens to live in + * a seeded schema NAME. + * + * Under `--profile supabase`, the shadow is pre-seeded with ~900 platform + * objects (`options.seededSchemas` + `options.seededRoutines`) before the + * user's SQL files load. The post-load body-validation pass re-validates + * routines with `check_function_bodies = on`: + * - a SEEDED platform routine with an imperfect reconstruction must warn, so + * an engine bug in platform-code reconstruction doesn't abort the user's + * apply on code the user doesn't own; + * - a USER-authored routine — including a new overload of a seeded name, or a + * CREATE OR REPLACE of a seeded routine — must still THROW, because the + * user owns it and (being reference-only assumed-schema state in the diff) + * it would otherwise be a silent no-op. + * + * Cases: + * 1. broken routine that IS in the seed set (identity + unchanged def) warns. + * 2. broken routine OUTSIDE any seeded schema throws (backward compat, no + * `seededRoutines` option passed). + * A. user-authored broken routine in a seeded schema (empty seed set) throws. + * B. broken NEW OVERLOAD of a seeded routine name throws. + * C. broken CREATE OR REPLACE of a seeded routine throws. + */ +import { describe, expect, test } from "bun:test"; +import { encodeId, type StableId } from "../src/core/stable-id.ts"; +import { + loadSqlFiles, + ShadowLoadError, +} from "../src/frontends/load-sql-files.ts"; +import { createTestDb } from "./containers.ts"; +import type { PoolClient } from "pg"; + +async function captureError(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} + +/** Mirror production (`deriveAssumedSchemaSeed`): build the encodedId -> def map + * for every function/procedure in a pre-seeded schema, using the SAME + * `format_type(proargtypes)` identity-args expression extraction uses, so the + * encoded ids reconstruct byte-for-byte. */ +async function seededRoutinesOf( + client: PoolClient, + schema: string, +): Promise> { + const res = await client.query( + ` + SELECT p.proname AS name, p.prokind AS prokind, + ARRAY(SELECT format_type(t.t, NULL) + FROM unnest(p.proargtypes) WITH ORDINALITY AS t(t, ord) + ORDER BY t.ord)::text[] AS args, + pg_get_functiondef(p.oid) AS def + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 AND p.prokind IN ('f', 'p')`, + [schema], + ); + const map = new Map(); + for (const r of res.rows as { + name: string; + prokind: string; + args: string[]; + def: string; + }[]) { + const id: StableId = { + kind: r.prokind === "p" ? "procedure" : "function", + schema, + name: r.name, + args: r.args.map(String), + }; + map.set(encodeId(id), r.def); + } + return map; +} + +describe("loadSqlFiles — seeded-routine body validation scoping", () => { + test("a broken routine in the seed set warns instead of blocking the load", async () => { + const shadow = await createTestDb("seededok"); + try { + // Simulate Phase 2b's pre-seed: a platform schema with a routine whose + // body is only invalid once check_function_bodies is turned back on. + const client = await shadow.pool.connect(); + let seededRoutines: Map; + try { + await client.query("CREATE SCHEMA platform"); + await client.query("SET check_function_bodies = off"); + await client.query( + "CREATE FUNCTION platform.broken() RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent'", + ); + seededRoutines = await seededRoutinesOf(client, "platform"); + } finally { + await client.query("RESET check_function_bodies").catch(() => {}); + client.release(); + } + + const result = await loadSqlFiles( + [ + { + name: "01_table.sql", + sql: "CREATE TABLE public.t (id integer PRIMARY KEY);", + }, + ], + shadow.pool, + { seededSchemas: ["platform"], seededRoutines }, + ); + + const warning = result.diagnostics.find( + (d) => d.code === "invalid_seeded_routine_body", + ); + expect(warning).toBeDefined(); + expect(warning?.severity).toBe("warning"); + expect(warning?.message.startsWith("platform.broken:")).toBe(true); + } finally { + await shadow.drop(); + } + }, 60_000); + + // A broken USER routine outside any seeded schema is lenient by DEFAULT: the + // load proceeds and the failure surfaces as a loud warning (Postgres accepted + // it under check-off, which pg-delta's own apply executor uses). Under the + // `strictFunctionBodies` opt-in it goes back to a fatal throw. + test("a broken routine outside seeded schemas warns by default, and the diagnostic names it", async () => { + const shadow = await createTestDb("seededbad"); + try { + const result = await loadSqlFiles( + [ + { + name: "01_fn.sql", + sql: "CREATE FUNCTION public.user_broken() RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent';", + }, + ], + shadow.pool, + ); + const warning = result.diagnostics.find( + (d) => d.code === "invalid_routine_body", + ); + expect(warning).toBeDefined(); + expect(warning?.severity).toBe("warning"); + expect(warning?.message).toContain("public.user_broken:"); + expect(warning?.message).toContain("nonexistent"); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("a broken routine outside seeded schemas still throws under strictFunctionBodies", async () => { + const shadow = await createTestDb("seededbadstrict"); + try { + const err = await captureError( + loadSqlFiles( + [ + { + name: "01_fn.sql", + sql: "CREATE FUNCTION public.user_broken() RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent';", + }, + ], + shadow.pool, + { strictFunctionBodies: true }, + ), + ); + expect(err).toBeInstanceOf(ShadowLoadError); + const shadowErr = err as ShadowLoadError; + const detail = shadowErr.details.find( + (d) => d.code === "invalid_routine_body", + ); + expect(detail).toBeDefined(); + expect(detail?.severity).toBe("error"); + expect(detail?.message).toContain("public.user_broken:"); + expect(detail?.message).toContain("nonexistent"); + } finally { + await shadow.drop(); + } + }, 60_000); + + // Case A — the Codex #329 hole: a USER-authored broken routine in a seeded + // schema NAME (but absent from the seed set) must throw, not warn. + test("a user-authored broken routine in a seeded schema (empty seed set) throws", async () => { + const shadow = await createTestDb("seedhole"); + try { + const client = await shadow.pool.connect(); + try { + await client.query("CREATE SCHEMA platform"); + } finally { + client.release(); + } + + const err = await captureError( + loadSqlFiles( + [ + { + name: "01_fn.sql", + sql: "CREATE FUNCTION platform.user_broken() RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent';", + }, + ], + shadow.pool, + { seededSchemas: ["platform"], seededRoutines: new Map() }, + ), + ); + expect(err).toBeInstanceOf(ShadowLoadError); + const detail = (err as ShadowLoadError).details.find( + (d) => d.code === "invalid_routine_body", + ); + expect(detail).toBeDefined(); + expect(detail?.message).toContain("platform.user_broken:"); + } finally { + await shadow.drop(); + } + }, 60_000); + + // Case B — a broken NEW OVERLOAD of a seeded routine name must throw: the + // seeded `platform.f(int)` is in the seed set, but `platform.f(text)` is the + // user's own object (a distinct stable id). + test("a broken new overload of a seeded routine name throws", async () => { + const shadow = await createTestDb("seedoverload"); + try { + const client = await shadow.pool.connect(); + let seededRoutines: Map; + try { + await client.query("CREATE SCHEMA platform"); + await client.query( + "CREATE FUNCTION platform.f(a integer) RETURNS int LANGUAGE sql AS 'SELECT 1'", + ); + seededRoutines = await seededRoutinesOf(client, "platform"); + } finally { + client.release(); + } + + const err = await captureError( + loadSqlFiles( + [ + { + name: "01_overload.sql", + sql: "CREATE FUNCTION platform.f(a text) RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent';", + }, + ], + shadow.pool, + { seededSchemas: ["platform"], seededRoutines }, + ), + ); + expect(err).toBeInstanceOf(ShadowLoadError); + const detail = (err as ShadowLoadError).details.find( + (d) => d.code === "invalid_routine_body", + ); + expect(detail).toBeDefined(); + expect(detail?.message).toContain("platform.f:"); + } finally { + await shadow.drop(); + } + }, 60_000); + + // Case C — a broken CREATE OR REPLACE of a seeded routine must throw: the + // identity matches the seed set, but the body has changed (def mismatch), so + // it is the user's code now. + test("a broken CREATE OR REPLACE of a seeded routine throws", async () => { + const shadow = await createTestDb("seedreplace"); + try { + const client = await shadow.pool.connect(); + let seededRoutines: Map; + try { + await client.query("CREATE SCHEMA platform"); + await client.query( + "CREATE FUNCTION platform.f(a integer) RETURNS int LANGUAGE sql AS 'SELECT 1'", + ); + seededRoutines = await seededRoutinesOf(client, "platform"); + } finally { + client.release(); + } + + const err = await captureError( + loadSqlFiles( + [ + { + name: "01_replace.sql", + sql: "CREATE OR REPLACE FUNCTION platform.f(a integer) RETURNS int LANGUAGE sql AS 'SELECT x FROM nonexistent';", + }, + ], + shadow.pool, + { seededSchemas: ["platform"], seededRoutines }, + ), + ); + expect(err).toBeInstanceOf(ShadowLoadError); + const detail = (err as ShadowLoadError).details.find( + (d) => d.code === "invalid_routine_body", + ); + expect(detail).toBeDefined(); + expect(detail?.message).toContain("platform.f:"); + } finally { + await shadow.drop(); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/load-sql-files-atomicity.test.ts b/packages/pg-delta/tests/load-sql-files-atomicity.test.ts new file mode 100644 index 000000000..c2086bf04 --- /dev/null +++ b/packages/pg-delta/tests/load-sql-files-atomicity.test.ts @@ -0,0 +1,221 @@ +/** + * Shadow loader transactional robustness (hardening Item 6 / review #5): + * each file applies inside an explicit transaction, so a mid-file failure + * leaves no partial state and the file retries cleanly; a non-transactional + * statement (CREATE INDEX CONCURRENTLY) still loads via a raw fallback. + */ +import { describe, expect, test } from "bun:test"; +import { + loadSqlFiles, + ShadowLoadError, +} from "../src/frontends/load-sql-files.ts"; +import { createTestDb } from "./containers.ts"; + +describe("loadSqlFiles — per-file transactional apply", () => { + test("a file that fails mid-way leaves no partial state and retries cleanly", async () => { + const shadow = await createTestDb("shadow_atomic"); + try { + // 1_a.sql: statement 1 (CREATE TABLE a) succeeds, statement 2 references + // b which does not exist yet → the whole file must roll back so that a + // is NOT created. Round 2 (after b loads) retries 1_a; if a had leaked + // from round 1, the retry would fail with "relation a already exists". + const result = await loadSqlFiles( + [ + { + name: "1_a.sql", + sql: `CREATE TABLE public.a (id integer PRIMARY KEY); + CREATE VIEW public.va AS SELECT id FROM public.b;`, + }, + { + name: "2_b.sql", + sql: `CREATE TABLE public.b (id integer PRIMARY KEY);`, + }, + ], + shadow.pool, + ); + expect(result.rounds).toBeGreaterThan(1); + expect( + result.factBase.has({ kind: "table", schema: "public", name: "a" }), + ).toBe(true); + expect( + result.factBase.has({ kind: "view", schema: "public", name: "va" }), + ).toBe(true); + expect( + result.factBase.has({ kind: "table", schema: "public", name: "b" }), + ).toBe(true); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("a file with an explicit COMMIT is rejected before any DDL is applied", async () => { + const shadow = await createTestDb("shadow_txn_control"); + try { + // The COMMIT would end the loader's per-file transaction early, letting + // table a commit before the (failing) reference to a nonexistent table. + // The loader must refuse the file outright, leaving the shadow untouched. + let error: unknown; + try { + await loadSqlFiles( + [ + { + name: "1_bad.sql", + sql: `CREATE TABLE public.a (id integer PRIMARY KEY); + COMMIT; + CREATE TABLE public.b (id integer REFERENCES public.missing);`, + }, + ], + shadow.pool, + ); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(ShadowLoadError); + + // nothing was applied — the shadow is still empty + const { rows } = await shadow.pool.query( + `SELECT count(*)::int AS n FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind = 'r'`, + ); + expect((rows[0] as { n: number }).n).toBe(0); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("round-budget exhaustion throws instead of returning partial state (review P1 #2)", async () => { + const shadow = await createTestDb("shadow_max_rounds"); + try { + // A two-round dependency chain: 00_table needs the schema 01_schema + // creates, so round 1 loads only the schema and round 2 is required for + // the table. With maxRounds: 1 the loader runs out of budget WITH the + // table still pending — it must fail loud, never return a fact base that + // silently omits the table (the partial-state bug). + let error: unknown; + try { + await loadSqlFiles( + [ + { name: "00_table.sql", sql: `CREATE TABLE app.t (id integer);` }, + { name: "01_schema.sql", sql: `CREATE SCHEMA app;` }, + ], + shadow.pool, + { maxRounds: 1 }, + ); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(ShadowLoadError); + // the error names the still-pending file and carries its last failure + expect((error as ShadowLoadError).message).toContain("00_table.sql"); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("maxRounds: 0 with any file throws before loading anything (review P1 #2)", async () => { + const shadow = await createTestDb("shadow_zero_rounds"); + try { + let error: unknown; + try { + await loadSqlFiles( + [{ name: "0_schema.sql", sql: `CREATE SCHEMA app;` }], + shadow.pool, + { maxRounds: 0 }, + ); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(ShadowLoadError); + // nothing was applied — the shadow is still empty + const { rows } = await shadow.pool.query( + `SELECT count(*)::int AS n FROM pg_namespace WHERE nspname = 'app'`, + ); + expect((rows[0] as { n: number }).n).toBe(0); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("a CREATE INDEX CONCURRENTLY file loads via the raw fallback", async () => { + const shadow = await createTestDb("shadow_concurrently"); + try { + const result = await loadSqlFiles( + [ + { + name: "0_table.sql", + sql: `CREATE TABLE public.t (id integer PRIMARY KEY, v integer);`, + }, + { + name: "1_index.sql", + sql: `CREATE INDEX CONCURRENTLY t_v_idx ON public.t (v);`, + }, + ], + shadow.pool, + ); + expect( + result.factBase.has({ + kind: "index", + schema: "public", + name: "t_v_idx", + }), + ).toBe(true); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("a non-transactional statement outside the allowlist is refused, not silently run", async () => { + const shadow = await createTestDb("shadow_fallback_deny"); + try { + // VACUUM raises SQLSTATE 25001 inside a transaction just like CREATE INDEX + // CONCURRENTLY, but it is not a declarative-schema statement. The raw + // fallback runs OUTSIDE the per-file transaction, so it must be restricted + // to CREATE INDEX CONCURRENTLY; any other 25001-raiser must be refused + // rather than executed unsandboxed against the live cluster. + let error: unknown; + try { + await loadSqlFiles( + [{ name: "0_vacuum.sql", sql: `VACUUM;` }], + shadow.pool, + ); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(ShadowLoadError); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("the 25001 raw fallback does not leak a cluster-global object", async () => { + const shadow = await createTestDb("shadow_fallback_leak"); + // CREATE DATABASE is cluster-global and non-transactional. On a co-located + // shadow it would persist on the target's live cluster. It must be refused, + // and the sibling database must never be created. + const leak = `pgdelta_fallback_leak_${Date.now().toString(36)}`; + try { + let error: unknown; + try { + await loadSqlFiles( + [{ name: "0_db.sql", sql: `CREATE DATABASE ${leak}` }], + shadow.pool, + ); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(ShadowLoadError); + const { rows } = await shadow.pool.query( + `SELECT count(*)::int AS n FROM pg_database WHERE datname = $1`, + [leak], + ); + expect((rows[0] as { n: number }).n).toBe(0); + } finally { + // best-effort: if a pre-fix run leaked the database, drop it. + await shadow.pool + .query(`DROP DATABASE IF EXISTS "${leak}" WITH (FORCE)`) + .catch(() => {}); + await shadow.drop(); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/load-sql-files-createrole-authz.test.ts b/packages/pg-delta/tests/load-sql-files-createrole-authz.test.ts new file mode 100644 index 000000000..17ad1c02c --- /dev/null +++ b/packages/pg-delta/tests/load-sql-files-createrole-authz.test.ts @@ -0,0 +1,80 @@ +/** + * Supabase hands users a CREATEROLE non-superuser `postgres`. On PG 16+, + * CREATE ROLE no longer auto-grants the creator membership, so + * `CREATE SCHEMA … AUTHORIZATION new_role` fails with + * "must be able to SET ROLE" unless `createrole_self_grant` is enabled for the + * load session. Stock-image superuser tests mask this; this file uses a + * CREATEROLE non-superuser to prove the loader sets the GUC. + */ +import { describe, expect, test } from "bun:test"; +import pg from "pg"; +import { loadSqlFiles } from "../src/frontends/load-sql-files.ts"; +import { createTestDb } from "./containers.ts"; + +const PG_MAJOR = Number( + /postgres:(\d+)/.exec( + process.env["PGDELTA_TEST_IMAGE"] ?? "postgres:17-alpine", + )?.[1] ?? "17", +); + +describe.skipIf(PG_MAJOR < 16)( + "loadSqlFiles — CREATEROLE non-superuser AUTHORIZATION", + () => { + test("CREATE ROLE + CREATE SCHEMA AUTHORIZATION converges", async () => { + const admin = await createTestDb("createrole_authz_admin"); + const roleName = `load_applier_${admin.name}`; + const schemaRole = `probe_owner_${admin.name}`; + let applier: pg.Pool | undefined; + try { + await admin.pool.query( + `CREATE ROLE ${roleName} LOGIN PASSWORD 'applier' CREATEROLE NOSUPERUSER INHERIT`, + ); + await admin.pool.query( + `GRANT CREATE ON DATABASE ${admin.name} TO ${roleName}`, + ); + await admin.pool.query(`GRANT ALL ON SCHEMA public TO ${roleName}`); + await admin.pool.query(`GRANT ${roleName} TO CURRENT_USER`); + + const url = new URL(admin.uri); + url.username = roleName; + url.password = "applier"; + applier = new pg.Pool({ connectionString: url.toString(), max: 2 }); + + const result = await loadSqlFiles( + [ + { + name: "cluster/roles.sql", + sql: `create role ${schemaRole} nologin; +create schema ${schemaRole} authorization ${schemaRole};`, + }, + ], + applier, + { mode: "isolatedCluster" }, + ); + expect(result.factBase.has({ kind: "role", name: schemaRole })).toBe( + true, + ); + expect(result.factBase.has({ kind: "schema", name: schemaRole })).toBe( + true, + ); + // createrole_self_grant bootstrap membership must not leak into the + // desired-state catalog (would plan GRANT … TO applier WITH ADMIN OPTION). + expect( + result.factBase.has({ + kind: "membership", + role: schemaRole, + member: roleName, + }), + ).toBe(false); + } finally { + await applier?.end().catch(() => undefined); + await admin.pool + .query( + `DROP SCHEMA IF EXISTS ${schemaRole} CASCADE; DROP ROLE IF EXISTS ${schemaRole}; DROP ROLE IF EXISTS ${roleName};`, + ) + .catch(() => undefined); + await admin.drop(); + } + }, 60_000); + }, +); diff --git a/packages/pg-delta/tests/load-sql-files-extension-rows.test.ts b/packages/pg-delta/tests/load-sql-files-extension-rows.test.ts new file mode 100644 index 000000000..66b0ff591 --- /dev/null +++ b/packages/pg-delta/tests/load-sql-files-extension-rows.test.ts @@ -0,0 +1,98 @@ +/** + * loadSqlFiles DML rejection scope (P2 of the 2026-06-16 handoff review). + * + * The shadow loader rejects user DATA statements in declarative files by + * observing whether any managed user table has rows after loading. "Managed + * user table" must mean the SAME thing the diff path manages — so the check + * reuses the extraction scope predicate AND excludes extension-owned relations. + * Otherwise installing an extension whose CREATE EXTENSION / setup seeds its own + * internal config table (here pg_partman's `part_config`) is wrongly rejected as + * if the user wrote DML. + * + * Uses the Supabase image (ships pg_partman). The complementary "user DML is + * still rejected" case is covered by tests/load-sql-files.test.ts (alpine). + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { + loadSqlFiles, + ShadowLoadError, +} from "../src/frontends/load-sql-files.ts"; +import { supabaseCluster, type TestDb } from "./containers.ts"; + +const dbs: TestDb[] = []; +afterAll(async () => { + await Promise.all(dbs.map((d) => d.drop().catch(() => {}))); +}); + +describe("loadSqlFiles: extension-owned internal rows are not DML", () => { + test("accepts declarative files that seed an extension's own config table", async () => { + const cluster = await supabaseCluster(); + const shadow = await cluster.createDb("loadsql_ext_rows"); + dbs.push(shadow); + + // create_parent seeds a row into partman.part_config — an EXTENSION-owned + // table (pg_depend deptype 'e'). Pre-fix, that row tripped the DML gate. + const result = await loadSqlFiles( + [ + { name: "0_schema.sql", sql: "CREATE SCHEMA partman;" }, + { + name: "1_ext.sql", + sql: "CREATE EXTENSION pg_partman WITH SCHEMA partman;", + }, + { + name: "2_parent.sql", + sql: `CREATE TABLE public.events ( + id bigint GENERATED ALWAYS AS IDENTITY, + created_at timestamptz NOT NULL + ) PARTITION BY RANGE (created_at);`, + }, + { + name: "3_create_parent.sql", + sql: `SELECT partman.create_parent( + p_parent_table := 'public.events', + p_control := 'created_at', + p_interval := '1 day');`, + }, + ], + shadow.pool, + ); + + // the load succeeds: part_config rows are extension-owned, not user DML, + // and the partitioned parent is captured as schema. + expect( + result.factBase.has({ + kind: "table", + schema: "public", + name: "events", + }), + ).toBe(true); + }, 240_000); + + test("still rejects genuine user DML alongside an extension", async () => { + const cluster = await supabaseCluster(); + const shadow = await cluster.createDb("loadsql_user_dml"); + dbs.push(shadow); + + const error = await loadSqlFiles( + [ + { name: "0_schema.sql", sql: "CREATE SCHEMA partman;" }, + { + name: "1_ext.sql", + sql: "CREATE EXTENSION pg_partman WITH SCHEMA partman;", + }, + { + name: "2_user.sql", + sql: "CREATE TABLE public.t (id int); INSERT INTO public.t VALUES (1);", + }, + ], + shadow.pool, + ).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(ShadowLoadError); + expect(String(error)).toMatch(/data statements/); + expect(String(error)).toMatch(/public/); + }, 240_000); +}); diff --git a/packages/pg-delta/tests/load-sql-files.test.ts b/packages/pg-delta/tests/load-sql-files.test.ts new file mode 100644 index 000000000..705c9cf17 --- /dev/null +++ b/packages/pg-delta/tests/load-sql-files.test.ts @@ -0,0 +1,509 @@ +/** Stage-7 shadow loader: ordering convergence + the rejection behaviors. */ +import { describe, expect, test } from "bun:test"; +import { + loadSqlFiles, + ShadowLoadError, +} from "../src/frontends/load-sql-files.ts"; + +async function captureError(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} +import { plan } from "../src/plan/plan.ts"; +import { provePlan } from "../src/proof/prove.ts"; +import { extract, type ExtractOptions } from "../src/extract/extract.ts"; +import { createTestDb, isolatedClusterPair } from "./containers.ts"; + +describe("loadSqlFiles (shadow frontend)", () => { + test("out-of-order files converge via bounded rounds", async () => { + const shadow = await createTestDb("shadow"); + try { + // lexicographic order is wrong on purpose: the view file sorts first + const result = await loadSqlFiles( + [ + { + name: "01_view.sql", + sql: "CREATE VIEW public.v AS SELECT id FROM public.t;", + }, + { + name: "02_table.sql", + sql: "CREATE TABLE public.t (id integer PRIMARY KEY);", + }, + ], + shadow.pool, + ); + expect(result.rounds).toBeGreaterThan(1); + expect( + result.factBase.has({ kind: "view", schema: "public", name: "v" }), + ).toBe(true); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("a stuck load names the offending statement (line + excerpt)", async () => { + const shadow = await createTestDb("shadow"); + try { + // one file that can never apply (references a relation nothing creates): + // the load gets stuck and must report WHICH statement failed, not just the + // file name + bare PG message — the failing line and a short excerpt. + const err = await captureError( + loadSqlFiles( + [ + { + name: "01_view.sql", + sql: "CREATE VIEW public.v AS\n SELECT id FROM public.missing_table;", + }, + ], + shadow.pool, + ), + ); + expect(err).toBeInstanceOf(ShadowLoadError); + const detail = (err as ShadowLoadError).details + .map((d) => d.message) + .join("\n"); + // the statement's location + excerpt, derived from the PG error position + expect(detail).toMatch(/at line \d+:/); + expect(detail).toContain("SELECT id FROM public.missing_table"); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("a deep dependency chain converges (rounds scale with depth, not the old 25 cap)", async () => { + // A linear chain of 30 views, each selecting from the next, with the base + // table last — and files named so lexicographic order is EXACTLY reverse + // dependency order. Round k resolves exactly one file, so it needs ~30 + // rounds. The old fixed maxRounds=25 fails this with "did not converge" + // even though it was making steady progress; the cap must scale with the + // file count so dependency depth is never an artificial ceiling. + const DEPTH = 30; + const shadow = await createTestDb("shadow"); + try { + const files = []; + for (let k = 0; k < DEPTH - 1; k++) { + files.push({ + name: `${String(k).padStart(2, "0")}_v${k}.sql`, + sql: `CREATE VIEW public.v${k} AS SELECT * FROM public.v${k + 1};`, + }); + } + files.push({ + name: `${String(DEPTH - 1).padStart(2, "0")}_v${DEPTH - 1}.sql`, + sql: `CREATE TABLE public.v${DEPTH - 1} (id integer);`, + }); + + const result = await loadSqlFiles(files, shadow.pool); + expect(result.rounds).toBeGreaterThanOrEqual(DEPTH); + expect( + result.factBase.has({ kind: "view", schema: "public", name: "v0" }), + ).toBe(true); + } finally { + await shadow.drop(); + } + }, 120_000); + + test("honors a caller-supplied extractor (profile-aware shadow projection)", async () => { + // schema apply passes its profile's ctx.extract so the shadow desired state + // is projected with the same handlers as the target (review P1). Verify the + // option is actually used: a custom extractor is invoked with the shadow + // pool and the sqlFiles provenance, and its result is returned verbatim. + const shadow = await createTestDb("shadow"); + try { + let calledWith: ExtractOptions | undefined; + const customExtract = ( + pool: typeof shadow.pool, + options?: ExtractOptions, + ): ReturnType => { + calledWith = options; + return extract(pool, options); + }; + const result = await loadSqlFiles( + [ + { + name: "01_table.sql", + sql: "CREATE TABLE public.t (id integer PRIMARY KEY);", + }, + ], + shadow.pool, + { extract: customExtract }, + ); + expect(calledWith).toEqual({ source: "sqlFiles" }); + expect( + result.factBase.has({ kind: "table", schema: "public", name: "t" }), + ).toBe(true); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("range type used by a table orders correctly from shuffled files (#282)", async () => { + const shadow = await createTestDb("shadow"); + try { + // the exact shuffle from supabase/pg-toolbelt#282: a table using a + // RANGE type before the type, before the schema. pg-topo classified + // CREATE TYPE AS RANGE as UNKNOWN and could not order it; the new + // engine's shadow loader resolves it by bounded rounds (no parser). + const result = await loadSqlFiles( + [ + { + name: "0_bookings.sql", + sql: "CREATE TABLE app.bookings (id int PRIMARY KEY, span app.int_range NOT NULL);", + }, + { + name: "1_range.sql", + sql: "CREATE TYPE app.int_range AS RANGE (subtype = int4);", + }, + { name: "2_schema.sql", sql: "CREATE SCHEMA app;" }, + ], + shadow.pool, + ); + expect(result.rounds).toBeGreaterThan(1); + expect( + result.factBase.has({ kind: "type", schema: "app", name: "int_range" }), + ).toBe(true); + expect( + result.factBase.has({ kind: "table", schema: "app", name: "bookings" }), + ).toBe(true); + // the column→type dependency edge resolved (span references int_range) + const edges = result.factBase.edges.map( + (e) => `${e.from.kind}->${e.to.kind}`, + ); + expect(edges).toContain("column->type"); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("unorderable input fails loudly with stuck statements, before extraction", async () => { + const shadow = await createTestDb("shadow"); + try { + const error = await captureError( + loadSqlFiles( + [ + { + name: "broken.sql", + sql: "CREATE VIEW public.v AS SELECT * FROM public.ghost;", + }, + ], + shadow.pool, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + expect(String(error)).toMatch(/stuck/); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("DML is rejected by observation, not parsing", async () => { + const shadow = await createTestDb("shadow"); + try { + const error = await captureError( + loadSqlFiles( + [ + { + name: "schema.sql", + sql: "CREATE TABLE public.t (id integer); INSERT INTO public.t VALUES (1);", + }, + ], + shadow.pool, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + expect(String(error)).toMatch(/data statements/); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("role-creating files are rejected in database-scratch mode", async () => { + const shadow = await createTestDb("shadow"); + try { + const error = await captureError( + loadSqlFiles( + [{ name: "roles.sql", sql: "CREATE ROLE shadow_leak_test NOLOGIN;" }], + shadow.pool, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + expect(String(error)).toMatch(/cluster-level/); + } finally { + await shadow.pool + .query("DROP ROLE IF EXISTS shadow_leak_test") + .catch(() => {}); + await shadow.drop(); + } + }, 60_000); + + test("typo'd function body is caught by re-validation", async () => { + const shadow = await createTestDb("shadow"); + try { + const error = await captureError( + loadSqlFiles( + [ + { + name: "fn.sql", + sql: `CREATE FUNCTION public.broken() RETURNS integer LANGUAGE sql AS 'SELECT id FROM public.missing_table';`, + }, + ], + shadow.pool, + // A user-routine body-lint is a WARNING by default now (lenient + // function bodies); the fatal re-validation throw is gated on the + // strictFunctionBodies opt-in. + { strictFunctionBodies: true }, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + const detail = (error as ShadowLoadError).details.find( + (d) => d.code === "invalid_routine_body", + ); + expect(detail).toBeDefined(); + expect(detail?.severity).toBe("error"); + } finally { + await shadow.drop(); + } + }, 60_000); + + test("declarative end-to-end: files -> shadow -> plan -> prove against a live target", async () => { + const shadow = await createTestDb("shadow"); + const target = await createTestDb("target"); + try { + await target.pool.query("CREATE TABLE public.old_stuff (id integer)"); + const loaded = await loadSqlFiles( + [ + { + name: "schema.sql", + sql: `CREATE TABLE public.users (id integer PRIMARY KEY, email text NOT NULL); + CREATE INDEX users_email_idx ON public.users (email);`, + }, + ], + shadow.pool, + ); + const current = await extract(target.pool); + const thePlan = plan(current.factBase, loaded.factBase); + const clone = await target.clone(); + try { + const verdict = await provePlan(thePlan, clone.pool, loaded.factBase); + expect(verdict.applyError).toBeUndefined(); + expect(verdict.driftDeltas).toEqual([]); + expect(verdict.ok).toBe(true); + } finally { + await clone.drop(); + } + } finally { + await Promise.all([shadow.drop(), target.drop()]); + } + }, 120_000); + + // ── Gap 1: isolatedCluster mode ────────────────────────────────────────── + + test("isolatedCluster mode: role-creating file loads successfully", async () => { + const [clusterA] = await isolatedClusterPair(); + const shadow = await clusterA.createDb("shadow_iso"); + const baselineRoles = await clusterA.listRoles(); + try { + const result = await loadSqlFiles( + [{ name: "roles.sql", sql: "CREATE ROLE iso_role_test NOLOGIN;" }], + shadow.pool, + { mode: "isolatedCluster" }, + ); + // loading must succeed without throwing + expect(result.rounds).toBeGreaterThanOrEqual(1); + } finally { + await clusterA.dropRolesExcept(baselineRoles); + await shadow.drop(); + } + }, 60_000); + + test("isolatedCluster mode: same role-creating file FAILS in databaseScratch mode and leaks no role", async () => { + const shadow = await createTestDb("shadow_scratch"); + try { + const error = await captureError( + loadSqlFiles( + [ + { + name: "roles.sql", + sql: "CREATE ROLE scratch_role_leak_test NOLOGIN;", + }, + ], + shadow.pool, + { mode: "databaseScratch" }, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + expect(String(error)).toMatch(/cluster-level/); + // the role must NOT survive on the shared cluster after the failed load + const present = await shadow.pool.query( + "SELECT 1 FROM pg_roles WHERE rolname = 'scratch_role_leak_test'", + ); + expect(present.rows.length).toBe(0); + } finally { + await shadow.pool + .query("DROP ROLE IF EXISTS scratch_role_leak_test") + .catch(() => {}); + await shadow.drop(); + } + }, 60_000); + + test("databaseScratch mode: role file + non-converging file FAILS and leaks no role", async () => { + const shadow = await createTestDb("shadow_scratch_multi"); + try { + const error = await captureError( + loadSqlFiles( + [ + { name: "roles.sql", sql: "CREATE ROLE leak_x NOLOGIN;" }, + { + name: "broken.sql", + sql: "CREATE TABLE public.t (c integer REFERENCES public.does_not_exist);", + }, + ], + shadow.pool, + { mode: "databaseScratch" }, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + // the role must NOT survive on the shared cluster after the failed load + const present = await shadow.pool.query( + "SELECT 1 FROM pg_roles WHERE rolname = 'leak_x'", + ); + expect(present.rows.length).toBe(0); + } finally { + await shadow.pool.query("DROP ROLE IF EXISTS leak_x").catch(() => {}); + await shadow.drop(); + } + }, 60_000); + + test("databaseScratch mode: DO-block CREATE ROLE evades preflight but the leaked role is restored", async () => { + const shadow = await createTestDb("shadow_scratch_doblock"); + try { + const error = await captureError( + loadSqlFiles( + [ + { + name: "doblock.sql", + sql: "DO $$ BEGIN EXECUTE 'CREATE ROLE do_block_leak NOLOGIN'; END $$;", + }, + ], + shadow.pool, + { mode: "databaseScratch" }, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + expect(String(error)).toMatch(/cluster-level/); + // the DO-block committed the role; the restore net must have dropped it + const present = await shadow.pool.query( + "SELECT 1 FROM pg_roles WHERE rolname = 'do_block_leak'", + ); + expect(present.rows.length).toBe(0); + } finally { + await shadow.pool + .query("DROP ROLE IF EXISTS do_block_leak") + .catch(() => {}); + await shadow.drop(); + } + }, 60_000); + + // ── Gap 2: pg_auth_members leak detection ──────────────────────────────── + + test("pg_auth_members leak: GRANT between pre-existing roles is detected in databaseScratch mode", async () => { + // We need two pre-existing roles on the shared cluster. + // Create them ahead of time, then attempt a GRANT in a file. + const shadow = await createTestDb("shadow_membership"); + const sharedPool = shadow.cluster.adminPool; + + // Set up two roles on the shared cluster before loading + await sharedPool + .query("CREATE ROLE membership_role_a NOLOGIN") + .catch(() => {}); + await sharedPool + .query("CREATE ROLE membership_role_b NOLOGIN") + .catch(() => {}); + + try { + const error = await captureError( + loadSqlFiles( + [ + { + name: "grant.sql", + // GRANT role_a TO role_b adds a pg_auth_members row without creating a new role + sql: "GRANT membership_role_a TO membership_role_b;", + }, + ], + shadow.pool, + { mode: "databaseScratch" }, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + // Must mention cluster-level or membership leak + expect(String(error)).toMatch(/cluster-level|membership/i); + } finally { + await sharedPool + .query("DROP ROLE IF EXISTS membership_role_b") + .catch(() => {}); + await sharedPool + .query("DROP ROLE IF EXISTS membership_role_a") + .catch(() => {}); + await shadow.drop(); + } + }, 60_000); + + // ── Gap 3: provenance tag ──────────────────────────────────────────────── + + test("provenance: loaded factBase.source === 'sqlFiles'", async () => { + const shadow = await createTestDb("shadow_provenance"); + try { + const result = await loadSqlFiles( + [ + { + name: "schema.sql", + sql: "CREATE TABLE public.prov_test (id integer PRIMARY KEY);", + }, + ], + shadow.pool, + ); + expect(result.factBase.source).toBe("sqlFiles"); + } finally { + await shadow.drop(); + } + }, 60_000); + + // ── Gap 4: mutual-FK split diagnostic ─────────────────────────────────── + + test("mutual-FK: two tables referencing each other inline get a split-FK hint", async () => { + const shadow = await createTestDb("shadow_mutualfk"); + try { + // a.sql creates table_a referencing table_b; b.sql creates table_b referencing table_a + // Neither can load first because the other doesn't exist yet. + const error = await captureError( + loadSqlFiles( + [ + { + name: "a.sql", + sql: `CREATE TABLE public.table_a ( + id integer PRIMARY KEY, + b_id integer REFERENCES public.table_b(id) + );`, + }, + { + name: "b.sql", + sql: `CREATE TABLE public.table_b ( + id integer PRIMARY KEY, + a_id integer REFERENCES public.table_a(id) + );`, + }, + ], + shadow.pool, + ), + ); + expect(error).toBeInstanceOf(ShadowLoadError); + const msg = String(error); + expect(msg).toMatch(/stuck/); + // Must include the split-FK remediation hint + expect(msg).toMatch(/ALTER TABLE|split/i); + } finally { + await shadow.drop(); + } + }, 60_000); +}); diff --git a/packages/pg-delta/tests/membership-revoke-no-cascade.test.ts b/packages/pg-delta/tests/membership-revoke-no-cascade.test.ts new file mode 100644 index 000000000..49d0e5143 --- /dev/null +++ b/packages/pg-delta/tests/membership-revoke-no-cascade.test.ts @@ -0,0 +1,86 @@ +/** + * A role-membership REVOKE must NOT be emitted with CASCADE. + * + * The membership drop rule rendered `REVOKE FROM CASCADE`. On + * PG16+, when the removed membership carried ADMIN OPTION and the member had + * granted the role onward, CASCADE ALSO deletes those downstream + * pg_auth_members rows — even ones that exist on BOTH diff sides and are meant + * to be KEPT. Extraction is grantor-blind by design, so nothing plans a + * corrective re-grant: the kept membership is silently destroyed. + * + * The fix drops CASCADE (plain REVOKE). On PG16+ with a dependent grant the + * REVOKE now fails LOUDLY ("dependent privileges exist") instead of silently + * destroying kept grants — the intended behaviour for now (convergent regrant + * is tracked separately, #333). Either way the kept (a → c) membership must + * survive the apply attempt. + * + * Isolated cluster (mutates cluster-global roles); Docker required. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { apply } from "../src/apply/apply.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { isolatedClusterPair } from "./containers.ts"; + +let cleanup: (() => Promise) | undefined; + +afterAll(async () => { + await cleanup?.(); +}); + +describe("role-membership revoke must not CASCADE", () => { + test("dropping an admin membership emits a plain REVOKE and keeps the downstream grant", async () => { + const [clusterA, clusterB] = await isolatedClusterPair(); + const baseA = await clusterA.listRoles(); + const baseB = await clusterB.listRoles(); + cleanup = async () => { + await clusterA.dropRolesExcept(baseA); + await clusterB.dropRolesExcept(baseB); + }; + + // SOURCE (cluster A): a → b WITH ADMIN OPTION, then b grants a → c. + await clusterA.adminPool.query(` + CREATE ROLE f3_a NOLOGIN; + CREATE ROLE f3_b NOLOGIN; + CREATE ROLE f3_c NOLOGIN; + GRANT f3_a TO f3_b WITH ADMIN OPTION; + SET ROLE f3_b; + GRANT f3_a TO f3_c; + RESET ROLE; + `); + // DESIRED (cluster B): keep a → c (granted directly), drop a → b. + await clusterB.adminPool.query(` + CREATE ROLE f3_a NOLOGIN; + CREATE ROLE f3_b NOLOGIN; + CREATE ROLE f3_c NOLOGIN; + GRANT f3_a TO f3_c; + `); + + const [src, dst] = [ + await extract(clusterA.adminPool), + await extract(clusterB.adminPool), + ]; + const thePlan = plan(src.factBase, dst.factBase); + + // exactly the (a → b) membership is revoked … + const revokes = thePlan.actions.filter((a) => + /REVOKE\s+"?f3_a"?\s+FROM\s+"?f3_b"?/i.test(a.sql), + ); + expect(revokes).toHaveLength(1); + // … and it must NOT carry CASCADE (that silently destroys the kept a → c). + expect(revokes[0]!.sql).not.toMatch(/CASCADE/i); + + // end-to-end: applying against the source cluster must not silently + // destroy the kept (a → c). Post-fix the plain REVOKE fails loudly on + // PG16+; today's CASCADE succeeds and cascades (a → c) away. + await apply(thePlan, clusterA.adminPool); + const remaining = await clusterA.adminPool.query(` + SELECT 1 + FROM pg_auth_members m + JOIN pg_roles r ON r.oid = m.roleid + JOIN pg_roles mem ON mem.oid = m.member + WHERE r.rolname = 'f3_a' AND mem.rolname = 'f3_c' + `); + expect(remaining.rowCount).toBe(1); + }, 120_000); +}); diff --git a/packages/pg-delta/tests/no-process-exit-guard.ts b/packages/pg-delta/tests/no-process-exit-guard.ts new file mode 100644 index 000000000..7781b085d --- /dev/null +++ b/packages/pg-delta/tests/no-process-exit-guard.ts @@ -0,0 +1,32 @@ +/** + * Test-process guard: turn any `process.exit()` call into a thrown Error while + * the bun test runner is the host process. + * + * Why: CLI command handlers (src/cli/commands/*.ts) are called in-process by + * tests (e.g. tests/profile-baseline.test.ts, tests/schema-apply-cron-guard.ts) + * and are also embeddable as a library. If a handler ever calls `process.exit` + * directly it tears down the WHOLE bun test process mid-run — the run aborts + * with the raw exit code and NO test summary, silently masking every test that + * would have run after it (this is exactly the regression this guard defends + * against: `main()` must be the sole exiter, handlers throw + * UsageError / CliExit / SchemaFrontendError instead). + * + * With this preload active, such a stray exit surfaces as an ordinary test + * failure instead of a masked abort. It only affects the bun TEST process: + * subprocess CLI runs spawned with `Bun.spawn(["bun", main.ts, …])` + * (tests/cli.test.ts) get their own process and their real exit codes, because + * this file is registered under bunfig's `[test].preload` (which does not apply + * to plain `bun