# 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 `"".`. 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 `), 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 -d ` 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`).