17. Fauna Decommission
Date: 2026-05-26
Status
Proposed
Context
FaunaDB’s vendor deactivated the product. Continuing to run on an unsupported database carries availability, security, and operational risk. The application has ~50 Fauna-backed collections to migrate to Postgres (Neon, per ADR 0008).
Many Postgres tables already exist with mirrored schemas; some collections dual-write today; most still treat Fauna as authoritative. The migration must work per-collection rather than as a single cutover so blast radius stays bounded and rollback is straightforward.
ADR 0014 established the resolver → service → repository architecture. This ADR specifies how to migrate each Fauna-backed collection into that architecture and the methodology shared by every cutover.
Sections below are numbered (§1–§10) so Linear issues, the cutover playbook, and code review notes can reference specific clauses (e.g., “rollback per §9”). §5 additionally defines a per-collection step sequence (0a–0e for the dark-code phase including substeps 0b.1–0b.6, then steps 7–10 for cutover and cleanup); references to “step N” mean §5’s step, while “§N” means the top-level section.
Decision
§1. Single target architecture
Every Fauna-backed collection migrates to the ADR 0014 three-layer architecture: resolver (transport) → service (application logic) → repository (data access). No new patterns for migrated code; no parallel architectures. The resolver loses fat-resolver responsibilities, the service stops extending TenantBaseService, and a repository owns all data access.
Resolvers are pure transport. They map GraphQL inputs to a service call and translate the result or error back to the GraphQL shape. The only logic permitted in a resolver during migration is the flag-gated decision between the Fauna and Postgres paths; that branch is deleted at §5 step 7.
§2. TenantBaseRepository rename
TenantBaseService (CRUD + RLS + tenant scoping over Prisma) is functionally a repository base — the name predates the introduction of a repository layer. The class is renamed to TenantBaseRepository and moved to repositories/base/, with TenantBaseService retained as a @deprecated re-export (no breaking change). New repositories extend TenantBaseRepository. A follow-up codemods the remaining call sites onto the canonical name and drops the deprecated alias.
The same applies to the non-tenant base. BaseService (Prisma CRUD over a single model, no tenant scoping) is likewise a repository base; TenantBaseRepository extends it to add RLS and tenant scoping. The class is renamed to BaseRepository and moved to repositories/base/, with BaseService retained as a @deprecated re-export. Global, non-tenant repositories (UserRepository, TenantsRepository, UserTenantsRepository) extend BaseRepository, and TenantBaseRepository extends BaseRepository. The same follow-up codemods call sites onto the canonical names and drops the deprecated aliases.
§3. One door — dataloader inside the repository
Repositories own request-scoped dataloaders. repo.byId(id) calls into the loader internally; repo.byIds(ids) is the batch function. Resolvers, services, and type-resolvers call repository methods only — never dataloader.X.load(id) directly. Dataloader instances are wired in createResolversContext per request and injected into repository constructors.
This collapses today’s seam where callers pick between dataloader.X.load(id) and services.X.findById(id) for the same fetch.
§4. Aggregate vs cross-cut service split
- Aggregate service: owns a single collection’s lifecycle (e.g.,
VehicleRemindersService,OrderService). Plain class, constructor-injected deps. - Cross-cut service: spans aggregates (e.g.,
ReminderEmailService,SearchIndexService,BillionSyncService). Called from aggregate services via DI; never imports another aggregate service. - No event bus. Cross-cut services are called via direct dependency injection.
- Lint/codereview convention: aggregate services do not import other aggregate services without going through DI.
Fat resolvers (e.g., createOrder at 410 lines, updateVehicle at 438) inline cross-cut concerns today. The split moves those concerns into named cross-cut services that aggregate services orchestrate.
§5. Per-collection migration steps
Every collection cutover follows this ordered sequence. The vehicleReminders playbook (referenced in §References) is the canonical worked example.
- 0a. Schema sync. Compare the Fauna shape against the GraphQL schema (primary source of truth) and
prisma/schema.prisma(since the DB has fields that are not exposed via GraphQL). Resolve drift via Prisma migration. Whenmigrate devblocks on drift, usedb execute+migrate resolve. Verify RLS policies cover the table for tenant + role. Blocker: drift unresolved → stop. Postgres data lands in 0c; schema must match. - 0b. Dark code. Implement the Postgres path behind
flags.faunaMigration<Collection>with the flag off. Production unchanged; new path compiled, type-checked, unit-tested with mocked repos. Substeps 0b.1–0b.6 below run in order.- 0b.1. Repository layer. Create the repository(ies) extending
TenantBaseRepository. ExposebyId(id)(loader-backed),byIds(ids)(batch), and domain queries. Move dataloader entries fromhelpers/dataloaderPostgres.jsinside the repo. Existing tests pass with repos in place; services still exist as facade during this step. Concrete example:repositories/vehicle-location-calendar/VehicleLocationCalendarRepository.ts(instantiated inservices/index.ts). - 0b.2. Service layer. Rewrite the aggregate service(s) as plain classes (no
TenantBaseServiceinheritance). Constructor takesdeps: { repo, ...siblingRepos, ...crossCutServices, identity }. Move existing Postgres helpers (e.g.,compute<X>StatusPrisma.js) into the service. Move side-effect helpers (e.g.,send<X>Email.js) into cross-cut services. Wire inservices/index.tscreateTenantServices. Service unit tests use mocked repos. - 0b.3. Transport layer — mutations. Each mutation resolver gains a flag-gated Postgres path next to the existing Fauna path. Resolvers stay pure transport per §1; the flag branch is the only logic permitted. Order within a collection:
delete→update→create→ relationship mutations. Both branches return the same GraphQL shape. The flag-on branch usesctx.identityinstead of callingQuery.identity(). - 0b.4. Transport layer — queries. Same shape as mutations. The query resolvers go through the new service.
- 0b.5. Type-resolver split. Move type-resolver objects out of
resolvers/index.jsinto co-located files underserver/graphql/resolvers/types/<TypeName>.ts. Register fromresolvers/index.jsvia import. - 0b.6. Cross-aggregate caller switches. Files outside the aggregate that call collection-specific Fauna helpers gain a flag-gated branch: flag off → existing Fauna helper; flag on →
ctx.services.<aggregate>Service.<method>(...). See §6 for the bridge exception.
- 0b.1. Repository layer. Create the repository(ies) extending
- 0c. Backfill. One-shot Fauna→Postgres script. Preserve Fauna document IDs as Postgres primary keys (or maintain a mapping table — decide before writing). Run after-hours when write volume is lowest. Verify row count parity within tolerance for in-flight writes. Spot-check 10–20 random rows for field-by-field correctness. Blocker: parity off > 0.1% or critical-field divergence → stop, investigate.
- 0d. Flip flag. Enable
flags.faunaMigration<Collection>for all tenants. No gradual rollout for leaf collections (those with no other aggregate depending on them — small blast radius). Larger or higher-traffic collections may stage by tenant — document the deviation per cutover. - 0e. Observation window (3–7 days for leaves; longer for high-stakes collections). Monitor error rates on mutations + queries, side effects (email sends, sync calls, pricing accuracy), and — if §9-mirror was chosen — mirror failure rate. Rollback per §9 if anomalies appear. Do not proceed to step 7 until window passes clean.
- 7. Cutover-clean. One atomic commit deletes: Fauna helpers specific to the collection, Fauna dataloader entries, Postgres dataloader entries (folded into repo per §3), the feature flag, and every
if (flags.faunaMigration<Collection>)branch. Definition of done:grep -rn "fql\”andgrep -rn “faunaMigration”` both return zero. - 8. Service shape teardown. Delete the original
TenantBaseService-extending service (replaced in 0b.2). Updateservices/index.tsimports. - 9. Tests. Collapse to a single path. Final test surface per aggregate: repository integration tests against test Postgres that verify queries return correct data (no Prisma-mocked repository unit tests — those would couple tests to the ORM); service unit tests with mocked repos to cover business logic; resolver tests only where input/output mapping is non-trivial. Full test suite + typecheck + lint green.
- 10. Update planning artifacts. Mark the collection as CUT OVER in the Fauna Decommission Dependency Map (see §References). Append a “Lessons” section to the playbook for the next collection.
§6. Bridge exception
A migrated repository may call a Fauna dataloader for joins on not-yet-migrated collections — this is the only permitted Fauna touch in migrated code, and it is temporary until those collections cut over.
The reverse direction is not permitted: not-yet-migrated callers cannot reach into a migrated aggregate via a deleted Fauna helper. Cross-aggregate churn lands ahead of the calling aggregate’s own migration (§5 step 0b.6). Callers use the new service. No shim.
§7. Deletion targets at cutover
At step 7 (per-collection), delete:
- Fauna helpers specific to the collection (e.g.,
compute<X>Status.js) - The collection’s entry in
helpers/dataloader.js(Fauna) - The collection’s entry in
helpers/dataloaderPostgres.js(folded into the repo per §3) flags.faunaMigration<Collection>from the feature-flag service- All gating branches across resolvers and cross-aggregate callers
At step 8, delete the legacy service file that extended TenantBaseService.
§8. Out-of-scope-but-temporary code
createPostgresOnlyTenantServices (in services/index.ts) exists because authentication wasn’t fully Postgres-aware when the factory was introduced. It branches off createTenantServices via a conditional in createResolversContext.js. Delete after all collections cut over and authentication is 100% Postgres. Tracked by TOP-4959.
§9. Rollback policy
Two policies; choose one per collection at planning time and document the choice in the cutover issue.
- §9-default: rollback = flip flag off. Writes that happened during the observation window are lost. Acceptable for low-volume collections, short windows, and recoverable data (user can recreate by repeating their action).
- §9-mirror: during observation, every Postgres write also mirrors to Fauna. Rollback = flip flag off; Fauna stays warm; no data loss. Adds write-path complexity and a small failure surface (mirror writes can fail independently — must be monitored).
Default is §9-default. Pick §9-mirror only when the collection has both (a) non-trivial write volume during the observation window, and (b) data loss from a rollback would be unrecoverable from user action (e.g., payments, orders, financial events).
§10. Final teardown
After every collection cuts over (Project 11 “Fauna Decommission & Cleanup” in the Sunset FaunaDB initiative):
grep -rn "from 'fauna'"returns zero hits in application codegrep -rn "fql\”` returns zero hits in application code- Delete
helpers/dataloader.js,clientX/rootClientX, all Fauna client wiring (helpers/FaunaX.js, etc.) - Delete
createPostgresOnlyTenantServicesper §8 - Remove all
flags.faunaMigration*flags from the feature-flag service - Verify zero Fauna network traffic for 7 consecutive days (monitoring check)
- Snapshot final Fauna database state for archival (cold storage)
- Shut down Fauna database server / cancel subscription
- Mark the
Sunset FaunaDBinitiative as Completed
Consequences
Benefits
- One supported database; FaunaDB risk eliminated
- Data layer conforms to ADR 0014 across the codebase
- Per-collection cutover is atomic at step 7; no half-migrated collections persist
- Cross-aggregate concerns get clean DI boundaries via §4
- Tests collapse to a single path per resolver (step 9)
- Repository pattern (§3) eliminates the dataloader-vs-service caller decision
Risks & Mitigations
- Long migration timeline keeps both stacks alive
- Mitigation: per-collection atomic cutover (§5 step 7); no collection lingers half-migrated
- Write loss during §9-default observation window
- Mitigation: explicit policy choice per collection; §9-mirror for high-stakes data
- Cross-aggregate churn lands ahead of the consumer’s own migration
- Mitigation: §6 bridge exception permits Fauna reads during the gap; writes go through the new service; no permanent shim
- Schema drift discovered mid-cutover
- Mitigation: step 0a blocks the chain until drift is resolved
- Mirror write failures (§9-mirror) create a parallel failure surface
- Mitigation: monitored explicitly during the observation window; failure rate is a rollback trigger
References
- ADR 0008: Migrate to Neon Database
- ADR 0009: Implement RLS with Drizzle Kit
- ADR 0011: Service Dependency Injection
- ADR 0014: Resolver → Service → Repository Architecture
- Linear initiative: Sunset FaunaDB
- Linear doc: Fauna Decommission Playbook — vehicleReminders Template
- Linear doc: Fauna Decommission Dependency Map