Migrating from the legacy Java IMS โ
Step-by-step runbook for moving reference data + user progress out of the legacy imsprod system into the CAP backend without polluting sap.changelog.Changes with thousands of bogus migration entries.
Prerequisites โ
cf loginto the target subaccount (DEV / TEST / PROD).IMS_BASE_URLโ usually the production IMS approuter URL.CAP_BASE_URLโ the target CAP srv URL.IMS_AUTH_TOKENโ bearer token for the source IMS.- Admin-role XSUAA token for the target CAP srv (e.g. via the bound migration tech user).
Step 1 โ Reference data โ
Export from IMS, import to CAP. The importer sends x-migration-mode: true so change tracking is automatically suppressed for the duration of each admin POST/PATCH.
npm run migrate:reference -- export
npm run migrate:reference -- import
node scripts/migrate-reference-data.js populate-slugsThe slug-population pass PATCHes Missions.slug and CompletionPaths.slug โ both are tracked entities; the same header suppresses changelog entries.
Step 2 โ User progress โ
npm run migrate:users -- export
npm run migrate:users -- importPaged + resumable. The header is sent here too as defense-in-depth โ none of the user-progress entities (Users, TaskRecords, AccomplishmentRecords, PrizeRecords) are @changelog-tracked today, but if any are added in the future the migrator is already guarded.
Step 3 โ Direct HANA-to-HANA (alternate to step 1) โ
npm run migrate:hanaโ ๏ธ Known gap. On HANA,
@cap-js/change-trackingdeploysAFTER INSERT/UPDATE/DELETEtriggers at the DB level. Directhdb-driver writes (which is whatmigrate-from-hana.jsdoes) DO fire those triggers โ the per-requestx-migration-modeheader only protects the REST migrators (Steps 1+2). Until the script is updated to setSESSION_CONTEXT('ct.skip') = 'true'on its target session, prefer Steps 1+2, OR truncatesap.changelog.Changesafter the run with a one-shot SQLDELETEscoped bycreatedAtorcreatedBy. Followup ticket recommended; explicitly out-of-scope of #394.
Noise cleanup for un-tracked entities (#658) โ
Nine entities had their @changelog annotation dropped after the admin Change History tile was flooded with no-delta entries:
- Configuration singletons:
ChatSettings,KnowledgeGraphSettings,UiEventsSettings,TenantSettings,DisplaySettings,SearchSettings,NavigatorSettingsโ each had abefore('READ')auto-init handler that INSERTed a default row on first read, tripping the AFTER INSERT trigger. - AI-generated KG tables:
Concepts,ConceptEdgesโ the extract-concepts cron does delete-then-insert on every run, producing thousands of trigger-fired rows per tick.
Historical noise is purged automatically once per deploy via autoPurgeOnce in srv/lib/purge-stale-changelog.js, held behind a JobLocks sentinel. To re-run the purge ad-hoc (e.g. if the entity list grows in a future PR), call the AdminService.purgeNoiseChangeLog(entities) OData action.
Spec: 2026-06-26-658-changelog-noise-cleanup-design.md.
Audit logging โ
Users is @PersonalData-annotated (db/audit-logging.cds). The @cap-js/audit-logging plugin still emits read/write events on personal data during migration โ this runbook does NOT suppress those. Expected volume is small (one event per user-create); if that becomes a problem, file a follow-up.
Verification โ
After the migration completes, count rows added to sap.changelog.Changes during the migration window. The expected delta for the migrated entities is zero (excluding any concurrent admin UI activity).
# Snapshot before; run migration; snapshot after; subtract.
# Example (cds bind --exec; uses ESM via --input-type=module):
npx cds bind --exec -- node --input-type=module -e "
import cds from '@sap/cds';
await cds.connect.to('db');
const Changes = cds.entities['sap.changelog.Changes'];
const since = process.argv[1];
const rows = await SELECT.from(Changes).where({ createdAt: { '>=': since } });
console.log(rows.length + ' changelog rows since ' + since);
" -- "$(date -u -Iseconds)"See also โ
- BTP role migration
- Spec:
docs/superpowers/specs/2026-06-18-394-disable-change-tracking-during-migration-design.md - Hybrid test (proves the suppression works on real HANA):
test/hybrid/migration-mode.test.js
Step 4 โ Backfill tutorial authorship โ
After migrate-user-progress.js (Step 2) succeeds, the Users table is populated. Step 1 has already loaded Tutorials, TutorialContributors, and TutorialMeta. Run the authorship backfill so existing tutorials and contributors get linked to the corresponding Users rows.
Spec: docs/superpowers/specs/2026-06-24-tutorial-authorship-fk-design.md.
# Dry run โ review the orphans report at .migration-data/tutorial-author-backfill-<ts>.json
npx cds bind --exec -- node scripts/backfill-tutorial-authors.cjs
# Commit (npm script alias for --commit)
npm run migrate:authorsWhat the backfill does:
- Builds a
LOWER(TRIM(email)) โ Users.IDmap once (warns on duplicate-email Users rows; picks lexicographically-first ID). - Phase A โ for every
TutorialContributorsrow withuser_ID IS NULL AND email IS NOT NULL, looks up the email in the map. Hit โUPDATE โฆ SET user_ID = ? WHERE ID = ? AND user_ID IS NULL. Miss โ orphan entry in the report. - Phase B โ for every
Tutorialsrow withauthor_ID IS NULL, resolves the primary author via the sharedsrv/lib/resolve-tutorial-author.jsresolver: (a) prefer contributors withrole IN ('author','owner')ordered bycreatedAt, (b) fallback to first contributor, (c) fallback toTutorialMeta.ownerEmail. First map-hit wins. - Writes the full report to
.migration-data/tutorial-author-backfill-<ISO-timestamp>.jsonโ summary counts, warnings (duplicate user emails), orphan rows by contributor and by tutorial (with the candidate emails tried).
The backfill is idempotent and non-destructive:
- Every UPDATE is gated by
WHERE โฆ_ID IS NULL. Re-running with no schema changes produces zero updates. - Re-running after a fresh
migrate-user-progress.jsbatch arrives picks up the new matches automatically. - Manual admin corrections (a future spec โ admin UI for editing authorship) are NEVER overwritten by either the backfill or the live publish path's
linkTutorialAuthorshipstep.
Orphans (contributors / tutorials whose email isn't in Users) stay null and are listed in the JSON report for manual review.
See also (Step 4) โ
- Hybrid test (pins the runbook order on real HANA):
test/hybrid/migration-runbook-order.test.js - Backfill script:
scripts/backfill-tutorial-authors.cjs - Pure resolver (shared with the live publish path):
srv/lib/resolve-tutorial-author.js
Step 5 โ Full-mirror resync of TutorialMeta.owner / ownerEmail (#862 reopen) โ
When to run: DEV or PROD's TutorialMeta.owner/ownerEmail is stale against live IMS. Symptom Riley reported (#862 comment 2026-07-02): GET /author/MyOwnedTutorials returns tutorials whose ownership was reshuffled in legacy IMS since the initial Jan-2025 backfill snapshot.
What it does: Re-reads IMS_TUTORIAL_META JOIN IMS_TUTORIAL_AUTHOR from live IMS and OVERWRITES DEV's OWNER and OWNEREMAIL row-by-row. Unlike Step 4's scripts/backfill-tutorial-meta-from-ims.cjs, this uses a full-mirror UPDATE (not COALESCE) โ so if IMS reassigned ownership to a different author, DEV catches up. Admin corrections made via the admin UI post-migration WILL be overwritten (per Tom's decision on the reopen thread: live IMS is ground truth until the July 2026 cutover).
Tutorials with no IMS row are skipped. New tutorials published after Jan-2025 don't have a corresponding IMS entry; the now-fixed publish path (PR #920) is authoritative for those. No NULL is invented where IMS is silent.
Fields NOT touched: REVIEWEDDATE, NOTIFICATION_*, REPOSITORY_ID. Those have different provenance; touching them would need its own authority argument.
# Prereqs
export IMS_HANA_CREDENTIALS=$(cat .migration-data/ims-creds.json)
export CAP_HANA_CREDENTIALS=$(cat .migration-data/cap-dev-creds.json)
# Dry-run โ writes .migration-data/resync-tutorial-meta-from-ims.dryrun.csv
node scripts/resync-tutorial-meta-from-ims.cjs --dry-run --verbose
# Review the CSV (grep for the tutorials you care about, sanity-check the
# "will-overwrite" set, confirm the summary counts feel right for the
# blast radius you expected).
# Commit within 60 minutes of the dry-run โ the script enforces the mtime
# gate to prevent stale-CSV commits.
node scripts/resync-tutorial-meta-from-ims.cjs --commit \
--initiator "scripts/resync-tutorial-meta-from-ims@$(whoami)"The CSV columns:
bucketโwill-overwrite|already-matches|no-target-rowtut_legacy_idโ the legacy IMS ID (matches the URL patternid=<n>in legacy prod IMS's redirect links)target_tutorial_uuidโ the DEVTutorials.IDderived deterministically viauuidv5(String(legacyId), NAMESPACES.tutorial)current_owner,current_ownerEmailโ what DEV had before the resyncnew_owner,new_ownerEmailโ what IMS says today.NULLwhen IMS's join returnsEMAIL=NULL, when the email is a@sap-tutorials.localsynthetic bot address, or when it's a@users.noreply.github.complaceholder that could NOT be resolved viaUsers.githubLogin(see next paragraph)resolved_from_noreplyโyeswhen the new value was derived by parsing a<userid>+<login>@users.noreply.github.complaceholder, looking<login>up inUsers.githubLogin, and writing the matchedUsers.email. Blank when the email came through unchanged.ims_raw_emailโ the raw value from IMS (useful when the resolver chose to null-out or transform: this column shows what was rejected or how the transformation was seeded)
@users.noreply.github.com resolution (PR C). GitHub's default commit email is <userid>+<login>@users.noreply.github.com (modern) or <login>@users.noreply.github.com (pre-2017). Live IMS returns this verbatim for authors who never set a corporate email in their GitHub profile โ Riley's case for tutorial 15733 was 10248021+rbrainey@users.noreply.github.com. The resync parses out the <login> segment (case-insensitive) and looks it up in DEV's Users.githubLogin โ Users.email map (built once at script startup from COM_SAP_DEVELOPERS_IMS_USERS WHERE githubLogin IS NOT NULL). Match โ that user's corporate email is written to TutorialMeta.ownerEmail; no match โ the row is treated as no-signal (written NULL).
For Riley's MyOwnedTutorials to show tutorial-first-steps after the resync, DEV's Users row for Riley MUST have githubLogin = 'rbrainey' AND email = 'riley.rainey@sap.com'. If either is missing, the resync NULLs out ownerEmail for tutorial 15733 and Riley sees zero rows on his panel. The resolved_from_noreply column in the CSV is what you audit here โ a summary count is also printed at the top.
Post-commit verification โ
- Riley's list should now be 1 row. Hit
https://tutorial-system-dev-tutorials-srv.cfapps.eu10-005.hana.ondemand.com/author/MyOwnedTutorialsas Riley and confirm exactly the tutorial legacy prod IMS attributes to him today (currentlytutorial-first-stepsโ "Get to Know SAP Tutorials", legacyId15733). - Any admin who set
ownerEmailvia the admin UI post-migration may see their value gone. If they push back, the correct response is: the admin UI edit was inconsistent with live IMS; either update IMS upstream or re-apply the correction post-resync (a follow-on cutover task will formalize CAP as the source of truth after the July 2026 PROD cutover). - Users with an unresolved
@users.noreply.github.comin IMS will see theirMyOwnedTutorialsempty on DEV until either: (a) theirUsers.githubLogingets populated (login-side; new logins auto-set; older Users needscripts/seed-users-github-login.cjs), OR (b) the tutorial gets re-published so the publish path's frontmatter resolution fillsownerEmailfromauthor_profile.
See also (Step 5) โ
- Script:
scripts/resync-tutorial-meta-from-ims.cjs - Unit test (kind-agnostic, pure decision logic):
scripts/__tests__/resync-tutorial-meta-from-ims.test.ts - Publish-path fix that made the resync safe to run: PR #920
- ADR 0006 ยง2026-07-02 update:
docs/decisions/0006-authorship-vs-ownership-semantics.md
Step 6 โ Migrate IMS_DASHBOARD_MONITOR_RECORD to CAP TutorialMonitors (#923) โ
When to run: DEV or PROD is missing the personal watch-list rows that drive Sage's "My Tutorials" panel. Symptom: users open Sage and see an empty panel even though they explicitly monitor tutorials in legacy IMS.
What it does: Reads IMS_DASHBOARD_MONITOR_RECORD JOIN IMS_TUTORIAL_META JOIN IMS_USER from live IMS, resolves each (user_id, tutorial_meta_id) pair against DEV via Users.sapId โ IMS_USER.SAP_ID and Tutorials.legacyId โ IMS_TUTORIAL_META.tutorial_id, and INSERTs rows into TutorialMonitors. Idempotent via uuidv5(sourceRowId, NS.tutorialmonitor) and the @assert.unique.userTutorial : [user, tutorial] constraint.
Orphan handling (per Tom, 2026-07-02): if Users.sapId or Tutorials.legacyId doesn't resolve on the DEV side, SKIP + log to CSV. Do NOT create placeholder Users rows. Safer than inventing identities; users log in via SAP IDP and their row gets JIT-provisioned โ after that, they can toggle monitor status from Sage and the row appears in TutorialMonitors naturally.
Prereqs:
- The
TutorialMonitorstable must exist in the target HDI container (i.e., PR #923 has been deployed via MTA orcds deploy). Otherwise the migrator errors with "invalid table name". - CF login to both the target (
tutorial-system/dev/eu10-005) for the service key AND briefly to the source (Developer Destination_IMS/PRODinus30) to readimsprodenv vars (DB_USERNAME=IMSDBUSER,DB_PASSWORD=<...>,DB_URL=...?currentschema=IMSDBUSER). See Step 4's IMS credential paragraph for the standard pattern.
# Stage creds (same shape as Step 4/5)
cf target -o "Developer Destination_IMS" -s PROD # source
cf env imsprod | awk '...extract DB_USERNAME/DB_PASSWORD/DB_URL...' # to .migration-data/ims-creds.json
cf target -o tutorial-system -s dev # target
cf service-key tutorials-hana tutorials-hana-key # to .migration-data/cap-dev-creds.json
# Dry-run โ writes .migration-data/migrate-dashboard-monitors.dryrun.csv
export IMS_HANA_CREDENTIALS=$(cat .migration-data/ims-creds.json)
export CAP_HANA_CREDENTIALS=$(cat .migration-data/cap-dev-creds.json)
node scripts/migrate-dashboard-monitors.cjs --dry-run --verbose
# Review the CSV. Sanity-check "will-insert" count against expected row
# count (legacy IMS TutorialMeta grid + "monitor" column has this signal
# but no bulk export; N per active-author is the rough estimate).
# Commit within 60 minutes
node scripts/migrate-dashboard-monitors.cjs --commit \
--initiator "scripts/migrate-dashboard-monitors@$(whoami)"
# Cleanup
rm .migration-data/ims-creds.json .migration-data/cap-dev-creds.jsonCSV columns: bucket, source_row_id, tut_legacy_id, user_sap_id, target_tutorial_uuid, target_user_uuid, monitor_row_uuid. Buckets:
will-insertโ both sides resolved; row will be createdorphan-tutorialโIMS_TUTORIAL_META.tutorial_idhas no matchingTutorials.legacyIdin DEV. Common when the source tutorial was renamed/deleted between the initial migration and now, or when the slug got case-shifted (see [tutorial-slugs-are-lowercase-canonical] gotcha in CLAUDE.md).orphan-userโIMS_USER.SAP_IDhas no matchingUsers.sapIdin DEV. Common when a user never logged into DEV via SAP IDP post-migration โ their row hasn't been JIT-provisioned. Not a bug in the migrator; they'll see their monitored tutorials once they log in and re-toggle.
Post-commit verification โ
- Riley's My Tutorials should now match legacy IMS. Hit
https://tutorial-system-dev-tutorials-srv.cfapps.eu10-005.hana.ondemand.com/author/MyOwnedTutorialsas Riley and confirm the same row(s) as legacy prod IMS. - Sage adoption: unchanged. Sage's
imsApiClient.tsstill calls/author/MyOwnedTutorialsโ same URL, new semantics, same JSON shape (minus thebestPrioritycolumn which Sage never read). - Toggle test: any authenticated Tutorial.Author caller can hit
POST /author/toggleMonitorwith{"tutorialId": "<uuid>", "status": true}to opt into watching a tutorial. Returnstrue. Second call same args also returnstrue(idempotent). Same call withstatus: falsereturnsfalse, DELETEs the row.
See also (Step 6) โ
- Script:
scripts/migrate-dashboard-monitors.cjs - Unit tests (pure decision logic):
scripts/__tests__/migrate-dashboard-monitors.test.ts - Hybrid test (entity + view + soft-delete filter):
test/hybrid/tutorial-monitors.test.js - Java source that motivated the fix:
D:/projects/com.sap.developers.ims/application/src/main/java/com/sap/developers/ims/specifications/TutorialMetaSpecifications.javalines 73-76 - ADR 0006 ยง2026-07-02b update:
docs/decisions/0006-authorship-vs-ownership-semantics.md