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 the demos database on the same server.
  • baseTag โ€” the release to pin. release.v1 freezes 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

PropertyBehavior
InstantThe tenant DB opens with every repo table already visible โ€” no import, no copy step.
Zero-diskReference rows are read through the repo's content-addressed blobs; the tenant stores only what it writes itself.
PinnedMounted at release.v1. The repo can publish release.v2 and move on โ€” the tenant stays on v1 until re-pointed.
Copy-on-writeA tenant write lands only in the tenant's own local layer; the shared repo is never modified.
Fail-loudA 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

  1. Curate & publish. Build the demo dataset in the demos repo database and publish it: POST /api/v2/_releases { "name": "v1" } (see Managing releases).
  2. Provision on signup. For each new trial account, create a tenant database whose store is basedOn the demos DB, pinned to release.v1. The user sees the demos the instant the account exists.
  3. 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.
  4. 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. Retire v1 once 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 from reads 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, purchases at 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.

FieldMeaning
fromSource table, as it appears in the mounted tree (a top-level name).
copyTarget table name in the tenant's own space.
replaceOptional 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). demos and demos/accounting are distinct databases โ€” / is a naming convention, not containment. A parent name is not a folder of its children: an empty demos has an empty head, so mounting base: { "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 (copy acting 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:

IntentOperation
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> is env.<name> for a branch (including env.master), or release.<name> for a published release. A bare name is read as a branch.
  • Pinned by nature. release.v1:catalog reads 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:

typeMeaning
localThe database's own disk. The default when store is omitted.
dbAnother database's storage on the same server โ€” used as a mount base.
basedOnMount base read-through under a thin writable store; baseTag pins a release.
backedUpA 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