feat(environments): clone & promote workflows end-to-end [CMS-153]#129
Conversation
Implements SPEC-009 environment clone and promote with atomic reference remap, MVP payload options, dryRun preview, and a Studio UI for both flows. Closes the Phase-6 advanced-operations epic. Backend (CMS-94, CMS-95, CMS-96, CMS-97): - POST /api/v1/environments/:id/clone — `include.content/settings`, `includeDrafts` (default true), `preservePaths`. Copies head rows (and latest published snapshot when present) into the target with new `document_id`s and preserved `translation_group_id`. Optional schema sync + registry copy when `include.settings: true`. Media inclusion is rejected as deferred. - POST /api/v1/environments/:id/promote — per-document overwrite or create matched by `(translation_group_id, locale)`, auto-publishes, supports `dryRun` (no writes, returns deterministic plan with pre-allocated target ids) and `includeUnpublished` (drafts skipped by default with `status: skipped_unpublished`). - Atomic reference remap (`environments-reference-remap.ts`) walks frontmatter through the schema registry and either rewrites every reference value to the target environment id or aborts the whole operation with `REFERENCE_REMAP_FAILED` (409). Tests cover top-level, nested-object, and array-of-references paths. - Integration suite (`environments-clone-promote.integration.test.ts`) covers clone happy path, settings copy, `includeDrafts: false`, atomic abort on broken ref, promote dryRun, real promote with overwrite + auto-publish v2, unpublished gating, atomic remap abort, and same-env validation. Studio (CMS-98): - Environment management page gets a per-row "Clone into <env>" action with payload-options dialog (content / settings / drafts / paths toggles, source picker, media-deferred copy). - New `/admin/promote` page implements the per-document workflow: source/target picker, multi-select content list, includeUnpublished toggle, dry-run preview with create/overwrite/skipped breakdown and remap-failure surfacing, confirmation dialog listing exact targets before execute, no-merge messaging. Sidebar gets a "Promote" item gated to admin surfaces. - `createStudioEnvironmentApi` extended with `clone()` and `promote()` client methods; payload validation runs before the network call. Spec delta: none — SPEC-009 already specifies the contracts. Plan in `.ai/plans/2026-05-01-cms-153-clone-promote-end-to-end.md`. Note: `apps/studio-review` has no source under version control on `origin/main` (only `.next` / `.generated` build artifacts), so there are no review-app fixtures to sync in this change.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds end-to-end environment cloning and document promotion: API routes with scoped authorization and CSRF, transactional DB implementations for clone/promote with reference remapping and deterministic preallocation, shared request/response contracts and validators, comprehensive server & UI tests, and Studio UI pages for cloning and promoting. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Studio Admin
participant UI as Promote UI
participant API as Environment API
participant Auth as Authorization Service
participant DB as Database
Client->>UI: select documents, click "Preview promote"
UI->>Auth: ensure session authenticated
UI->>API: POST /environments/:targetId/promote (dryRun: true, documentIds)
API->>Auth: authorizeScoped("environments:promote")
Auth-->>API: authorized
API->>DB: load source docs, schemas, preallocate IDs
DB-->>API: docs + schema + preallocations
API->>API: remapFrontmatterReferences (source→target)
API-->>UI: PromotionResult[] (planned created/overwrote/skipped)
Client->>UI: confirm promotion
UI->>API: POST /environments/:targetId/promote (dryRun: false, snapshot, preallocatedTargetIds)
API->>Auth: authorizeScoped("environments:promote")
Auth-->>API: authorized
API->>DB: begin transaction, create/update drafts, insert versions, update publishedVersion
DB-->>API: commit or rollback on remap/conflict
API-->>UI: final PromotionResult[]
sequenceDiagram
participant Client as Studio Admin
participant UI as Clone UI
participant API as Environment API
participant Auth as Authorization Service
participant DB as Database
Client->>UI: open clone dialog, choose source and options
UI->>Auth: ensure session authenticated
UI->>API: POST /environments/:targetId/clone (sourceEnvironmentId, include, includeDrafts)
API->>Auth: authorizeScoped("environments:clone")
Auth-->>API: authorized
API->>DB: load source docs, schema snapshot
DB-->>API: source data
API->>API: preallocate target IDs, remapFrontmatterReferences
API->>DB: begin transaction, insert documents/versions, copy settings if requested
DB-->>API: commit or rollback on errors
API-->>UI: CloneResult { targetEnvironmentId, documentsCloned }
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 0/1 reviews remaining, refill in 60 minutes.Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (1)
packages/shared/src/lib/contracts/environments.ts (1)
220-282: 🏗️ Heavy liftAvoid mutating caller-owned payloads in these shared validators.
Both helpers normalize by
Object.assign-ing back into the original object. In@mdcms/shared, that is a surprising side effect for a validator API, and it will blow up on frozen/readonly payloads. A safer shape is to return a normalized payload from a parse/helper function and keep the assertion helpers side-effect free.Based on learnings: Treat the public API of mdcms/shared as a contract - changes here affect every other package.
Also applies to: 284-317
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.ai/plans/2026-05-01-cms-153-clone-promote-end-to-end.md:
- Line 17: Update the plan text to use the actual route-option name used in the
server wiring: replace the mention of authorizeWriteScope with authorizeScoped
so the plan matches the implementation (referencing the route-option name
authorizeScoped and the server module runtime-with-modules.ts where the
authorization is wired).
In `@apps/server/src/lib/environments-clone-promote.ts`:
- Around line 152-155: The current loadSourceContext/clone flow uses fields
row.body, row.frontmatter, and row.path from the documents table even when the
source row has a published revision, causing unpublished draft content to be
used; change loadSourceContext (and the clone/includeDrafts:false path) to
explicitly load the published payload from documentVersions when a
published_revision_id (or equivalent) exists: query documentVersions for that
revision and use its body/frontmatter/path as the "published" payload while
keeping the documents row as the live/draft payload so callers can select
published vs draft; update any code branches that copy version-1 snapshots or
call clone() to choose the published payload when includeDrafts is false
(references: loadSourceContext, clone, documentVersions, documents,
published_revision_id, row.body/frontmatter/path).
- Around line 349-377: The code always writes row.path into the new document and
only echoes input.preservePaths in the error payload, so preservePaths does
nothing; fix by computing a targetPath variable before the txDb.insert that
respects input.preservePaths (e.g. if input.preservePaths is true use row.path,
otherwise compute a new path — e.g. use remap.path if available or generate a
unique path based on documentId/translationGroupId or a suffix/prefix), then use
that targetPath in the insert call (replace row.path) and in the
buildConflictError payload so the conflict message reflects the actual attempted
path; also update any other references in this function that still read row.path
to use targetPath (look for txDb.insert(... path: ...) and the conflict error
block that includes path: row.path).
- Around line 723-745: The update path in overwriteTargetDraft() currently lets
a Postgres unique-violation (SQLSTATE '23505') bubble out as a 500; catch
database errors around the db.update(...) call inside overwriteTargetDraft(),
detect error.code === '23505' (or error.constraint if used elsewhere), and
convert/throw the same structured conflict response used by createTargetDraft()
(HTTP 409 / Conflict) so path-collision uniqueness violations are normalized to
409s; reference overwriteTargetDraft(), createTargetDraft(), and the
db.update(documents).set(...) block when applying the fix.
- Around line 535-548: The current promoteDocuments logic uses randomUUID() to
generate preallocatedTargetIds (see preallocatedTargetIds, targetMap, and
randomUUID usage inside the loop over sourceContext.rows), causing
non-deterministic targetDocumentIds across calls; update promoteDocuments to
accept an optional preallocated IDs map (e.g., input.preallocatedTargetIds) and
use that value for a given targetMapKey before falling back to generating a new
UUID, and ensure dry-run returns the supplied/preallocated IDs so a subsequent
real run can reuse them for deterministic replay.
In `@apps/server/src/lib/environments-reference-remap.ts`:
- Around line 177-183: The code currently returns the raw value when an object
schema field contains references but the stored value has the wrong container
shape; instead of silently returning input.value, update the branches in the
function handling input.field.kind === "object" (the block using
containsReference(input.field) and isRecord(input.value)) and the analogous
block at the other occurrence to throw a descriptive Error (or otherwise cause
the operation to fail) when containsReference(input.field) is true but
isRecord(input.value) is false, so malformed reference containers fail fast and
prevent unremapped IDs from being committed; reference the containsReference and
isRecord checks and the input.field / input.value variables when making the
change.
In `@packages/shared/src/lib/contracts/environments.ts`:
- Around line 38-43: The exported public input types (start with
EnvironmentCloneInput) declare required fields that validators accept as
omitted; update EnvironmentCloneInput so the fields that have runtime defaults
are optional (make include, includeDrafts, preservePaths optional) and also scan
and make optional any other exported input types in this file that use defaulted
fields (e.g., types that include includeUnpublished and dryRun) so TS callers
can omit them without casting; keep the same property names and types, only add
optionality (?) to those defaulted properties.
In `@packages/studio/src/lib/runtime-ui/app/admin/promote-page.test.tsx`:
- Around line 78-82: The test's non-deterministic assertion allows "Loading
environments" to pass and hide regressions; update the assertion that inspects
the SSR markup (the variable named markup used with assert.match) to only assert
the missing-project message by replacing the current regex (/requires an active
project|Loading environments/i) with a deterministic check for the
missing-project text (e.g., /requires an active project/i) so the test fails if
the missing-route state does not render.
In `@packages/studio/src/lib/runtime-ui/app/admin/promote-page.tsx`:
- Around line 303-326: The execution can differ from the preview because
handleConfirmExecute reads live state (selectedIds, includeUnpublished,
sourceEnv, targetEnv) instead of the snapshot used to produce preview; fix by
capturing a snapshot of all inputs that drive the preview when the preview is
generated (e.g., store previewSnapshot = { selectedIds, includeUnpublished,
sourceEnv, targetEnv } alongside preview) and then use that snapshot inside
handleConfirmExecute (replace references to
selectedIds/includeUnpublished/sourceEnv/targetEnv with previewSnapshot.*), or
alternatively clear/invalidate preview whenever any of those inputs change so
the user must re-preview before executing; update createStudioEnvironmentApi and
promote call to use the snapshot values (function handleConfirmExecute, variable
preview, and the promote call) accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: fdafeb5e-6e16-43fd-bb5a-922bb0487c2f
📒 Files selected for processing (17)
.ai/plans/2026-05-01-cms-153-clone-promote-end-to-end.mdapps/server/src/lib/environments-api.route.test.tsapps/server/src/lib/environments-api.tsapps/server/src/lib/environments-clone-promote.integration.test.tsapps/server/src/lib/environments-clone-promote.tsapps/server/src/lib/environments-reference-remap.test.tsapps/server/src/lib/environments-reference-remap.tsapps/server/src/lib/runtime-with-modules.tspackages/shared/src/lib/contracts/environments.tspackages/studio/src/lib/environment-api.test.tspackages/studio/src/lib/environment-api.tspackages/studio/src/lib/remote-studio-app.tsxpackages/studio/src/lib/runtime-ui/app/admin/environments-page.test.tsxpackages/studio/src/lib/runtime-ui/app/admin/environments-page.tsxpackages/studio/src/lib/runtime-ui/app/admin/promote-page.test.tsxpackages/studio/src/lib/runtime-ui/app/admin/promote-page.tsxpackages/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx
- Plan doc: rename `authorizeWriteScope` → `authorizeScoped` to match the actual route option in environments-api.ts. - Contracts (packages/shared/.../environments.ts): make defaulted fields optional on EnvironmentCloneInput / EnvironmentPromoteInput so TS callers can omit them. Add optional `preallocatedTargetIds` on the promote input for deterministic dryRun → real-run replay; the validator coerces and UUID-validates entries. - loadSourceContext now also pulls the published payload from `documentVersions` for any source row with a non-null `publishedVersion`, exposed as `publishedByDocumentId`. Clone with `includeDrafts: false` copies that payload (not the draft state on the documents row), and the cloned version-1 snapshot uses the published payload always — so source draft state cannot leak into a target's published history. - Clone now respects `preservePaths` (previously a no-op echoed only in the conflict error). `preservePaths: false` derives a deterministic suffix from the new target documentId so the target doesn't collide on the active-path unique index. - overwriteTargetDraft catches Postgres 23505 around the update so uniqueness violations surface as the same 409 conflict shape createTargetDraft uses, instead of bubbling as 500. - promoteDocuments accepts `input.preallocatedTargetIds` (source → target map). Used before generating fresh UUIDs so a real run replays the dry run's plan exactly. Studio promote page captures the dryRun snapshot (source/target/selection/includeUnpublished) and feeds the preallocated ids back on confirm; preview is invalidated when any input changes so the confirmation dialog never lies. - Reference remap throws REFERENCE_REMAP_FAILED with `container_shape_mismatch` when the schema declares an object/array of references but the stored value is the wrong shape, instead of silently passing unremapped ids through. - PromotePage initial state honors missing routing synchronously so SSR matches the hydrated state; tighten its missing-route test to require the missing-route message and explicitly forbid the loading message. - Add integration tests for: published-payload selection on includeDrafts:false; preservePaths:false suffix derivation; deterministic dryRun → real-run replay via preallocatedTargetIds. Add unit tests for the new shape-mismatch failure paths in the remap walker.
Closes the CMS-153 epic ("Clone & Promote End-to-End", Phase-6 advanced operations).
Tickets in scope:
content/settings/includeDrafts/preservePaths)dryRun, overwrite confirmation,includeUnpublishedtranslation_group_id + localeSpec delta
None — SPEC-009 already specifies both endpoints, defaults, error codes, and atomic-remap rule. Implementation follows the spec verbatim. Implementation plan:
.ai/plans/2026-05-01-cms-153-clone-promote-end-to-end.md.Summary
Backend (CMS-94 / CMS-95 / CMS-96 / CMS-97)
POST /api/v1/environments/:id/clone— bulk-insert source documents into the target with newdocument_ids and preservedtranslation_group_id. Copies the latest published snapshot (as version 1) when one exists. Supportsinclude.content,include.settings,includeDrafts(defaulttrue),preservePaths. Media inclusion is rejected as deferred per spec.POST /api/v1/environments/:id/promote— per-document overwrite-or-create, matched by(translation_group_id, locale). Always auto-publishes (writes adocument_versionsrow at the next version). SupportsdryRun(no writes; returns the deterministic plan with pre-allocated target ids) andincludeUnpublished(drafts skipped by default withstatus: skipped_unpublished).environments-reference-remap.ts) walks frontmatter through the source environment's schema registry. Everyfield.referenceUUID is rewritten by(translation_group_id, locale)to the matching target id. Unresolved references throwREFERENCE_REMAP_FAILED(409); the whole operation runs in a Drizzle transaction so any throw rolls back atomically (no partial writes).session_or_api_keymode with explicitenvironments:clone/environments:promotescopes; CSRF required on both writes; project routing required.Studio (CMS-98)
/admin/promotepage implements the workflow: source + target pickers, multi-select content list scoped to the source env,includeUnpublishedtoggle, dry-run preview showing create/overwrite/skipped counts with remap-failure surfacing (which field on which document failed), and a confirmation dialog that lists the exact target documents that will be overwritten before executing the real run. No-merge messaging is rendered explicitly.createStudioEnvironmentApiextended withclone()andpromote()methods that validate payloads before the network call.Tests
environments-reference-remap.test.ts(6 tests) and extendedenvironments-api.route.test.ts(7 tests) covering payload validation, scope auth, CSRF, project routing.environments-clone-promote.integration.test.ts(9 tests) running against real Postgres. Skips when no DB.environment-api.test.ts(12 tests including clone/promote happy paths and validation), updatedenvironments-page.test.tsx, newpromote-page.test.tsx.Notes
apps/studio-reviewhas no source under version control onorigin/main(only.next/.generatedbuild artifacts), so there are no review-app fixtures to sync in this PR.Test plan
bun run format:checkbun run checkbun test --cwd apps/server src/lib/environments-reference-remap.test.ts src/lib/environments-api.route.test.tsbun test --cwd apps/server src/lib/environments-clone-promote.integration.test.ts(with Postgres up)bun test --cwd packages/studio src/lib/environment-api.test.ts src/lib/runtime-ui/app/admin/environments-page.test.tsx src/lib/runtime-ui/app/admin/promote-page.test.tsxSummary by CodeRabbit
New Features
Bug Fixes / Validation
Tests
Documentation