Contents · Releases and partners

Docs / Releases and partners

Atomic Edge Promote

How the release path works — idle tour pauses on hover.

Immutable snapshot, then flip the published pointer. Roll back by pointing at the previous release.
Public reads report which locale each field resolved from. Locale-exact fields never cascade — promote refuses gaps.
One origin serves draft and published. A header or cookie selects the channel — no second stack.
Click-to-edit overlays on the draft host. Edits PATCH the entry on the same Worker — then promote when ready.

Cloudflare-only content release loop: immutable snapshots, channel promote/rollback, same-Worker draft/published perspectives, locale provenance, and Storyblok-class visual edit on demo routes.

PRD: DL-11 (Partial)

What shipped

PieceWhere
D1 schema: content_releases, channel_pointers, locale_cascadesmigration 0038_content_releases.sql
Create release → KV payload + D1 metadataPOST /api/admin/releases
Promote / rollback channel pointersPOST /api/admin/releases/promote, /rollback
Public reads honor channel release when promotedGET /api/public/:collection…
Draft perspective (header/cookie + preview token)X-EdgeCMS-Perspective: draft + previewToken
Locale response metadataX-Content-Locale-Resolved, X-Content-Locale-Provenance
Locale-exact promote gateField localeExact + GET …/releases/:id/locale-readiness; promote → LOCALE_EXACT_NOT_READY on gaps
Author UIAdmin → Releases
Demo routes + visual overlaysGET /api/demo/pages/:slug?visualEdit=1
Visual-edit host (postMessage → D1 PATCH)GET /api/demo/host/:slug + admin entry editor listener
Field commit APIPATCH /api/admin/entries/:id/visual-edit
Client SDK overlays@edgecms/client → mountVisualEdit, listenToVisualEditCommits, listenToVisualEditMessages, createVisualEditPatchQueue, visualEditAttrs
Visual-edit polishBidirectional highlight (admin ↔ iframe), demo field coverage (faq/gallery/seo), conflict overwrite + ACK on host PATCH

Local run (wrangler)

bash
cd edgecms-saas
bun install
bun run --filter edgecms-api db:migrate:local
bun run db:seed:smoke -- --tenant-slug partner   # first-time local admin+tenant
bun run dev:api      # API/worker :8787
bun run dev:admin    # admin :5173  (or `bun run dev` for api+admin+website)

Admin: http://localhost:5173 → tenant → Releases.

Demo page (after a pages entry exists and a release is promoted):

bash
curl -i "http://localhost:8787/api/tenants/<tenant>/api/demo/pages/home?locale=en&visualEdit=1"
# or global public mount when tenant middleware resolves:
curl -i -H "x-tenant: <tenant>" "http://localhost:8787/api/demo/pages/home?visualEdit=1"

Draft on the same route:

bash
# mint via POST /api/public/preview-token (or admin live-preview flow)
curl -i \
  -H "x-edgecms-perspective: draft" \
  -H "x-edgecms-preview-token: <token>" \
  "http://localhost:8787/api/public/pages/home?locale=zh-CN"

Deploy (staging/production) uses existing wrangler envs; apply migration 0038 before relying on releases:

bash
cd apps/api
bun run db:migrate:remote   # or env-specific migrate scripts
bun run deploy:staging

Author loop

  1. Releases → Ensure demo content (idempotent pages / home) if starting cold.
  2. Edit via Visual edit host or admin entries (DB is content truth).
  3. Releases → Publish snapshot (optionally promote to published).
  4. Channel pointer flip is the promote/rollback — no rebuild.
  5. Open /api/demo/pages/<slug>?visualEdit=1 for overlays; prefer /api/demo/host/<slug> so the iframe is draft + preview token and PATCH includes CSRF.
  6. Persist in-place edits → PATCH …/api/admin/entries/:id/visual-edit (D1 truth).

Partner script (5 minutes): partner-demo-5min.md.

bash
# Host shell (auth required; iframes draft perspective; CSRF on PATCH)
curl -i -b <session> "http://localhost:8787/api/tenants/<tenant>/api/demo/host/home?locale=en"

Environments × channel releases

Linked environments (link / diff / push) are cross-instance transfer, not a second promote UX.

ModeBehavior
Release-sourced pushPOST …/environments/:id/push with sourceChannel or sourceReleaseId → entries from the immutable KV release (schemas still from local D1)
Remote promotepromoteChannel on the push (or receiver options.promoteChannel) → after D1 apply, createRelease + channel pointer flip on the target
Live-draft pushOmit source fields → legacy bootstrap sync of live D1 rows (not a promote)

Audit: composed pushes emit environment.release_pushed with join keys environmentId × sourceReleaseId × sourceChannel / promoteChannel / promotedReleaseId. Remote create/promote still emit release.created / release.promoted.

Admin → Environments defaults to source=published + promote=published. Do not market environments as equal to channel promote.

Still stubbed / follow-ups

  • Live public path without a channel pointer uses the pre-existing KV list/entry cache and now emits the same served-locale / field provenance metadata as the release path (_localeProvenance, X-Content-Locale-Resolved, X-Content-Locale-Provenance). Legacy KV payloads without provenance keep chain headers until the next snapshot miss regenerates them.
  • GraphQL public reads (/api/graphql, tenant-scoped variants) reuse the same cascade + field provenance contract: entry _localeProvenance / _localeChain plus the same delivery headers on successful queries.
  • Packaged SIEM connectors / Git dual-write / public SLO marketing — out of slice (hookable audit webhook/NDJSON sink + content lifecycle audit events — operations-security.md).
  • Bet ⑦ release-scoped semantic retrieval — spike only (not GA): opt-in ENABLE_RELEASE_SEMANTIC indexes on promote + admin search; limits in release-semantic-spike.md.
  • Partner synergy glue: GET …/releases/demo/synergy + POST …/releases/demo/abac-deny bind visual-edit × locale-exact readiness × promote × environment×release push × locale provenance × ABAC denials (partner-demo-5min.md).
  • Internal latency hooks: releases.edge_read (draft/published edge reads) and releases.promote_flip (create/promote/rollback). Structured Workers Logs (type: "latency") carry aggregation labels plus sink-only join keys (releaseId, channel, requestId, locale) so ops can correlate draft / published / promote samples without exploding series cardinality. Optional Analytics Engine METRICS binding appends the same join blobs. Isolate samples expose p50/p95 for ops — no customer-facing SLO claim. Internal board

Snapshot materialization

createRelease pages D1 via findVisibleByCollection (perPage 100, the repository ceiling) until a collection is exhausted. Visibility uses one frozen now for the whole snapshot. Payloads over 20 MiB fail with SNAPSHOT_TOO_LARGE before any KV write (Cloudflare KV hard-caps ~25 MiB).

Channel promote / rollback (hardened)

  • Channels are independent: published and staging pointers never share history.
  • Create may promote via promoteToChannel (published | staging) or legacy promoteToPublished.
  • Pointer flips use compare-and-swap on the current releaseId. Concurrent losers get POINTER_CONFLICT (HTTP 409); clients retry after refresh. Optional expectedReleaseId on promote/rollback is an explicit CAS guard.
  • Re-promoting the release already on a channel is idempotent (history preserved).
  • Promote refuses when the immutable KV payload is missing (SNAPSHOT_MISSING).
  • Promote refuses when any locale-exact field lacks an exact value for a configured cascade target or collection defaultLocale (LOCALE_EXACT_NOT_READY, HTTP 409). Preview with GET /api/admin/releases/:releaseId/locale-readiness. Fallible localizable fields still cascade on delivery; locale-exact fields never do.
  • Create → promote failures leave the immutable release in place and return PROMOTE_FAILED with the new releaseId so the author can retry the pointer flip.

Edit docs/public/atomic-edge-promote.md on GitHub