# dev-deploy managed rows (row-data sync) By default dev-deploy migrates only metadata (tables, fields, views, pages, etc.) between environments and never touches the actual rows users entered. The managed-rows feature is the opt-in extension that also synchronizes row *data* for tables an admin explicitly marks. It does this with a per-table data mode, a hidden cross-environment row identity column (`_dd_row_uuid`), row-level ops in the journal (`insert_row` / `update_row` / `drop_row` / `set_table_mode`), and UUID-based foreign-key and file translation so a row created on Dev lands correctly on Test/Prod even though every instance assigns its own integer ids. See also: [architecture.md](./architecture.md) for the ops journal, stable entity UUIDs, the wrap/apply layers, and conflict handling; [peering.md](./peering.md) for the HMAC transport and sync anchors; [multitenancy.md](./multitenancy.md) for per-tenant isolation. Code references below are `file:line` into the plugin source. ## Contents - [Data modes: user / starter / managed](#data-modes-user--starter--managed) - [The hidden `_dd_row_uuid` column](#the-hidden-_dd_row_uuid-column) - [Switching a table's data mode](#switching-a-tables-data-mode) - [Journaling row changes (the wrap layer)](#journaling-row-changes-the-wrap-layer) - [Applying row ops (the apply layer)](#applying-row-ops-the-apply-layer) - [Foreign-key handling](#foreign-key-handling) - [File propagation](#file-propagation) - [Tables and columns](#tables-and-columns) - [Endpoints](#endpoints) ## Data modes: user / starter / managed Each table has a data mode stored in `_dd_table_modes.data_mode`, keyed by the table's stable UUID. The three valid values are defined in `constants.js` (`DATA_MODES`): `user`, `starter`, `managed`. A table with no `_dd_table_modes` row is treated as `user` (the default; `rowPayload.js`, `rowPayload.js`). | Mode | When rows sync | Who owns rows after | Intended use | | --- | --- | --- | --- | | `user` (default) | Never. Row CRUD passes through silently and is never journaled. | The local environment. | End-user-entered data; the only safe choice for it. | | `starter` | Once, at the moment the table is switched to `starter` (the "initial ship"). After that, further row changes do not propagate. | The target, after first install. | Default roles, sample categories, template data the user is expected to customize. | | `managed` | Always. Every insert/update/delete is journaled and replayed; source is canonical. | The source; target edits get overwritten or surface as conflicts. | Catalogs, lookup tables, anything dev-curated. | The per-table decision of whether a given CRUD operation should be journaled is made by `journalDecision(tableId)` in `rowPayload.js`: - `managed` -> always journal (`shouldJournal: true`, `rowPayload.js`). - `starter` -> journal only while the table has not yet been shipped, i.e. `_dd_table_modes.starter_shipped_at` is still NULL (`rowPayload.js`, `isStarterShipped` at `rowPayload.js`). - `user` (or a table with no entity mapping) -> never journal (`rowPayload.js`, `rowPayload.js`). The `users` table is hard-locked to `user` and cannot be changed (`routes.js`, `routes.js`). ## The hidden `_dd_row_uuid` column A row's integer `id` is local to one instance and cannot identify the same row on a peer. The feature therefore adds a hidden TEXT column named `_dd_row_uuid` (the constant `COLUMN_NAME` in `rowIdentity.js`) to the underlying SQL table of every `managed` or `starter` table. This column is the row's cross-environment identity. Key properties (`rowIdentity.js`): - It is added by raw `ALTER TABLE ... ADD COLUMN _dd_row_uuid TEXT` (`rowIdentity.js`) and is deliberately NOT registered in Saltcorn's `_sc_fields`, so Saltcorn's table builder and auto-generated views never display it (`rowIdentity.js`). - Existing rows are backfilled with `crypto.randomUUID()` values immediately after the column is added (`rowIdentity.js`). - A lookup index `"_dd_row_uuid_idx"` is created on the column (best-effort; `rowIdentity.js`). - The SQL table is referenced through `tableSqlRef()`, which schema-qualifies via `db.getTenantSchemaPrefix()` and `db.sqlsanitize()` (`rowIdentity.js`), so it works inside any tenant. ### SQLite PRAGMA vs Postgres information_schema introspection `columnExists(tableName)` (`rowIdentity.js`) detects whether the column is already present, and it branches on the backend: - SQLite: `PRAGMA table_info("
")` and checks whether any returned row has `name === "_dd_row_uuid"` (`rowIdentity.js`). - Postgres: a query against `information_schema.columns` filtered by `table_schema = db.getTenantSchema()`, `table_name`, and `column_name = "_dd_row_uuid"` (`rowIdentity.js`). The Postgres path explicitly queries the tenant's own schema rather than relying on `current_schema()`. The code comment at `rowIdentity.js` documents why: Saltcorn qualifies queries with `getTenantSchemaPrefix()` instead of `SET search_path`, so `current_schema()` returns `public` even inside a tenant. Using it caused `columnExists` to falsely report the column missing on every call after the first, after which the explicitly-qualified `ALTER` failed with `column "_dd_row_uuid" already exists` -- breaking apply, which calls `ensureManagedSchema` once per `set_table_mode` and once per `insert_row`. `ensureManagedSchema(tableName)` (`rowIdentity.js`) is the idempotent entry point: if the column already exists it returns `{ added: false }`; otherwise it adds the column, backfills, indexes, and returns `{ added: true, backfilled: }`. `dropManagedSchema(tableName)` (`rowIdentity.js`) reverses it with `ALTER TABLE ... DROP COLUMN` (relies on SQLite 3.35+ or Postgres; older SQLite throws and the caller treats the drop as best-effort, `routes.js`). ## Switching a table's data mode An admin changes a table's mode at the `GET /admin/dev-deploy/tables` page (`tablesView`, `routes.js`) and submits to `POST /admin/dev-deploy/tables/set` (`tablesSet`, `routes.js`). The form warns that switching to managed/starter adds the hidden column and ships current rows (`routes.js`). `tablesSet` (`routes.js`) performs, in order: 1. Validates `table_uuid` and that `data_mode` is one of `DATA_MODES` (`routes.js`); rejects the `users` table (`routes.js`). 2. Upserts the `_dd_table_modes` row, resetting `starter_shipped_at` to NULL (`routes.js`). 3. Journals a `set_table_mode` op FIRST, so a target's apply sees the mode change before any row ops (`routes.js`, payload `{ table_uuid, data_mode }`). 4. If the new mode is `managed` or `starter`: calls `ensureManagedSchema` on the local table (`routes.js`), then does the **initial ship** -- reads every row via `allRowsWithUuid` (`rowIdentity.js`), assigns a `_dd_row_uuid` to any row missing one (`routes.js`), converts the row to its portable form, and journals one `insert_row` op per row with payload `{ table_uuid, after: portable }` (`routes.js`). 5. If the new mode is `starter`, calls `markStarterShipped` (`routes.js`, `rowPayload.js`) so subsequent CRUD will not journal. 6. If reverting from a managed/starter mode back to `user`, drops the hidden column best-effort (`routes.js`). `managed` tables intentionally do not set `starter_shipped_at`, so they always journal going forward. ## Journaling row changes (the wrap layer) Once a table is `managed` (or `starter` and not yet shipped), ongoing row CRUD is captured by wrapping `Table.prototype.insertRow`, `updateRow`, and `deleteRows` in `wrapTableRows()` (`wrap.js`). Each wrap: - Passes through unchanged if journaling is suppressed (i.e. we are inside an apply, `wrap.js`) or if `journalDecision` says not to journal (`wrap.js`). - Otherwise records a `table_row` op via `safeJournal` (`wrap.js`), which swallows errors so a journaling failure never breaks the user's write. | CRUD method | Op type | Identity used | Payload key | | --- | --- | --- | --- | | `insertRow` (`wrap.js`) | `insert_row` | new `_dd_row_uuid` assigned via `setRowUuid` after insert (`wrap.js`) | `after` (portable row, `wrap.js`) | | `updateRow` (`wrap.js`) | `update_row` | existing `_dd_row_uuid` read via `getRowUuid` (`wrap.js`); skipped if the row has none | `patch` (portable, `wrap.js`) | | `deleteRows` (`wrap.js`) | `drop_row` | `_dd_row_uuid` read from each row captured BEFORE deletion (`wrap.js`, `wrap.js`) | `before` (portable, `wrap.js`) | Every row op payload carries the table's stable UUID as `table_uuid` so the target can find the corresponding local table. Portable conversion is done by `rowToPortable` (`rowPayload.js`); see [Foreign-key handling](#foreign-key-handling). ## Applying row ops (the apply layer) On the receiving instance, the row handlers live in `apply.js` and are dispatched by op type (`apply.js`). They all resolve the table the same way -- `findLocalTableByUuid` maps `payload.table_uuid` to the local table via `_dd_entity_ids` (`apply.js`) -- and use `_dd_row_uuid` to map identity: | Op | Handler | Behavior | | --- | --- | --- | | `insert_row` | `applyInsertRow` (`apply.js`) | `ensureManagedSchema`, then idempotency check via `findIdByRowUuid` (noop if the row UUID already exists, `apply.js`); converts `payload.after` with `portableToRow`, inserts, and stamps the row's `_dd_row_uuid` to the op's `entity_uuid` (`apply.js`). | | `update_row` | `applyUpdateRow` (`apply.js`) | Finds the local id by row UUID; if absent, treats the update as an insert from `payload.patch` (`apply.js`); otherwise applies the patch via `updateRow` (skips an empty patch, `apply.js`). | | `drop_row` | `applyDropRow` (`apply.js`) | Noop if the table or the row UUID is not present locally (`apply.js`); otherwise deletes by local id. | | `set_table_mode` | `applySetTableMode` (`apply.js`) | For `managed`/`starter`, calls `ensureManagedSchema` on the local table (`apply.js`); upserts the local `_dd_table_modes` row (`apply.js`). | A row's identity (`_dd_row_uuid`) is carried in the op's `entity_uuid` field, not in the payload; the apply handlers read `op.entity_uuid` as the row UUID (`apply.js`, `apply.js`). Because apply runs the model methods inside a suppressed context (`apply.js`, `runSuppressed`), the row CRUD wraps do not re-journal these changes. ## Foreign-key handling A row may contain foreign keys whose integer values are meaningless on a peer. `rowToPortable` (`rowPayload.js`) and `portableToRow` (`rowPayload.js`) translate FK fields through row UUIDs. For each FK field (`field.is_fkey && field.reftable_name`), the portable form stores the value under `"__uuid"` (the `UUID_SUFFIX` constant, `rowPayload.js`) so it does not collide with the original field name. On the source (`rowToPortable`, `rowPayload.js`): | Referenced table's mode | Action | | --- | --- | | `managed` or `starter` | Look up the referenced row's `_dd_row_uuid` via `getRowUuid` and store it as `__uuid` (`rowPayload.js`). If the referenced row has no UUID yet, store `null` and attach a warning (`rowPayload.js`). | | `user` | Cannot translate; store `null` and attach a warning that the FK will be null on the target (`rowPayload.js`). | On the target (`portableToRow`, `rowPayload.js`): - If the portable value is `null`, the local field is set to `null` (`rowPayload.js`). - Otherwise, for an FK field, `findIdByRowUuid` resolves the referenced row's local id by its `_dd_row_uuid` (`rowPayload.js`). This may itself be `null` if the referenced row has not been applied to the target yet. Both directions skip the `id` and `_dd_row_uuid` columns when building the portable/local row (`rowPayload.js`, `rowPayload.js`). Warnings collected on the source side ride along on the journaled op payload (`wrap.js`) so they can surface in the admin UI. ## File propagation Row payloads (and metadata payloads) can reference files. dev-deploy normalizes file references so they survive transport between instances that have different file-store roots. Reference translation (`payloadRefs.js`): a recursive walker (`transformFileRefs`, `payloadRefs.js`) visits payload keys named `fileid`, `file_id`, `bgFileId`, or `image_id` (`FILE_REF_KEYS`, `payloadRefs.js`). - On promote, `toPlaceholders` (`payloadRefs.js`) replaces a local file reference (numeric id or relative path) with a portable placeholder string `"__dd_file_ref::"` (`PLACEHOLDER_PREFIX`, `payloadRefs.js`), looking the file up in `_dd_entity_ids` by `current_name` or `current_id` (`lookupFileByValue`, `payloadRefs.js`). - On apply, `fromPlaceholders` (`payloadRefs.js`) resolves the placeholder back to the target's local relative path (`ent.current_name`). This runs in `applyBatch` before the op handler executes (`apply.js`), so handlers always see local paths. Writing the path back works because Saltcorn's `/files/serve` accepts either a numeric id or a relative path (`payloadRefs.js`, `payloadRefs.js`). Binary transfer (`apply.js` + `routes.js`): the placeholder only carries the file's identity, not its bytes. The bytes move via a dedicated `create_file` op and a pull endpoint: - `applyCreateFile` (`apply.js`) fetches the binary from the source peer with a signed `GET /dev-deploy/api/file/` request (`apply.js`), verifies the SHA-256 against `after.content_hash` (`apply.js`; `sha256Buffer` from `files.js`), writes it to the local absolute path, and creates the local `File` record (`apply.js`). - The serving side is `apiFile` (`routes.js`, registered at `routes.js`): it requires peer auth, looks the file up in `_dd_entity_ids` by `uuid` and `kind = "file"` (`routes.js`), and `sendFile`s the bytes. Tenant-scoped file paths: the absolute path is always reconstructed from the file store root plus the tenant schema plus the relative serve path. On the serving side `apiFile` builds it as `path.join(db.connectObj.file_store, db.getTenantSchema(), mapping.current_name)` (`routes.js`). On the applying side the same construction lives in `toAbsolutePath(File, db, relPath)` (`files.js`), which joins `db.connectObj.file_store`, `db.getTenantSchema()`, and the relative path. The relative (serve) path is what is transported, because each instance has a different file-store root (`files.js`); `toRelativePath` uses `File.absPathToServePath` to produce it (`files.js`). `apiFile` passes `{ dotfiles: "allow" }` to `sendFile` so paths containing dot-directories such as `.dev-state` are not silently treated as not-found by Express (`routes.js`). ## Tables and columns | Table | Purpose | Key columns | | --- | --- | --- | | `_dd_table_modes` (`schema.js`) | One row per table that has a non-default mode. | `table_uuid` (PRIMARY KEY, the table's stable UUID), `data_mode`, `updated_at`, `starter_shipped_at` (`schema.js`). | | `_dd_entity_ids` (`schema.js`) | Maps stable UUIDs to local integer ids for tables, files, etc. | `uuid`, `kind`, `current_name`, `current_id` (`schema.js`). Used to resolve `table_uuid` and file uuids on apply. | | `` | The managed/starter table itself. | Hidden `_dd_row_uuid` TEXT column added by `ensureManagedSchema` (`rowIdentity.js`, `rowIdentity.js`). | The `_dd_row_uuid` column name is a single source of truth: it is exported as `COLUMN_NAME` from `rowIdentity.js` and re-imported wherever needed (for example as `ROW_UUID_COL` in `apply.js`, `wrap.js`, and `routes.js`). ## Endpoints | Method | Path | Handler | Auth | Role in row sync | | --- | --- | --- | --- | --- | | GET | `/admin/dev-deploy/tables` | `tablesView` (`routes.js`) | admin (`routes.js`) | Lists tables and their data modes. | | POST | `/admin/dev-deploy/tables/set` | `tablesSet` (`routes.js`) | admin (`routes.js`) | Sets a table's data mode; journals `set_table_mode` + the initial-ship `insert_row` ops. | | GET | `/dev-deploy/api/file/:uuid` | `apiFile` (`routes.js`) | peer HMAC (`noCsrf`, `routes.js`) | Serves a file's bytes to a pulling peer during `create_file` apply. | The row ops themselves (`insert_row`, `update_row`, `drop_row`, `set_table_mode`) are not standalone endpoints; they travel inside the normal journal exchange (the `GET /dev-deploy/api/journal` and `POST /dev-deploy/api/ingest` flow described in [peering.md](./peering.md)) and are dispatched by the apply handler table (`apply.js`).