#8 [pg] Field Region/Zone Migration#3783
Draft
rdonigian wants to merge 5 commits into
Draft
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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
DrizzleDtoRepositoryandPolicyExecutorthat introduces shared helpers (applyReadFilter,EMPTY_PAGE,subFilter,resource/getActualChangeson the base class), simplifies the four already-migrated domain repos (User, Location, Organization, Education, Unavailability), and unblocks update paths underDATABASE=postgres. Plus a one-line DI fix that exposesPolicyExecutorto 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
Design decisions
director_id is NOT NULL on both tables
field_zones.director_idandfield_regions.director_idaretext NOT NULL REFERENCES users(id).CreateFieldZoneandCreateFieldRegioninputs already requiredirector: ID<'User'>!, and the service validates the user has the correct role (FieldOperationsDirector/RegionalDirector) before insert. The DB constraint mirrors the application invariant.toDto()and gives us referential integrity for free.Backfill
locations.default_field_region_idFK in the same migrationALTER TABLE locations ADD CONSTRAINT … FOREIGN KEY (default_field_region_id) REFERENCES field_regions(id)runs in migration 0004, simultaneously with thefield_regionstable creation.textwithout an FK and left amigration-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 requireALTER TABLEdowntime planning later.Sub-filter helper + per-domain
*FilterClausesfunctions<domain>FilterClauses(db, filter)function that returnsSQL[]of WHERE conditions on its own table. A sharedsubFilter(db, linkedColumn, subTable, clauses)helper composes them intoinArray(linkedCol, db.select(...).from(subTable).where(...))subqueries with automatic soft-delete handling on the sub-table.filter.director: UserFiltersandfilter.fieldZone: FieldZoneFilterson FieldRegion. The Neo4j side handles this viafilter.sub(); we needed a Drizzle equivalent. Composable clause functions + a tinysubFilterwrapper give us the same shape without per-repo boilerplate.userFilterClauseswas lifted out ofuser.drizzle.repository.ts(where it was inline inlist()) into an exported helper, then re-consumed byusers.list()itself. Net result: zero behavior change in User, plus a reusable export.applyReadFilter+EMPTY_PAGEcollapse the read-auth short-circuitPolicyExecutor:applyReadFilter(resource, conditions): boolean. Resolves the read-policy filter, mutatesconditionsin place, returnsfalsewhen access is denied. Paired with an exportedEMPTY_PAGEconstant for the{ items: [], total: 0, hasMore: false }literal.list()in the migrated repos had the same 6-line block: calldrizzleFilter, check forfalse→ return empty, check fortruevsSQL→ push. (For context:drizzleFilterreturnsfalse/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;conditions: SQL[]instead of returning a value. Worth it; alternative API shapes were uglier.resourceandgetActualChangeslive on the base classDrizzleDtoRepository's constructor now takes a third argument — the DTO class — and the base ownsprotected readonly resource: EnhancedResource<...>andreadonly getActualChanges = getChanges(dto). Subclasses passsuper(db, table, Dto)and drop their local declarations.resource: Every Drizzle repo hadprivate readonly resource = EnhancedResource.of(Dto). Lifting it deduplicates and matches the Neo4j and Gel bases.getActualChanges: Services callthis.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'supdate*mutation threwthis.repo.getActualChanges is not a functionat runtime underDATABASE=postgres. Type system can't catch this because services type theirrepofield against the Neo4j repo class (see splitDb cast in Deferred items).private readonly resource = ...locally in a subclass now triggers a TypeScript variance error onchildKeys/securedPropsPlusExtra(narrowEnhancedResource<typeof Foo>vs base'sEnhancedResource<ResourceShape<Foo>>). The fix is to delete the subclass declaration, which is the point.Export
PolicyExecutorfromPolicyModulepolicy.module.tsaddsPolicyExecutorto itsexportsarray.PolicyExecutordirectly fordrizzleFilter/applyReadFilter. Neo4j repos never did — they use the higher-levelPrivilegeswrapper — so the missing export was invisible until the app booted underDATABASE=postgresand Nest tried to instantiate the Drizzle repo, producingUnknownDependenciesException.Deferred items
splitDb(..., { postgres: XDrizzleRepository as any })castfield-zone.module.ts,field-region.module.ts, and prior migrated modules.as anywith amigration-todo:comment.splitDbprovider is deleted along with the Neo4j-side repos, taking the casts with it.splitDb<T>requiresType<PublicOf<T>>whereTis the Neo4j repo class.PublicOf<>retains every public/protected key from Neo4j'sDtoRepositorybase (getActualChanges,db: DatabaseService,privileges,getBaseNode, etc.) that doesn't exist onDrizzleDtoRepository. 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
LocationDrizzleRepositorylocation.drizzle.repository.ts—addLocationToNode,removeLocationFromNode,listLocationsFromNodeNoSecGroups.NotImplementedExceptionwithmigration-todo:comments.locationsresolver, Partner, Project, etc.) port to PostgreSQL.organization.locationsresolver does hit these stubs, so a UI smoke test againstDATABASE=postgreswill see the error there.Unused
UserFilterssub-filter fieldsuserFilterClausesinuser.drizzle.repository.ts.id,name,title,status,roles. Does NOT handlepinned(per-requester state, not stored on the user row) —pinned: falseis hardcoded intoDto.pinnedmechanism is reintroduced post-cutover as per-requester state (audit log or join).userFilterClausesjust inherited it. Not a regression.locations.funding_account_idFKlocationstable.text, no FK constraint.FundingAccountdomain migrates to PostgreSQL.default_field_region_idbefore this PR — wait for the referenced table to exist, thenALTER TABLE ADD CONSTRAINTin that domain's migration.