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:
belongsToandhasManyincludes load vialoadIncludesafter the parentfind(batchINqueries on related collections).Q.onparity usesQueryAst.relationFilterscompiled toWHERE fk IN (SELECT pk FROM related WHERE …)on SQLite andapplyRelationFiltersin-memory.capabilities.joinsstaysfalse; noSELECT … JOINresult 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).
