Melon-dbMelon-db
Architecture

Architectural decisions

Key design decisions with context, pros, cons, and alternatives.

These ADRs capture the main architectural choices made during Phases 0–26. Each entry is intentionally short — see Architecture overview for diagrams.


ADR-001: AST-first single query representation

Context: WatermelonDB, RxDB, and ORMs each expose different query syntaxes. Adapters need one stable input.

Decision: All query surfaces compile to QueryAst + PreparedQuery before execution. Adapters receive prepared queries only.

Pros: One SQL generator; devtools can show AST + SQL; new syntaxes (Mango, Prisma) do not touch adapters.

Cons: Some user-facing ergonomics are lost at the adapter boundary; advanced SQL features require AST extensions.

Alternatives: Per-surface adapter encoding (rejected — duplicated logic and drift).


ADR-002: Split storage and sync packages

Context: Teams want offline CRUD without sync; sync has its own release cadence and backend contracts.

Decision: @melon-db/db owns storage, outbox primitives, and applyRemoteChanges. @melon-db/sync owns pull/push orchestration and depends only on @melon-db/db.

Pros: Tree-shakeable; local-only apps skip sync; sync can evolve without breaking query APIs.

Cons: Two packages to install for full-stack offline apps; some sync types live on the engine for outbox access.

Alternatives: Monolithic @melon-db/db with sync built-in (rejected — coupling and bundle size).


ADR-003: Three query surfaces over one AST

Context: Teams prefer fluent TS, JSON Mango, or Prisma-style args depending on background.

Decision: Ship @melon-db/db-query, @melon-db/db-query-mango, and @melon-db/db-prisma as separate compilers to the same AST.

Pros: Familiar APIs; Mango is serializable for tests/config; Prisma schema reuse for RN teams.

Cons: Documentation and mental overhead; feature parity must be maintained across surfaces.

Alternatives: Single fluent API only (rejected — migration and Prisma adoption friction).


ADR-004: Adapter contract — PreparedQuery only

Context: Adapters should not parse Mango, Prisma, or fluent syntax.

Decision: StorageAdapter.find/count accept PreparedQuery; write ops use AdapterWriteOperation structs.

Pros: Clear boundary for new backends (IndexedDB, etc.); pure SQL layer in @melon-db/db-sqlite.

Cons: Include/join semantics must be resolved in engine or SQL layer with explicit v1 limits.

Alternatives: Adapters accept raw AST (rejected — planning hints belong in PreparedQuery).


ADR-005: Dual RN SQLite path (Expo Go vs dev build)

Context: Expo Go cannot load custom native modules; production RN apps use dev/client builds.

Decision: @melon-db/db-sqlite/expo for Expo Go; @melon-db/db-sqlite/rn + @melon-db/db-sqlite-native for dev builds with mode: 'auto'.

Pros: Expo Go demo works out of the box; native path available when teams need JSI throughput.

Cons: Two code paths to test; documentation must explain which path applies.

Alternatives: Expo Go only (rejected — no path to native perf); native-only (rejected — Expo Go DX).


ADR-006: Layered native stack — TurboModule then C++ JSI

Context: RN New Architecture supports TurboModules; sync JSI avoids promise marshaling for hot paths.

Decision: Phase 22–23: codegen TurboModule on iOS/Android. Phase 25–26: C++ JSI host object (global.melonSqliteJsi) with dedicated native DB thread; TurboModule remains async fallback.

Pros: Progressive delivery; mode: 'auto' picks best available binding; aligns with WatermelonDB-style native throughput goals.

Cons: Significant native maintenance; sync JSI blocks JS thread until native queue completes (documented).

Alternatives: TurboModule-only forever (rejected — perf gap vs WatermelonDB native); bridge modules (deprecated on new arch).


ADR-007: ChangeEmitter fallback vs native observeQuery

Context: Fine-grained SQLite triggers for query invalidation are complex; v1 needed reactive hooks quickly.

Decision (Phase 0–26): Engine ChangeEmitter re-runs queries on collection changes when the adapter has no observeQuery.

Amendment (Phase 27): @melon-db/db-sqlite implements observeQuery with predicate-aware post-write invalidation (rowMatchesWhere + subscription registry). Per-table SQLite triggers append to _melon_observation_events. In-memory and other adapters still use ChangeEmitter.

Amendment (Phase 29): flushObservationQueue() drains trigger events into the same invalidation path (covers raw SQL / external writes). Native jsi-sync installs sqlite3_update_hook → setObservationFlushCallback on the JS thread (CallInvoker on iOS, RuntimeExecutor on Android). External delete events without a fetchable row conservatively invalidate affected subscriptions.

Amendment (Phase 33): Unified shouldInvalidateSubscription evaluates full AST (WHERE + relationFilters + field-aware updates). Subscriptions index related collections from relationFilters; writes to related tables (e.g. projects) invalidate parent queries (e.g. tasks with Q.on). Field-aware UPDATE narrowing skips notify when predicate membership unchanged and changed fields are not in observation fields (WHERE, orderBy, FK). Adapter writes drain trigger events after direct invalidation (no double-notify). QueryPlan.postFilter is set when limit/skip/orderBy/relationFilters present.

Pros: SQLite reactive queries skip irrelevant writes; hooks unchanged (observe.ts prefers adapter when present); external mutations can refresh observers without polling; Q.on reactive queries refresh when related rows change.

Cons: orderBy+limit top-N membership is not exact (sort-field changes notify; boundary edge cases may still over-invalidate); turbo native path has no update hook in v1; in-memory ChangeEmitter remains collection-wide; external SQL deletes without row snapshot conservatively invalidate.

Alternatives: Block reactive APIs until native triggers (rejected — delayed RN playground and hooks).


ADR-008: Prisma as schema/codegen layer, not runtime engine

Context: Prisma RN support is Early Access; teams still want Prisma schema and client ergonomics locally.

Decision: @melon-db/db-prisma imports schema, generates types/client stubs, and compiles Prisma-like args to AST. Runtime is always MelonDatabase.

Pros: No Prisma engine in the app; honest positioning; Bun workspace codegen fits monorepo.

Cons: Not full Prisma feature parity; migrations remain Melon-db's add-column/create-table subset.

Alternatives: Embed Prisma engine (rejected — RN/engine constraints and coupling).


ADR-009: Bun monorepo + Fumadocs docs site

Context: Package READMEs alone could not carry phase history, ADRs, playgrounds, and API reference.

Decision: Out-of-cycle investment in apps/docs — Fumadocs + TanStack Start, TypeDoc API, live CRUD/sync playgrounds, committed benchmark artifacts.

Pros: Single docs source; search; package-scoped API; dogfood reactive demos in browser.

Cons: Extra CI step (docs:api, build:docs); content must stay in sync with phases (see Contributing — docs).

Alternatives: GitHub wiki / README-only (rejected — poor API reference and interactivity).


ADR-010: @melon-db/db depends on @melon-db/db-query for fluent collection queries

Context: MelonCollection.query((b) => …) needs QueryBuilder and resolveCollectionQuery. Strict package boundaries would duplicate builder logic in core or force every app to compile AST manually.

Decision: @melon-db/db lists @melon-db/db-query as a runtime dependency. resolveCollectionQueryInput delegates builder callbacks to @melon-db/db-query.

Pros: One builder implementation; Watermelon-style collection.query(Q => Q.where(...)) works out of the box in monorepo apps.

Cons: Core is no longer free of other melon-* packages; tree-shaking cannot drop @melon-db/db-query if you use fluent collection APIs.

Alternatives: Optional peer dependency with runtime error (rejected — poor DX in the common case).


ADR-011: Post-fetch includes and relationFilters (no SQL JOIN SELECT)

Context: Watermelon and Prisma apps expect nested include / Q.on filters. SQLite adapters already compile single-table SELECTs; adding JOIN-shaped result sets would complicate adapters, observation, and partial select.

Decision:

  • belongsTo and hasMany includes load via loadIncludes after the parent find (batch IN queries on related collections).
  • Q.on parity uses QueryAst.relationFilters compiled to WHERE fk IN (SELECT pk FROM related WHERE …) on SQLite and applyRelationFilters in-memory.
  • capabilities.joins stays false; no SELECT … JOIN result shaping in v1.

Pros: Works on all adapters with one engine path; predictable SQL; easier tests.

Cons: Extra round-trips for includes; include.limit is global on the child query, not per-parent; observeQuery with orderBy+limit may still over-invalidate at top-N boundaries (Phase 33 improved relationFilters + field-aware updates).

Alternatives: SQL JOIN in adapter (deferred — revisit if post-fetch becomes a bottleneck).