Docs / Releases and partners
Atomic Edge Promote
How the release path works — idle tour pauses on hover.
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
| Piece | Where |
|---|---|
D1 schema: content_releases, channel_pointers, locale_cascades | migration 0038_content_releases.sql |
| Create release → KV payload + D1 metadata | POST /api/admin/releases |
| Promote / rollback channel pointers | POST /api/admin/releases/promote, /rollback |
| Public reads honor channel release when promoted | GET /api/public/:collection… |
| Draft perspective (header/cookie + preview token) | X-EdgeCMS-Perspective: draft + previewToken |
| Locale response metadata | X-Content-Locale-Resolved, X-Content-Locale-Provenance |
| Locale-exact promote gate | Field localeExact + GET …/releases/:id/locale-readiness; promote → LOCALE_EXACT_NOT_READY on gaps |
| Author UI | Admin → Releases |
| Demo routes + visual overlays | GET /api/demo/pages/:slug?visualEdit=1 |
| Visual-edit host (postMessage → D1 PATCH) | GET /api/demo/host/:slug + admin entry editor listener |
| Field commit API | PATCH /api/admin/entries/:id/visual-edit |
| Client SDK overlays | @edgecms/client → mountVisualEdit, listenToVisualEditCommits, listenToVisualEditMessages, createVisualEditPatchQueue, visualEditAttrs |
| Visual-edit polish | Bidirectional highlight (admin ↔ iframe), demo field coverage (faq/gallery/seo), conflict overwrite + ACK on host PATCH |
Local run (wrangler)
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):
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:
# 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:
cd apps/api
bun run db:migrate:remote # or env-specific migrate scripts
bun run deploy:stagingAuthor loop
- Releases → Ensure demo content (idempotent
pages/home) if starting cold. - Edit via Visual edit host or admin entries (DB is content truth).
- Releases → Publish snapshot (optionally promote to
published). - Channel pointer flip is the promote/rollback — no rebuild.
- Open
/api/demo/pages/<slug>?visualEdit=1for overlays; prefer/api/demo/host/<slug>so the iframe is draft + preview token and PATCH includes CSRF. - Persist in-place edits →
PATCH …/api/admin/entries/:id/visual-edit(D1 truth).
Partner script (5 minutes): partner-demo-5min.md.
# 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.
| Mode | Behavior |
|---|---|
| Release-sourced push | POST …/environments/:id/push with sourceChannel or sourceReleaseId → entries from the immutable KV release (schemas still from local D1) |
| Remote promote | promoteChannel on the push (or receiver options.promoteChannel) → after D1 apply, createRelease + channel pointer flip on the target |
| Live-draft push | Omit 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/_localeChainplus 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_SEMANTICindexes on promote + admin search; limits in release-semantic-spike.md. - Partner synergy glue:
GET …/releases/demo/synergy+POST …/releases/demo/abac-denybind 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) andreleases.promote_flip(create/promote/rollback). Structured Workers Logs (type: "latency") carry aggregationlabelsplus sink-onlyjoinkeys (releaseId,channel,requestId,locale) so ops can correlate draft / published / promote samples without exploding series cardinality. Optional Analytics EngineMETRICSbinding appends the same join blobs. Isolate samples expose p50/p95 for ops — no customer-facing SLO claim. Internal board- join-key alerts: latency-slo-ops.md.
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:
publishedandstagingpointers never share history. - Create may promote via
promoteToChannel(published|staging) or legacypromoteToPublished. - Pointer flips use compare-and-swap on the current
releaseId. Concurrent losers getPOINTER_CONFLICT(HTTP 409); clients retry after refresh. OptionalexpectedReleaseIdon 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 withGET /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_FAILEDwith the newreleaseIdso the author can retry the pointer flip.