Content service owns merchant-created content and static file storage for each store. It manages custom pages, blog posts, multilingual content, and the file library from which merchant JS, CSS, fonts, and images are served. It also owns the content sandbox — the staging, approval, and publish mechanism for all content changes and high-risk settings changes across the platform.
Schema#
Pages and Blogs#
CREATE TABLE page (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID NOT NULL,
slug VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'ACTIVE', 'ARCHIVED')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (store_id, slug)
);
CREATE TABLE page_translation (
page_id UUID NOT NULL REFERENCES page(id) ON DELETE CASCADE,
locale CHAR(5) NOT NULL,
title VARCHAR(512) NOT NULL,
body_html TEXT,
meta_title VARCHAR(255),
meta_desc VARCHAR(512),
PRIMARY KEY (page_id, locale)
);
CREATE TABLE blog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID NOT NULL,
slug VARCHAR(255) NOT NULL,
UNIQUE (store_id, slug)
);
CREATE TABLE blog_translation (
blog_id UUID NOT NULL REFERENCES blog(id) ON DELETE CASCADE,
locale CHAR(5) NOT NULL,
name VARCHAR(255) NOT NULL,
PRIMARY KEY (blog_id, locale)
);
CREATE TABLE blog_post (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
blog_id UUID NOT NULL REFERENCES blog(id) ON DELETE CASCADE,
store_id UUID NOT NULL,
slug VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'ACTIVE', 'ARCHIVED')),
author_id UUID, -- principal UUID from AGM
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (store_id, slug)
);
CREATE TABLE blog_post_translation (
post_id UUID NOT NULL REFERENCES blog_post(id) ON DELETE CASCADE,
locale CHAR(5) NOT NULL,
title VARCHAR(512) NOT NULL,
body_html TEXT,
excerpt TEXT,
meta_title VARCHAR(255),
meta_desc VARCHAR(512),
PRIMARY KEY (post_id, locale)
);File Library#
Static files (merchant JS, CSS, fonts, images) are stored in S3. Content service holds the metadata record and serves as the origin for CloudFront delivery.
CREATE TABLE store_file (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID NOT NULL,
filename VARCHAR(512) NOT NULL,
s3_key TEXT NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
cdn_url TEXT, -- CloudFront URL after propagation
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Content Sandbox#
The sandbox mechanism coordinates staged changes for content and high-risk settings across the platform. Content service owns the sandbox record and approval workflow. Participating services expose an ApplySandbox API that content service calls at publish time.
CREATE TABLE sandbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'PUBLISHED', 'ROLLED_BACK')),
created_by UUID NOT NULL, -- principal UUID
approved_by UUID,
scheduled_at TIMESTAMPTZ,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Each change within a sandbox, keyed by owning service + entity
CREATE TABLE sandbox_change (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sandbox_id UUID NOT NULL REFERENCES sandbox(id) ON DELETE CASCADE,
service VARCHAR(64) NOT NULL, -- e.g. "catalog", "content", "tax"
entity_type VARCHAR(64) NOT NULL,
entity_id UUID NOT NULL,
diff JSONB NOT NULL, -- JSON Patch (RFC 6902)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON sandbox_change (sandbox_id, service, entity_type, entity_id);
-- Sandbox audit log
CREATE TABLE sandbox_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sandbox_id UUID NOT NULL REFERENCES sandbox(id) ON DELETE CASCADE,
action VARCHAR(32) NOT NULL,
actor_id UUID NOT NULL,
note TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Conflict detection runs at publish time: if two sandboxes both carry a sandbox_change for the same (service, entity_type, entity_id) tuple, the second publish is blocked until the conflict is resolved manually.
Sandbox Publishing Flow#
1. Sandbox reaches APPROVED status (manual or via scheduled_at)
2. content-service groups sandbox_change rows by service
3. For each service, content-service calls ApplySandbox(sandboxId, changes[]) via gRPC
4. Each service applies its changes atomically and acknowledges
5. If all services acknowledge → sandbox status = PUBLISHED, publish Kafka event
6. If any service fails → rollback: content-service calls RollbackSandbox on all
services that already acknowledged, sandbox status = ROLLED_BACKREST API#
Pages#
| Method | Route | Description |
|---|---|---|
GET | /content/pages | List pages |
POST | /content/pages | Create page |
GET | /content/pages/{id} | Get page |
PATCH | /content/pages/{id} | Update page |
DELETE | /content/pages/{id} | Archive page |
GET | /content/pages/{id}/translations | List translations |
PUT | /content/pages/{id}/translations/{locale} | Upsert translation |
Blog#
| Method | Route | Description |
|---|---|---|
GET | /content/blogs | List blogs |
POST | /content/blogs | Create blog |
GET | /content/blogs/{id}/posts | List posts |
POST | /content/blogs/{id}/posts | Create post |
PATCH | /content/blogs/{id}/posts/{postId} | Update post |
PUT | /content/blogs/{id}/posts/{postId}/translations/{locale} | Upsert translation |
File Library#
| Method | Route | Description |
|---|---|---|
GET | /content/files | List files |
POST | /content/files | Upload file (S3 presigned URL flow) |
DELETE | /content/files/{id} | Delete file |
Sandbox#
| Method | Route | Description |
|---|---|---|
GET | /content/sandboxes | List sandboxes |
POST | /content/sandboxes | Create sandbox |
GET | /content/sandboxes/{id} | Get sandbox and its changes |
POST | /content/sandboxes/{id}/changes | Add change to sandbox |
DELETE | /content/sandboxes/{id}/changes/{changeId} | Remove change |
POST | /content/sandboxes/{id}/submit | Submit for approval |
POST | /content/sandboxes/{id}/approve | Approve sandbox |
POST | /content/sandboxes/{id}/reject | Reject sandbox |
POST | /content/sandboxes/{id}/schedule | Set scheduled publish time |
POST | /content/sandboxes/{id}/publish | Publish immediately |
POST | /content/sandboxes/{id}/rollback | Roll back a published sandbox |
GET | /content/sandboxes/{id}/preview-token | Generate shareable preview token |
gRPC Interface#
Storefront-service calls content service to resolve pages and blog posts during SSR.
service ContentReaderService {
rpc GetPage(GetPageRequest) returns (PageResponse);
rpc GetBlogPost(GetBlogPostRequest) returns (BlogPostResponse);
rpc GetSandboxPreviewToken(GetSandboxPreviewTokenRequest) returns (PreviewTokenResponse);
}All services that participate in the sandbox mechanism implement:
service SandboxParticipant {
rpc ApplySandbox(ApplySandboxRequest) returns (ApplySandboxResponse);
rpc RollbackSandbox(RollbackSandboxRequest) returns (RollbackSandboxResponse);
}Kafka Events#
| Topic | Trigger | Payload |
|---|---|---|
content.sandbox.published | Sandbox publish completes | { sandboxId, storeId, publishedAt } |
content.sandbox.rolled_back | Sandbox rollback completes | { sandboxId, storeId, rolledBackAt } |
content.page.published | Page goes active | { pageId, storeId, slug } |
content.blog_post.published | Post goes active | { postId, blogId, storeId, slug } |
Storefront-service consumes content.sandbox.published to invalidate its page render cache.
Vault Configuration#
| Key path | Purpose |
|---|---|
ss3/kv/content-service/db.url | PostgreSQL JDBC URL |
ss3/kv/content-service/db.username | Database username |
ss3/kv/content-service/db.password | Database password |
ss3/kv/content-service/s3.bucket | File library S3 bucket |
ss3/kv/content-service/s3.region | AWS region |
ss3/kv/content-service/cdn.base_url | CloudFront base URL for file delivery |
ss3/kv/shared/ | Shared Kafka bootstrap and OTel config |