39948-vm/documentation/publishing-workflow.md
2026-07-03 16:11:24 +02:00

38 KiB

Publishing Workflow

Complete documentation for the Tour Builder Platform's publishing system including the three-tier environment model (dev → stage → production), publish events, and transaction locking.

Overview

The platform implements a three-tier environment publishing system:

  • Dev - Active editing environment (constructor always edits here)
  • Stage - Preview/testing environment for stakeholder review
  • Production - Live environment for public access

The publishing workflow has two steps:

  1. Save to Stage: Copy dev content to stage for preview
  2. Publish to Production: Copy stage content to production

Projects maintain environment-specific data (pages, audio tracks) that can be independently edited and published. The system uses database transactions with project-level locks to prevent concurrent publishing conflicts. Page elements and transitions are stored directly in tour_pages.ui_schema_json.

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                    Three-Tier Publishing Flow                            │
│                                                                          │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐                 │
│  │     Dev     │    │    Stage    │    │ Production  │                 │
│  │ Environment │───▶│ Environment │───▶│ Environment │                 │
│  └─────────────┘    └─────────────┘    └─────────────┘                 │
│        │                  │                   │                         │
│        │ Edit in          │ Preview           │ Public Access           │
│        │ Constructor      │                   │                         │
│        ▼                  ▼                   ▼                         │
│   /constructor       /p/[slug]/stage      /p/[slug]                    │
│                                                                          │
│  [Save to Stage]──────────▶                                             │
│                    [Publish to Production]──────────▶                    │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                      Database Structure                                  │
│                                                                          │
│  Projects ─────────┬─────────────────┬─────────────────┐                │
│     │              │                 │                 │                │
│     ▼              ▼                 ▼                 ▼                │
│  tour_pages    audio_tracks   transition_settings  publish_events      │
│  (env field)   (env field)    (env field)          (history)           │
│                                                                          │
│  environment: 'dev' | 'stage' | 'production'                           │
│                                                                          │
│  Note: Elements, links, and transitions are stored in                  │
│        tour_pages.ui_schema_json (no separate tables)                  │
└─────────────────────────────────────────────────────────────────────────┘

Stage vs Production Environments

Environment-Specific Entities

Each of these entities maintains an environment field:

Entity Environment Field Description
tour_pages Yes Pages with backgrounds, content, and ui_schema_json
project_audio_tracks Yes Project audio files
project_transition_settings Yes CSS transition settings (type, duration, easing, overlay color)

Note: Page elements, navigation links, and video transitions are stored directly in tour_pages.ui_schema_json and are copied with the page when publishing. Project-level CSS transition settings are stored separately in project_transition_settings and also copied during publish.

URL Patterns

Stage Environment:

/p/[projectSlug]/stage
  • Route: frontend/src/pages/p/[projectSlug]/stage.tsx
  • Loads pages with environment='stage' only

Production Environment:

/p/[projectSlug]
  • Route: frontend/src/pages/p/[projectSlug]/index.tsx
  • Loads pages with environment='production' only

Environment Isolation (Security)

CRITICAL: Strict environment filtering prevents data leaks between environments.

Defense in Depth

The system uses two layers of environment protection:

Layer File Protection
Frontend RuntimePresentation.tsx Strict filter: p.environment === environment
Backend db/api/runtime-context.ts Filters by X-Runtime-Environment header

Frontend Filtering

Environment filtering happens in the usePageDataLoader hook, which is used by RuntimePresentation.tsx:

// hooks/usePageDataLoader.ts - STRICT environment match only
// For runtime mode, filter by environment client-side
if (projectSlug) {
  pageRows = pageRows.filter((p) => p.environment === environment);
}

// Sort by sort_order
pageRows.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
  • Production mode (/p/cardiff): Only shows environment='production' pages
  • Stage mode (/p/cardiff/stage): Only shows environment='stage' pages
  • Dev mode (Constructor): Only shows environment='dev' pages
  • No fallbacks - missing environment data shows empty, never leaks from other environments

Backend Filtering

The backend also filters based on the X-Runtime-Environment header:

// db/api/runtime-context.ts
// Only 'production' and 'stage' allowed from header
// 'dev' is BLOCKED to prevent unauthorized access
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
// 'dev' header returns null → no backend filter (constructor handles separately)

Database Constraints

Environment columns are enforced at the database level:

-- tour_pages.environment: NOT NULL, default 'dev'
-- project_audio_tracks.environment: NOT NULL, default 'dev'
-- project_transition_settings.environment: NOT NULL

This prevents NULL values from bypassing environment filters.

Constructor (Dev Environment):

/constructor?projectId=[id]
  • Route: frontend/src/pages/constructor.tsx
  • Always loads and edits pages with environment='dev'
  • "Save to Stage" button copies dev → stage

Runtime Mode Detection

Primary Method: Route-Based Access

The platform uses route-based environment access, not subdomains:

Route Environment Component
/p/[slug] production pages/p/[projectSlug]/index.tsx
/p/[slug]/stage stage pages/p/[projectSlug]/stage.tsx
/constructor?projectId= dev pages/constructor.tsx

Frontend Headers for API Calls:

// RuntimePresentation sends these headers
headers: {
  'X-Runtime-Project-Slug': projectSlug,
  'X-Runtime-Environment': environment  // 'production' | 'stage' | 'dev'
}

Backend Middleware (backend/src/middlewares/runtime-context.ts):

The middleware reads environment and project slug from headers for route-based access:

// req.runtimeContext structure
{
  mode: 'admin',                     // default mode
  projectSlug: null,                 // not used in current implementation
  headerEnvironment: string | null,  // from X-Runtime-Environment header ('production', 'stage', 'dev')
  headerProjectSlug: string | null,  // from X-Runtime-Project-Slug header
}

Environment Resolution (backend/src/db/api/runtime-context.ts):

The getRuntimeEnvironment() function resolves environment in order:

  1. Hostname-based detection (stage/production subdomains)
  2. Header-based fallback (X-Runtime-Environment header)
// Only 'production' and 'stage' are allowed from headers
// 'dev' is blocked to prevent unauthorized access to dev data
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';

Project Slug

The slug field on projects determines public URLs:

Property Value
Pattern /^[a-z0-9_-]+$/i
Length 1-255 characters
Uniqueness Must be unique across all projects
Mutability Should not change (breaks public URLs)

Example:

slug: 'cardiff'
Stage URL: /p/cardiff/stage
Production URL: /p/cardiff

Publish Events

Database Schema

File: backend/src/db/models/publish_events.js

Field Type Required Description
id UUID Yes Primary key
title STRING(255) No* User-provided event name (*DB allows null, service validates, max 255 chars)
description TEXT No* User-provided details (*DB allows null, service validates, max 5000 chars)
from_environment ENUM Yes Source: 'dev', 'stage', 'production'
to_environment ENUM Yes Target: 'dev', 'stage', 'production'
status ENUM Yes 'queued', 'running', 'success', 'failed'
started_at DATETIME No When publish began
finished_at DATETIME No When publish completed
error_message TEXT No Failure reason if applicable
pages_copied INTEGER No Number of pages published
transitions_copied INTEGER No Number of transitions published (legacy field, not currently populated)
audios_copied INTEGER No Number of audio tracks published
projectId UUID Yes FK to projects (CASCADE)
userId UUID Yes FK to users who initiated

Status Lifecycle

queued → running → success
                 ↘ failed
Status Meaning Fields Set
queued Event created, waiting to process title, description, environments
running Publish in progress started_at
success Completed successfully finished_at, *_copied counts
failed Encountered error finished_at, error_message

API Endpoints

Save to Stage (Dev → Stage):

POST /api/publish/save-to-stage
Content-Type: application/json
Authorization: Bearer {token}

{
  "projectId": "uuid"
}

Response (Success):

{
  "success": true,
  "publishEventId": "uuid"
}

Note: Save to Stage is non-blocking - the API returns immediately after creating the publish event, and the actual copy operation continues in the background. Check the publish_events table for final status (success or failed).

Publish to Production (Stage → Production):

Note: Both /api/publish and /api/publish/publish route to the same handler.

POST /api/publish
Content-Type: application/json
Authorization: Bearer {token}

{
  "projectId": "uuid",
  "title": "Release 1.0.3",
  "description": "Added new tour pages and fixed navigation"
}

Response (Success):

{
  "success": true,
  "publishEventId": "uuid",
  "summary": {
    "pages_copied": 5,
    "audios_copied": 2
  }
}

Publish Events CRUD:

GET    /api/publish_events              - List with pagination
GET    /api/publish_events/:id          - Get details
PUT    /api/publish_events/:id          - Update
DELETE /api/publish_events/:id          - Delete
GET    /api/publish_events?project=id   - Filter by project

Publishing Process

Blocking vs Non-Blocking Operations

Operation Blocking Behavior
Save to Stage No Returns immediately, copy runs in background via setImmediate()
Publish to Production Yes Waits for entire copy operation before returning

Save to Stage uses background processing because it's a frequent operation during development and shouldn't block the UI. Publish to Production remains blocking because it's a deliberate action that users expect to complete before seeing results.

Complete Flow (Publish to Production)

┌─────────────────────────────────────────────────────────────────────────┐
│ Phase 1: Validation & Event Creation                                    │
├─────────────────────────────────────────────────────────────────────────┤
│ POST /api/publish                                                       │
│   ↓                                                                     │
│ PublishService.publishToProduction()                                    │
│   ├─ Validate projectId exists                                         │
│   ├─ Validate title is non-empty                                       │
│   ├─ Validate description is non-empty                                 │
│   └─ CREATE publish_events record with status='queued'                 │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│ Phase 2: Acquire Lock & Begin Transaction                               │
├─────────────────────────────────────────────────────────────────────────┤
│ PublishService.withProjectPublishLock(projectId, callback)              │
│   ├─ BEGIN TRANSACTION                                                  │
│   ├─ SELECT * FROM projects WHERE id={id} FOR UPDATE                   │
│   ├─ SELECT * FROM publish_events WHERE status='running' FOR UPDATE    │
│   └─ If running publish exists → ERROR 400                             │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│ Phase 3: Copy Stage → Production                                        │
├─────────────────────────────────────────────────────────────────────────┤
│ 1. FETCH all stage data:                                                │
│    ├─ tour_pages WHERE environment='stage'                             │
│    ├─ project_audio_tracks WHERE environment='stage'                   │
│    └─ project_transition_settings WHERE environment='stage'            │
│                                                                         │
│ 2. PURGE existing production data:                                      │
│    ├─ DELETE tour_pages WHERE environment='production'                 │
│    ├─ DELETE project_audio_tracks WHERE environment='production'       │
│    └─ DELETE project_transition_settings WHERE environment='production'│
│                                                                         │
│ 3. CREATE production records via bulkCreate:                            │
│    ├─ tour_pages (with environment='production', source_key tracking)  │
│    ├─ project_audio_tracks                                             │
│    └─ project_transition_settings (CSS transition defaults)            │
│                                                                         │
│ Note: Video transitions are stored in tour_pages.ui_schema_json and    │
│       copied with the page. CSS transition settings (fade, duration,   │
│       easing) are stored in project_transition_settings per environment.│
│       Slugs are used for navigation (not IDs), so no remapping needed. │
│                                                                         │
│ Element defaults (element_type_defaults → project_element_defaults)    │
│ are NOT environment-specific. Settings are embedded in elements when   │
│ created, so ui_schema_json contains complete elements with settings.   │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│ Phase 4: Mark Success or Failure                                        │
├─────────────────────────────────────────────────────────────────────────┤
│ Success:                                                                │
│   UPDATE publish_events SET status='success', finished_at=NOW(),       │
│          pages_copied=N, audios_copied=N                               │
│                                                                         │
│ Failure:                                                                │
│   UPDATE publish_events SET status='failed', finished_at=NOW(),        │
│          error_message=error.message                                   │
│   ROLLBACK TRANSACTION                                                  │
└─────────────────────────────────────────────────────────────────────────┘

Data Copied During Publish

Entities Copied:

Entity Copied Notes
Tour Pages All stage pages → production (includes sort_order, ui_schema_json with elements, navigation, video transitions, and page media fields)
Project Audio All stage audio → production
Project Transition Settings CSS transition settings (type, duration, easing, overlay color)

Entities NOT Copied:

Entity Reason
Assets Shared across environments
Project metadata Name, slug, description unchanged
User permissions Independent of environment
PWA caches Generated separately
Element type defaults Global platform-wide (not project-specific)
Project element defaults Project-wide (not environment-specific) - settings are already embedded in elements within ui_schema_json when elements are created

Page Order Propagation

Page order is stored on tour_pages.sort_order. Runtime loaders sort pages by this field and use the first sorted page as the presentation entry page.

The constructor can change the dev page set and order directly:

  • Reorder pages via POST /api/tour_pages/reorder. This updates only tour_pages.sort_order.
  • Duplicate the active dev page via POST /api/tour_pages/:id/duplicate. This creates a new independent dev page at the end of the order, copies page settings and ui_schema_json, and regenerates inline element IDs.
  • Delete the active dev page via DELETE /api/tour_pages/:id after constructor confirmation.

Stage and production are intentionally read-only for direct constructor page writes:

  1. Reorder, duplicate, or delete pages in the constructor (environment='dev').
  2. Click Save to Stage to copy dev pages, including sort_order, to environment='stage'.
  3. Publish to Production to copy stage pages, including sort_order, to environment='production'.

This means stage preview keeps the previous page set/order until Save to Stage finishes, and the public production presentation keeps the previous page set/order until Publish finishes.

Data Sanitization

Before copying, records are sanitized:

sanitizeRecordForClone(modelInstance) {
  const data = modelInstance.toJSON();

  // Delete auto-generated fields
  delete data.id;           // Gets new UUID
  delete data.createdAt;    // New timestamp
  delete data.updatedAt;    // New timestamp
  delete data.deletedAt;    // Not paranoid for production
  delete data.deletedBy;    // Clear soft-delete actor
  delete data.importHash;   // Unique field

  // Ensure JSON fields are objects, not strings (avoid double-encoding)
  if (data.ui_schema_json && typeof data.ui_schema_json === 'string') {
    try {
      data.ui_schema_json = JSON.parse(data.ui_schema_json);
    } catch {
      // Keep as-is if parsing fails
    }
  }

  return data;
}

Transaction Locking

Concurrency Control

The system prevents concurrent publishing using row-level locks:

// Acquire exclusive lock on project row
const project = await db.projects.findByPk(projectId, {
  transaction,
  lock: transaction.LOCK.UPDATE  // SELECT FOR UPDATE
});

// Check for running publish (also locked)
const runningEvent = await db.publish_events.findOne({
  where: { projectId, status: 'running' },
  transaction,
  lock: transaction.LOCK.UPDATE
});

if (runningEvent) {
  throw Error('Publish already running for this project');
}

Lock Properties

Property Value
Isolation Level READ COMMITTED (Postgres default)
Lock Duration Transaction start → commit/rollback
Lock Scope Project row + publish_events rows
Deadlock Handling Auto-rollback on deadlock
Lock Timeout 5-30 seconds (DB config)

Lock Workflow

1. Transaction Starts
   └─ BEGIN TRANSACTION

2. Acquire Project Lock
   └─ SELECT * FROM projects WHERE id=? FOR UPDATE

3. Check Running Publishes
   └─ SELECT * FROM publish_events WHERE status='running' FOR UPDATE
   └─ If exists → ROLLBACK + ERROR 400

4. Execute Publish Operations
   └─ All operations protected by transaction
   └─ Concurrent requests block until lock released

5. Commit or Rollback
   └─ Success → COMMIT (locks released)
   └─ Error → ROLLBACK (locks released)

Error Handling

Pre-Lock Validation:

if (!projectId || !title || !description) {
  throw new ValidationError('Missing required fields');
  // No transaction started
}

Lock Conflict:

if (runningEvent) {
  throw new ValidationError('Publish is already running for this project');
  // HTTP 400 returned
}

Data Errors:

try {
  await copyStageToProduction(projectId, userId, transaction);
} catch (error) {
  // Transaction automatically rolls back
  // Error captured in publish_events.error_message
  throw error;
}

Frontend Integration

Save to Stage Button (Constructor)

Files:

  • frontend/src/pages/constructor.tsx - Uses the hook
  • frontend/src/hooks/useConstructorPageActions.ts - Contains the saveToStage implementation

The Constructor uses the useConstructorPageActions hook which provides the saveToStage function:

// hooks/useConstructorPageActions.ts
const saveToStage = useCallback(async () => {
  if (!projectId) {
    onError?.('Project ID is required to save to stage.');
    return;
  }

  // First save current state, then copy to stage
  await saveConstructor();

  try {
    setIsSavingToStage(true);
    // Note: axios baseURL adds '/api' prefix automatically
    // Non-blocking: returns immediately, copy runs in background
    await axios.post('/publish/save-to-stage', { projectId });
    onSuccess?.('Saved to stage.');
  } catch (error: any) {
    onError?.(error?.response?.data?.message || 'Failed to save to stage');
  } finally {
    setIsSavingToStage(false);
  }
}, [projectId, saveConstructor, onError, onSuccess]);

Note: The Save to Stage operation is non-blocking - the button returns to normal immediately while the actual copy operation continues in the background. The user sees a brief "Saved to stage" confirmation.

// constructor.tsx - Hook usage const { isSavingToStage, saveToStage, // ... other actions } = useConstructorPageActions({ projectId, pages, // ... other options });

// UI passes saveToStage to ConstructorMenu component <ConstructorMenu onSaveToStage={saveToStage} // ... />


### Publish Status Visibility

The platform displays timestamps showing when content was last saved or published, providing users with visual feedback on content freshness.

#### usePublishStatus Hook

**File:** `frontend/src/hooks/usePublishStatus.ts`

Fetches the last successful publish events for a project:

```typescript
interface UsePublishStatusResult {
  lastSavedToStage: string | null;        // Last dev → stage timestamp
  lastPublishedToProduction: string | null; // Last stage → production timestamp
  isLoading: boolean;
  refresh: () => Promise<void>;           // Refresh after new publish
}

const { lastPublishedToProduction, refresh } = usePublishStatus({ projectId });

Timestamp Display

Timestamps are displayed inside buttons using the subtitle prop of BaseButton:

Project Dashboard (Publish to Production):

<BaseButton
  label={isPublishing ? 'Publishing...' : 'Publish to Production'}
  subtitle={lastPublishedToProduction
    ? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
    : undefined}
  color='success'
  onClick={() => setIsPublishModalActive(true)}
/>
// Shows: "Publish to Production" with "Last: 5 min ago" below

Constructor Menu (Save / Save to Stage):

// Project-level save timestamp: most recent updatedAt across all pages
const lastProjectSaveAt = useMemo(() => {
  if (!pages.length) return null;
  return pages.reduce((latest, page) => {
    if (!page.updatedAt) return latest;
    if (!latest) return page.updatedAt;
    return new Date(page.updatedAt) > new Date(latest) ? page.updatedAt : latest;
  }, null as string | null);
}, [pages]);

<BaseButton
  label={isSaving ? 'Saving...' : 'Save'}
  subtitle={lastProjectSaveAt ? dataFormatter.relativeTimestamp(lastProjectSaveAt) : undefined}
  onClick={onSave}
/>
// Shows: "Save" with project-level timestamp (same on all pages)

Relative Timestamp Format

The dataFormatter.relativeTimestamp() method formats dates as human-readable relative times:

Time Difference Display Format
< 2 minutes "Just now"
< 60 minutes "5 min ago"
< 24 hours "2 hours ago"
Same day "Today at 14:30"
Yesterday "Yesterday at 09:15"
Older "Apr 28 at 16:45"

Publish Button (Project Dashboard)

File: frontend/src/pages/projects/[projectsId].tsx

const [isPublishing, setIsPublishing] = useState(false);
const [isPublishModalActive, setIsPublishModalActive] = useState(false);
const [publishTitle, setPublishTitle] = useState('');
const [publishDescription, setPublishDescription] = useState('');

// Publish status for timestamp display
const { lastPublishedToProduction, refresh: refreshPublishStatus } = usePublishStatus({
  projectId,
});

<BaseButton
  label={isPublishing ? 'Publishing...' : 'Publish to Production'}
  subtitle={lastPublishedToProduction
    ? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
    : undefined}
  color='success'
  onClick={() => setIsPublishModalActive(true)}
  disabled={isPublishing || !projectId}
/>

Publish Modal

<CardBoxModal
  title='Publish to production'
  buttonColor='success'
  buttonLabel={isPublishing ? 'Publishing...' : 'Confirm publish'}
  isConfirmDisabled={
    isPublishing || !publishTitle.trim() || !publishDescription.trim()
  }
  isActive={isPublishModalActive}
  onConfirm={handlePublish}
  onCancel={() => { if (!isPublishing) setIsPublishModalActive(false); }}
>
  <FormField label='Event title'>
    <input
      placeholder='e.g. Release 1.0.3'
      value={publishTitle}
      onChange={(e) => setPublishTitle(e.target.value)}
    />
  </FormField>
  <FormField label='Event description'>
    <textarea
      placeholder='Describe what was published'
      value={publishDescription}
      onChange={(e) => setPublishDescription(e.target.value)}
    />
  </FormField>
</CardBoxModal>

Publish Handler

const handlePublish = async () => {
  if (!projectId) {
    toast('Project is required', { type: 'warning', position: 'bottom-center' });
    return;
  }

  const title = publishTitle.trim();
  const description = publishDescription.trim();

  if (!title || !description) {
    toast('Title and description are required', { type: 'warning', position: 'bottom-center' });
    return;
  }

  setIsPublishing(true);
  try {
    // Note: axios baseURL adds '/api' prefix automatically
    const response = await axios.post('/publish', {
      projectId,
      title,
      description
    });

    const summary = response?.data?.summary;
    toast(
      summary
        ? `Published: ${summary.pages_copied} pages`
        : 'Publish completed successfully',
      { type: 'success', position: 'bottom-center' }
    );

    setIsPublishModalActive(false);
    setPublishTitle('');
    setPublishDescription('');
  } catch (error: any) {
    const message = error?.response?.data || error?.message || 'Publish failed';
    toast(typeof message === 'string' ? message : 'Publish failed', { type: 'error', position: 'bottom-center' });
  } finally {
    setIsPublishing(false);
  }
};
const presentationLinks = useMemo(() => {
  const projectSlug = project?.slug?.trim();
  if (!projectSlug) return { production: '', stage: '' };

  return {
    production: `/p/${projectSlug}`,
    stage: `/p/${projectSlug}/stage`,
  };
}, [project?.slug]);

// UI buttons to open presentations (uses openPresentation helper)
<BaseButton
  label='To Production Presentation'
  color='info'
  onClick={() => openPresentation(presentationLinks.production, 'production presentation')}
  disabled={!projectId || !project?.slug}
/>
<BaseButton
  label='To Stage Presentation'
  color='lightDark'
  onClick={() => openPresentation(presentationLinks.stage, 'stage presentation')}
  disabled={!projectId || !project?.slug}
/>

PWA Integration

Manifest Generation

PWA manifests are generated separately from publishing:

GET /api/projects/:id/offline-manifest?deviceType=desktop

Response:

{
  "version": "v1679856000000",
  "projectId": "uuid",
  "assets": [
    {
      "id": "asset-uuid",
      "url": "/uploads/file.mp4",
      "filename": "background.mp4",
      "variantType": "mp4_high",
      "assetType": "video",
      "mimeType": "video/mp4",
      "sizeBytes": 15000000,
      "pageIds": ["page-uuid-1", "page-uuid-2"]
    }
  ],
  "totalSizeBytes": 150000000,
  "generatedAt": 1679856000000
}

PWA Cache Model

File: backend/src/db/models/pwa_caches.js

Field Type Description
id UUID Primary key
environment ENUM 'dev', 'stage', 'production'
cache_version TEXT Version identifier
manifest_json JSON Full manifest data
asset_list_json JSON Asset URLs for caching
generated_at DATETIME Generation timestamp
is_active BOOLEAN Currently active cache
projectId UUID FK to projects

Relationship to Publishing

  • PWA manifests are NOT automatically regenerated on publish
  • Publishing copies data to production environment
  • PWA caches must be explicitly regenerated via separate API

Asset Preloading & Environment Filtering

How Preloading Respects Environments

When viewing a tour in any environment, assets are preloaded only from pages in that environment. The filtering happens in the usePageDataLoader hook, and RuntimePresentation receives already-filtered pages:

// usePageDataLoader.ts - Environment filtering during data load
if (projectSlug) {
  pageRows = pageRows.filter((p) => p.environment === environment);
}
pageRows.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));

// RuntimePresentation.tsx - Uses filtered pages from hook
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader({
  projectSlug,
  environment,
  apiHeaders: {
    'X-Runtime-Project-Slug': projectSlug,
    'X-Runtime-Environment': environment,
  },
});

// Extract navigation links only from same-environment pages
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);

// Preload orchestrator uses filtered pages
const preloadOrchestrator = usePreloadOrchestrator({
  pages,                  // Already filtered by environment
  pageLinks,              // Navigation links with transitionVideoUrl
  elements: preloadElements,
  currentPageId: selectedPageId,
  pageHistory,
  enabled: !isLoading && !error,
});

S3 Presigned URLs for Assets

Assets are downloaded directly from S3 using presigned URLs:

POST /api/file/presign
{ urls: ["assets/project-x/image.jpg", ...] }
→ Returns presigned URLs (1-hour expiry, max 50 per request)

This works identically across all environments since assets are project-scoped, not environment-scoped.

Instant Navigation with Preloaded Assets

Navigation between pages uses pre-decoded blob URLs for instant display:

const pageSwitch = usePageSwitch({
  preloadCache: {
    getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,  // O(1) instant lookup
    getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
    preloadedUrls: preloadOrchestrator.preloadedUrls,
  },
});

This ensures smooth transitions regardless of environment (dev preview, stage, or production).

Manual Regeneration Pattern:

  1. Admin publishes via "Publish to Production"
  2. Admin generates PWA manifest via separate action
  3. New manifest stored with environment='production'
  4. Previous manifest marked inactive

File Reference

File Purpose
backend/src/routes/publish.ts Publish API endpoints (save-to-stage, publish)
backend/src/services/publish.ts Publishing business logic
backend/src/db/models/publish_events.js Publish events model
backend/src/db/api/publish_events.ts Publish events DB operations
backend/src/db/api/runtime-context.ts Environment resolution from headers
backend/src/middlewares/runtime-context.ts Runtime environment detection
backend/src/services/pwa_manifest.js PWA manifest generation
frontend/src/pages/p/[projectSlug]/index.tsx Production presentation
frontend/src/pages/p/[projectSlug]/stage.tsx Stage presentation
frontend/src/pages/constructor.tsx Constructor with Save to Stage button
frontend/src/pages/projects/[projectsId].tsx Project dashboard with publish UI
frontend/src/pages/publish_events/* Publish events list/view pages
frontend/src/components/RuntimePresentation.tsx Runtime presentation component
frontend/src/hooks/usePageDataLoader.ts Data loading with environment filtering
frontend/src/hooks/useConstructorPageActions.ts Constructor actions (saveToStage)
frontend/src/hooks/usePublishStatus.ts Publish status timestamps
frontend/src/lib/extractPageLinks.ts Extract navigation links from pages
frontend/src/hooks/usePreloadOrchestrator.ts Asset preloading with ready blob URLs
frontend/src/hooks/usePageSwitch.ts Page navigation using preloaded assets

Summary Table

Aspect Dev Stage Production
URL /constructor?projectId= /p/[slug]/stage /p/[slug]
Purpose Active editing Preview/testing Public access
Data Source environment='dev' environment='stage' environment='production'
Editing Full editing Read-only Read-only
Publish Action "Save to Stage" (non-blocking) → "Publish to Production" (blocking) → Final destination
PWA Cache Not applicable Can be generated Primary target
Visibility Constructor only Stage URL Public URL

Troubleshooting

"Publish already running" Error

  1. Check publish_events table for status='running' records
  2. If stuck, manually update to status='failed'
  3. Check for database connection issues causing uncommitted transactions

Publishing Takes Too Long

  1. Check number of pages/elements being copied
  2. Review database performance
  3. Check for lock contention from concurrent operations

Production Data Not Updating

  1. Verify publish event completed with status='success'
  2. Check pages_copied count is non-zero
  3. Clear browser cache and reload presentation
  4. Verify correct project slug in URL

Stage/Production Mismatch

  1. Confirm stage data exists (tour_pages WHERE environment='stage')
  2. Check publish event error_message for failures
  3. Review transaction logs for rollback issues