Add SQLite as a second database backend alongside PostgreSQL - #119
felixgateru wants to merge 11 commits into
Conversation
…-001)
Adds the internal storage boundary described in
product-docs/development/database-backends/RFC.md: DatabaseKind and a
cloneable Database enum own pool construction, URL scheme classification,
migration dispatch, and sanitized (credential-free) startup logging;
DbTransaction wraps a transaction with nested-savepoint support via its own
begin(). Only a Postgres variant exists -- DATABASE_URL schemes other than
postgres://[ql] (including sqlite://) now fail startup fast, before any pool
or migration work, with an actionable message.
AppState.pool: PgPool is replaced by AppState.db: Database. AppState::new
takes impl Into<Database> so every existing call site passing a raw PgPool
(main.rs, ~20 test fixtures) keeps compiling unchanged via the From<PgPool>
impl. A transitional AppState::pool()/Database::as_postgres() accessor
returns the same &PgPool as before, so the ~640 existing repository/handler
call sites that read state.pool need only the mechanical `.pool` ->
`.pool()` rename (this commit) -- no query logic changes. Removing that
transitional accessor from every call site is Milestone A's exit criterion
(DB-003 through DB-006), not this phase.
Zero intended behavior change. Verified: cargo fmt --check, cargo clippy
--lib --bins -- -D warnings, full workspace compiles (lib + bin + all
integration test binaries), 332 non-DB unit tests, and ~115 DB-gated
integration tests across authz, identity/credentials, tenants, PKI,
audit/outbox, bootstrap, and soft-delete/restore run green against a real
Postgres instance including the single-connection-pool canaries
(creating/deleting_role_assignment_works_with_a_single_connection_pool).
One pre-existing test-order flake (m34_pki_renewal, unrelated fixture
collision with m30/32/33 sharing one database) was confirmed to reproduce
identically on unmodified main -- not a regression from this change.
Also collapses two pre-existing `else { if .. }` clippy findings in
src/certs/graphql.rs (unrelated to this change, but needed to unblock
`cargo clippy -- -D warnings` as a green gate for this phase).
…cation (DB-002) Migrates every transaction-taking function (all ~200 sites across 22 identity/authz/tenants/certs/PKI/audit/bootstrap files) from sqlx::Transaction<'_, Postgres> to the backend-neutral db::DbTransaction<'_> introduced in DB-001, and the six audit.rs commit helpers (commit_with_audit, commit_with_observation, commit_observed_with_cache[_*], observe_in_tx) to take it by value/reference instead of a concrete Postgres transaction. Mechanical, behavior-preserving transform, not a rewrite: - `tx: &mut Transaction<'_, Postgres>` -> `tx: &mut DbTransaction<'_>`; by-value commit-helper params the same way. - Every direct query call against the connection (`&mut **tx` / `&mut *tx`) becomes `tx.as_postgres_mut()`, which returns the exact same live &mut PgConnection -- same SQL, same connection, same transaction. - Nested savepoints (PKI serial-collision retries in certs::service) use DbTransaction::begin(&mut self), mirroring sqlx's own Transaction::begin and preserving the existing SAVEPOINT-scoped rollback behavior. - Guardrail validators that intentionally take &mut PgConnection directly (guardrails::*, *_on_connection) are unchanged; call sites now pass tx.as_postgres_mut() instead of the connection reference directly. - A "wrapper" function that keeps its public &PgPool parameter (kept because tests and internal callers depend on it, per AGENTS.md) opens its transaction via `Database::from(pool.clone()).begin()` instead of `pool.begin()`, so it still hands a DbTransaction to the _in_tx sibling it calls -- zero signature change for those ~40 callers. error.rs: introduces DatabaseErrorKind (NotFound/Unique/ForeignKey/Check/ Internal) and classify_database_error, and routes db_err/IntoResponse/ tonic::Status through it instead of the ad hoc constraint-code match inline at each site. Only the classification that Postgres already produces is represented -- no busy/unavailable variant, since nothing produces one yet and adding an unreachable branch either does nothing or risks silently changing the fallback-to-500 behavior for errors like PoolTimedOut. That stays scoped to whichever backend phase first needs it. restore_conflict/entity_write_conflict keep matching on the raw sqlx::Error directly, since they need the violated constraint's name, not just its kind. Zero intended behavior change. Verified: cargo fmt --check, cargo clippy -- -D warnings (lib+bins, the AGENTS.md-specified gate) and cargo clippy --tests (only 3 findings, all pre-existing on unmodified code and unrelated to this change), full workspace compiles (lib + bin + all 60+ integration test binaries), 334 non-DB unit tests, and ~230 DB-gated integration tests across authz, identity/credentials, tenants, PKI (issuance/renewal/revocation/CRL/OCSP/enrollment/lifecycle-automation/ purge-after-revocation), audit/outbox, bootstrap, cache invalidation (including every lock-ordering/cache-barrier test), and the config-managed same-transaction-recheck guards all run green against a real Postgres (and Redis, for cache tests) instance -- including the single-connection- pool canaries. Two pre-existing test-order-dependent flakes (m34_pki_renewal, m35_pki_revocation -- a shared profile fixture collides across PKI test files sharing one database) and one pre-existing environment-gated test (m41_pki_est, requires an external ATOM_EST_CLIENT) were confirmed to reproduce identically on unmodified code / require setup unavailable here -- not regressions from this change.
Every statement now executes through crate::db::{query, query_as,
query_scalar, QueryBuilder} against a Target (Database, transaction or
connection). SQL is authored once in the PostgreSQL dialect; Postgres runs it
unchanged and SQLite runs a cached mechanical translation (or an explicit
.sqlite() override). Adds the SQLite connection/lock/location plumbing and
the translator; Postgres behaviour is unchanged (full integration suite).
migrations/sqlite/001_initial.sql mirrors the PostgreSQL baseline (tables, indexes, views, seeds, protected-object registry and PKI triggers). The PostgreSQL built-ins and schema functions it depends on are implemented in Rust and registered on every SQLite connection.
… layer - flip application code from PgPool to the backend-neutral Database and add DbExecutor for code that runs on a transaction or a pooled connection - translate PostgreSQL SQL to SQLite (arrays, JSON operators, intervals, LATERAL, DELETE..USING, data-modifying CTEs via sqlite_all, encode/decode, generate_series, EXTRACT(epoch)); explicit .sqlite() overrides where a mechanical rewrite is not possible - register the SQL functions SQLite lacks (uuid/timestamp/json helpers, grant_scope_matches, md5, ...) and keep table constraints built-in only - classify SQLite errors (unique/FK/check/busy) and expose is_*_violation - shared test fixtures (ATOM_TEST_BACKEND=sqlite), rejecting-trigger helper, SQLite operational tests, boundary/parity check script, SQLite CI lane - operator docs page for running Atom on SQLite
…sorted parity fixes - ($n IS NULL OR cond) is specialised per call so SQLite plans the filter that is present and uses its index - jsonb key removal, encode/decode nesting, float casts, restrict-violation classification, effective pool ceiling in /health - backend-neutral FK assertion in the in-crate tests
Pulls in rotating refresh tokens for session authentication (#107), released to main after this branch diverged. Ports the new code onto the backend-neutral query layer and Database/ DbTransaction facade this branch introduced: src/identity/refresh_tokens.rs now takes &mut DbTransaction instead of sqlx::Transaction<'_, Postgres> and goes through crate::db::{query, query_scalar} instead of sqlx::query*; src/purge.rs, src/graphql/auth.rs and src/identity/service.rs's new call sites use state.pool()/&Database instead of &state.pool: PgPool. Adds migrations/sqlite/002_refresh_tokens.sql as the SQLite counterpart of migrations/002_refresh_tokens.sql (schema parity enforced by scripts/check-db-boundary.sh, updated here to check every migration pair, not just the frozen v1.0.0 baseline). tests/m51_refresh_tokens.rs and tests/api_contract.rs ported onto the query layer; all 12 refresh-token tests, including the concurrent-exchange and lock-ordering ones, pass on both PostgreSQL and SQLite. AGENTS.md documents the two-backend invariants: route all SQL through crate::db, keep the two migrations directories paired, use only SQLite built-ins in schema objects, classify errors with error::is_*_violation instead of comparing SQLSTATEs.
…n SQLite The old translation of array_position($1::uuid[], id) was a json_each correlated subquery: (SELECT key FROM json_each($1) WHERE unhex(value) = id). json_each exposes its own fixed columns (key, value, type, atom, id, parent, fullkey, path); a bare `id` inside that subquery's WHERE clause resolves to json_each's *own* id column, not the correlated outer row, regardless of aliasing json_each itself. The comparison was therefore always NULL, so the ORDER BY produced whatever order the query plan's underlying scan happened to use -- silently wrong, and only visibly wrong once a query plan (e.g. an index seek from an IN-list optimization) diverged from insertion order. Every real call site orders by array_position($N::uuid[], id) (six bare `id`, one already-qualified `g.id` that happened not to trigger it) across api_endpoints/resources/roles/role_assignments/direct_policies/entities/ groups "fetch these ids, in this order" listings. Fixed by computing the position with instr() against the parameter's JSON array text directly, entirely in the outer scope -- no new FROM-source, so nothing to shadow. Every array element is a fixed-width token (32-hex UUID, or any other scalar in its own JSON quotes), so substring position is strictly increasing in element order, same as array index for ordering purposes. Caught by tests/m44_list_sorting.rs's authorized_entity_order_survives_id_refetch_and_paginates, which (unlike the narrower cases that happened to pass) queries a table with rows besides the two being ordered, so the query planner's index-seek plan surfaced the bug. Added a translator-level regression test pinning the fix.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
atom-docs | 8bf1d7c | Commit Preview URL Branch Preview URL |
Sep 23 2026, 08:51 AM |
Two independent problems in one statement, neither caught by the local verification runs that excluded this test (it needs a locally-unavailable EST client, since fixed by installing github.com/globalsign/est's estclient@v1.0.7 the way CI does): 1. `SELECT generate_series(0, 1) AS step, ...` used generate_series as a PostgreSQL set-returning-function-in-SELECT-list expansion, not as a FROM-clause table source. The translator's generate_series rule only handles the latter (`FROM generate_series(...) AS alias`, proven by tests/m31_entity_external_id.rs), so it left this occurrence untouched, and the surrounding statement's other rewrites (epoch/interval arithmetic) produced valid SQL fragments wrapped around an invalid one. Fixed by giving the test's own query the portable FROM-clause form, which PostgreSQL treats identically and the translator already handles. 2. Once fixed, the same statement hit an unrelated SQLite grammar limitation: `INSERT ... SELECT ... FROM <source>` (no WHERE) immediately followed by an upsert clause is ambiguous to SQLite's parser and is rejected with "near \"DO\": syntax error", even though it is valid SQL SQLite otherwise executes. Confirmed directly against sqlite3 (CLI and Python binding, 3.47.2), independent of this crate's translator; adding any WHERE resolves it. Fixed with a `WHERE true` no-op filter, safe on both backends. Audited every other INSERT...ON CONFLICT in the codebase (grep, all 52 occurrences) for the same shape; this was the only one. Documents both in AGENTS.md's two-backend invariants so a future query does not reintroduce them, and pins the CTE/generate_series/epoch translation with a permanent regression test.
31df0ad to
8bf1d7c
Compare
|
Please revise this PR to use domain-level repository contracts, following the pattern used by Magistrala's alarms service, instead of making a PostgreSQL-to-SQLite query translator the main database abstraction. The requested boundary is service -> repository contract -> backend implementation. We do not want an interface around every SQL/helper function or one giant repository interface. Reference: Magistrala alarmsThe alarms service receives a These links use the historical revision before the service moved to EE. Requested Rust design
An illustrative layout (adapt the grouping to Atom's actual callers): Design the transaction boundary firstAtom's mutations can span multiple domains and must publish their outbox event atomically. Do not make every repository method open and commit an independent transaction. Choose an explicit shared transaction/unit-of-work design, or operation-level storage methods that own the entire atomic operation. Preserve:
Suggested implementation sequence and acceptance criteria
This deliberately accepts some duplication of storage queries in exchange for explicit, independently testable backend implementations. Shared business logic should remain shared. Neon can normally reuse the PostgreSQL adapter; it does not need a separate repository implementation merely because it is a different PostgreSQL hosting provider. |
Responds to review on this PR: replace the general-purpose PostgreSQL->SQLite
query translator with domain-shaped repository contracts, each backend owning
its native SQL (Magistrala alarms-service pattern), staged one domain at a
time starting with a documented pilot rather than a single pass across the
whole codebase (~34,000 lines / ~24 files) — see
product-docs/development/database-backends/REPOSITORY-PATTERN.md for the
full contract, the transaction/unit-of-work design (unchanged: Database/
DbTransaction, the existing crate::audit outbox-commit helpers), and the
backend-specific translation notes this pilot needed.
src/authz/resources/{mod,postgres,sqlite}.rs implements
create_resource_with_audit, create_resource, get_resource,
list_resources_by_ids and list_resources: a plain read, a harder read
(recursive group-hierarchy CTE, jsonb/JSON containment, search, pagination),
and the atomic multi-write outbox mutation the review asked to see proven.
Backend selection is a match on the already-connected Database enum (no new
selector type, no dyn dispatch — matches the codebase's existing enum-match
convention); postgres.rs/sqlite.rs are private (`mod`, not `pub mod`) so nothing
outside the domain can reach a specific backend's implementation.
crate::db::native::uuid_array_json is the one small shared encoding helper
this needed (matches the existing UuidArray wire format).
scripts/check-db-boundary.sh: the driver-type boundary now also accepts a
domain's own postgres.rs/sqlite.rs (in addition to src/db/), and separately
verifies no adapter has been widened to `pub mod` (which would silently widen
the exemption past its own domain — Rust's own privacy check is the real
enforcement for adapters that stay private).
All 11 test suites touching resources verified on both PostgreSQL and
SQLite (146 tests), including outbox_failure_rolls_back_the_domain_mutation,
which specifically proves the mutation+outbox atomicity guarantee survived
the conversion on both backends.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…repository Completes the resources domain's move to the repository-per-domain pattern: the four remaining mutations (with their audit-commit variants) now dispatch through authz::resources with native SQL in the postgres and sqlite adapters instead of the general-purpose translator. SQLite notes: FOR UPDATE is dropped (BEGIN IMMEDIATE already serialises writes), IS NOT DISTINCT FROM becomes IS, JSON binds as TEXT, now() is a registered function so it stays as-is. The shared purge_authz_references_for_ids cleanup stays in authz::repo for the not-yet-ported purge paths. Co-Authored-By: Claude Code <noreply@anthropic.com>
Summary
Full delivery of the database-backends initiative described in
product-docs/development/database-backends/(PRD/RFC/ROADMAP), as a single PR per the documented delivery model. Atom now runs on PostgreSQL or SQLite, selected by theDATABASE_URLscheme, with identical application behavior on both.Architecture note (departs from the RFC's per-domain dual-adapter design — see
product-docs/development/database-backends/IMPLEMENTATION-NOTES.mdfor the full rationale): rather than hand-porting every repository twice, all ~700 PostgreSQL-specific SQL constructs in the codebase are now routed through one backend-neutral query layer (crate::db::{query, query_as, query_scalar, QueryBuilder}). SQL is written once, in the PostgreSQL dialect:src/db/translate.rs) of the same text — arrays ↔ JSON,jsonboperators, interval arithmetic,LATERAL,ANY/ALL, data-modifying CTEs, etc. — or an explicit.sqlite("…")/.sqlite_all([...])override where a rewrite isn't mechanical (a few dozen call sites).What's in this PR
Database/DbTransaction/DbConnreplacePgPool/sqlx::Transactioneverywhere;DatabaseErrorKindclassification.crate::db;scripts/check-db-boundary.sh(new CI step) fails the build if a rawsqlx::query*or driver type (PgPool,SqliteConnection, …) appears outsidesrc/db/.src/db/sqlite.rs— WAL,synchronous=FULL,foreign_keys=ON, 30s busy timeout,BEGIN IMMEDIATEtransactions, single-owner file lock (a second Atom process refuses to start against the same file), busy/locked errors map to HTTP 503 / gRPCUNAVAILABLE.migrations/sqlite/001_initial.sqlmirrorsmigrations/001_initial.sqltable-for-table (and002_refresh_tokens.sqlmirrors the refresh-token migration from main). Tables/indexes/views/CHECK constraints use only SQLite built-ins (so any SQLite tool —sqlite3,VACUUM INTO, backup agents — can open and copy the database); the schema's invariant triggers (protected-object registry, PKI parentage/ceilings, revocation immutability, shared-key rules, …) call a small set ofatom_*SQL functions the app registers on every connection (src/db/sqlite_functions.rs, vialibsqlite3-sys).Z), JSON =TEXT+json_valid, arrays = JSON text, bool =INTEGER.error::{is_unique_violation, is_foreign_key_violation, is_check_violation}replace ad hoc SQLSTATE string comparisons everywhere (including in tests), so the same code path works on both backends' error codes.#[ignore]d unit tests run against both backends —cargo test -- --include-ignored(PostgreSQL, default) andATOM_TEST_BACKEND=sqlite cargo test -- --include-ignored(SQLite, no external service — each test process gets a fresh file). CI runs both lanes.tests/sqlite_operations.rscovers what has no PostgreSQL analogue: fixed PRAGMAs, restart persistence, single-owner lock, rollback atomicity, contention/busy mapping, backup-and-restore.docs/content/docs/operations/sqlite.mdx— configuration, durability policy, backup/restore, behavioral differences, when to choose which backend.AGENTS.mdupdated with the two-backend invariants (query-layer-only rule, migration pairing, built-ins-only schema, no second pool connection under a transaction).Merged current
main(rotating refresh tokens, #107) into this branch;migrations/sqlite/002_refresh_tokens.sqladded as its SQLite counterpart and the newsrc/identity/refresh_tokens.rs/m51_refresh_tokens.rsported onto the query layer — all 12 refresh-token tests pass on both backends, including the concurrent-exchange and lock-ordering ones.Test plan
cargo fmt --checkcargo clippy --locked -- -D warningsscripts/check-db-boundary.sh(driver-type boundary + PostgreSQL/SQLite schema parity)scripts/check-v1-contracts.shcargo test --lib— 420 tests pass on both PostgreSQL and SQLite (--include-ignored)m27_live_amqp_delivery/m28_amqp_mtls_local_principal, which need a real AMQP broker CI doesn't provision either) pass on both PostgreSQL and SQLite, each test binary against a freshly created/migrated database — includingm41_pki_est(needsestclient, installed locally to verify) andm42_pki_pkcs11(PostgreSQL only by design: it proves the HSM key backend, independent of the storage engine)tests/sqlite_operations.rs(SQLite-only operational contract) — 10/10One CI run caught two real gaps this local matrix had missed (both fixed, both re-verified locally end to end before the fix commit, and now covered by regression tests): a
json_each-scopedarray_positiontranslation that silently matched nothing rather than raising an error, masked by simpler test cases; andm41_pki_est, excluded from earlier local runs for lacking theestclientbinary, which hit an SQLite-onlygenerate_series-in-SELECT-list gap plus an unrelated SQLiteINSERT...SELECT...ON CONFLICTgrammar ambiguity. Both are now documented inAGENTS.md's two-backend invariants.