ADR 0002 — Full Postgres→Appwrite domain migration & TypeORM decommission
Purpose: record the Wave 3 decision to migrate all remaining domain data off Postgres/TypeORM onto Appwrite before the production cutover, and to decommission the TypeORM stack — closing the “legacy kept until cutover” deferral that ADR-0001 opened. Every Track B migration ticket cites this ADR.
- Status: Accepted
- Date: 2026-07-11
- Deciders: Product Owner + terminales engineering (Wave 3 planning session; PO chose “full migration before go-live”)
Status update (2026-07-12): the legacy TypeORM teardown described here is DONE — all 23 domain entities were dropped in one PR ([terminales-api#296]). The staged “Bucket 1/2/3” sequencing below is historical; several Bucket-2 tables (
deposits,charges,payments,merchant_balances,balance_movements,providers,provider_products) now exist. For current state + the remaining Appwrite rebuild, seewave3-appwrite-migration-resequence.
- Repos: Multi —
terminales-api(NestJS/TypeORM),terminales-web(Next.js),terminales(Android/Kotlin) - Related: 0001-appwrite-integration.md (supersedes its “Legacy stack — kept until cutover” clause for domain data), ../business-rules/pos-domain-model.md, ../pdr/0001-mvp-golive-scope.md,
terminales-api/docs/decommission/typeorm-and-betterauth.md,wave3-mvp-analysis§3.2 + Track B
Context
Section titled “Context”ADR-0001 decided that Appwrite is the primary
backend and that the legacy TypeORM + BetterAuth stack would be kept until cutover
so the migration could proceed safely and reversibly. The POS domain was migrated first
(40 Appwrite tables live in terminales-api/infra/appwrite/appwrite.config.json), and the auth mechanism was
swapped off BetterAuth (#143/#148/#150).
At the start of Wave 3 the migration is half done. An audit (2026-07-11) found:
- ~23 legacy non-POS domains still live in Postgres via TypeORM —
@Entityclasses underterminales-api/src/*/entities/*.entity.ts, wired throughTypeOrmModule.forFeature(...), resolved bysrc/database/data-source.ts(synchronize:truestill permitted in dev/test — auto-DDL against Postgres). - Six concepts exist twice —
merchants,products,terminals,payment_intents,transactions,audit_logare modeled as both a Postgres TypeORM entity and an Appwrite table. Ownership is split by surface (legacy admin/fintech vs. new POS), a correctness and data-integrity risk in production. - GraphQL resolvers are unguarded after the BetterAuth removal (decommission doc §3a: “do not deploy to production without a GraphQL Appwrite guard”).
The Product Owner decided in Wave 3 planning: complete the migration and decommission TypeORM before go-live (the most architecturally complete of the options weighed), so the solution ships with a single source of truth — the target architecture, not a mid-migration state. Note: the system is still pre-production (no live customer data), so “migration” is predominantly schema-as-code + controller repoint + entity removal, not a large data backfill; the only data to move is seeded/reference/stage data. This ADR records the strategy; the per-domain execution lives in Track B of the Wave 3 analysis.
Decision — migrate all domain data to Appwrite; Postgres holds only disposable aux
Section titled “Decision — migrate all domain data to Appwrite; Postgres holds only disposable aux”All business/domain data moves to Appwrite TablesDB (and Appwrite Storage for files). After Wave 3, Postgres contains only disposable auxiliary state: DBOS durable-workflow tables, the TypeORM migration-bookkeeping table (until TypeORM is fully removed), and the Redis query cache is retired or kept as a pure cache. Postgres becomes rebuildable from nothing — no backup strategy required for it; the Appwrite data is the persistent, backed-up source of truth (row 76 backup/restore runbook).
Rationale: the “Appwrite holds all solution data” rule stops being aspirational only when TypeORM no longer owns any domain entity; a split source of truth (six dual-modeled concepts) is a launch-blocking integrity risk; and doing this pre-production — with no live customer data — is far cheaper than a post-launch migration.
Alternatives rejected
Section titled “Alternatives rejected”- Guard-and-defer (ship mid-migration, track as debt) → rejected by the PO in favor of a clean architecture at launch; leaving 23 TypeORM domains and six dual-modeled concepts in production was judged too risky and too slow to pay down later.
- Audit-then-decide per-domain (some migrate, some stay) → rejected; produces a
permanent hybrid whose seams (dual writes,
synchronize:true) are exactly the risk we are removing.
Domain classification — three migration buckets
Section titled “Domain classification — three migration buckets”The 23 legacy domains are not homogeneous; each Track B ticket declares its bucket.
- Bucket 1 — Consolidate onto an existing Appwrite table (the six overlaps).
merchants,products,terminals,payment_intents,transactions,audit_logalready have a canonical Appwrite table. Migration = retire the Postgres entity, repoint every legacy controller/resolver onto the Appwrite table as the single source of truth, and reconcile any column the legacy admin surface needs (added additively per ADR-0001 schema-as-code). - Bucket 2 — VAS / financial-services domains → migrate as the VAS module.
telecom-providers(airtime),gift-cards,service-providers,deposits,bank-account,merchant-balance(+balance-movement),chargesare the special-categories/financial vertical that PDR-0001 brings into MVP scope. They are modeled fresh per value-added-services.md andTDR-0003rather than lifted-and-shifted — Track C owns their Appwrite schema; Track B only ensures the old TypeORM entities are dropped. - Bucket 3 — Admin/support domains → migrate or reclassify.
users(BetterAuth identity — already export-and-freeze per row 79; identity now lives in Appwrite Auth,employeesis the POS profile),api-keys,addresses,personal-information,navigation,platforms,templates,email,storage(legacy Supabase/Postgres object model, superseded bysrc/storage/appwrite-storage.service.ts),cronjobs(operational scheduling — a candidate to fold into DBOS scheduled workflows rather than a data table). Each is migrated to an additive Appwrite table, reclassified as Appwrite Auth/Storage/DBOS, or dropped if the audit shows it is dead.
A migration inventory (per-domain: bucket, target Appwrite table, controllers to repoint, data to move, drop plan) is the first Track B deliverable and the source of the §3.2 table in the Wave 3 analysis.
GraphQL — guard first, then retire
Section titled “GraphQL — guard first, then retire”Sequencing rule: the unguarded GraphQL resolvers are secured before any other Track B
work. ADR-0001 already decided GraphQL is not used going forward (SDK-primary). So the
end state is GraphQL removed, not migrated. But because removal follows the per-domain
controller repoints, the interim exposure is closed immediately by putting the resolvers
behind the AppwriteAuthGuard + store-role scopes (the same layer as row 3).
Rationale: an unguarded GraphQL surface authenticated-but-not-authorized is a P0 production blocker; guarding is hours, full removal rides the domain migrations.
Alternatives rejected
Section titled “Alternatives rejected”- Migrate the GraphQL resolvers to read Appwrite and keep GraphQL → rejected; contradicts ADR-0001’s SDK-primary/no-GraphQL decision and preserves a surface we intend to delete.
Decommission sequencing & the DDL path
Section titled “Decommission sequencing & the DDL path”- Guard GraphQL (P0, immediate).
- Per-domain migration, dependency-ordered (identity/
usersfreeze and the six overlaps first; VAS/financial via Track C; admin/support last). Each ticket: create/confirm the additive Appwrite schema, move any seeded data, repoint controllers to the Appwrite SDK, delete the TypeORM entity +forFeaturemodule + tests. - Drop TypeORM domain wiring — remove the last
@Entity/data-source domain globs; setsynchronize:falseeverywhere (also the row 1 A05 security fix). - Reintroduce migrations as the only DDL path at M6 (row 77) —
for the Appwrite side this means the
appwrite push+ additive-only regime graduates to managed schema changes; for the residual Postgres aux (DBOS), migrations own DDL.
Rationale: guarding removes the acute risk first; per-domain ordering keeps every step
reversible until its entity is deleted; disabling synchronize and reintroducing migrations
closes the auto-DDL-against-live-data hazard ADR-0001’s pre-prod regime tolerated.
Alternatives rejected
Section titled “Alternatives rejected”- Big-bang cutover of all domains at once → rejected (same reasoning as ADR-0001): no fallback if a domain’s repoint is wrong; per-domain keeps blast radius small.
- Keep
synchronize:truefor the DBOS/aux Postgres → rejected; even aux DDL should go through migrations post-cutover so schema change is auditable.
Consequences
Section titled “Consequences”- ADR-0001’s “Legacy stack — kept until cutover” clause is closed for domain data by this ADR; the clause’s reversibility guarantee holds only per-domain during step 2.
- Postgres is now disposable: no Appwrite-grade backup story for it; the money ledger and all domain data are in Appwrite and covered by row 76.
- The VAS/financial migration is not a Track B lift-and-shift — it is new modeling owned by
Track C +
TDR-0003; Track B only removes the old entities. - Scope risk: this is the largest Wave 3 track; the migration inventory must be produced and sized before committing the go-live date.
References
Section titled “References”- 0001-appwrite-integration.md — the decision this ADR extends.
terminales-api/docs/decommission/typeorm-and-betterauth.md— the authoritative migration state and the unguarded-GraphQL warning.- ../business-rules/pos-domain-model.md — entity→table mapping.
- ../pdr/0001-mvp-golive-scope.md — VAS + Clientes in scope.
0003-vas-fulfillment-dbos— VAS modeling.