Several databases¶
A resource names the connection it lives on, so putting two resources in two databases has always been a one-word change:
Resource("deals", provider="db.main#deals", ...)
Resource("audit_2019", provider="db.archive#deals_2019", ...)
This page covers the three things that get harder once you do: showing rows from both in one screen, migrating them, and seeding them.
One resource, several backends¶
Union declares that a resource's rows live in more than one place. The views
do not change, because a union is a provider and every view is written against
providers.
from app.providers.union import Union, UnionSource, source_choices
DEAL_STORES = Union(
UnionSource("db.main#deals", label="live"),
UnionSource("db.archive#archived_deals", label="archive"),
source_field="store",
)
Resource(
"all_deals",
provider=DEAL_STORES,
policy=ReadOnlyPolicy(),
fields=[
TextField("name", searchable=True),
SelectField("store", choices=source_choices(DEAL_STORES), in_filter=True),
CurrencyField("amount"),
StatusField("stage", choices=STAGES, in_filter=True),
],
)
That is the whole declaration. modules/demo_archive is the working version,
and it runs with no services beyond a second SQLite file:
CRM_ARCHIVE_ENABLED=true CRM_MODULES=demo_crm,demo_sales,demo_archive uv run crm migrate --all
CRM_ARCHIVE_ENABLED=true CRM_MODULES=demo_crm,demo_sales,demo_archive uv run crm seed
CRM_ARCHIVE_ENABLED=true CRM_MODULES=demo_crm,demo_sales,demo_archive uv run crm seed -c db.archive
CRM_ARCHIVE_ENABLED=true CRM_MODULES=demo_crm,demo_sales,demo_archive uv run crm dev
What it costs¶
Serving page N of a merged, sorted list needs offset + page_size rows from
each source, merged and then sliced — asking each for one page and
interleaving would skip rows. So a union's cost is bounded by how deep you page
and by the number of sources, never by how much data they hold. Past
max_rows (5000 by default) it refuses rather than truncating, exactly as the
capability shim does.
Measured on the demo, two SQLite databases:
| Page | Queries | Where the work happens |
|---|---|---|
| list, both stores | 4 | rows + count, once per database |
| list, filtered to one store | 2 | the other database is never opened |
| chart or pivot | 2 | each database groups its own rows; only the groups are merged |
The source column¶
Every record is stamped with the source it came from, under source_field
("source" unless you rename it — the demo uses "store" because deals
already has a column called source). Declare a field of that name and it
behaves like any other: displayable, filterable, groupable.
Filtering on it is the cheapest filter a union has. store = "archive" is not
pushed into the backends — the column does not exist in either — it decides
which backends to open, so filtering to one store does not query the other
at all. Only =, !=, in and not in can be answered that way, and only in
a top-level AND; source = 'a' OR amount > 10 is refused rather than
answered approximately, because it would need rows from a source the condition
excludes.
What a union will not do¶
Write. A row belongs to one of several databases and a form has no way to
say which, so create, update and delete are refused with a message rather than
guessed at. Declare a resource per source for editing, or give the union
resource a write_provider.
Average across sources. The mean of two means is not the mean, and weighting it correctly needs a count of non-null values the query language cannot ask for. Counts, sums, minima and maxima all merge from partial results and stay in the databases; an average falls back to merging rows, under the same row budget.
Hide a failure. If a source is unreachable the query fails. A union that silently dropped it would return a short answer that looks complete, which is the same failure the shim refuses to make when a filter cannot be pushed down.
Guarantee unique keys. Two databases will both have an id of 5. Pass
prefixed=True and every key becomes <label>:<key>, so a detail route
addresses one row; leave it off and sources are searched in declaration order,
which is right when the keys are UUIDs or the ranges are known not to overlap.
Migrating¶
Each database has its own migration history, in its own directory, tracked in its own version table:
| Connection | History | Version table |
|---|---|---|
db.main |
migrations/versions/ |
alembic_version |
db.archive |
migrations/versions_db_archive/ |
alembic_version_db_archive |
Both halves matter, and they fail differently. A shared history would run one database's migrations against another; a shared version table would leave two databases at two revisions each believing they were at the other's. The default connection keeps alembic's own layout, so a single-database deployment is unaffected by any of this.
uv run crm make-migration --connection db.archive "archive tables"
uv run crm migrate --connection db.archive
uv run crm migrate --all # every database, platform first
Autogenerate compares only the tables belonging to --connection, so a
migration for one database never proposes creating another's.
Where the mapping comes from¶
Nothing declares it. app.core.placement reads it back out of the resource
declarations — provider="db.archive#archived_deals" says where
archived_deals lives — so the schema and its placement cannot drift apart.
Tables nothing claims belong to the default connection, which is why half the
schema (reset tokens, timeline entries) needs no annotation and a
single-database deployment needs none at all.
Foreign keys do not cross¶
A constraint between two databases is not a constraint: it cannot be created,
and if it is, it points at a table that is not there. Design around it — the
demo's archived_deals keeps company_name rather than a company_id — and
resolve the reference in the application with a RelationField, which is
already how relations work across backends.
Seeding¶
crm seed creates and drops the tables belonging to one connection, so seeding
one database cannot delete another's:
uv run crm seed # the platform database
uv run crm seed -c db.archive # its own tables, its own sample data
The accounts live wherever the users table does. Seeding a database that does
not hold them creates its tables and stops, which is the right answer rather
than a limitation: there is one set of accounts, not one per database. A
module whose tables are on another connection is skipped rather than allowed to
fail halfway through inserting.
Resources that are not in a database at all¶
A resource may name a connection that holds no tables — an HTTP endpoint, a queue, a Redis keyspace. That is ordinary, and it interacts with the placement rule above in one way worth knowing.
"Tables nothing claims belong to the default connection" is what makes a single-database deployment need no configuration. Left alone it would also adopt the name of a resource that is deliberately not in SQL, and create a permanently empty table for it. So a name spoken for by a non-table connection is recorded as belonging nowhere, and no database creates it.
One consequence: moving a resource off SQL makes its old table dead weight,
and the next autogenerated migration will propose dropping it. That is
correct — the schema genuinely no longer includes it — but it is a destructive
operation appearing in a migration you did not ask for, so read what
crm make-migration produces, as ever. An existing table is not dropped until
you run such a migration; switching CRM_JOBS_CONNECTION to Redis leaves the
SQL jobs table sitting there, empty and harmless, until you say otherwise.
Operational notes¶
- Connections open lazily and independently. A dead archive does not stop the live screens from serving — until something reads the union, which will then fail rather than quietly return half the rows.
crm check-connectionsopens every configured database and reports each one, which is the fastest way to find out that only one of the two is reachable.- A disabled connection is never opened, so
enabled: falseis how a second database stays configured and costs nothing. - Row scoping applies per source. The identity's scope filter is pushed into each backend separately; a union cannot be used to see rows a policy would have hidden.