Skip to content

tutorials-ims Gotchas ​

Overflow for project-specific gotchas that used to live in CLAUDE.md. The top ~10 that repeatedly bite live in CLAUDE.md itself; everything else is here.

Cross-references:

Build pipeline ​

  • POC tutorial list is dynamic β€” Tutorials are discovered from sap-tutorials GitHub org via discoverAllTutorials() in scripts/parsers/github.ts. EXCLUDED_REPOS (just tutorials-ims) skipped. Private repos excluded by default; INCLUDED_PRIVATE_REPOS is allowlist (currently meta-tutorials). -Contribution private repos gated by INCLUDE_CONTRIBUTION_REPOS / ONLY_CONTRIBUTION_REPOS. Discovery cached in .tutorial-cache/discovery-map.json. npm run discover-repos lists without fetching.
  • Validation quiz data from -Contribution repos β€” fetchRulesVr() in scripts/parsers/github.ts fetches rules.vr from private -Contribution repos. Needs GITHUB_TOKEN. Cached at .tutorial-cache/<slug>.rules.vr. Parsed by scripts/parsers/rules.ts, injected into Hugo frontmatter steps.
  • GITHUB_TOKEN env var β€” scripts/parsers/github.ts optionally uses it to avoid GitHub API rate limits.
  • CAP_BASE_URL env var β€” Used by scripts/parsers/cap.ts and migration scripts. Defaults to http://localhost:4004.
  • Node.js >= 20 required β€” Build scripts use native fetch (no polyfill).
  • Slug fields β€” Missions.slug and CompletionPaths.slug must be populated for the build pipeline to generate mission/group pages. Run node scripts/migrate-reference-data.js populate-slugs after data import.

Directory layout ​

  • app/ vs hugo-apps/ β€” app/ = standalone UI apps with their own builds (admin-shell, admin, analytics-explorer, scanner, display-app), each deploys by copying dist//webapp/ into approuter/static/<route>/. hugo-apps/ = single Vite project compiling ~17 Vue 3 page-level islands into hugo/static/js/ β€” loaded by Hugo templates as <script> tags, not deployed as routes. hugo-apps/src/{shared,composables}/ are utility modules, not islands. See mta.yaml.
  • Vite ↔ Hugo js.Build output collisions β€” Vite entries write to hugo/static/js/<name>.js. Hugo's resources.Get "js/<X>.ts" | js.Build writes to hugo/public/js/<X>.js after Hugo copies static/ β†’ public/, silently clobbering Vite if names collide. postbuild:apps runs tsx scripts/check-build-collisions.ts β€” fix by renaming.
  • /admin/ is OData only β€” AdminService OData lives at /admin/. The admin shell UI is served at /admin-ui/ to avoid path collisions.
  • Hugo vs VitePress β€” Project migrated from VitePress to Hugo. site/.vitepress/ still exists (with built dist/) but is legacy. Active frontend work targets hugo/.
  • hugo/content/tutorials/ is entirely generated β€” Never edit these files directly; they're overwritten by npm run fetch-tutorials. Edit scripts/parsers/ or source tutorials in the sap-tutorials GitHub org.
  • Cache clearing β€” .tutorial-cache/ caches raw markdown, GitHub metadata, and CAP catalog data. Delete it to force a full re-fetch. No incremental invalidation.

Content persistence & publish ​

  • Tutorials are DB-only β€” HTML served exclusively from HANA BLOBs. No static file fallback. If nothing published, /tutorials/* returns 404.
  • Content garbage collection β€” Daily cron (03:00) prunes SUPERSEDED/ROLLED_BACK versions older than 7 days, keeping the 3 most recent for rollback. Never touches ACTIVE/PUBLISHING.
  • publish-content.ts flags β€” Default mode is now correctness-equivalent to --force: server's commit carries forward unchanged slugs. --force is a perf/CI-convenience flag (skips /content/hashes round-trip). CLI auto-verifies after publish; exits 2 on hash mismatch. --verify-only / --heal / --dry-run. --force/--heal/--verify-only mutually exclusive.
  • HANA LOB locator expiry β€” CDS QL returns HANA BLOBs as Readable streams with locators that expire before consumption when mixed with non-BLOB columns. srv/lib/content-store.js uses raw SQL (db.run()) for BLOB retrieval on HANA, CDS QL for SQLite tests. Never SELECT a BLOB alongside metadata in a single CDS QL query on HANA.
  • Tutorial embeddings live in TutorialEmbedding and are HANA-only at query time β€” SQLite test path uses JS-side cosine. Never SELECT the embedding BLOB alongside metadata in a single CDS QL query on HANA; use db.run() raw SQL in srv/lib/embedding-query.js.
  • Tutorial/Mission/Group slugs are unique (case-insensitive) β€” @assert.unique.slug on Tutorials, Missions, Groups. New write paths MUST upsert on slug, not blind-INSERT. Canonical pattern at srv/lib/content-publish-session.js:285. Hybrid test test/hybrid/duplicate-slugs.test.js guards. Repair: npx cds bind --exec -- node scripts/merge-duplicate-slugs.cjs --commit.
  • TutorialMeta is a logical singleton (one row per tutorial) β€” @assert.unique.tutorial on TutorialMeta. Auto-init at srv/lib/content-publish-session.js:349 checks existing before INSERT. Hybrid test test/hybrid/duplicate-tutorial-meta.test.js guards. Repair: npx cds bind --exec -- node scripts/dedupe-tutorial-meta.cjs --commit.
  • MyTutorialsView.repositoryName sources from RepoCatalog.repo, NOT TutorialMeta.repository (#1063) β€” The TutorialMeta.repository β†’ TutorialRepositories.name chain is de-facto empty in DEV (0/2930 rows have repository_ID set; publish flow never populates it, only the legacy backfill script does), and TutorialRepositories is missing rows for the flagship Tutorials repo entirely. RepoCatalog is populated on every content publish by srv/lib/repo-catalog.js and covers 100% of live tutorials β€” that's what MyTutorialsView + MyMonitoredTutorialsView now left-join to. TutorialMeta.repository FK is retained for other consumers (scripts/soft-delete-sandbox-tutorials.cjs pass-1) but is no longer the source of truth for the Sage-facing view. If you're adding a new repositoryName-shaped field on any view, join RepoCatalog on slug; do not chase the TutorialMeta chain.

QA channel ​

  • QA channel content β€” /tutorials-qa/* is gated by XSUAA scope Tutorial.Author. Content sourced only from *-Contribution repos via ONLY_CONTRIBUTION_REPOS=true. Lives in tutorials-db-qa HDI; never queries prod tables.
  • .tutorial-cache-qa/ vs .tutorial-cache/ β€” separate caches per channel. fetch-tutorials writes a .channel marker; dev warns if content channel doesn't match.
  • CONTENT_API_KEY_QA env var β€” required for POST /content/publish and /content/rollback on QA srv.
  • hugo.qa.toml β€” sibling Hugo config for QA. Strips Joule FAB, rating, completion buttons, progress UI when site.Params.qa = true.
  • QA bootstrap runbook β€” docs/developers/operations/qa-channel-bootstrap.md.

Rebuild workflow & admin writes ​

  • rebuild-content.yml mode auto-infer β€” gh workflow run rebuild-content.yml -f slug=X auto-infers mode=slug-targeted when inputs.mode is default full AND a slug input is set. Don't pass -f mode=slug-targeted. Only workflow_dispatch auto-infers; repository_dispatch (admin auto-trigger) uses srv/lib/_classify-rebuild-mode.js. Wall-clock: catalog-only ~5min, slug-targeted ~2min, full ~10min. Runbook: rebuild-content-workflow.md.
  • GITHUB_DISPATCH_TOKEN env var β€” Read by srv/lib/rebuild-trigger.js; admin saves debounce-dispatch rebuild-content.yml after 60s. Sourced from DISPATCH_TOKEN GitHub Actions secret (not GITHUB_DISPATCH_TOKEN β€” GH reserves GITHUB_ prefix). All four mtaext placeholders resolve at deploy time via envsubst writing deploy/<env>.resolved.mtaext. Rotation: github-dispatch-pat-rotation.md.
  • Alert saves do NOT trigger rebuilds β€” Alerts are runtime-served via /api/alerts*. Rebuild classifier returns mode: 'none' for Alerts (_classify-rebuild-mode.js). Cache-bust on save is the only freshness mechanism; up-to-60s delay expected.

Content model quirks ​

  • Tag labels are DB-driven; slugs are the join key β€” Frontmatter carries raw slugs (software-product>sap-s-4hana). At Hugo build, fetch-tutorials.ts fetches slugβ†’label map from /build/tag-labels, emits displayTags (label) + displayTagSlugs (slug) into frontmatter + _nav.json. Navigator filter equality, license detection, topic categorization use displayTagSlugs; rendering uses displayTags. Labels admin-edited at /admin-ui/#tags-display. Missing slug falls back to lossy humanizeTag(). Seed from legacy AEM Solr: npm run seed-tag-labels.
  • Categories taxonomy is fixed in v1 β€” 8 categories seeded via db/data/com.sap.developers.ims-Categories.csv with stable UUIDs. Admins edit label/sortOrder/seedDescription but cannot add/remove.
  • Categories reclassify is destructive β€” Admin classifyCategories and per-OP "Classify this item" DELETE-then-INSERT junction rows. Manual category edits survive only until next reclassify run.
  • Tutorial slugs are lowercase canonical β€” Hugo emits lowercase URLs; read path 301-redirects mixed-case (see srv/lib/content-store.js:694). Write path lowercases via tutorialsTableInfo helper. Source markdown filenames may ship with capitals; never compare slugs to publish payload without .toLowerCase(). Mismatches manifest as "0 steps" on group SSR. Repair: scripts/repair-mixed-case-tutorial-duplicates.cjs.

AI features ​

  • AI code-check (issue #171, behind ChatSettings.codeCheckEnabled) β€” Author opt-in via [CODECHECK_N] blocks in rules.vr; trimmed spec ships in Hugo frontmatter, full spec in CodeCheckSpecs. Inline UI hits /api/codecheck (XSUAA, 30/hr/user, 5/5min/step); also checkCode Joule chat tool. Persistence: CodeCheckSubmissions. Spec: 2026-06-02-ai-code-check-spike-design.md.
  • AI-authored quizzes (issue #208, always-on as of #312) β€” Author opt-in via [AUTOAUTHOR_*] in rules.vr. Post-parse expansion in scripts/fetch-tutorials.ts. Per-tutorial content-hash cache at .tutorial-cache/<slug>.ai-quiz-cache.json. Hard cap default 200 LLM calls/build (AI_AUTHOR_BUILD_CAP). Bulk-seed: npm run seed-ai-quizzes. Model switch does NOT auto-invalidate cache β€” delete cache file manually. Kill-switch: set AI_AUTHOR_AICORE_SERVICE_KEY empty. Eval: scripts/evaluate-ai-quizzes.ts + scripts/aggregate-ai-quiz-eval.ts.
  • ChatSettings.ragEnabled β€” Feature flag for the getRelevantSteps tool. When toggling on first time, click "Seed Embeddings Now" in Joule Chat Settings tile. Reconciliation cron at minute 17 catches drift.
  • HYBRID_AI_TESTS=true to opt into category-classifier hybrid test β€” Default hybrid runs are $0/run. This env var enables test/hybrid/categories-classifier.test.js (one classify call per mission fixture).
  • AICORE_EXPLAINER_GENERATOR_DISABLED env var β€” Kill-switch for homepage explainer AI generation (#759). Set 'true' β†’ all three AdminService.generate*Explainers actions return HTTP 503. Hand-authored content survives.

Observability & load ​

  • Feature Flag Viewer (/admin-ui/#featureFlags) β€” read-only tile listing every runtime feature flag's live resolved state (effective value, winning layer, raw db/env/default). Source of truth: srv/lib/feature-flags/registry.js; a drift test (test/unit/feature-flags-registry.test.js) fails the build when a new *_ENABLED/*_WEIGHT env var or settings boolean is added unregistered. Known gap: the drift regex misses process.env[var] bracket-notation reads.
  • Observability metrics module (srv/lib/metrics.js, #805) β€” In-memory counters/gauges/reservoirs drained every 5min by srv/jobs/metrics-rollup-job.js into MetricSnapshots. Env flags: METRICS_ENABLED (default true; kill-switch), METRICS_DB_WRAP (default false; installs passive cds.db.run/cds.db.tx wrapper). Rollup does NOT use job-lock; retention (30d/90d) does. Live snapshot: /admin-ui/#metrics, GET /admin/getMetricsSnapshot(), GET /admin/metrics/live. See observability.md.
  • Load tests (test/load/) are k6, not Vitest, and do NOT run on PRs β€” Five scenarios drive deployed DEV. CI runs weekly (Mon 03:00 UTC) + manual. Never on push/PR (DEV quota isn't free). Thresholds in test/load/config.js; never hardcode ms in scenarios. Aborts if /content/hashes shows publish in flight. Runbook: load-testing.md.

Runtime env vars ​

  • CONTENT_API_KEY env var β€” Required for POST /content/publish and POST /content/rollback. Set in CI secrets and locally. Without it, publish returns 401.
  • SUBMISSION_SALT_SECRET env var β€” Required by srv/lib/feedback-salt.js for hashing submitter IPs on POST /feedback/submit. Express bridge returns 503 if missing.

Data privacy ​

  • @cap-js/data-privacy deferred, annotations shipped (#960) β€” Plugin install rolled back at 0.6.2 due to two cds build --production crashes. Annotation cleanups landed anyway. When retrying plugin adoption: verify cds build --production succeeds against schema FIRST; pick up Tasks 7/8/9 blueprints; do NOT re-annotate BranchDecisions as DataSubjectDetails. Spec: 2026-07-04-960-data-privacy-plugin-design.md.

Migration ​

  • Change tracking suppression for REST migrators β€” x-migration-mode: true header sent by migrate-reference-data.js and migrate-user-progress.js. HANA-to-HANA path (migrate-from-hana.js) still fires DB-level changelog triggers β€” see migration-from-ims.md for mitigations.

Personalization ​

  • Personalization endpoint MUST set X-Personalization: 1 and Cache-Control: private, no-store β€” the approuter is documented to never cache this header combination. Dropping either header silently allows a shared cache to serve one user's personalized payload to another user or to anonymous visitors. The smoke test (test/smoke/homepage-personalized.test.js) asserts both headers on every deployed environment.
  • Client-side ETag round-trip lives in sessionStorage['sap-devs-homepage-personalized'] β€” clearing sessionStorage forces the coordinator to fetch fresh (no If-None-Match header, 200 response). The session key is sap-devs-homepage-personalized; the bypass flag is sap-devs-homepage-default. Both are sessionStorage (not localStorage), so they clear on tab close.

Cron jobs ​

  • Reshuffle-video-rotation cron is TRUNCATE + INSERT β€” must run inside a single transaction. srv/jobs/reshuffle-video-rotation.js uses cds.tx to wrap DELETE FROM HomepageVideoRotation + bulk INSERT. If a future refactor splits these into two top-level db.run(...) calls, a mid-cycle failure will empty the sidecar and visitors will see anchor-only tiles until the next successful cron pass β€” silently. #1031.

  • kg-community-labels job skips stable clusters β€” nightly LLM spend is near-zero after first backlog (issue #1126). srv/jobs/kg-community-label-job.js runs at 04:12 UTC (after Louvain at 03:57). It upserts KgCommunityLabel rows keyed on communityFingerprint; if a community's memberSlugsHash (SHA-256 of sorted member slugs) matches the stored value, the row is skipped without an LLM call. First-run ramps the full backlog over several nights because communityLabelLlmBudgetPerDay (default 50) caps daily spend. The budget counter resets daily: communityLabelLlmCallsToday / communityLabelLlmCallsCountedOn on ChatSettings. If the job runs but Louvain has not yet populated KgCommunity, summaries is empty β†’ no LLM calls β†’ no error. Tool is gated by communityPeersEnabled on ChatSettings (default false), enabled via PATCH /admin/ChatSettings(<ID>) (Admin-gated; the /admin-ui/#joule Joule Settings page edits the same singleton but does not yet list this flag). No env var reads it.

Devtoberfest ​

  • Devtoberfest banner is admin-uploadable β€” per-DevtoberfestConfig DevtoberfestBanner composition (wide WebP BLOB, uploadBanner/clearBanner actions), served anonymously at GET /api/devtoberfest/banner for the active row; the Vue island renders it as the hero with the CTA overlaid lower-right, falling back to the CSS gradient header when unset. Full deploy required (schema + admin bundle + approuter). Spec: docs/superpowers/specs/2026-07-29-devtoberfest-banner-upload-design.md.

Tutorial Navigator ​

  • Navigator "Featured" rail is curated via /admin-ui/#/operations β†’ Featured Tasks β€” draft CRUD (pick items by title via FeaturedTaskCandidates value-help, unique per item, order defaults to next integer); SSR from browse.json's featured[] array (mission-curated or first-6-missions fallback when empty); live-rehydrated from GET /build/featured (ETag/304, 60s server cache, mixed tutorial/mission/group types); cache busts automatically on FeaturedTasks save/delete via resetFeaturedCache().