Skip to content

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, see wave3-appwrite-migration-resequence.

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 — @Entity classes under terminales-api/src/*/entities/*.entity.ts, wired through TypeOrmModule.forFeature(...), resolved by src/database/data-source.ts (synchronize:true still permitted in dev/test — auto-DDL against Postgres).
  • Six concepts exist twice — merchants, products, terminals, payment_intents, transactions, audit_log are 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.

  • 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_log already 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), charges are the special-categories/financial vertical that PDR-0001 brings into MVP scope. They are modeled fresh per value-added-services.md and TDR-0003 rather 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, employees is the POS profile), api-keys, addresses, personal-information, navigation, platforms, templates, email, storage (legacy Supabase/Postgres object model, superseded by src/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.

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.

  • 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.
  1. Guard GraphQL (P0, immediate).
  2. Per-domain migration, dependency-ordered (identity/users freeze 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 + forFeature module + tests.
  3. Drop TypeORM domain wiring — remove the last @Entity/data-source domain globs; set synchronize:false everywhere (also the row 1 A05 security fix).
  4. 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.

  • 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:true for the DBOS/aux Postgres → rejected; even aux DDL should go through migrations post-cutover so schema change is auditable.
  • 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.