Skip to content

Build Architecture and Content Pipeline โ€‹

Source: extracted from project README and merged with the former docs/content-pipeline.md, 2026-05-25.

Build Architecture โ€‹

How tutorial markdown becomes deployed HTML, and how code becomes deployed apps. Three independent trigger paths share the parser pipeline but write to different targets.

mermaid
flowchart TB
    subgraph sources[Source repositories]
        ProdRepos["sap-tutorials/*<br/>(public tutorial repos)"]
        ContribRepos["sap-tutorials/*-Contribution<br/>(in-flight authoring)"]
        ThisRepo["this repo<br/>(db/, srv/, srv-qa/, app/,<br/>hugo/, hugo-apps/, scripts/)"]
    end

    subgraph triggers[Build triggers]
        DeployCI["deploy.yml<br/>(push to main / manual)"]
        RebuildCI["rebuild-content.yml<br/>(schedule / manual / TUTORIAL_SLUG)"]
        QaCI["rebuild-content-qa.yml<br/>(repository_dispatch from<br/>any -Contribution repo)"]
        Local["local dev<br/>(npm run dev / cds watch)"]
    end

    subgraph fetch[Fetch + parse]
        FetchProd["scripts/fetch-tutorials.ts<br/>--target hugo<br/>cache: .tutorial-cache/"]
        FetchQa["scripts/fetch-tutorials.ts<br/>--target hugo --channel qa<br/>cache: .tutorial-cache-qa/"]
        Parsers["scripts/parsers/<br/>(v1 ACCORDION, v2 H3,<br/>images, options, rules,<br/>sanitize-html)"]
    end

    subgraph hugoBuild[Hugo render]
        HugoProd["hugo --minify<br/>โ†’ hugo/public/"]
        HugoQa["hugo --config hugo.qa.toml<br/>โ†’ hugo/public-qa/"]
    end

    subgraph apps[App bundles]
        AdminShell["app/admin-shell<br/>(UI5 + 11 Fiori Elements)"]
        Analytics["app/analytics-explorer<br/>(Vue 3 + Vite + Monaco)"]
        Scanner["app/scanner<br/>(UI5)"]
        Display["app/display-app<br/>(Vue 3 + Vite)"]
        HugoApps["hugo-apps/<br/>(9 Vue 3 islands)"]
    end

    subgraph mta[MTA assembly]
        CdsBuild["cds build --production<br/>โ†’ gen/srv, gen/srv-qa,<br/>gen/db, gen/db-qa"]
        ApprouterBuild["approuter build<br/>(copies hugo/public + qa<br/>+ admin-ui + analytics-ui<br/>+ scanner-ui into static/)"]
        Mbt["mbt build<br/>โ†’ mta_archives/<br/>tutorials-ims_*.mtar"]
    end

    subgraph publish[Content publish]
        PublishProd["publish-content.ts<br/>delta-aware, gzip,<br/>sha256 hash compare"]
        PublishQa["publish-content.ts<br/>--channel qa<br/>(always --force)"]
    end

    subgraph deployed[Deployed targets]
        SrvDeployed["tutorials-srv +<br/>tutorials-approuter"]
        SrvQaDeployed["tutorials-srv-qa"]
        HanaProd[("tutorials-hana<br/>(ContentFiles +<br/>ContentManifest BLOBs)")]
        HanaQa[("tutorials-hana-qa")]
        LocalSqlite[("local SQLite<br/>or hybrid HANA<br/>via cds bind")]
    end

    ProdRepos --> FetchProd
    ContribRepos --> FetchProd
    ContribRepos --> FetchQa

    Local --> FetchProd
    DeployCI --> FetchProd
    RebuildCI --> FetchProd
    QaCI --> FetchQa

    FetchProd --> Parsers
    FetchQa --> Parsers
    Parsers --> HugoProd
    Parsers --> HugoQa

    Local --> HugoProd
    Local -.->|cds watch| LocalSqlite

    ThisRepo --> CdsBuild
    ThisRepo --> AdminShell
    ThisRepo --> Analytics
    ThisRepo --> Scanner
    ThisRepo --> Display
    ThisRepo --> HugoApps
    HugoApps --> HugoProd

    DeployCI --> CdsBuild
    DeployCI --> AdminShell
    DeployCI --> Analytics
    DeployCI --> Scanner
    DeployCI --> Display

    CdsBuild --> Mbt
    AdminShell --> ApprouterBuild
    Analytics --> ApprouterBuild
    Scanner --> ApprouterBuild
    HugoProd --> ApprouterBuild
    HugoQa --> ApprouterBuild
    ApprouterBuild --> Mbt
    Display --> Mbt

    Mbt -->|cf deploy| SrvDeployed
    Mbt -->|cf deploy| SrvQaDeployed
    Mbt -->|hdb deployer| HanaProd
    Mbt -->|hdb deployer| HanaQa

    HugoProd --> PublishProd
    HugoQa --> PublishQa
    RebuildCI --> PublishProd
    QaCI --> PublishQa
    PublishProd -->|"POST /content/publish<br/>(bearer)"| SrvDeployed
    PublishQa -->|"POST /content/publish<br/>(bearer)"| SrvQaDeployed
    SrvDeployed -.->|gzip BLOBs| HanaProd
    SrvQaDeployed -.->|gzip BLOBs| HanaQa

    classDef trigger fill:#fef3e7,stroke:#d97706,color:#92400e
    class DeployCI,RebuildCI,QaCI,Local trigger
    classDef target fill:#e7f4ee,stroke:#15803d,color:#14532d
    class SrvDeployed,SrvQaDeployed,HanaProd,HanaQa,LocalSqlite target

Notes:

  • Local dev uses in-memory SQLite by default (cds watch); use npm run dev:hybrid for the full stack against real HANA via cds bind.
  • deploy.yml does NOT publish content โ€” it deploys the apps and HDI schemas. The post-deploy step triggers rebuild-content.yml to populate HANA. This separation lets content rebuilds run independently of code deploys (a single tutorial fix doesn't require redeploying the srv).
  • rebuild-content.yml runs in one of three scopes โ€” catalog-only (~1 min, admin Mission/Group/etc. saves), slug-targeted (~2 min, one-tutorial fix), full (~10 min, everything). Manual gh workflow run ... -f slug=<slug> auto-infers slug-targeted. Admin writes auto-classify per entity via srv/lib/_classify-rebuild-mode.js. Full runbook: rebuild-content-workflow.md.
  • QA channel is end-to-end isolated: separate fetch cache (.tutorial-cache-qa/), separate Hugo config (hugo.qa.toml), separate srv (tutorials-srv-qa), separate HDI (tutorials-hana-qa), separate API key (CONTENT_API_KEY_QA). It never touches prod tables.
  • VSCode extension preview is in-process โ€” Hugo binary bundled into tutorials-srv-qa's deploy artifact, shells out per request to render markdown into HTML using preview-site/ layouts. No content is persisted; tmpdir is cleaned per call.

Build Pipeline โ€‹

Two parallel content pipelines feed two HDI containers. Both end at POST /content/publish on a CAP srv app โ€” there is no static-file fallback for tutorial HTML.

Prod pipeline โ†’ tutorials-hana โ€‹

sap-tutorials GitHub repos (live discovery via discoverAllTutorials)
  โ†“
scripts/fetch-tutorials.ts --target hugo            (cached in .tutorial-cache/)
  โ”œโ”€ scripts/parsers/*                              parse frontmatter, steps, images, options
  โ”œโ”€ fetchRulesVr() โ†’ .tutorial-cache/*.rules.vr    quiz data from *-Contribution repos
  โ””โ”€ writes hugo/content/tutorials/*.md             (gitignored)

CAP_BASE_URL/build/catalog (unauth)
  โ†“
hugo/content/missions/*.md, groups/*.md             mission + completion-path pages

build:css   โ†’ PostCSS Fundamental Styles โ†’ hugo/assets/css/sap-fundamental.css
build:apps  โ†’ Vite bundles hugo-apps/ Vue 3 islands โ†’ hugo/static/js/*.js
              (navigator, app-space, event-display, nav-dropdown, scanner-vue,
               tutorial-feedback, tutorial-rating, cmd-palette, me)
build:highlight โ†’ syntax-highlights .cds samples

build:hugo  โ†’ hugo --minify โ†’ hugo/public/                       (full site, incl. tutorials/)
  โ†“
scripts/publish-content.ts                          SHA-256 diff vs GET /content/hashes
  โ†“                                                 gzip โ†’ base64 โ†’ POST /content/publish
                                                    (CONTENT_API_KEY bearer; --force to bypass delta)
CAP srv (tutorials-srv) /content/publish
  โ†“
ContentFiles + ContentManifest BLOBs in tutorials-hana
  โ†“
GET /tutorials/{slug}  โ†’  approuter rewrites โ†’ /content/tutorials/{slug}
                          โ†’ decompress, ETag, bounded LRU cache (50MB)

Tutorials are explicitly removed from approuter/static/ during build (rm -rf approuter/static/tutorials). Hugo public/tutorials/* exists only as the source for publish-content.ts.

QA channel pipeline โ†’ tutorials-hana-qa โ€‹

Parallel author-preview track. Sources only *-Contribution repos, gated by XSUAA scope Tutorial.Author, never touches prod tables.

*-Contribution GitHub repos (ONLY_CONTRIBUTION_REPOS=true)
  โ†“
fetch-tutorials:qa  โ†’ .tutorial-cache-qa/  (.channel marker prevents cross-contamination)
  โ†“
build:qa  โ†’ hugo --config ../hugo.qa.toml โ†’ hugo/public-qa/
              (strips Joule FAB, rating, completion buttons, progress UI)
  โ”œโ”€ verify-qa-build.ts  fails the build if QA-only stripping didn't apply
  โ†“
publish-content:qa  (always --force; CONTENT_API_KEY_QA)
  โ†“
tutorials-srv-qa /content/publish
  โ†“
ContentFiles + ContentManifest in tutorials-hana-qa
  โ†“
GET /tutorials-qa/{slug}  (XSUAA + Tutorial.Author at approuter)

QA srv re-renders tutorials at runtime using srv-qa/lib/parsers.bundle.mjs, produced by prebuild:parsers-bundle (esbuild ESM bundle of scripts/parsers/). This lets the QA srv accept author-pushed markdown without rebuilding Hugo per author.

Standalone app builds โ€‹

Each lives in its own subtree and copies a dist/ (or webapp/) into the AppRouter's static/<route>/ during MTA build:

SourceBuilt byApprouter path
app/admin-shell/build:adminstatic/admin-ui/
app/analytics-explorer/build:analytics-explorerstatic/analytics-ui/
app/display-app/build:displaystatic/display-app/
app/scanner/webapp/(UI5 โ€” copied directly)static/scanner-ui/
hugo-apps/scanner-vue (island)build:appshugo/static/js/scanner-vue.js (loaded as <script> from Hugo)

Build orchestration โ€‹

build:all chains the pieces in order:

prebuild (parsers bundle)
  โ†’ fetch-tutorials --regenerate
  โ†’ build:css โ†’ build:apps โ†’ build:analytics-explorer
  โ†’ copy-joule-vendor โ†’ build:hugo โ†’ build:highlight โ†’ build:display

During build:all, the fetch step calls GET /build/homepage-shelves from the CAP backend and bakes the result into hugo/data/homepage_shelves.json. This JSON drives the verb-spine previews, the comprehensive directory footer, and the per-verb sub-page shelf listings โ€” the same pattern used by /build/catalog for missions and groups.

Admin shell (build:admin) and QA pipeline (fetch-tutorials:qa โ†’ build:qa โ†’ publish-content:qa) are not in build:all โ€” they're run independently or via qa:full for the QA loop. Tutorials must be fetched at least once before dev or build:hugo (otherwise hugo/content/tutorials/ is empty).

Parsers (scripts/parsers/) โ€‹

The fetch step (scripts/fetch-tutorials.ts) hands raw markdown + repo metadata to composeTutorial() (compose.ts), which orchestrates format detection, content transforms, and Hugo frontmatter emission. The same module set is bundled into srv-qa/lib/parsers.bundle.mjs (via prebuild:parsers-bundle) and re-used at runtime by the QA srv to render author-pushed drafts without re-running Hugo.

Format detection โ€‹

ParserDetectionDelimiter
v2.ts (current)parser: v2 in frontmatter### (H3) headings = step titles
v1.ts (legacy)Default[ACCORDION-BEGIN] / [ACCORDION-END] markers

Both produce the same in-memory Tutorial shape (types.ts) so downstream consumers don't branch on format.

Module map โ€‹

FileRole
compose.tsOrchestrator โ€” selects v1/v2, runs transforms, returns the rendered tutorial
v1.ts / v2.tsFormat-specific step splitters
frontmatter.tsgray-matter wrapper, typed against TutorialFrontmatter
frontmatter-utils.tsTag humanization (preserves SAP/HANA/CAP/BTP/etc. acronyms), prerequisite list splitting
render-frontmatter.tsEmits the YAML frontmatter Hugo consumes (escapes Hugo delimiters, formats tags)
hugo-delimiters.tsEscapes &#123;&#123; / &#125;&#125; in tutorial source so Hugo doesn't interpret them as templates
images.tsRewrites relative image paths to raw.githubusercontent.com CDN URLs
image-dimensions.tsExtracts width/height (cached on disk) so Hugo can emit <img> size attrs and avoid layout shift
options.tsConverts [OPTION BEGIN] / [OPTION END] blocks into Vue/Hugo shortcodes
sanitize-html.tsStrips unsafe HTML embedded in tutorial source
rules.tsParses rules.vr quiz files (fetched from *-Contribution repos) into ValidationQuestion objects
cap.tsFetches mission/group catalog from CAP_BASE_URL/build/catalog for mission/group page generation
github.tsdiscoverAllTutorials() + commit metadata; honors EXCLUDED_REPOS and TUTORIAL_SLUG for single-slug rebuilds
recommendations.tsComputes related-tutorial suggestions from the catalog graph
types.tsShared TS types (Tutorial, TutorialFrontmatter, Step, ValidationQuestion, TutorialNavEntry)
index.tsRe-exports for the QA-srv runtime bundle
discovery-baseline.jsonSnapshot of discoverAllTutorials() output โ€” third-tier discovery fallback when GitHub is unreachable

Shared transforms (in compose order) โ€‹

  1. frontmatter.ts extracts YAML
  2. v1/v2 splits the body into ordered steps
  3. images.ts + image-dimensions.ts rewrite + size image references
  4. options.ts converts option blocks
  5. sanitize-html.ts strips unsafe HTML
  6. hugo-delimiters.ts escapes &#123;&#123; / &#125;&#125;
  7. rules.ts injects ValidationQuestion[] into the matching steps
  8. render-frontmatter.ts emits the Hugo .md file

For OS-conditional content (Windows / macOS / Linux / BAS variants), the parser consults scripts/parsers/os-classifier.ts, a fuzzy-match dictionary that canonicalizes the messy real-world OS labels in OPTION blocks. OS-flavored groups emit a new &#123;&#123;< os-options >&#125;&#125; shortcode (one panel per canonical OS, with combined labels like "Mac and Linux" duplicating their body across multiple panels). The page-level hasOsOptions: true frontmatter flag is auto-injected when any group on the page is classified OS โ€” the OP layout uses it to conditionally render the global OS picker. Author override via the osOverrides: frontmatter key when the heuristic misclassifies. See the spec at docs/superpowers/specs/2026-06-09-173-os-conditional-content-design.md.

The navigator endpoint exposes tutorial reachability via three independent data paths, allowing front-ends to surface tutorials through missions, groups, or as standalone learnings.

Data PathSourceMapping
Mission tutorialsNavigatorCatalog SQL view + Mission CompletionPathItems where taskType='TUTORIAL'Direct tutorial references inside mission completion paths
Nested group tutorialsMission CompletionPathItems where taskType='GROUP' (JS-side expansion)Handler expands nested Groups, pairs each tutorial with its parent mission + group
Standalone groupsGroups.published=true with no Mission link + GroupPathItems (JS-side scan)Tutorials reachable through published Groups without a mission parent; emitted as (group, tutorial) pairs with missionId=null

Response shape (top-level fields):

  • missions[] โ€” mission summary refs (existing)
  • groups[] โ€” Group refs including standalone published Groups
  • tutorialMappings[] โ€” array of { slug, missionId, missionTitle, missionSlug, groupId, groupTitle, groupSlug, prev, next } tuples (mission fields are null for standalone-Group tutorials; prev/next are slug strings or null for end-of-path)
  • checkpointMappings[] โ€” NEW โ€” array of { title, missionId, missionTitle, missionSlug, pathId, pathSlug, itemOrder } milestone markers from CompletionPathItems where taskType='CHECKPOINT' (currently consumer-side TODO for rendering)

Handler: srv/lib/navigator-catalog.js โ€” in-memory cache (5-minute TTL, auto-invalidated on AdminService writes to Missions, Groups, or CompletionPath* entities).

Cache โ€‹

Two parallel cache directories โ€” one per channel โ€” back the fetch step. Both are gitignored.

PathChannelSource repos
.tutorial-cache/prodAll sap-tutorials repos minus EXCLUDED_REPOS
.tutorial-cache-qa/QA*-Contribution repos only (ONLY_CONTRIBUTION_REPOS=true)

.tutorial-cache-qa/ carries a .channel marker file. npm run dev warns if the cache content channel doesn't match the build target โ€” switching channels without clearing the cache silently mixes prod and draft content.

Cache contents โ€‹

ArtifactPurposeInvalidation
<slug>.mdRaw tutorial markdown from GitHubSHA mismatch via <slug>.sha
<slug>.shaSHA-256 of the upstream .md for change detectionReplaced on each fetch
<slug>.rules.vrQuiz validation rules (from *-Contribution repos via fetchRulesVr())SHA mismatch
_discovery.jsonOutput of discoverAllTutorials() โ€” slug โ†’ repo + path mapPer-fetch refresh; falls back to scripts/parsers/discovery-baseline.json if GitHub unreachable
cap-catalog.jsonCAP_BASE_URL/build/catalog snapshot (missions, completion paths)24h TTL (CACHE_TTL_MS in parsers/cap.ts)
github-meta.json / github-meta.v2.jsonCommit author + timestamp metadata per slugPer-fetch (rate-limited; honor GITHUB_TOKEN)
image-dimensions.jsonWidth/height for every referenced image (avoids layout shift)Manual delete only โ€” extraction is expensive
errors.jsonFetch error log (per slug, last attempt)Overwritten per run
_prod-tut.htmlCaptured production HTML used for parser-output comparisonManual
quarantine/Tutorials that failed validation (scripts/validate-tutorials.ts)Created on demand

Invalidation โ€‹

  • Whole-cache reset: rm -rf .tutorial-cache/ (or .tutorial-cache-qa/) โ€” forces a full re-fetch from GitHub.
  • Single slug: delete <slug>.md and <slug>.sha. The rebuild-content.yml workflow does this when an author dispatches the workflow with the optional slug input โ€” it busts that one slug, regenerates the rest from cache, and skips the RepoCatalog baseline upload so the partial run doesn't overwrite it.
  • Catalog only: delete cap-catalog.json to force a fresh CAP fetch before the 24h TTL expires.
  • Images: delete image-dimensions.json only when image references change shape (rare).

Detailed Content Pipeline โ€‹

Complete flow of tutorial content from GitHub source to end-user delivery, including exception handling, versioning, and tracking.

Pipeline Overview โ€‹

text
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                        CONTENT PIPELINE                                  โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  FETCH   โ”‚โ”€โ”€โ”€โ–ถโ”‚  PARSE   โ”‚โ”€โ”€โ”€โ–ถโ”‚  BUILD   โ”‚โ”€โ”€โ”€โ–ถโ”‚     PUBLISH      โ”‚  โ”‚
โ”‚  โ”‚ (GitHub) โ”‚    โ”‚ (MDโ†’AST) โ”‚    โ”‚  (Hugo)  โ”‚    โ”‚ (Delta โ†’ HANA)   โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚       โ”‚                                                    โ”‚             โ”‚
โ”‚       โ–ผ                                                    โ–ผ             โ”‚
โ”‚  .tutorial-cache/                               ContentFiles (BLOB)      โ”‚
โ”‚  errors.json                                    ContentManifest          โ”‚
โ”‚                                                                          โ”‚
โ”‚                        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                              โ”‚
โ”‚                        โ”‚      SERVE       โ”‚โ—€โ”€โ”€ LRU Cache (50MB)          โ”‚
โ”‚                        โ”‚ (Decompress+ETag)โ”‚                              โ”‚
โ”‚                        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                              โ”‚
โ”‚                                 โ–ฒ                                        โ”‚
โ”‚                                 โ”‚                                        โ”‚
โ”‚                        AppRouter /tutorials/*                             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Phase 1: Fetch (scripts/fetch-tutorials.ts) โ€‹

Downloads tutorial markdown from the sap-tutorials GitHub organization.

Steps โ€‹

StepActionConcurrencyOutput
1.1GraphQL discovery of reposSequential (paginated, 100/page).tutorial-cache/_discovery.json
1.2Batch metadata prefetch3 repos ร— 20 tutorials/batch.tutorial-cache/github-meta.v2.json
1.3Download markdown5 concurrent tutorials.tutorial-cache/{slug}.md + .sha
1.4Parse & transformInline (per tutorial)hugo/content/tutorials/{slug}.md
1.5Fetch CAP catalogSingle request.tutorial-cache/cap-catalog.json
1.6Generate navigationInlinehugo/content/tutorials/_nav.json

Cache Strategy (SHA-based) โ€‹

text
For each tutorial slug:
  local_sha  = read .tutorial-cache/{slug}.sha
  remote_sha = latest commit SHA from GitHub

  if local_sha == remote_sha โ†’ use cached .md (status: "cached")
  if local_sha != remote_sha โ†’ re-fetch .md (status: "refreshed")
  if no local file           โ†’ fetch new   (status: "fetched")

Cache stored in .tutorial-cache/ (gitignored). Delete directory to force full re-fetch.

Exception Handling โ€‹

FailureScopeBehaviorRecovery
Markdown 404Single tutorialError thrown, caughtLogged to errors.json; pipeline continues
GitHub rate limitBatchBatch metadata failsFallback metadata applied ({lastCommitSha: '', ...})
GraphQL errorsDiscoveryWarnings loggedContinues with discovered repos
rules.vr fetch failSingle tutorialReturns null silentlyTutorial proceeds without quiz data
CAP catalog failAll missionsWarning loggedProceeds without mission/group assignments
Network timeoutPer requestStandard fetch rejectionCaught per-tutorial; logged

Error Tracking โ€‹

Failed tutorials are written to .tutorial-cache/errors.json:

json
[
  {
    "slug": "tutorial-slug",
    "repo": "sap-tutorials/repo-name",
    "error": "HTTP 404: Not Found",
    "timestamp": "2026-05-05T10:30:00.000Z"
  }
]

Phase 2: Parse (scripts/parsers/) โ€‹

Transforms raw markdown into Hugo-compatible content pages.

Parser Selection โ€‹

Determined by frontmatter field parser: v2:

  • V2 (current): H3 headings (###) delimit steps
  • V1 (legacy): [ACCORDION-BEGIN] / [ACCORDION-END] markers

Transformations Applied โ€‹

ParserFileTransformation
Frontmatterparsers/frontmatter.tsExtract YAML metadata (title, level, tags, time)
Stepsparsers/steps.tsSplit into numbered steps with titles
Imagesparsers/images.tsResolve relative paths โ†’ raw.githubusercontent.com CDN URLs
Optionsparsers/options.ts[OPTION BEGIN]/[OPTION END] โ†’ Hugo shortcodes
Rulesparsers/rules.tsParse .rules.vr quiz validation files
CAPparsers/cap.tsInject mission/group metadata from build catalog
HTMLInlineEscape dangerous HTML; preserve allowed tags

Safety: HTML Escaping โ€‹

  • HTML outside code fences is escaped (prevents XSS in rendered tutorials)
  • Allowed tags preserved: TutorialStep, OptionTabs, template
  • Component tag balancing: missing closing tags auto-added

Phase 3: Build (Hugo) โ€‹

Standard Hugo static site generation.

bash
npm run build:hugo  # โ†’ hugo/public/tutorials/*/index.html

Output: One index.html per tutorial slug in hugo/public/tutorials/.


Phase 4: Publish (scripts/publish-content.ts) โ€‹

Delta-aware upload of changed tutorial HTML to SAP HANA Cloud.

Delta Detection Algorithm โ€‹

text
1. Scan hugo/public/tutorials/ for index.html files
2. Compute SHA-256 hash of each local file
3. GET /content/hashes โ†’ { slug: remoteHash }
4. Compare:
   - slug in local but not remote     โ†’ NEW (publish)
   - local hash != remote hash        โ†’ MODIFIED (publish)
   - local hash == remote hash        โ†’ UNCHANGED (skip)
5. If /content/hashes unreachable     โ†’ publish ALL (fail-open)

Payload Construction โ€‹

For each changed slug:

  1. Read HTML file
  2. Gzip compress
  3. Base64 encode
  4. Include __nav__ special entry (navigation metadata)
text
POST /content/publish
Authorization: Bearer <CONTENT_API_KEY>
Content-Type: application/json

{
  "trigger": "ci@<commit-sha>",
  "hugoVersion": "0.139.0",
  "files": {
    "tutorial-slug-1": "<base64-gzipped-html>",
    "tutorial-slug-2": "<base64-gzipped-html>",
    "__nav__": "<base64-gzipped-json>"
  }
}

CLI Flags โ€‹

FlagEffect
--dry-runShow what would change without uploading
--forceSkip delta detection, republish all files
--verboseExtra logging of hash comparisons

Exception Handling โ€‹

FailureBehavior
/content/hashes returns 503Treat all files as changed (publish all)
Network error on POSTScript exits with non-zero code
401 UnauthorizedMissing/wrong CONTENT_API_KEY
409 ConflictAnother publish in progress (retry later)

Slug canonicalization โ€‹

Tutorial slugs are case-sensitive identifiers in the database (Tutorials.slug, ContentFiles.slug, etc.) and the canonical form is lowercase. This is enforced at:

Source markdown filenames in the sap-tutorials GitHub org are not policed for case (some ship with uppercase, e.g. abap-environment-sbpa-workflow-extend-RAP-App). Both surfaces must therefore canonicalize independently.

If you ever see a tutorial display "0 steps" on the group/mission catalog page while the tutorial itself renders correctly, suspect a case mismatch between Tutorials.slug (catalog FK target) and the slug the publisher wrote metadata under. The one-shot repair is scripts/repair-mixed-case-tutorial-duplicates.cjs (dry-run by default; pass --apply to mutate).

The same case-insensitive pattern is applied to serveHandler's soft-delete status check around the SELECT.from(Tutorials).where({ slug }) lookup (the lookup that detects whether a Tutorials row has been soft-deleted via status='INACTIVE'). Without the case-insensitive lookup, an admin soft-delete via AdminService would silently fail to 404 the URL when the canonical slug shipped mixed-case โ€” the exact-match where({ slug }) would miss the row. A defensive multi-row preference picks the ACTIVE row when both an INACTIVE legacy row and an ACTIVE legacy row coexist for the same lowercased slug.

The repair script scripts/repair-mixed-case-tutorial-duplicates.cjs hard-deletes orphan rows that have zero FK references and INACTIVE-flags only when references survive (Steps, GroupPathItems, CompletionPathItems, NgdsResults, TaskRecords). This avoids leaving INACTIVE landmines that exact-match where({ slug }) lookups might find as the soft-delete row instead of the canonical ACTIVE row.


Phase 5: Content Store (srv/lib/content-store.js) โ€‹

Server-side persistence, versioning, and serving layer.

Database Schema โ€‹

text
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚       ContentManifest            โ”‚    โ”‚          ContentFiles              โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค    โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ PK version: Integer              โ”‚    โ”‚ PK slug: String(255)              โ”‚
โ”‚    status: Enum                  โ”‚โ—€โ”€โ”€โ–ถโ”‚ PK version: Integer               โ”‚
โ”‚    trigger: String(500)          โ”‚    โ”‚    content: LargeBinary (gzip)    โ”‚
โ”‚    fileCount: Integer            โ”‚    โ”‚    contentHash: String(64)        โ”‚
โ”‚    totalSizeBytes: Int64         โ”‚    โ”‚    sizeBytes: Integer             โ”‚
โ”‚    changedSlugs: LargeString     โ”‚    โ”‚    compressedBytes: Integer       โ”‚
โ”‚    hugoVersion: String(20)       โ”‚    โ”‚    mimeType: String(100)          โ”‚
โ”‚    publishDurationMs: Integer    โ”‚    โ”‚    created_at: Timestamp          โ”‚
โ”‚    created_at: Timestamp         โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚    updated_at: Timestamp         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

ContentManifest.status:
  PUBLISHING  โ†’ in-progress write (transient)
  ACTIVE      โ†’ currently served to users
  SUPERSEDED  โ†’ replaced by newer version
  ROLLED_BACK โ†’ explicitly reverted

Publish Handler (POST /content/publish) โ€‹

text
โ”Œโ”€ Acquire distributed lock (content-publish, 120s TTL) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                          โ”‚
โ”‚  1. Create manifest (status: PUBLISHING, version: max+1)                โ”‚
โ”‚  2. For each file in payload:                                            โ”‚
โ”‚     - Decode base64 โ†’ gzipped buffer                                    โ”‚
โ”‚     - Decompress โ†’ compute SHA-256                                       โ”‚
โ”‚     - Record: slug, version, content, hash, sizes                       โ”‚
โ”‚  3. Batch INSERT ContentFiles (groups of 50)                             โ”‚
โ”‚  4. Mark previous ACTIVE manifest โ†’ SUPERSEDED                          โ”‚
โ”‚  5. Update current manifest โ†’ ACTIVE + stats                            โ”‚
โ”‚  6. Invalidate LRU cache                                                 โ”‚
โ”‚  7. Log to PipelineLog                                                   โ”‚
โ”‚                                                                          โ”‚
โ””โ”€ Release lock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Response 201:
{
  "version": 42,
  "filesWritten": 5,
  "totalSizeBytes": 1234567,
  "durationMs": 3200
}

Concurrency Control โ€‹

  • Distributed lock via JobLocks table (expiry-based claiming)
  • Lock key: content-publish
  • TTL: 120 seconds (auto-expires if process crashes)
  • Conflict response: 409 Conflict with retry guidance

Serve Handler (GET /content/tutorials/:slug) โ€‹

text
Request: GET /content/tutorials/abap-dev-create-table
         If-None-Match: "abc123..."

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 1. Resolve active version from ContentManifest            โ”‚
โ”‚ 2. Check LRU cache (key: slug@version)                    โ”‚
โ”‚    โ”œโ”€ HIT + ETag match โ†’ 304 Not Modified                โ”‚
โ”‚    โ”œโ”€ HIT             โ†’ 200 (X-Content-Source: cache)    โ”‚
โ”‚    โ””โ”€ MISS            โ†’ continue to DB                    โ”‚
โ”‚ 3. Query ContentFiles (slug + active version)             โ”‚
โ”‚    โ”œโ”€ HANA: raw SQL (avoids LOB locator expiry bug)      โ”‚
โ”‚    โ””โ”€ SQLite: CDS QL (unit tests)                        โ”‚
โ”‚ 4. Decompress gzip โ†’ HTML                                โ”‚
โ”‚ 5. Store in LRU cache                                     โ”‚
โ”‚ 6. Return 200 (X-Content-Source: db)                     โ”‚
โ”‚                                                           โ”‚
โ”‚ Headers:                                                  โ”‚
โ”‚   ETag: <contentHash>                                     โ”‚
โ”‚   Cache-Control: public, max-age=300                      โ”‚
โ”‚   Content-Type: text/html; charset=utf-8                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

LRU Cache Details โ€‹

ParameterValue
Max size50 MB
EvictionLeast-recently-used
InvalidationFull flush on publish or rollback
Key format{slug}@{version}

HANA LOB Workaround โ€‹

HANA BLOB columns return Readable streams with locators that expire before consumption when selected alongside non-BLOB columns in CDS QL. The content store uses raw SQL (cds.run(sql)) for BLOB retrieval on HANA, bypassing the CDS QL layer. SQLite (used in unit tests) uses standard CDS QL since it doesn't have this limitation.


Phase 5.5: Embedding Hook (post-publish) โ€‹

After the manifest goes ACTIVE, per-step embeddings are generated for RAG (Retrieval-Augmented Generation) in the Joule chat.

Pipeline โ€‹

text
Hugo build โ†’ publish-content โ†’ /content/publish โ†’ manifest ACTIVE
                                                         โ†“ setImmediate
                                                    embedSlugs(changed)

Flow โ€‹

  1. Immediate embed (non-blocking) โ€” After POST /content/publish completes and the manifest is marked ACTIVE, srv/lib/content-store.js schedules embedSlugs(changedSlugs) via setImmediate. The publish HTTP response returns immediately (201) without waiting for embeddings to complete.

  2. Hourly reconciliation โ€” A cron job at minute :17 of every hour (srv/jobs/embedding-reconciliation.js, orchestrated in srv/jobs/scheduler.js) runs runReconciliationJob. It:

    • Re-embeds any step whose contentHash no longer matches the embedding row's stored contentHash (drift detection).
    • Embeds any rows in the active manifest that have no embedding yet.
    • Uses distributed locking via runWithLock (key: embedding-reconciliation, 30-minute timeout) for multi-instance safety.
  3. Daily orphan cleanup โ€” At 03:30 UTC, srv/jobs/embedding-reconciliation.js prunes embeddings for tutorials no longer in the active ContentManifest. This keeps the table bounded after content rollbacks or deletions.

All embeddings use the model specified in ChatSettings.embeddingModel (default: text-embedding-3-small via the tutorials-aicore AI Core destination).


Phase 6: Rollback (POST /content/rollback) โ€‹

Reverts to a previous content version without re-publishing.

text
POST /content/rollback
Authorization: Bearer <CONTENT_API_KEY>
Body: { "targetVersion": 41 }  (optional โ€” defaults to most recent SUPERSEDED)

Steps:
  1. Find target version (must be SUPERSEDED status)
  2. Current ACTIVE โ†’ ROLLED_BACK
  3. Target โ†’ ACTIVE
  4. Flush LRU cache
  5. Return new active version info

Rollback is instantaneous since all version data persists in ContentFiles.


Phase 7: Garbage Collection (srv/jobs/cleanup.js) โ€‹

Scheduled daily at 03:00 UTC by the job scheduler.

Content Version Pruning โ€‹

ParameterDefaultPurpose
keepCount3Minimum superseded versions retained for rollback
olderThanDays7Only prune versions older than this
text
Candidates = ContentManifest WHERE
  status IN ('SUPERSEDED', 'ROLLED_BACK')
  AND created_at < (now - 7 days)

Candidates sorted by version DESC โ†’ skip first 3 (keepCount)
For remaining: DELETE ContentFiles + DELETE ContentManifest

Safety: Never touches ACTIVE or PUBLISHING manifests.

Other Cleanup Tasks โ€‹

TaskRetentionSchedule
Content version pruning3 versions / 7 daysDaily 03:00
PipelineLog entries30 daysDaily 03:00
StepFailures records90 daysDaily 03:00
Unused tagsImmediateDaily 03:00

Tracking & Observability โ€‹

ContentManifest (Version History) โ€‹

Each publish creates a manifest row tracking:

  • Version number (monotonically increasing)
  • Status lifecycle: PUBLISHING โ†’ ACTIVE โ†’ SUPERSEDED
  • Trigger source (e.g., ci@abc123, manual)
  • File count and total size
  • List of all changed slugs (JSON array in changedSlugs)
  • Hugo version used
  • Server-side publish duration

PipelineLog โ€‹

Records all pipeline events (publishes, rollbacks) with timestamps, initiator, and outcome. Retained for 30 days.

Response Headers (Serve) โ€‹

HeaderPurpose
X-Content-Sourcecache or db โ€” indicates whether LRU cache was hit
X-Content-VersionActive manifest version number
ETagSHA-256 hash of content (enables 304 responses)
Cache-Controlpublic, max-age=300 (5-minute browser cache)

Error Surfaces โ€‹

EndpointErrorHTTP CodeMeaning
/content/publishLock held409Another publish in progress
/content/publishBad token401Missing/invalid CONTENT_API_KEY
/content/tutorials/:slugNo active version503No content published yet
/content/tutorials/:slugSlug not found404Tutorial not in active manifest
/content/rollbackNo target404No SUPERSEDED version available
/content/hashesNo active version503No content published yet

End-to-End Flow (CI/CD) โ€‹

text
โ”Œโ”€ CI Pipeline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                          โ”‚
โ”‚  1. npm install                                                          โ”‚
โ”‚  2. npm run fetch-tutorials          โ† GitHub โ†’ .tutorial-cache/        โ”‚
โ”‚  3. npm run build:all                โ† Hugo โ†’ hugo/public/              โ”‚
โ”‚  4. npm run publish-content          โ† Delta โ†’ HANA (ContentFiles)      โ”‚
โ”‚     โ””โ”€ CONTENT_API_KEY required                                          โ”‚
โ”‚     โ””โ”€ CAP_BASE_URL points to deployed srv                               โ”‚
โ”‚  5. npm run test:smoke               โ† Verify /tutorials/* responds     โ”‚
โ”‚                                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Environment Variables โ€‹

VariableRequired ByPurpose
GITHUB_TOKENfetchAvoid GitHub API rate limits
CONTENT_API_KEYpublish, rollbackBearer token for write operations
CAP_BASE_URLfetch (catalog), publishTarget CAP server URL
SMOKE_BASE_URLsmoke testsAppRouter URL for integration tests

Performance Metrics โ€‹

Based on recent runs (May 2026) against the full tutorial corpus of 1,378 tutorials across 1,387 repos in the sap-tutorials GitHub organization.

Dataset Profile โ€‹

MetricValue
Total tutorials discovered1,387
Successfully processed1,378
Parse errors (malformed frontmatter)8
Raw markdown cache size14.2 MB
Avg markdown file size10.5 KB
Built HTML files2,509 (includes step sub-pages)
Total HTML output60.6 MB
Avg HTML file size24.7 KB
Largest HTML file298 KB
Median HTML file19.6 KB
Gzip compression ratio~78%
Estimated HANA storage (compressed)~22.6 MB

Phase 1: Fetch Timing โ€‹

Cached Run (regenerate from local .tutorial-cache/) โ€‹

PhaseDurationNotes
Discovery (GraphQL)0 msSkipped in --regenerate mode
Metadata prefetch0 msSkipped in --regenerate mode
Tutorial processing3.1 sParse + Hugo page generation
CAP missions/groups123 msCatalog fetch (0 missions if CAP not running)
Total3.2 s

Per-Tutorial Stats (cached) โ€‹

MetricValue
Average7 ms/tutorial
Slowest18 ms
Fastest3 ms
Throughput426.6 tutorials/sec

Cold Run (fresh fetch from GitHub) โ€‹

Estimated from concurrency settings and network characteristics:

PhaseEstimated DurationNotes
Discovery (GraphQL)3โ€“5 sPaginated, ~14 pages ร— 100 repos
Metadata prefetch15โ€“30 s3 concurrent repos ร— 20 tutorials/batch
Tutorial download60โ€“90 s5 concurrent, ~1,378 fetches from raw.githubusercontent.com
Tutorial processing3โ€“5 sCPU-bound parsing (same as cached)
CAP missions/groups0.5โ€“2 sSingle HTTP request to catalog endpoint
Total (cold)~90โ€“130 sDominated by GitHub API/network time

GitHub rate limit: 5,000 requests/hour with GITHUB_TOKEN; unauthenticated: 60/hour (will fail for full corpus).

Phase 3: Hugo Build โ€‹

MetricValue
Input pages~2,500+ (tutorials + missions + groups + static)
Output size70 MB (full hugo/public/)
Typical build time5โ€“10 s
Build commandhugo --minify

Phase 4: Publish Timing โ€‹

Delta Publish (typical CI โ€” 1โ€“10 changed files) โ€‹

StepDurationNotes
Local hash computation< 500 msSHA-256 of 2,509 files
Remote hash fetch (GET /content/hashes)200โ€“500 msNetwork to BTP + HANA query
Delta calculation< 10 msIn-memory comparison
Gzip + base64 encoding< 100 msFor changed files only
Network upload200โ€“1,000 msPayload typically < 1 MB
Server-side persist500โ€“2,000 msDecompress, hash, batch INSERT, manifest update
Total (delta)~2โ€“4 s

Full Publish (all 2,509 files, --force) โ€‹

StepDurationNotes
Gzip + base64 encoding2โ€“3 sAll 2,509 files
Payload size~25 MBCompressed + base64 overhead
Network upload5โ€“15 sDepends on bandwidth to BTP region
Server-side persist10โ€“30 s50 files/batch ร— ~50 batches, plus SHA-256 per file
Total (full)~20โ€“50 s

Server-side publishDurationMs (recorded in ContentManifest) excludes network transfer โ€” measures only DB writes and hash computation.

Phase 5: Content Serving โ€‹

ScenarioResponse TimeNotes
LRU cache hit + ETag match< 1 msReturns 304 immediately
LRU cache hit (no ETag)1โ€“2 msReturns decompressed buffer
Cache miss (HANA query)20โ€“80 msRaw SQL BLOB fetch + gunzip
Cold start (first request)50โ€“150 msNo cache populated yet

Cache Warm-Up Behavior โ€‹

After a CAP srv restart, the LRU cache is empty. First ~50 unique tutorial requests populate the cache. At steady state with the 50 MB limit:

MetricValue
Cache capacity~2,000 tutorials (at 24.7 KB avg)
Coverage~80% of corpus fits in cache
EvictionLRU โ€” rarely-accessed tutorials evicted first
Hit rate (steady state)90โ€“95% (typical usage patterns favor popular tutorials)

Garbage Collection โ€‹

OperationDurationFrequency
Content version pruning1โ€“5 sDaily 03:00
PipelineLog cleanup< 1 sDaily 03:00
StepFailures cleanup< 1 sDaily 03:00
Unused tags cleanup< 1 sDaily 03:00

End-to-End Pipeline (CI) โ€‹

StageCachedCold
npm install10โ€“20 s30โ€“60 s
npm run fetch-tutorials3 s90โ€“130 s
npm run build:all15โ€“25 s15โ€“25 s
npm run publish-content2โ€“4 s(first deploy: 20โ€“50 s)
npm run test:smoke5โ€“10 s5โ€“10 s
Total CI (cached)~40โ€“60 s
Total CI (cold)~3โ€“5 min

Bottlenecks & Scaling Notes โ€‹

ConcernCurrent StateMitigation
GitHub API rate limit1,378 fetches fit in 5,000/hr budgetSHA-based cache prevents re-fetch
Payload size (full publish)~25 MB JSONDelta detection reduces to < 1 MB typical
HANA BLOB insert50 files/batch to avoid tx size limitsParallel batches not used (sequential)
LRU cache cold start~50 requests to warm popular contentPre-warm could be added but not needed
Hugo build timeLinear with page countAlready fast (< 10 s for 2,500 pages)

Request Routing (Production) โ€‹

text
Browser โ†’ AppRouter (xs-app.json)
  /tutorials/(.*)  โ†’  rewrite to /content/tutorials/$1  โ†’  CAP srv
                                                              โ”‚
                                                              โ–ผ
                                                    content-store.js
                                                              โ”‚
                                                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                                    โ”‚   LRU Cache Hit?   โ”‚
                                                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                                         yes / no
                                                        /       \
                                                   200          HANA query
                                                              (raw SQL)
                                                                โ”‚
                                                            decompress
                                                                โ”‚
                                                            200 + cache

Tutorial HTML is served exclusively from HANA BLOBs. There is no static file fallback โ€” if no content has been published, /tutorials/* returns 404.


Resilience: AppRouter Restage / Filesystem Loss โ€‹

Cloud Foundry containers are ephemeral โ€” a restage, restart, or crash recovery destroys the local filesystem. This section documents the impact on content serving.

What the AppRouter filesystem contains โ€‹

The AppRouter's approuter/static/ directory holds:

  • Hugo-built static assets (CSS, JS, images, landing pages)
  • NOT tutorials โ€” explicitly removed during build (rm -rf approuter/static/tutorials)

What happens on restage โ€‹

text
โ”Œโ”€ AppRouter restaged โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                          โ”‚
โ”‚  Lost:                                                                   โ”‚
โ”‚    โ€ข Static assets (CSS, JS, images)                                    โ”‚
โ”‚    โ€ข Landing pages, mission pages, group pages                          โ”‚
โ”‚                                                                          โ”‚
โ”‚  NOT lost (never on filesystem):                                         โ”‚
โ”‚    โ€ข Tutorial HTML content (lives in HANA)                              โ”‚
โ”‚    โ€ข Content manifests and version history (HANA)                       โ”‚
โ”‚    โ€ข Navigation metadata (HANA)                                          โ”‚
โ”‚                                                                          โ”‚
โ”‚  Temporarily lost (rebuilt on first request):                            โ”‚
โ”‚    โ€ข CAP srv in-memory LRU cache (50MB) โ€” cold start, repopulates      โ”‚
โ”‚                                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Impact by component โ€‹

ComponentStorageRestage ImpactRecovery
Tutorial HTMLHANA BLOBsNone โ€” never on AppRouter filesystemImmediate
Content versionsHANA (ContentManifest)NoneImmediate
LRU cache (CAP srv)In-memory (srv process)Lost if srv also restagedAuto-rebuilds on requests
Static assets (CSS/JS)AppRouter filesystemLost โ€” must redeployMTA deploy restores from build artifact
Hugo landing pagesAppRouter filesystemLost โ€” must redeployMTA deploy restores from build artifact

Why tutorials survive โ€‹

The architectural decision to store tutorials in HANA rather than as static files was made specifically for this reason:

  1. Decoupled lifecycle โ€” Content publishes independently of app deploys. A new tutorial can go live without redeploying the AppRouter.
  2. Restage-proof โ€” CF container recreation doesn't affect content availability. The AppRouter is a stateless proxy for /tutorials/*.
  3. Rollback without redeploy โ€” POST /content/rollback reverts content instantly without touching CF at all.

The request path after restage โ€‹

text
Browser: GET /tutorials/abap-dev-create-table

AppRouter (freshly restaged, empty filesystem):
  1. xs-app.json route: /tutorials/(.*) โ†’ destination "srv-api", path /content/tutorials/$1
  2. AppRouter does NOT look for /tutorials/ on its own filesystem
  3. Proxies to CAP srv

CAP srv:
  4. content-store.js resolves active ContentManifest version
  5. LRU cache miss (cold start) โ†’ query HANA
  6. Decompress BLOB โ†’ return HTML
  7. Populate LRU cache for subsequent requests

Result: 200 OK โ€” user sees tutorial content as normal

Recovery scenarios โ€‹

ScenarioTutorial ContentStatic AssetsAction Required
AppRouter restage onlyUnaffectedLostRedeploy MTA (or just approuter module)
CAP srv restage onlyUnaffected (HANA)UnaffectedNone โ€” LRU cache rebuilds automatically
Both restagedUnaffected (HANA)LostRedeploy MTA
HANA Cloud restartTemporarily unavailableUnaffectedWait for HANA recovery; content intact
Full MTA redeployUnaffected (HANA)Restored from buildNone

Edge case: First deploy (no content in HANA) โ€‹

If the AppRouter is deployed before any content has been published to HANA, /tutorials/* returns 404. This is the expected "empty state." Run npm run publish-content against the deployed CAP srv to populate content.

Branching paths (issue #172) โ€‹

Mission curators can declare alt-groups on CompletionPathItems / GroupPathItems. At build time, scripts/parsers/cap.ts and srv/lib/build-catalog.js emit an optional altGroups array on mission frontmatter alongside groups. At runtime, the auth-aware endpoint GET /build/mission/:slug:

  1. groups items by (altGroupKey, itemOrder) within each path
  2. for each alt-group, calls srv/lib/branch/engine.js#pickBranch with the user's frozen userState
  3. caches the response per (slug, userId, fingerprint) for 5 min (honours ?nocache=1)
  4. writes one BranchDecisions row per recommendation (telemetry; surface=missionAltGroup, source=pageLoad)

The whole runtime is gated by ChatSettings.branchingEnabled โ€” when false, the endpoint returns the catalog without the recommendation field. PR 1 (srv/lib/branch/{condition,engine,ranker,user-state}.js, BranchDecisions, branchingEnabled) provides the engine; PR 2 wires the endpoint, the AdminService validator, and the side-nav rendering. PR 3 ships the hydration island + tutorial-level branches.

See the design doc at docs/superpowers/specs/2026-06-09-172-branching-paths-design.md ยง5.2.1, ยง5.6.

Step-level branches and skip-runs (PR 3) โ€‹

Authors mark alternative step-runs with [BRANCH_BEGIN ...]โ€ฆ[BRANCH_END] and skippable steps with skipIf: step frontmatter. Build pipeline:

  1. scripts/parsers/branches.ts runs BEFORE scripts/parsers/v2.ts (compose.ts orchestrates). It rewrites the markdown to a linear stream and stashes branchGroups on parent step entries.
  2. scripts/publish-content.ts#extractAllBranchSpecs walks parsed YAML frontmatter and POSTs branchSpecs alongside bodyTexts to /content/publish.
  3. CAP persists into BranchSpecs (sidecar; one row per slug; mirrors TutorialBodyText).
  4. At runtime, GET /api/branches/decide?slug=X reads BranchSpecs, builds userState, calls pickBranch per branchPoint and evaluateSkip per skipPoint, returns recommendations + skip decisions. Cached per (slug, userId, fingerprint) for 5 min; honours ?nocache=1.
  5. The tutorial-branches Vue island mounts on tutorial-branch-mount / tutorial-skip-mount markers + the mission-side-nav data-altgroup-needs-hydration="true" wrapper, and hydrates with the API response.

Gated by ChatSettings.branchingEnabled. When false: the endpoint returns 404 and the island degrades to "render all branches statically, no recommendation."