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:
- CAP/CDS gotchas β cap-cds-gotchas.md
- HANA / HDI gotchas β hana-hdi-gotchas.md
- Vue islands gotchas β vue-islands-gotchas.md
Build pipeline β
- POC tutorial list is dynamic β Tutorials are discovered from
sap-tutorialsGitHub org viadiscoverAllTutorials()inscripts/parsers/github.ts.EXCLUDED_REPOS(justtutorials-ims) skipped. Private repos excluded by default;INCLUDED_PRIVATE_REPOSis allowlist (currentlymeta-tutorials).-Contributionprivate repos gated byINCLUDE_CONTRIBUTION_REPOS/ONLY_CONTRIBUTION_REPOS. Discovery cached in.tutorial-cache/discovery-map.json.npm run discover-reposlists without fetching. - Validation quiz data from
-Contributionrepos βfetchRulesVr()inscripts/parsers/github.tsfetchesrules.vrfrom private-Contributionrepos. NeedsGITHUB_TOKEN. Cached at.tutorial-cache/<slug>.rules.vr. Parsed byscripts/parsers/rules.ts, injected into Hugo frontmatter steps. GITHUB_TOKENenv var βscripts/parsers/github.tsoptionally uses it to avoid GitHub API rate limits.CAP_BASE_URLenv var β Used byscripts/parsers/cap.tsand migration scripts. Defaults tohttp://localhost:4004.- Node.js >= 20 required β Build scripts use native
fetch(no polyfill). - Slug fields β
Missions.slugandCompletionPaths.slugmust be populated for the build pipeline to generate mission/group pages. Runnode scripts/migrate-reference-data.js populate-slugsafter data import.
Directory layout β
app/vshugo-apps/βapp/= standalone UI apps with their own builds (admin-shell,admin,analytics-explorer,scanner,display-app), each deploys by copyingdist//webapp/intoapprouter/static/<route>/.hugo-apps/= single Vite project compiling ~17 Vue 3 page-level islands intohugo/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.Buildoutput collisions β Vite entries write tohugo/static/js/<name>.js. Hugo'sresources.Get "js/<X>.ts" | js.Buildwrites tohugo/public/js/<X>.jsafter Hugo copiesstatic/βpublic/, silently clobbering Vite if names collide.postbuild:appsrunstsx 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 builtdist/) but is legacy. Active frontend work targetshugo/. hugo/content/tutorials/is entirely generated β Never edit these files directly; they're overwritten bynpm run fetch-tutorials. Editscripts/parsers/or source tutorials in thesap-tutorialsGitHub 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_BACKversions older than 7 days, keeping the 3 most recent for rollback. Never touchesACTIVE/PUBLISHING. publish-content.tsflags β Default mode is now correctness-equivalent to--force: server's commit carries forward unchanged slugs.--forceis a perf/CI-convenience flag (skips/content/hashesround-trip). CLI auto-verifies after publish; exits 2 on hash mismatch.--verify-only/--heal/--dry-run.--force/--heal/--verify-onlymutually exclusive.- HANA LOB locator expiry β CDS QL returns HANA BLOBs as
Readablestreams with locators that expire before consumption when mixed with non-BLOB columns.srv/lib/content-store.jsuses 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
TutorialEmbeddingand are HANA-only at query time β SQLite test path uses JS-side cosine. Never SELECT theembeddingBLOB alongside metadata in a single CDS QL query on HANA; usedb.run()raw SQL insrv/lib/embedding-query.js. - Tutorial/Mission/Group slugs are unique (case-insensitive) β
@assert.unique.slugonTutorials,Missions,Groups. New write paths MUST upsert on slug, not blind-INSERT. Canonical pattern atsrv/lib/content-publish-session.js:285. Hybrid testtest/hybrid/duplicate-slugs.test.jsguards. Repair:npx cds bind --exec -- node scripts/merge-duplicate-slugs.cjs --commit. - TutorialMeta is a logical singleton (one row per tutorial) β
@assert.unique.tutorialonTutorialMeta. Auto-init atsrv/lib/content-publish-session.js:349checks existing before INSERT. Hybrid testtest/hybrid/duplicate-tutorial-meta.test.jsguards. Repair:npx cds bind --exec -- node scripts/dedupe-tutorial-meta.cjs --commit. MyTutorialsView.repositoryNamesources fromRepoCatalog.repo, NOTTutorialMeta.repository(#1063) β TheTutorialMeta.repository β TutorialRepositories.namechain is de-facto empty in DEV (0/2930 rows haverepository_IDset; publish flow never populates it, only the legacy backfill script does), andTutorialRepositoriesis missing rows for the flagshipTutorialsrepo entirely.RepoCatalogis populated on every content publish bysrv/lib/repo-catalog.jsand covers 100% of live tutorials β that's whatMyTutorialsView+MyMonitoredTutorialsViewnow left-join to.TutorialMeta.repositoryFK is retained for other consumers (scripts/soft-delete-sandbox-tutorials.cjspass-1) but is no longer the source of truth for the Sage-facing view. If you're adding a newrepositoryName-shaped field on any view, joinRepoCatalogonslug; do not chase the TutorialMeta chain.
QA channel β
- QA channel content β
/tutorials-qa/*is gated by XSUAA scopeTutorial.Author. Content sourced only from*-Contributionrepos viaONLY_CONTRIBUTION_REPOS=true. Lives intutorials-db-qaHDI; never queries prod tables. .tutorial-cache-qa/vs.tutorial-cache/β separate caches per channel.fetch-tutorialswrites a.channelmarker;devwarns if content channel doesn't match.CONTENT_API_KEY_QAenv var β required forPOST /content/publishand/content/rollbackon QA srv.hugo.qa.tomlβ sibling Hugo config for QA. Strips Joule FAB, rating, completion buttons, progress UI whensite.Params.qa = true.- QA bootstrap runbook β docs/developers/operations/qa-channel-bootstrap.md.
Rebuild workflow & admin writes β
rebuild-content.ymlmode auto-infer βgh workflow run rebuild-content.yml -f slug=Xauto-infersmode=slug-targetedwheninputs.modeis defaultfullAND a slug input is set. Don't pass-f mode=slug-targeted. Onlyworkflow_dispatchauto-infers;repository_dispatch(admin auto-trigger) usessrv/lib/_classify-rebuild-mode.js. Wall-clock:catalog-only~5min,slug-targeted~2min,full~10min. Runbook: rebuild-content-workflow.md.GITHUB_DISPATCH_TOKENenv var β Read bysrv/lib/rebuild-trigger.js; admin saves debounce-dispatchrebuild-content.ymlafter 60s. Sourced fromDISPATCH_TOKENGitHub Actions secret (notGITHUB_DISPATCH_TOKENβ GH reservesGITHUB_prefix). All four mtaext placeholders resolve at deploy time viaenvsubstwritingdeploy/<env>.resolved.mtaext. Rotation: github-dispatch-pat-rotation.md.- Alert saves do NOT trigger rebuilds β Alerts are runtime-served via
/api/alerts*. Rebuild classifier returnsmode: 'none'forAlerts(_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.tsfetches slugβlabel map from/build/tag-labels, emitsdisplayTags(label) +displayTagSlugs(slug) into frontmatter +_nav.json. Navigator filter equality, license detection, topic categorization usedisplayTagSlugs; rendering usesdisplayTags. Labels admin-edited at/admin-ui/#tags-display. Missing slug falls back to lossyhumanizeTag(). 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.csvwith stable UUIDs. Admins editlabel/sortOrder/seedDescriptionbut cannot add/remove. - Categories reclassify is destructive β Admin
classifyCategoriesand 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 viatutorialsTableInfohelper. 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 inCodeCheckSpecs. Inline UI hits/api/codecheck(XSUAA, 30/hr/user, 5/5min/step); alsocheckCodeJoule 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_*]inrules.vr. Post-parse expansion inscripts/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: setAI_AUTHOR_AICORE_SERVICE_KEYempty. Eval:scripts/evaluate-ai-quizzes.ts+scripts/aggregate-ai-quiz-eval.ts. ChatSettings.ragEnabledβ Feature flag for thegetRelevantStepstool. When toggling on first time, click "Seed Embeddings Now" in Joule Chat Settings tile. Reconciliation cron at minute 17 catches drift.HYBRID_AI_TESTS=trueto opt into category-classifier hybrid test β Default hybrid runs are $0/run. This env var enablestest/hybrid/categories-classifier.test.js(one classify call per mission fixture).AICORE_EXPLAINER_GENERATOR_DISABLEDenv var β Kill-switch for homepage explainer AI generation (#759). Set'true'β all threeAdminService.generate*Explainersactions 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/*_WEIGHTenv var or settings boolean is added unregistered. Known gap: the drift regex missesprocess.env[var]bracket-notation reads. - Observability metrics module (
srv/lib/metrics.js, #805) β In-memory counters/gauges/reservoirs drained every 5min bysrv/jobs/metrics-rollup-job.jsintoMetricSnapshots. Env flags:METRICS_ENABLED(defaulttrue; kill-switch),METRICS_DB_WRAP(defaultfalse; installs passivecds.db.run/cds.db.txwrapper). Rollup does NOT usejob-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 intest/load/config.js; never hardcode ms in scenarios. Aborts if/content/hashesshows publish in flight. Runbook: load-testing.md.
Runtime env vars β
CONTENT_API_KEYenv var β Required forPOST /content/publishandPOST /content/rollback. Set in CI secrets and locally. Without it, publish returns 401.SUBMISSION_SALT_SECRETenv var β Required bysrv/lib/feedback-salt.jsfor hashing submitter IPs onPOST /feedback/submit. Express bridge returns 503 if missing.
Data privacy β
@cap-js/data-privacydeferred, annotations shipped (#960) β Plugin install rolled back at 0.6.2 due to twocds build --productioncrashes. Annotation cleanups landed anyway. When retrying plugin adoption: verifycds build --productionsucceeds against schema FIRST; pick up Tasks 7/8/9 blueprints; do NOT re-annotate BranchDecisions asDataSubjectDetails. Spec: 2026-07-04-960-data-privacy-plugin-design.md.
Migration β
- Change tracking suppression for REST migrators β
x-migration-mode: trueheader sent bymigrate-reference-data.jsandmigrate-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: 1andCache-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 (noIf-None-Matchheader, 200 response). The session key issap-devs-homepage-personalized; the bypass flag issap-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.jsusescds.txto wrapDELETE FROM HomepageVideoRotation+ bulk INSERT. If a future refactor splits these into two top-leveldb.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-labelsjob skips stable clusters β nightly LLM spend is near-zero after first backlog (issue #1126).srv/jobs/kg-community-label-job.jsruns at 04:12 UTC (after Louvain at 03:57). It upsertsKgCommunityLabelrows keyed oncommunityFingerprint; if a community'smemberSlugsHash(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 becausecommunityLabelLlmBudgetPerDay(default 50) caps daily spend. The budget counter resets daily:communityLabelLlmCallsToday/communityLabelLlmCallsCountedOnonChatSettings. If the job runs but Louvain has not yet populatedKgCommunity,summariesis empty β no LLM calls β no error. Tool is gated bycommunityPeersEnabledonChatSettings(defaultfalse), enabled viaPATCH /admin/ChatSettings(<ID>)(Admin-gated; the/admin-ui/#jouleJoule 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-
DevtoberfestConfigDevtoberfestBannercomposition (wide WebP BLOB,uploadBanner/clearBanneractions), served anonymously atGET /api/devtoberfest/bannerfor 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 viaFeaturedTaskCandidatesvalue-help, unique per item, order defaults to next integer); SSR frombrowse.json'sfeatured[]array (mission-curated or first-6-missions fallback when empty); live-rehydrated fromGET /build/featured(ETag/304, 60s server cache, mixed tutorial/mission/group types); cache busts automatically onFeaturedTaskssave/delete viaresetFeaturedCache().