11 KiB
dev-deploy multi-tenancy
dev-deploy runs unchanged inside a multi-tenant Saltcorn instance, where each
tenant lives in its own Postgres schema. The plugin treats every tenant as a
fully independent dev-deploy environment: each tenant has its own _dd_*
service tables (schema-qualified), its own env_id and journal, and its own set
of peers. Nothing is shared across tenants.
This document covers how that isolation is achieved, the per-tenant installer,
and the Postgres-portability rules the code relies on. Code references are
file:line into the plugin source.
See also: architecture.md for the core ops-journal / entity-UUID / wrap / apply model that this builds on.
Contents
- Per-tenant schema qualification
- Tenant-keyed environment identity
- The per-tenant installer
- Postgres-portability rules
- Tenant introspection: getTenantSchema vs current_schema
Per-tenant schema qualification
On Postgres, Saltcorn places each tenant in its own schema and does NOT use
SET search_path; instead it qualifies queries with a schema prefix. dev-deploy
follows the same rule for every raw SQL statement it issues against its own
_dd_* tables and against managed user tables.
The prefix comes from db.getTenantSchemaPrefix(). On SQLite it is empty; on
Postgres it is "<schema>".. Every CREATE TABLE, index, and raw query
interpolates it:
- All six
_dd_*tables are created with${schema}_dd_...inschema.js(schema.js,schema.js,schema.js,schema.js,schema.js,schema.js). - Their indexes are likewise prefixed (
schema.js,schema.js). - Raw reads in the routes layer build SQL against
${schema}_dd_opsetc. (routes.js,routes.js,routes.js,routes.js,routes.js,routes.js,routes.js). - Conflict detection in apply queries
${schema}_dd_ops(apply.js). - The hidden-column infrastructure qualifies the user table it alters via
tableSqlRef(), which prepends the same prefix (rowIdentity.js).
Note: db.insert / db.select / db.selectMaybeOne / db.updateWhere /
db.deleteWhere take an unqualified logical table name (e.g. "_dd_ops") and
Saltcorn applies the tenant prefix internally. The explicit
getTenantSchemaPrefix() interpolation is only needed where the plugin drops to
raw db.query SQL.
Because every statement is schema-scoped, a single Node process serving many
tenants reads and writes a different physical _dd_* table per tenant with no
code changes.
Tenant-keyed environment identity
The environment identity (the singleton row in _dd_env) is schema-scoped, so a
process serving multiple tenants must not cache one identity row across them.
env.js keys its cache by tenant schema rather than using a module-level
singleton (env.js).
| Symbol | Behavior | Location |
|---|---|---|
cachedEnvByTenant |
Map keyed by tenant schema, not a single cached row |
env.js |
tenantKey() |
Returns db.getTenantSchema(), or "public" if unavailable |
env.js |
getEnv() |
Looks up / populates the cache entry for the current tenant key | env.js |
refreshEnvCache() |
Deletes only the current tenant's cache entry, then reloads | env.js |
initEnvIfMissing() |
Inserts a fresh _dd_env row with a new env_id if none exists, caches it under the current tenant key |
env.js |
markBootstrapped() |
Stamps bootstrapped_at; only mutates the cached row if its env_id matches |
env.js |
Each tenant therefore gets:
- its own
env_id(a fresh v4 UUID generated ininitEnvIfMissing,env.js), - its own
_dd_opsjournal (schema-qualified, above), - its own
_dd_peersrows and HMAC pairings.
Two tenants pairing with the same remote base URL are independent: each has a
distinct env_id, a distinct shared secret, and a distinct journal, so promote
/ pull between tenants never cross-contaminates.
The per-tenant installer
The plugin is installed into each tenant schema by
scripts/installDevDeployTenant.js, driven by the wrapper
scripts/installDevDeployTenant.sh.
Prerequisites (installDevDeployTenant.sh): the tenants must already exist
(saltcorn create-tenant <name>), and the plugin must already be installed into
the Postgres public schema once via the normal install-plugin -d ./dev-deploy
path so the shared plugins_folder copy exists.
Usage (from project root, with the PG environment sourced from
.dev-state-pg/env.sh):
./dev-deploy/scripts/installDevDeployTenant.sh t1 t2 # named tenants
./dev-deploy/scripts/installDevDeployTenant.sh '*' # all tenants
What the script does:
| Step | Detail | Location |
|---|---|---|
Re-root @saltcorn/* |
Uses createRequire against the Saltcorn checkout's node_modules (up two dirs to project root, then saltcorn/packages/...) |
installDevDeployTenant.js |
| Resolve tenants | Empty args or * means all tenants, read from getAllTenants run inside the default schema |
installDevDeployTenant.js |
| Init per-tenant State | init_multi_tenant(Plugin.loadAllPlugins, true, tenants) so getState() resolves inside runWithTenant, without running migrations; this also re-runs each tenant's existing plugins' (idempotent) onLoad |
installDevDeployTenant.js |
| Permit local plugin on tenants | Sets root config tenants_unsafe_plugins = true |
installDevDeployTenant.js |
| Install per tenant | For each tenant, installInto(tenant) |
installDevDeployTenant.js |
installInto (installDevDeployTenant.js) runs inside
db.runWithTenant(tenant, ...) and a transaction:
- Deletes any prior
_sc_pluginsrow fordev-deployso it converges on exactly one row (no duplicate source of truth) (installDevDeployTenant.js). - Constructs a
Pluginwithsource: "local",location: DEV_DEPLOY_DIR, and callsPlugin.loadAndSaveNewPlugin(plugin, true, false)(installDevDeployTenant.js). - Verifies against dev-deploy's own table: confirms the
_sc_pluginsrow exists AND_dd_envexists in this tenant's schema (viainformation_schema.tablesfiltered ondb.getTenantSchema()), so a stale plugin row cannot pass; throws ifonLoaddid not run (installDevDeployTenant.js).
Why this script exists instead of the CLI: the CLI
install-plugin -t <tenant> -d <dir> cannot install a local (-d) plugin on a
non-root tenant. In this Saltcorn build loadAndSaveNewPlugin skips any
non-npm plugin on a non-root tenant before its allowUnsafe argument is
consulted; the supported lever is the root-only tenants_unsafe_plugins config,
which the CLI never sets and this script does
(installDevDeployTenant.js, installDevDeployTenant.js).
Running onLoad per tenant is what creates that tenant's _dd_* tables and
bootstraps its env_id; because both createAllTables() and
initEnvIfMissing() are idempotent, re-running the installer is safe.
Postgres-portability rules
dev-deploy supports both SQLite and Postgres from one DDL/SQL codebase. The
following rules were learned to keep the _dd_* tables portable; deviating from
them breaks on Postgres specifically.
Auto-increment primary key: integer vs serial
SQLite's integer primary key auto-assigns rowids; Postgres needs serial
(AUTOINCREMENT is SQLite-only syntax). The DDL picks the type at runtime:
const serial = db.isSQLite ? "integer" : "serial";
Used for _dd_peers.peer_id (schema.js, schema.js). _dd_anchors
uses peer_id INTEGER as a foreign reference, not an auto-increment, so it stays
INTEGER (schema.js).
ADD COLUMN IF NOT EXISTS on Postgres to avoid transaction poisoning
For idempotent column migrations on already-installed instances, a bare ALTER TABLE ... ADD COLUMN caught in a JS try/catch is NOT enough on Postgres: a
failed statement poisons the surrounding transaction. So the code branches on
db.isSQLite:
- SQLite lacks
ADD COLUMN IF NOT EXISTS, so it runs the bareALTERand swallows the "column already exists" error. - Postgres uses
ADD COLUMN IF NOT EXISTSso the statement never errors and the transaction is never poisoned.
This pattern appears for _dd_ops.conflict_with_op_id (schema.js) and
_dd_table_modes.starter_shipped_at (schema.js).
A related case: createDdOps creates a partial index with a WHERE status = 'conflict' predicate and .catch(() => {}) (schema.js); that swallow is
fine because it is a standalone db.query, not part of a migration that must
keep a transaction alive.
Every _dd_* insert needs { noid: true }
The _dd_* tables have no column literally named id (they use named PKs:
env_id, peer_id, uuid, op_id, table_uuid, or a composite PK). Saltcorn's
db.insert assumes an id column unless told otherwise, so every insert into a
_dd_* table passes { noid: true }:
| Insert target | Location |
|---|---|
_dd_env |
env.js |
_dd_ops |
apply.js |
_dd_anchors (inbound/outbound) |
routes.js, routes.js |
_dd_table_modes |
routes.js, apply.js |
(_dd_peers and _dd_entity_ids inserts live in peers.js / entityIds.js,
outside the files reviewed here, but follow the same { noid: true } rule by the
same constraint.)
Tenant introspection: getTenantSchema vs current_schema
NEVER use current_schema() to discover which tenant schema you are in.
Because Saltcorn qualifies queries with getTenantSchemaPrefix() rather than
issuing SET search_path, current_schema() returns "public" even while
serving inside a tenant. The correct source of truth is db.getTenantSchema().
This bit the hidden-column check in rowIdentity.js. The Postgres branch of
columnExists() queries information_schema.columns filtered by
table_schema = db.getTenantSchema() (rowIdentity.js, rowIdentity.js)
-- the same schema that tableSqlRef() / the ALTER target use
(rowIdentity.js). An earlier version used current_schema(), which made the
check falsely report the _dd_row_uuid column missing on every call after the
first; the explicitly-qualified ALTER then failed with column "_dd_row_uuid" already exists, breaking apply (which calls
ensureManagedSchema once per set_table_mode and per insert_row)
(rowIdentity.js).
Consistent introspection sources used throughout:
| Need | Use | Examples |
|---|---|---|
| Qualify raw SQL | db.getTenantSchemaPrefix() |
schema.js, routes.js, rowIdentity.js |
Name the current schema (for information_schema, file paths) |
db.getTenantSchema() |
rowIdentity.js, installDevDeployTenant.js, routes.js |
| Cache key per tenant | db.getTenantSchema() (via tenantKey()) |
env.js |
The same db.getTenantSchema() is used to locate per-tenant file storage in
the binary file endpoint: path.join(file_store, db.getTenantSchema(), relative_path) (routes.js).