sc-dev-deploy/docs/multitenancy.md
2026-06-01 16:43:43 -05:00

222 lines
11 KiB
Markdown

# 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](architecture.md) for the core ops-journal /
entity-UUID / wrap / apply model that this builds on.
## Contents
- [Per-tenant schema qualification](#per-tenant-schema-qualification)
- [Tenant-keyed environment identity](#tenant-keyed-environment-identity)
- [The per-tenant installer](#the-per-tenant-installer)
- [Postgres-portability rules](#postgres-portability-rules)
- [Tenant introspection: getTenantSchema vs current_schema](#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_...` in `schema.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_ops` etc.
(`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 in `initEnvIfMissing`,
`env.js`),
- its own `_dd_ops` journal (schema-qualified, above),
- its own `_dd_peers` rows 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:
1. Deletes any prior `_sc_plugins` row for `dev-deploy` so it converges on
exactly one row (no duplicate source of truth) (`installDevDeployTenant.js`).
2. Constructs a `Plugin` with `source: "local"`, `location: DEV_DEPLOY_DIR`, and
calls `Plugin.loadAndSaveNewPlugin(plugin, true, false)` (`installDevDeployTenant.js`).
3. Verifies against dev-deploy's own table: confirms the `_sc_plugins` row
exists AND `_dd_env` exists in this tenant's schema (via
`information_schema.tables` filtered on `db.getTenantSchema()`), so a stale
plugin row cannot pass; throws if `onLoad` did 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 bare `ALTER` and
swallows the "column already exists" error.
- Postgres uses `ADD COLUMN IF NOT EXISTS` so 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`).