Mountable Repositories (v2)
A mountable repository lets a tenant database serve a shared dataset โ demo data for a free trial, or reference data like postal codes and company registries โ instantly, with zero extra disk, by mounting a released copy of another database read-through. No upload step, no per-tenant duplication: the tenant opens with the data already present.
This is the mechanism behind free-trial demo data. It builds directly on releases: a shared repository publishes immutable release versions, and each tenant mounts a chosen one.
The model
A mount is the read-through layer role of the ref model โ it points a tenant's storage at another database's release and layers its own writes on top:
- A repository database (e.g.
demos) holds the shared tables and publishes immutable releases โrelease.v1,release.v2, โฆ (see Releases). - A tenant database declares, in its catalog entry, that its storage is based on a release of the repo. On open it layers the repo's committed data underneath a thin writable layer of its own.
- The result is instant, zero-disk, copy-on-write, and pinned: the tenant sees every repo table immediately, stores only its own later writes, and stays fixed on the release it mounted even as the repo moves on.
How a tenant mounts a release
Every database on a multi-tenant server is defined by a catalog entry โ a row in
the server's system db registry โ carrying a name, an auth block, and a
store. The store is where a mount is declared:
{
"name": "trial-4821",
"auth": { "type": "keys", "readWriteApiKey": "โฆ", "readOnlyApiKey": "โฆ" },
"store": {
"type": "basedOn",
"store": { "type": "local" },
"base": { "type": "db", "db": "demos" },
"baseTag": "release.v1"
}
}
baseโ the repository to mount.{ "type": "db", "db": "demos" }points at thedemosdatabase on the same server.baseTagโ the release to pin.release.v1freezes the tenant on that version; omit it to track the repo's current head instead.store(inner) โ the tenant's own writable layer.{ "type": "local" }is its own disk; every tenant write lands here, never in the repo.
Provision it exactly as you create any tenant database โ through the server's
database-provisioning (system) API โ with the one addition of the store
field. A definition with no store defaults to { "type": "local" } (an
ordinary standalone database).
What the tenant sees
| Property | Behavior |
|---|---|
| Instant | The tenant DB opens with every repo table already visible โ no import, no copy step. |
| Zero-disk | Reference rows are read through the repo's content-addressed blobs; the tenant stores only what it writes itself. |
| Pinned | Mounted at release.v1. The repo can publish release.v2 and move on โ the tenant stays on v1 until re-pointed. |
| Copy-on-write | A tenant write lands only in the tenant's own local layer; the shared repo is never modified. |
| Fail-loud | A missing or retired baseTag refuses to open, rather than silently mounting the wrong data: IllegalStateException: cannot mount base version 'release.bogus': no such release (retired, or never published). Available: [release.v1, release.v2, state]. |
These are the exact behaviors the mount round-trip test asserts: mount
release.v1 โ 2 rows visible (not the repo's current 3); the tenant adds a row
โ tenant 3, repo unchanged; pin a bogus release โ loud failure at open.
The free-trial lifecycle
- Curate & publish. Build the demo dataset in the
demosrepo database and publish it:POST /api/v2/_releases { "name": "v1" }(see Managing releases). - Provision on signup. For each new trial account, create a tenant database
whose
storeisbasedOnthedemosDB, pinned torelease.v1. The user sees the demos the instant the account exists. - Let them play. The user queries, writes, and breaks things freely โ every change is copy-on-write in their own layer, so the shared demos stay pristine for every other trial.
- Refresh without disruption. Improve the demos and publish
release.v2. New trials mount v2; existing trials stay pinned to v1 until you re-point them, so nobody's data shifts under them. Retirev1once no live tenant pins it, and GC reclaims its storage.
Because a release is a durable GC root, the versions a tenant might mount are safe from garbage collection for as long as they're published โ no per-tenant pinning bookkeeping.
Organising a repo into demos (the loader recipe)
When one repo holds several independent demos โ an accounting set, a crm
set, โฆ โ group each demo's tables under its own sub-environment so a tenant
can take just one. accounting/invoices, accounting/purchases, crm/contacts
are a single database's content tree (the second hierarchy from
above) โ not separate databases.
Ingestion is flat (a table name is one path segment), so the loader ingests
each table, then moves it into its demo's sub-env โ one atomic relocate,
leaving no flat duplicate:
{ "operations": [
{ "move": "invoices", "to": "accounting/invoices" },
{ "move": "purchases", "to": "accounting/purchases" },
{ "move": "contacts", "to": "crm/contacts" } ] }
move grafts the table at the new (possibly nested) name and drops the source,
materialising the sub-env if needed. Then publish a release as usual.
Once a tenant mounts the repo, it can work with a demo two ways:
- Query it in place โ a nested
fromreads straight through the mount, no copy:{ "from": "accounting/invoices", "select": ["id"] }. - Adopt it โ take ownership of the whole demo in one call with the subtree
copy below (
{ "from": "accounting/*", "copy": "" }โinvoices,purchasesat the tenant's top level, clean names).
Adopting a mounted table as your own
A mount is a live reference, not a copy: every repo table the tenant sees is
read through the repo's blobs. When a trial user should take ownership of a
demo table โ edit it as their own data, independent of the repo โ copy it into
the tenant's own space. No new endpoint is involved; the copy resolves the mount
once and grafts the rows into the tenant's local layer (content-addressed, so
it is zero-copy at rest), after which the two diverge.
Because the mount is read-through, the repo's tables sit at the tenant's top level. Copy them by name with the atomic modify endpoint on the tenant database:
POST /api/v2/data/_modify
{ "operations": [
{ "from": "invoices", "copy": "invoices" },
{ "from": "employees", "copy": "employees" }
] }
A single op may be sent unwrapped ({ "from": โฆ, "copy": โฆ }); several go in an
operations array.
| Field | Meaning |
|---|---|
from | Source table, as it appears in the mounted tree (a top-level name). |
copy | Target table name in the tenant's own space. |
replace | Optional boolean. Without it, copying onto an existing name is rejected. |
The version is not in the copy body โ which release the tenant sees is fixed
once, at mount time, by baseTag. Seed after mounting: provision the
tenant basedOn a release, then _modify-copy the tables it should own.
Two hierarchies โ don't cross them
The / in a name can mean two different things, and copying is where the
difference bites:
- Separate databases (the registry).
demosanddemos/accountingare distinct databases โ/is a naming convention, not containment. A parent name is not a folder of its children: an emptydemoshas an empty head, so mountingbase: { "db": "demos" }gives you nothing. To seed from a specific demo, mount that database directly โbase: { "type": "db", "db": "demos/accounting" }โ then copy its tables by name as above. - One database's content tree. Within a single database, tables may be
grouped under nested sub-environments (
accounting/invoices). Only there does a path โ and the subtree wildcard{ "from": "accounting/*", "copy": "" }, which grafts every table under a sub-env (copyacting as a name prefix) โ apply. That is the exception, not the usual demo layout.
Rule of thumb: choose the database to mount (registry level), then copy
tables by name โ resorting to a path/* wildcard only if that one database
nests envs internally.
Copying across refs โ migration, restore, release seeding
The copies above read from the current view of the database. Prefix from with
a ref and a colon to read from another ref of the same database instead โ
a branch, or a published release:
from: "<ref>:<path>"
The : separates the ref from the path inside it; no : keeps today's
meaning (the current env). Everything after the colon is the same path grammar
as always โ a table, a nested path, or a * subtree. This one addition serves
three jobs:
| Intent | Operation |
|---|---|
| Partial migration (branch โ here) | { "from": "env.sandbox:orders", "copy": "orders", "replace": true } |
| Version / backup restore (a pinned snapshot) | { "from": "release.v1:catalog", "copy": "catalog", "replace": true } |
| Seed from a release (a whole subtree) | { "from": "release.demos-v1:*", "copy": "" } |
<ref>isenv.<name>for a branch (includingenv.master), orrelease.<name>for a published release. A bare name is read as a branch.- Pinned by nature.
release.v1:catalogreads the release's frozen head, so you get exactly that version's rows even after the live table has moved on โ which is what makes it a restore. - Zero-copy. Every ref of one database shares the same content-addressed storage, so copying across refs costs nothing at rest: it points at the same blobs, then diverges on the next write โ just like a mount.
- Fail-loud. An unknown ref is rejected, never silently resolved to empty.
Two boundaries worth stating:
- Same database only. Cross-ref copy moves between the refs of one DB (branches, releases). Copying from a separate database โ a demos repo that is its own DB โ is still the mount path, then an ordinary in-tree copy.
- Restore is table/subtree-scoped. To restore specific tables, copy them
with
"replace": true. Rolling a whole database back to a snapshot is a ref move (repointing the branch), not a copy.
Store definitions
The store field is a small composable algebra. The console builds a mount by
nesting these:
type | Meaning |
|---|---|
local | The database's own disk. The default when store is omitted. |
db | Another database's storage on the same server โ used as a mount base. |
basedOn | Mount base read-through under a thin writable store; baseTag pins a release. |
backedUp | A store fronting a backup store (durability), independent of mounting. |
A tenant mount is always a basedOn wrapping a local write layer over a db
base, optionally pinned with baseTag.
Coming: declaring repos once with remotes
Today each tenant declares its mount explicitly with a basedOn store. A
planned remotes: [...] field on the database definition will let a server
declare shared repositories once โ mounted uniformly under a remote/<name>
path, and eventually across servers over the network โ so a tenant references
remote/demos rather than repeating the full basedOn block. The engine
mechanism is exactly the one documented here; remotes is catalog sugar over
it. This page will grow the remotes syntax when it ships.
Related: Releases ยท Environments & Auth ยท Clusters & Scaling