Skip to content

#8 [pg] Field Region/Zone Migration#3783

Draft
rdonigian wants to merge 5 commits into
organization-pgfrom
field-region-pg
Draft

#8 [pg] Field Region/Zone Migration#3783
rdonigian wants to merge 5 commits into
organization-pgfrom
field-region-pg

Conversation

@rdonigian
Copy link
Copy Markdown
Contributor

Summary

Ports the FieldZone and FieldRegion domains to PostgreSQL via Drizzle as the next step in the Neo4j → PostgreSQL migration. Both land in one PR because FieldRegion's filter sub-delegates to FieldZone (Tier 3 → Tier 2 dependency) and they share the director-on-user pattern — committing them together keeps the sub-filter graph internally consistent.

Also includes a cross-cutting refactor of DrizzleDtoRepository and PolicyExecutor that introduces shared helpers (applyReadFilter, EMPTY_PAGE, subFilter, resource/getActualChanges on the base class), simplifies the four already-migrated domain repos (User, Location, Organization, Education, Unavailability), and unblocks update paths under DATABASE=postgres. Plus a one-line DI fix that exposes PolicyExecutor to Drizzle consumer modules.

Migration boundary unchanged: splitDb(NeoRepo, { postgres: DrizzleRepo as any }) routes the repo class to whichever engine is active — Neo4j stays the default; PostgreSQL is local-dev only until cutover.

Schema DDL

CREATE TABLE "field_zones" (
  "id"          text        PRIMARY KEY,
  "name"        text        NOT NULL UNIQUE,
  "director_id" text        NOT NULL REFERENCES "users"("id"),
  "created_at"  timestamptz NOT NULL DEFAULT now(),
  "updated_at"  timestamptz NOT NULL DEFAULT now(),
  "deleted_at"  timestamptz
);

CREATE TABLE "field_regions" (
  "id"            text        PRIMARY KEY,
  "name"          text        NOT NULL UNIQUE,
  "field_zone_id" text        NOT NULL REFERENCES "field_zones"("id"),
  "director_id"   text        NOT NULL REFERENCES "users"("id"),
  "created_at"    timestamptz NOT NULL DEFAULT now(),
  "updated_at"    timestamptz NOT NULL DEFAULT now(),
  "deleted_at"    timestamptz
);

CREATE INDEX "field_zones_director_id_idx"     ON "field_zones"   ("director_id");
CREATE INDEX "field_regions_field_zone_id_idx" ON "field_regions" ("field_zone_id");
CREATE INDEX "field_regions_director_id_idx"   ON "field_regions" ("director_id");

-- Backfills the FK deferred in migration 0002 (locations).
ALTER TABLE "locations"
  ADD CONSTRAINT "locations_default_field_region_id_fkey"
  FOREIGN KEY ("default_field_region_id") REFERENCES "field_regions"("id");

Design decisions

director_id is NOT NULL on both tables

  • What: Both field_zones.director_id and field_regions.director_id are text NOT NULL REFERENCES users(id).
  • Why: The GraphQL CreateFieldZone and CreateFieldRegion inputs already require director: ID<'User'>!, and the service validates the user has the correct role (FieldOperationsDirector / RegionalDirector) before insert. The DB constraint mirrors the application invariant.
  • Tradeoff: Gel and Neo4j model director as a relationship that can technically be missing — we've never observed that in prod and the create path can't produce it. NOT NULL eliminates the nullable-direct or branch in toDto() and gives us referential integrity for free.

Backfill locations.default_field_region_id FK in the same migration

  • What: ALTER TABLE locations ADD CONSTRAINT … FOREIGN KEY (default_field_region_id) REFERENCES field_regions(id) runs in migration 0004, simultaneously with the field_regions table creation.
  • Why: Migration 0002 (Locations) declared the column as text without an FK and left a migration-todo: comment explicitly waiting on FieldRegion to migrate. Now that it has, the cleanup belongs in the same change set — and adding the constraint after data lands would require ALTER TABLE downtime planning later.

Sub-filter helper + per-domain *FilterClauses functions

  • What: Each Drizzle repo exports a <domain>FilterClauses(db, filter) function that returns SQL[] of WHERE conditions on its own table. A shared subFilter(db, linkedColumn, subTable, clauses) helper composes them into inArray(linkedCol, db.select(...).from(subTable).where(...)) subqueries with automatic soft-delete handling on the sub-table.
  • Why: The FE explicitly uses filter.director: UserFilters and filter.fieldZone: FieldZoneFilters on FieldRegion. The Neo4j side handles this via filter.sub(); we needed a Drizzle equivalent. Composable clause functions + a tiny subFilter wrapper give us the same shape without per-repo boilerplate.
  • Example call site:
    if (input.filter?.director) {
      conditions.push(subFilter(
        this.db, fieldRegions.directorId, users,
        userFilterClauses(this.db, input.filter.director),
      ));
    }
  • Refactor scope: userFilterClauses was lifted out of user.drizzle.repository.ts (where it was inline in list()) into an exported helper, then re-consumed by users.list() itself. Net result: zero behavior change in User, plus a reusable export.

applyReadFilter + EMPTY_PAGE collapse the read-auth short-circuit

  • What: New method on PolicyExecutor: applyReadFilter(resource, conditions): boolean. Resolves the read-policy filter, mutates conditions in place, returns false when access is denied. Paired with an exported EMPTY_PAGE constant for the { items: [], total: 0, hasMore: false } literal.
  • Why: Every list() in the migrated repos had the same 6-line block: call drizzleFilter, check for false → return empty, check for true vs SQL → push. (For context: drizzleFilter returns false/true/SQL — denied / unconditional / conditional access.) Five repos, identical boilerplate. Helper collapses it to one line: if (!this.executor.applyReadFilter(this.resource, conditions)) return EMPTY_PAGE;
  • Tradeoff: In-place mutation of conditions: SQL[] instead of returning a value. Worth it; alternative API shapes were uglier.

resource and getActualChanges live on the base class

  • What: DrizzleDtoRepository's constructor now takes a third argument — the DTO class — and the base owns protected readonly resource: EnhancedResource<...> and readonly getActualChanges = getChanges(dto). Subclasses pass super(db, table, Dto) and drop their local declarations.
  • Why for resource: Every Drizzle repo had private readonly resource = EnhancedResource.of(Dto). Lifting it deduplicates and matches the Neo4j and Gel bases.
  • Why for getActualChanges: Services call this.repo.getActualChanges(existing, input) regardless of engine — getChanges() is a pure DTO diff helper, not Neo4j-specific. The Neo4j and Gel bases already expose it. Without it on the Drizzle base, every service's update* mutation threw this.repo.getActualChanges is not a function at runtime under DATABASE=postgres. Type system can't catch this because services type their repo field against the Neo4j repo class (see splitDb cast in Deferred items).
  • Gotcha: Declaring private readonly resource = ... locally in a subclass now triggers a TypeScript variance error on childKeys / securedPropsPlusExtra (narrow EnhancedResource<typeof Foo> vs base's EnhancedResource<ResourceShape<Foo>>). The fix is to delete the subclass declaration, which is the point.

Export PolicyExecutor from PolicyModule

  • What: policy.module.ts adds PolicyExecutor to its exports array.
  • Why: Drizzle repos inject PolicyExecutor directly for drizzleFilter / applyReadFilter. Neo4j repos never did — they use the higher-level Privileges wrapper — so the missing export was invisible until the app booted under DATABASE=postgres and Nest tried to instantiate the Drizzle repo, producing UnknownDependenciesException.

Deferred items

splitDb(..., { postgres: XDrizzleRepository as any }) cast

  • Where: field-zone.module.ts, field-region.module.ts, and prior migrated modules.
  • Behavior: Each Drizzle repo registration uses as any with a migration-todo: comment.
  • Resolved when: Phase 7 cutover. The entire splitDb provider is deleted along with the Neo4j-side repos, taking the casts with it.
  • Why deferred: splitDb<T> requires Type<PublicOf<T>> where T is the Neo4j repo class. PublicOf<> retains every public/protected key from Neo4j's DtoRepository base (getActualChanges, db: DatabaseService, privileges, getBaseNode, etc.) that doesn't exist on DrizzleDtoRepository. The proper fix is per-domain interfaces (IFieldRegionRepository, etc.) — but since splitDb itself disappears at cutover, polishing transition-only typing is wasted effort.

Cross-domain stubs in LocationDrizzleRepository

  • Where: location.drizzle.repository.tsaddLocationToNode, removeLocationFromNode, listLocationsFromNodeNoSecGroups.
  • Behavior: All throw NotImplementedException with migration-todo: comments.
  • Resolved when: the domains owning those node-relationship calls (Organization's locations resolver, Partner, Project, etc.) port to PostgreSQL.
  • Why deferred: These methods bridge to Neo4j-style node-anchored location lists that haven't been ported yet. Not introduced by this PR — flagging because the GraphQL organization.locations resolver does hit these stubs, so a UI smoke test against DATABASE=postgres will see the error there.

Unused UserFilters sub-filter fields

  • Where: userFilterClauses in user.drizzle.repository.ts.
  • Behavior: Handles id, name, title, status, roles. Does NOT handle pinned (per-requester state, not stored on the user row) — pinned: false is hardcoded in toDto.
  • Resolved when: the pinned mechanism is reintroduced post-cutover as per-requester state (audit log or join).
  • Why deferred: Same gap as before this PR; userFilterClauses just inherited it. Not a regression.

locations.funding_account_id FK

  • Where: locations table.
  • Behavior: Column is text, no FK constraint.
  • Resolved when: the FundingAccount domain migrates to PostgreSQL.
  • Why deferred: Same pattern as default_field_region_id before this PR — wait for the referenced table to exist, then ALTER TABLE ADD CONSTRAINT in that domain's migration.

rdonigian and others added 5 commits May 11, 2026 17:29
Both directors are NOT NULL — the create/update inputs already require
them and the service validates the user's role.

Backfills the locations.default_field_region_id FK that 0002 deferred,
along with FK indexes on director_id and field_zone_id columns.
The Drizzle repos inject PolicyExecutor directly (for drizzleFilter /
applyReadFilter). Neo4j repos only use the Privileges wrapper, so the
missing export went undetected until the app booted under
DATABASE=postgres — splitDb resolves to the Drizzle repo, Nest tries
to instantiate it, and the unknown dependency fires.
- Lift `resource` and `getActualChanges` to DrizzleDtoRepository.
  Repos pass the DTO class as the third super() arg; the base owns
  the EnhancedResource and the diff helper. Matches the Neo4j and Gel
  bases. Without this on the base, every service's update path threw
  'this.repo.getActualChanges is not a function' under DATABASE=postgres.
- Add PolicyExecutor.applyReadFilter() that wraps the drizzleFilter
  call + the policy short-circuit + the conditions push. Each list()
  loses ~4 lines and reads as one intent: build conditions; bail if
  denied.
- Export EMPTY_PAGE constant from ~/core/drizzle — the shared empty
  paginated-list literal returned on policy-denied reads.
- Add subFilter() helper that wraps the inArray + subquery + soft-delete
  shape for filter sub-delegation. Pairs with the *FilterClauses
  helpers each repo exports.
- Lift userFilterClauses out of user.drizzle.repository.ts as an
  exported function, then have user.list() consume it. Other domains
  use it via subFilter for director-on-user filters.

Touches every existing Drizzle repo (User, Location, Organization,
Education, Unavailability) to drop local resource declarations and
pass the DTO class through super().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both domains together since FieldRegion's filter sub-delegates to
fieldZoneFilters (Tier 3 → Tier 2 dependency) and they share the
director-on-user pattern. Including them in one commit keeps the
graph of *FilterClauses producers and consumers internally consistent.

- field-zone.drizzle.repository.ts: full CRUD + readAllByDirector
  (used by the UserUpdatedHook handler). Exports fieldZoneFilterClauses
  for FieldRegion's sub-filter.
- field-region.drizzle.repository.ts: full CRUD + readAllByDirector.
  list() supports name (ilike), director, and fieldZone sub-filters
  via the subFilter helper.
- splitDb postgres entries wired in both modules with `as any` plus a
  migration-todo comment (resolves at cutover).
Seeds users (incl. root with auth identity), educations,
unavailabilities, locations, organizations + join tables, field zones
and field regions. Idempotent — TRUNCATE before insert, safe to re-run.

Root user uses the dev hardcoded ID from determineRootUser() so the
existing login flow (devops@tsco.org / admin) works against
PostgreSQL without further config. Assumes PASSWORD_SECRET is unset;
regen the argon2 hash if you have it set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rdonigian rdonigian changed the base branch from develop to organization-pg May 11, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant