From d22df3a2e7b34979a8dda3267f35dd84850c5805 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 09:58:11 -0600 Subject: [PATCH 01/42] fix(smoke): derive asset probe path from output tree, not extension (#2533) Vite emits each island's CSS sidecar into the JS output tree under assets/ (hugo/static/js/assets/-.css). Those bundles ship at /js/assets/... , never /css/... . The asset-retention smoke gate derived the probe kind from the file extension (.css -> /css/), so every assets/-prefixed .css entry 404'd on a perfectly healthy deploy -- a false positive that flapped the post-deploy gate. Fix: assets/-prefixed bundles are always probed under /js/ regardless of extension; only top-level Hugo-fingerprinted .css lives under /css/. Verified against live PROD: all 627 manifest bundles now serve 200. --- test/smoke/asset-retention.smoke.test.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/smoke/asset-retention.smoke.test.js b/test/smoke/asset-retention.smoke.test.js index b50456bc3..82a7caba9 100644 --- a/test/smoke/asset-retention.smoke.test.js +++ b/test/smoke/asset-retention.smoke.test.js @@ -58,7 +58,14 @@ describeIf('asset retention', () => { async function worker() { while (cursor < manifest.length) { const { file } = manifest[cursor++]; - const kind = file.endsWith('.css') ? 'css' : 'js'; + // Serving path is NOT a pure function of the file extension. Vite emits + // each island's CSS sidecar into the JS output tree under `assets/` + // (hugo/static/js/assets/-.css), so an `assets/`-prefixed + // bundle — even a `.css` one — ships at /js/assets/… , never /css/… . + // Deriving kind from the extension alone probed /css/assets/*.css and + // 404'd on healthy deploys (false positive). Only top-level `.css` + // (Hugo-fingerprinted) bundles live under /css/. + const kind = file.startsWith('assets/') ? 'js' : (file.endsWith('.css') ? 'css' : 'js'); const r = await fetchWithRetry(`${BASE_URL}/${kind}/${file}`, { method: 'HEAD', redirect: 'follow' }); if (r.status !== 200) failures.push(`${file} → ${r.status}`); } From d4663b5635aa54737b0ca49fdd470afe13b90831 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 10:29:54 -0600 Subject: [PATCH 02/42] docs(ops): document #2975 idle-runner cds.flush() recovery in scheduler runbook The scheduler-troubleshooting runbook still described the ~10-min self-heal tick as 'alert-only' and 'the reaper is the recovery, the alert is just the signal'. Since #2975 that tick also calls the documented cds.flush() on every pass to drain the 'processor idle, queue healthy' stall mode (CAP 10.2's in-memory runner does one boot flush then only re-arms off an event-driven chain; when that chain doesn't carry forward after a deploy, ready status=NULL rows sit unconsumed until the ~1h fallback). Fold that signature and its active recovery into the runbook, per the #2975 acceptance criteria: - Periodic self-heal bullet now lists all four passes (reap / re-seed / flush / canary) and reframes passes 1-3 as the active recovery, canary as last-resort. - New 'idle-runner drain via cds.flush()' subsection: the signature, the 1h fallback / event-chain mechanism, PROD 2026-11-02 + 2026-11-01 proof, the fail-open flush, and the open CF readiness-gate (health-check-http) follow-up. - Manual-restart fallback note now reflects reap+re-seed+flush all ran. --- .../operations/scheduler-troubleshooting.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/developers/operations/scheduler-troubleshooting.md b/docs/developers/operations/scheduler-troubleshooting.md index 617f5d503..586ddb795 100644 --- a/docs/developers/operations/scheduler-troubleshooting.md +++ b/docs/developers/operations/scheduler-troubleshooting.md @@ -155,7 +155,7 @@ Deleting the stuck `processing` row (Force-unwedge / the HANA escape hatch above Since #2782, every srv boot arms a **tombstone reaper** (`srv/lib/scheduler-reaper.js`, from `CronService.init()`). It targets the root cause directly: the NULL-`lastAttemptTimestamp` `processing` rows CAP's lease can never reclaim. - **Boot sweep.** Right after init, a deferred `reapTombstonedOutboxRows()` pass clears any tombstone left by the instance that just cut over. -- **Periodic self-heal.** Every ~10 min (`SCHEDULER_SELFHEAL_INTERVAL_MS`) a single `.unref()`'d timer re-reaps — a tombstone can be created *after* an instance booted, which the boot sweep can't catch — **and** runs an **alert-only** canary check (`metrics-rollup` `*/5`, `kg-ondemand-drain` `*/2`): if **both** are stale past the grace window it raises a `SchedulerStallDetected` **ALERT**. There is **no `process.exit`** — the reaper is the recovery, the alert is just the signal. +- **Periodic self-heal.** Every ~10 min (`SCHEDULER_SELFHEAL_INTERVAL_MS`) a single `.unref()`'d timer runs, on every tick: (1) `reapTombstonedOutboxRows()` (clears NULL-`lastAttemptTimestamp` tombstones), (2) `reseedMissingSuccessorRows()` (#2827, re-seeds jobs with zero rows), (3) a documented `cds.flush()` (#2975, drains ready `status=NULL` rows the idle CAP 10.2 runner stopped polling — see "processor-idle" below), and (4) a canary check (`metrics-rollup` `*/5`, `kg-ondemand-drain` `*/2`): if **both** are stale past the grace window it raises a `SchedulerStallDetected` **ALERT**. There is **no `process.exit`** — passes 1–3 are the active recovery; the canary alert is the signal of last resort (fires only if those three didn't clear it this tick). - **What the reaper deletes.** Only rows at `status='processing'` that are genuinely **stale** per `scheduler-wedge.isRowStale` (the 60-min floor + cron-period rule). A legitimately long-running job (e.g. `extractConcepts` ~40 min) inside the floor is **never** reaped; a pending `status=NULL` future row is never touched. Deleting (rather than nulling status) lets the next `CronService.init()` `.every().as()` UPSERT — or the next healthy tick — re-seed a fresh future row; nulling status would instead make CAP immediately **retry** the dead row. - **HANA-HA-failover fast path (#2814).** #2782's fast recovery assumed the tombstone always coincided with an app restart (blue-green cutover → boot sweep). A **HANA HA failover** (uptime patch / standby takeover) tombstones the same row **without** bouncing srv, so neither `init()` nor the boot sweep re-runs — leaving only the periodic reaper, which used to wait out the full 60-min floor (~70 min total stall). The reaper now cross-checks each stuck job's `PipelineLog` (`loadRunningScheduledJobNames`): a `processing` row whose job has **no RUNNING `SCHEDULED_JOB` `PipelineLog` row** is not actually in-flight, so it is reaped **regardless of age** (recovery within one ~10-min reaper tick, no manual restart). A row whose job **does** have a RUNNING `PipelineLog` row is protected at **any** age — even past the floor — so a genuinely long run is never reaped mid-flight. If both the outbox tombstone **and** an orphaned RUNNING `PipelineLog` row linger (a handler that died after writing its RUNNING row), the RUNNING row vetoes the fast path until `reconcileOrphanedRunningJobs` closes it at the 60-min floor — recovery then matches the pre-#2814 timing (no worse). - **Fail-open throughout.** A missing entity, DB fault, or a single row's DELETE throwing never aborts the pass, never kills the timer, and never fails a scheduled tick. The #2814 `PipelineLog` lookup is fail-open too: if it can't be read it falls back to **floor-only** reaping — never reaps more aggressively on error. Enabled on CF, off locally (`SCHEDULER_HEALTHCHECK_ENABLED` overrides). @@ -173,9 +173,19 @@ Since #2827, the same ~10-min self-heal tick that runs the reaper now also runs - **Fail-open throughout.** A missing outbox entity, a faulting queue SELECT, one job's `reseedJob` UPSERT throwing, or no callback at all never aborts the pass, never kills the timer, and never fails a scheduled tick. -- **Knobs** (default-safe): `SCHEDULER_HEALTHCHECK_ENABLED` (on in CF, off locally), `SCHEDULER_HEALTHCHECK_GRACE_MS` (default 6 min, canary staleness), `SCHEDULER_SELFHEAL_INTERVAL_MS` (default 10 min, reaper cadence). +### Automatic recovery — idle-runner drain via `cds.flush()` (#2975) -If the `SchedulerStallDetected` alert fires but the board **still** shows all jobs overdue after a couple of reaper ticks, fall back to the manual rolling restart below. +The reaper (#2782/#2814) and re-seeder (#2827) both act on **row-data states** — a `processing` tombstone, or a job with zero rows. But there is a third stall mode where the **data is perfectly healthy** and only the live in-memory processor is idle: + +- **The signature: processor idle, queue healthy.** CAP 10.2's in-memory outbox runner does **one boot flush** on `cds.on('listening')`, then keeps draining only via an **event-driven re-trigger chain** (each `succeeded` context plans the next poll). The periodic `setInterval(_flush, flushInterval)` is only a **fallback wakeup**, and `flushInterval` defaults to **`'1h'`** (undocumented, not a supported `cds.requires.queue` option — do **not** set it). A blue-green cutover SIGTERMs the draining instance and breaks that chain mid-flight; the surviving/new instance re-arms its own interval and does its boot flush, but if the event chain doesn't carry forward, the **only guaranteed next wakeup is the 1h fallback**. Every job's row stays at a healthy `status=NULL` future tick — **nothing is consuming them** — for up to ~60 min. There is no tombstone to reap and no missing row to re-seed, so passes 1 and 2 are both no-ops. A manual `cf restart-app-instance` "works" only because it forces a fresh `listening` → one boot flush; it re-arms nothing the 1h timer wouldn't eventually hit. + - **Observed on PROD 2026-11-02:** both instances booted 13:01, `metrics-rollup` (`*/5`) ticked once at boot, then the 13:05 and 13:15 fires **no-showed** with the row stuck at `status=NULL` and **no queue error**. (Also matches 2026-11-01: stalled at the 20:16 deploy boot, recovered at the 21:01 manual restart — ~45 min in, i.e. the restart beat the 1h timer.) +- **What it does.** On every ~10-min self-heal tick, call the **documented** `cds.flush()`. This forces the outbox runner to process all ready messages immediately — idempotent (drains what's ready, no-op otherwise), so calling it unconditionally every tick is safe. This bounds post-deploy drain latency to **≤ one self-heal interval (~10 min)** regardless of whether the runner's event chain re-armed, with **no manual restart** — even when the reaper and re-seeder find nothing to do. Uses only public CAP API (no `flushInterval` tweak). +- **Fail-open.** A `flush()` fault is logged and swallowed — it never skips the canary check, never kills the timer, and never fails a scheduled tick. Injectable (`opts.flush`) for the SQLite unit test. +- **Readiness note (open follow-up).** The deeper cause of Fault A — `tutorials-srv` has no `health-check-type: http` / `health-check-http-endpoint: /health` in `mta.yaml`/`prod.mtaext`, so CF retires the blue instances the instant green's TCP port is up (`cds.on('listening')`, before the queue is cycling) — is **not** closed by #2975. The `cds.flush()` self-heal is the in-process safety net; wiring CF's health check at `/health` (gated on `CronService.init()` complete + poll armed) is tracked separately. Until then, the ≤10-min flush is the guaranteed recovery. + + + +If the `SchedulerStallDetected` alert fires but the board **still** shows all jobs overdue after a couple of self-heal ticks (reaper + re-seed + `cds.flush()` all ran and none cleared it), fall back to the manual rolling restart below. ### Recovery — rolling restart (manual) From 833f48ea78cf4c75c82ffa316cb9f9650ccbfabf Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 10:36:33 -0600 Subject: [PATCH 03/42] fix(fetch): fail loud on degraded QA discovery instead of shipping stale (#2974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA channel fetches private *-Contribution repos over github.com only. A wrong-host token (github.tools.sap bridged by the deploy-watcher WSL wrapper) or an expired one makes discovery 401 and fail SOFT — falling back to the on-disk/HANA cache and collecting per-slug 404s into an error list that never throws. The step exits 0 and the deploy ships a QA channel built from stale cache minus the private slugs (the 1.42.0 symptom: 31 failed QA fetches, degraded /tutorials-qa/). Add qaDiscoveryIsDegraded() and gate it in main(): when channel=qa and discovery degraded to 'disk'/'hana', exit non-zero so deploy-mta.cjs Step 2.7 aborts. Opt out with ALLOW_STALE_QA=true for a genuine github.com outage. Prod is unaffected (cache fallback tolerated by design). The companion deploy-watcher SKILL.md token-bridge fix (bridge a github.com token, not github.tools.sap) lives in ~/.claude/skills/ (not this repo). --- scripts/__tests__/fetch-tutorials-qa.test.ts | 29 ++++++++++- scripts/fetch-tutorials.ts | 54 +++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/scripts/__tests__/fetch-tutorials-qa.test.ts b/scripts/__tests__/fetch-tutorials-qa.test.ts index 5f06432e3..c2c6cf967 100644 --- a/scripts/__tests__/fetch-tutorials-qa.test.ts +++ b/scripts/__tests__/fetch-tutorials-qa.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseChannel, getQaCacheDir, getHugoContentDir } from '../fetch-tutorials'; +import { parseChannel, getQaCacheDir, getHugoContentDir, qaDiscoveryIsDegraded } from '../fetch-tutorials'; describe('fetch-tutorials qa channel', () => { it('parses --channel qa', () => { @@ -17,3 +17,30 @@ describe('fetch-tutorials qa channel', () => { expect(getHugoContentDir('prod')).toMatch(/hugo[\\/]content$/); }); }); + +// #2974 — QA channel fetches private *-Contribution repos over github.com only. +// A wrong-host/expired token makes discovery 401 and silently degrade to the +// on-disk/HANA cache, shipping a stale QA channel. The deploy must fail loudly +// on that degradation instead of fail-soft. +describe('qaDiscoveryIsDegraded (#2974 fail-loud gate)', () => { + it('flags QA discovery that fell back to the disk cache', () => { + expect(qaDiscoveryIsDegraded('qa', 'disk', false)).toBe(true); + }); + it('flags QA discovery that fell back to HANA', () => { + expect(qaDiscoveryIsDegraded('qa', 'hana', false)).toBe(true); + }); + it('does NOT flag QA discovery served from live GitHub', () => { + expect(qaDiscoveryIsDegraded('qa', 'github', false)).toBe(false); + }); + it('does NOT flag QA discovery served from the live REST fallback (token is valid)', () => { + expect(qaDiscoveryIsDegraded('qa', 'rest', false)).toBe(false); + }); + it('never flags the prod channel (prod cache fallback is tolerated by design)', () => { + expect(qaDiscoveryIsDegraded('prod', 'disk', false)).toBe(false); + expect(qaDiscoveryIsDegraded('prod', 'hana', false)).toBe(false); + }); + it('honors ALLOW_STALE_QA opt-out for a genuine github.com outage', () => { + expect(qaDiscoveryIsDegraded('qa', 'disk', true)).toBe(false); + expect(qaDiscoveryIsDegraded('qa', 'hana', true)).toBe(false); + }); +}); diff --git a/scripts/fetch-tutorials.ts b/scripts/fetch-tutorials.ts index eb512c69f..0a5bba044 100644 --- a/scripts/fetch-tutorials.ts +++ b/scripts/fetch-tutorials.ts @@ -5,7 +5,7 @@ import { performance } from 'node:perf_hooks' import { hugoFrontmatterStringify as yamlStringify } from './lib/hugo-yaml.js' import { flushDimensionsCache, populateImageDimensions, exportDimensionsForHugo } from './parsers/image-dimensions.js' import { composeTutorial } from './parsers/compose.js' -import { discoverAllTutorials, fetchGitHubMetaBatch, fetchGitHubMeta, fetchRulesVr, fetchWithRetry, uploadDiscoveryToHana, saveDiscoveryBaseline, EXCLUDED_REPOS, type DiscoveredTutorial } from './parsers/github.js' +import { discoverAllTutorials, fetchGitHubMetaBatch, fetchGitHubMeta, fetchRulesVr, fetchWithRetry, uploadDiscoveryToHana, saveDiscoveryBaseline, EXCLUDED_REPOS, type DiscoveredTutorial, type DiscoverySource } from './parsers/github.js' import { fetchBuildCatalog, fetchCoCompletions, loadCapCache, saveCapCache, type BrowseFeaturedEntry } from './parsers/cap.js' import { computeFeedFingerprint, @@ -94,6 +94,40 @@ export function getHugoContentDir(channel: Channel): string { : join(__dirname, '..', 'hugo', 'content') } +/** + * #2974 — Decide whether a QA-channel discovery result is a *degraded* (stale) + * fetch that the deploy must reject rather than silently ship. + * + * The QA channel (`ONLY_CONTRIBUTION_REPOS=true`) fetches the private + * `sap-tutorials/*-Contribution` repos over github.com **only**. A wrong-host + * token (e.g. a `github.tools.sap` PAT bridged in by the deploy-watcher WSL + * wrapper) or an expired one makes GraphQL + REST discovery 401, and + * `discoverAllTutorials()` fails *soft* — it falls back to the on-disk + * `_discovery.json` (`source: 'disk'`) or HANA RepoCatalog (`source: 'hana'`) + * and the per-slug content 404s are collected into an error list that never + * throws. The step exits 0 and the deploy ships a QA channel built from stale + * cache minus the private slugs the bad token couldn't reach. + * + * A QA fetch is only trustworthy when discovery actually reached GitHub this + * run — `'github'` (GraphQL) or `'rest'` (live REST fallback, which still + * proves the token is valid). `'disk'`/`'hana'` mean GitHub was unreachable or + * the token was rejected → degraded. + * + * Scoped to QA: the prod channel tolerates cache fallback by design (it keeps + * the public site serving during a GitHub outage). `allowStale` is the + * `ALLOW_STALE_QA` opt-out for a *genuine* github.com outage, where proceeding + * on stale QA cache is the lesser evil. + */ +export function qaDiscoveryIsDegraded( + channel: Channel, + source: DiscoverySource, + allowStale: boolean, +): boolean { + if (channel !== 'qa') return false + if (allowStale) return false + return source === 'disk' || source === 'hana' +} + /** * Front matter for the QA site's root `_index.md`. * @@ -902,6 +936,24 @@ async function main() { const discovery = await discoverAllTutorials() allTutorials = discovery.tutorials + + // #2974 — Fail LOUD on a degraded QA fetch. QA discovery that fell back to + // the disk/HANA cache means the github.com token couldn't reach the private + // *-Contribution repos (wrong-host or expired token), so the QA channel + // would bake from stale cache minus the private slugs. Exit non-zero so the + // deploy (deploy-mta.cjs Step 2.7) aborts instead of shipping degraded QA. + // Opt out with ALLOW_STALE_QA=true during a genuine github.com outage. + if (qaDiscoveryIsDegraded(channel, discovery.source, process.env.ALLOW_STALE_QA === 'true')) { + console.error( + `\n✗ [channel=qa] discovery degraded to '${discovery.source}' fallback — the github.com token ` + + `could not reach the private sap-tutorials/*-Contribution repos.\n` + + ` A QA channel built now would ship STALE cache missing private slugs (see #2974).\n` + + ` Fix: bridge a github.com token (NOT github.tools.sap) with read access to the\n` + + ` *-Contribution repos into GITHUB_TOKEN/TUTORIALS_GITHUB_TOKEN.\n` + + ` To proceed anyway during a genuine github.com outage, set ALLOW_STALE_QA=true.\n`, + ) + process.exit(1) + } // #2585 — skip DELETED/INACTIVE tutorials before any downstream consumer // (cache write, HANA baseline, task map). Fail-open: unreachable endpoint // → empty set → no filtering. From d49150eaab5285ef852a628fb0c31bc010ca2c4c Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 10:59:59 -0600 Subject: [PATCH 04/42] =?UTF-8?q?docs(asset-retention):=20correct=20stale?= =?UTF-8?q?=20comment=20=E2=80=94=20pipeline=20DOES=20emit=20assets/=20isl?= =?UTF-8?q?and=20CSS=20(#2982)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/lib/asset-retention.cjs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/lib/asset-retention.cjs b/scripts/lib/asset-retention.cjs index 660e2b531..e55c54159 100644 --- a/scripts/lib/asset-retention.cjs +++ b/scripts/lib/asset-retention.cjs @@ -39,14 +39,18 @@ function mergeRetention({ currentFiles, retainedManifest, nowMs, windowMs }) { const toDownload = []; for (const e of prior) { if (current.has(e.file)) continue; // already in this build - // Legacy carry-forward prune (2026-10-25). Old Vite layouts emitted island - // CSS as separate `assets/-.css` files; the current pipeline - // injects island CSS via JS (vite-plugin-css-injected-by-js) and emits NO - // `assets/`-prefixed bundles. Such an entry can never match a current file - // and its carry-forward download (`/css/assets/…` or `/js/assets/…`) always - // 404s, so it lingers forever as a prior-build entry and the post-deploy - // asset-retention smoke gate flaps on it. Drop these permanently — nothing - // the current build produces or references lives under `assets/`. + // Carry-forward prune for `assets/`-prefixed island CSS sidecars. + // The pipeline DOES still emit these: `hugo/static/js/assets/-.css` + // sidecars live under the JS output tree and resolve at `/js/assets/…` (never + // `/css/…`). Current-build `assets/` files re-enter normally via `currentFiles` + // (the `current.has(e.file)` check above already kept them this build). + // What we skip here is only the PRIOR-manifest carry-forward path: an `assets/` + // entry from a prior build must not be re-downloaded via the generic + // `/css/assets/…` route (wrong tree ⇒ 404) and pinned as a stale prior entry. + // Dropping it from carry-forward is harmless — if the current build still + // produces it, it is already in `currentFiles`; if it doesn't, it is genuinely + // gone and should not linger. See issue #2982 (this comment) and PR #2981 (the + // smoke-gate side of the same `assets/` ⇒ `/js/` path invariant). if (e.file.startsWith('assets/')) continue; const last = lastOf(e); const inPriorBuild = hasLast(e) && priorBuildMs !== null && e.lastSeenMs === priorBuildMs; From f64db5fa78869d81ebf062d20bb5f5dd5e8b279b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:04:48 -0600 Subject: [PATCH 05/42] docs(scheduler): clarify self-heal liveness + add orphan JobLastRun cleanup (#2983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The __scheduler_selfheal__ JobLastRun row is a dead orphan from a previous self-heal implementation. The current armSchedulerSelfHeal (scheduler-reaper.js) is a setInterval timer, not a runWithLock job, so it never writes a JobLastRun row and the orphan sits permanently stale — an operator footgun that read as a false 'scheduler is dead' signal after the #2975 PROD deploy. - Runbook: document that self-heal liveness is verified via scheduled jobs advancing since boot, NOT a self-heal JobLastRun row; exclude the orphan from any JobLastRun-sorted overdue view. - scripts/cleanup-selfheal-orphan.cjs: guarded, idempotent one-time cleanup (dry-run default, --delete to remove; no-op if absent). Uses cds.ql, no raw SQL. Verified: cleanup CQL tested on in-memory SQLite against the real JobLastRun schema — dry-run preserves rows, --delete removes only the orphan, re-run no-ops. --- .../operations/scheduler-troubleshooting.md | 17 ++++++ scripts/cleanup-selfheal-orphan.cjs | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 scripts/cleanup-selfheal-orphan.cjs diff --git a/docs/developers/operations/scheduler-troubleshooting.md b/docs/developers/operations/scheduler-troubleshooting.md index 617f5d503..2ab1f8235 100644 --- a/docs/developers/operations/scheduler-troubleshooting.md +++ b/docs/developers/operations/scheduler-troubleshooting.md @@ -126,6 +126,8 @@ UPDATE COM_SAP_DEVELOPERS_IMS_PIPELINELOG The most severe mode: **every** scheduled job shows stale/overdue on `/admin-ui/#board` at once, none dispatch, yet **manual Run-now still works**. First observed in PROD on 2026-10-12. +> **Before you trust a "scheduler is dead" signal:** a `JobLastRun` row named `__scheduler_selfheal__` is **not** a tracked job and reads as permanently stale — ignore it, and verify liveness by scheduled jobs advancing since boot (see [Self-heal liveness](#self-heal-liveness--do-not-read-it-off-a-joblastrun-row-2983)). #2983. + ### How to recognise it - The board shows **all** (not one) jobs overdue — `Last success` stale across the board, next-fire times in the past. @@ -177,6 +179,21 @@ Since #2827, the same ~10-min self-heal tick that runs the reaper now also runs If the `SchedulerStallDetected` alert fires but the board **still** shows all jobs overdue after a couple of reaper ticks, fall back to the manual rolling restart below. +### Self-heal liveness — do NOT read it off a `JobLastRun` row (#2983) + +The self-heal timer (`armSchedulerSelfHeal`, `srv/lib/scheduler-reaper.js`) is a plain `.unref()`'d `setInterval` — reap tombstones + #2827 re-seed + #2975 `cds.flush()` + alert-only canary. It is **not** a `runWithLock` job and **never writes a `JobLastRun` row**. There is no `__scheduler_selfheal__` tracked job. + +- **Verify self-heal liveness by whether scheduled jobs are advancing since boot** — a couple of fresh `SCHEDULED_JOB` SUCCESS rows for the high-frequency jobs (`kg-ondemand-drain` `*/2`, `metrics-rollup` `*/5`) within ~5 min (the "Verify recovery" query below), **not** by any self-heal `lastSuccessAt`. +- **Ignore / exclude `__scheduler_selfheal__` from any JobLastRun-sorted "overdue" view.** If a `JobLastRun` row named `__scheduler_selfheal__` is present it is a **dead orphan from a previous self-heal implementation** (removed by #2983). No running code advances it, so it will read as permanently stale — it is **not** evidence the scheduler is down. A false "scheduler is dead" verification after the #2975 PROD deploy was caused by exactly this row. If you still see it, delete it (one-time cleanup — see below); nothing re-creates it. + +**One-time cleanup** (safe on any env — guarded, idempotent, no-op if the row is absent): + +```bash +cf target # MUST confirm the intended space before PROD +cds bind --exec -- node scripts/cleanup-selfheal-orphan.cjs # dry-run: prints the row if present +cds bind --exec -- node scripts/cleanup-selfheal-orphan.cjs --delete # delete it +``` + ### Recovery — rolling restart (manual) A **rolling restart of `tutorials-srv`** is the fix. On boot, `CronService.init()` re-runs `schedule().every().as()` for every registered job, re-seeding all outbox rows; `reconcileOrphanedRunningJobs()` cleans up orphans. diff --git a/scripts/cleanup-selfheal-orphan.cjs b/scripts/cleanup-selfheal-orphan.cjs new file mode 100644 index 000000000..fbf92d461 --- /dev/null +++ b/scripts/cleanup-selfheal-orphan.cjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// Remove the dead `__scheduler_selfheal__` JobLastRun orphan row (#2983). +// +// The self-heal timer (srv/lib/scheduler-reaper.js `armSchedulerSelfHeal`) is a +// plain setInterval — NOT a runWithLock job — so it never writes a JobLastRun +// row. A `__scheduler_selfheal__` row, if present, is a leftover from a previous +// self-heal implementation and reads as permanently stale on any +// JobLastRun-sorted health view. It is an operator footgun, not a liveness +// signal. See docs/developers/operations/scheduler-troubleshooting.md +// ("Self-heal liveness"). +// +// Idempotent and safe on any env: dry-run by default (prints the row if present), +// deletes only with --delete, no-ops if the row is absent. +// +// cds bind --exec -- node scripts/cleanup-selfheal-orphan.cjs # dry-run +// cds bind --exec -- node scripts/cleanup-selfheal-orphan.cjs --delete # delete + +const cds = require("@sap/cds"); + +const ORPHAN = "__scheduler_selfheal__"; +const DO_DELETE = process.argv.includes("--delete"); + +(async () => { + await cds.connect.to("db"); + const { JobLastRun } = cds.entities("com.sap.developers.ims"); + + const [row] = await cds.run( + SELECT.from(JobLastRun).where({ jobName: ORPHAN }) + ); + + if (!row) { + console.log(`No '${ORPHAN}' row in JobLastRun — nothing to do.`); + process.exit(0); + } + + console.log( + `Found orphan: jobName=${row.jobName} lastSuccessAt=${row.lastSuccessAt} ` + + `lastErrorAt=${row.lastErrorAt ?? "null"}` + ); + + if (!DO_DELETE) { + console.log("Dry-run (no --delete): leaving the row in place."); + process.exit(0); + } + + const deleted = await cds.run(DELETE.from(JobLastRun).where({ jobName: ORPHAN })); + console.log(`Deleted ${deleted} '${ORPHAN}' row(s) from JobLastRun.`); + process.exit(0); +})().catch((e) => { + console.error(e); + process.exit(1); +}); From cd9013afe40b84aaeb28a3ea3e9ad0f03d7343c8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:23:40 -0600 Subject: [PATCH 06/42] docs(plan): scheduler readiness gate design + implementation plan (#2975) --- .../2026-11-02-scheduler-readiness-gate.md | 299 ++++++++++++++++++ ...6-11-02-scheduler-readiness-gate-design.md | 237 ++++++++++++++ 2 files changed, 536 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-scheduler-readiness-gate.md create mode 100644 docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md diff --git a/docs/superpowers/plans/2026-11-02-scheduler-readiness-gate.md b/docs/superpowers/plans/2026-11-02-scheduler-readiness-gate.md new file mode 100644 index 000000000..4cc98c7eb --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-scheduler-readiness-gate.md @@ -0,0 +1,299 @@ +# Scheduler Readiness Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an HTTP readiness probe (`/health/ready`) that returns 200 only once `CronService.init()` has wired the scheduler and the DB answers, and gate the CF blue-green deploy on it so blue instances aren't retired before green's scheduler is up (#2975 Fault A). + +**Architecture:** A tiny dependency-free in-process latch (`scheduler-ready.js`) is flipped at the end of `CronService.init()` and read by a new anonymous `/health/ready` Express route in the custom `server.js`. The route delegates its 200/503 decision to a pure `computeReadiness()` helper (unit-testable without standing up Express). The CF `health-check-type: http` module parameter in both live mta.yaml files points at the endpoint. + +**Tech Stack:** SAP CAP (`@sap/cds` 10.2), Node.js ESM, Express (custom `srv/server.js`), Vitest (SQLite in-memory), MBT/MTA on Cloud Foundry. + +**Spec:** `docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md` + +## Global Constraints + +- Node.js ESM throughout (`import`/`export`), matching `srv/lib/*.js`. +- `/health/ready` MUST be anonymous — registered before `app.use(basicAuthMiddleware)` (server.js:319), inside the `cds.on('bootstrap')` block (server.js:233), adjacent to `/health` (server.js:292). +- The latch module MUST NOT import `@sap/cds` (no import cycle; `server.js` ↔ `cron-service.js` have none today — keep it that way). +- Endpoint work: one in-memory boolean read + at most one `SELECT 1 FROM DUMMY`. No heavier work (anonymous DoS surface). +- MTA edits go in **both** `.deploy/mta.yaml` (local deploy source) AND root `mta.yaml` (CI deploy source), kept in sync, on modules `tutorials-srv` + `tutorials-srv-qa` only. **Never** `mta-mcp.yaml` (`srv-mcp` has no CronService → would 503 forever). +- MTA keys per srv module: `health-check-type: http`, `health-check-http-endpoint: /health/ready`, `health-check-invocation-timeout: 10`, `timeout: 180`. +- Tests are SQLite/in-memory unit tests under `test/unit/srv/lib/`. Do not add a hybrid/HANA dependency. + +## Review Focus + +- **Latch is process-global, not per-request** — a second `markSchedulerReady()` call (init re-entry) must stay idempotent-true, never flip back. Covered in Task 1. +- **`cds.db` undefined at probe time** (DB not yet connected on a very early probe) — `computeReadiness` must treat "db check threw / db absent" as not-ready (503), never 200. Covered in Task 2. +- **Probe before `CronService.init()` ran** (green just booted) — latch false → 503, which is the *entire point*; must not accidentally default-true. Covered in Task 1 + Task 2. +- **`/health` must keep its existing contract** — the new route must not alter or shadow `/health` or `/health/db`. Covered in Task 3 (assert `/health` untouched by reading the file). +- **MBT schema rejects an unknown key** — if the installed MBT rejects `health-check-invocation-timeout` or `timeout`, the deploy fails at build. Task 4 verifies `mbt build` (or a schema parse) accepts the file before commit. + +--- + +### Task 1: Readiness latch module + +**Files:** +- Create: `srv/lib/scheduler-ready.js` +- Test: `test/unit/srv/lib/scheduler-ready.test.js` + +**Interfaces:** +- Produces: `markSchedulerReady(): void`, `isSchedulerReady(): boolean`, `_resetForTests(): void`. Module-level `let _ready = false`. + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect, beforeEach } from 'vitest'; +import { markSchedulerReady, isSchedulerReady, _resetForTests } from '../../../../srv/lib/scheduler-ready.js'; + +describe('scheduler-ready latch', () => { + beforeEach(() => _resetForTests()); + it('defaults to false before init', () => { + expect(isSchedulerReady()).toBe(false); + }); + it('flips to true after markSchedulerReady()', () => { + markSchedulerReady(); + expect(isSchedulerReady()).toBe(true); + }); + it('stays true on a second mark (idempotent, never flips back)', () => { + markSchedulerReady(); + markSchedulerReady(); + expect(isSchedulerReady()).toBe(true); + }); + it('_resetForTests() clears it back to false', () => { + markSchedulerReady(); + _resetForTests(); + expect(isSchedulerReady()).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/srv/lib/scheduler-ready.test.js` +Expected: FAIL — cannot resolve `srv/lib/scheduler-ready.js`. + +- [ ] **Step 3: Implement the latch in `srv/lib/scheduler-ready.js`** + +Module-level `let _ready = false;`. `markSchedulerReady` sets it true; `isSchedulerReady` returns it; `_resetForTests` sets it false. No imports. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/srv/lib/scheduler-ready.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/scheduler-ready.js test/unit/srv/lib/scheduler-ready.test.js +git commit -m "feat(scheduler): readiness latch for blue-green health gate (#2975)" +``` + +--- + +### Task 2: `computeReadiness` pure helper + wire the latch flip + +**Files:** +- Modify: `srv/lib/scheduler-ready.js` (add `computeReadiness`) +- Modify: `srv/cron-service.js` (call `markSchedulerReady()` before `super.init()`, ~line 105) +- Test: `test/unit/srv/lib/scheduler-ready.test.js` (extend) + +**Interfaces:** +- Consumes: `markSchedulerReady` (Task 1). +- Produces: `computeReadiness({ schedulerArmed: boolean, dbOk: boolean }): { ready: boolean, status: string, scheduler: string, db: string }` — `ready = schedulerArmed && dbOk`; `status` is `'ready'|'not-ready'`; `scheduler` is `'armed'|'pending'`; `db` is `'connected'|'error'`. + +- [ ] **Step 1: Write the failing test** (append to the Task 1 file) + +```js +import { computeReadiness } from '../../../../srv/lib/scheduler-ready.js'; + +describe('computeReadiness', () => { + it('ready when scheduler armed and db ok', () => { + expect(computeReadiness({ schedulerArmed: true, dbOk: true })) + .toEqual({ ready: true, status: 'ready', scheduler: 'armed', db: 'connected' }); + }); + it('not-ready (pending) when scheduler not armed, even if db ok', () => { + expect(computeReadiness({ schedulerArmed: false, dbOk: true })) + .toEqual({ ready: false, status: 'not-ready', scheduler: 'pending', db: 'connected' }); + }); + it('not-ready (db error) when db check failed, even if scheduler armed', () => { + expect(computeReadiness({ schedulerArmed: true, dbOk: false })) + .toEqual({ ready: false, status: 'not-ready', scheduler: 'armed', db: 'error' }); + }); + it('not-ready when both false', () => { + expect(computeReadiness({ schedulerArmed: false, dbOk: false }).ready).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/srv/lib/scheduler-ready.test.js` +Expected: FAIL — `computeReadiness` is not exported. + +- [ ] **Step 3: Implement `computeReadiness(...)` in `srv/lib/scheduler-ready.js`** + +Pure function of the two booleans, returning the object shape in Interfaces. No `@sap/cds` import. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/srv/lib/scheduler-ready.test.js` +Expected: PASS (8 tests). + +- [ ] **Step 5: Wire the flip in `srv/cron-service.js`** + +Add `import { markSchedulerReady } from './lib/scheduler-ready.js';` at the top with the other imports. Immediately before `await super.init();` (currently line 106), call `markSchedulerReady();` with a one-line comment referencing #2975 (state 2: schedules registered + self-heal armed). Do not gate it on CF detection — `init()` running is the signal. + +- [ ] **Step 6: Run the cron-service unit tests to confirm no regression** + +Run: `npx vitest run test/unit/srv/lib/scheduler-reaper.test.js test/unit/srv/run-with-lock.test.js` +Expected: PASS (existing counts unchanged — 38 + the run-with-lock suite). + +- [ ] **Step 7: Commit** + +```bash +git add srv/lib/scheduler-ready.js srv/cron-service.js test/unit/srv/lib/scheduler-ready.test.js +git commit -m "feat(scheduler): computeReadiness helper + flip latch at end of CronService.init() (#2975)" +``` + +--- + +### Task 3: `/health/ready` endpoint + +**Files:** +- Modify: `srv/server.js` (add route adjacent to `/health`, ~line 294, inside `cds.on('bootstrap')`, before `app.use(basicAuthMiddleware)` at 319) +- Test: `test/unit/srv/lib/scheduler-ready.test.js` (the endpoint's logic is already covered via `computeReadiness`; add one integration assertion via `cds.test` only if cheap — see Step 1 note) + +**Interfaces:** +- Consumes: `isSchedulerReady` + `computeReadiness` (Tasks 1–2). +- Produces: `GET /health/ready` → `res.status(ready?200:503).json({ status, scheduler, db, timestamp })`. + +- [ ] **Step 1: Write the failing test** + +The decision logic is fully covered by `computeReadiness` (Task 2). For the wiring, add one `cds.test`-served HTTP assertion that the route exists and returns 200 once the app is up (init has run, latch true): + +```js +// test/integration/health-ready.test.js +import { describe, it, expect } from 'vitest'; +import cds from '@sap/cds'; +const { GET } = cds.test('serve', '--project', '.', '--in-memory'); +describe('/health/ready (#2975)', () => { + it('returns 200 ready once the served app has initialised the scheduler', async () => { + const res = await GET('/health/ready'); + expect(res.status).toBe(200); + expect(res.data.status).toBe('ready'); + expect(res.data.scheduler).toBe('armed'); + }); +}); +``` + +Note: the 503 paths are proven by Task 2's `computeReadiness` unit tests (standing up Express in a false-latch state is racy against `init()`); this integration test only proves the route is mounted anonymously and returns the ready shape over HTTP. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/integration/health-ready.test.js` +Expected: FAIL — `/health/ready` 404 (route not yet added). + +- [ ] **Step 3: Implement the route in `srv/server.js`** + +Add `import { isSchedulerReady, computeReadiness } from './lib/scheduler-ready.js';` with the other `./lib` imports. Adjacent to `/health` (line 292–294), add an async handler: probe `dbOk` via `await cds.db?.run('SELECT 1 FROM DUMMY')` in a try/catch (catch or absent `cds.db` → `dbOk=false`), call `computeReadiness({ schedulerArmed: isSchedulerReady(), dbOk })`, respond `res.status(r.ready?200:503).json({ status:r.status, scheduler:r.scheduler, db:r.db, timestamp: new Date().toISOString() })`. Leave `/health` and `/health/db` untouched. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/integration/health-ready.test.js` +Expected: PASS. + +- [ ] **Step 5: Confirm `/health` is unchanged** + +Run: `git diff srv/server.js` +Expected: only an added import line and the added `/health/ready` block; the `/health` (292) and `/health/db` (296) handlers are byte-for-byte unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add srv/server.js test/integration/health-ready.test.js +git commit -m "feat(scheduler): anonymous /health/ready readiness probe (#2975)" +``` + +--- + +### Task 4: MTA readiness gate (both files) + exclusion verification + +**Files:** +- Modify: `.deploy/mta.yaml` (modules `tutorials-srv`, `tutorials-srv-qa`) +- Modify: `mta.yaml` (modules `tutorials-srv`, `tutorials-srv-qa`) + +**Interfaces:** +- Consumes: `/health/ready` (Task 3). + +- [ ] **Step 1: Add the four keys to each srv module in `.deploy/mta.yaml`** + +Under the `parameters:` block of `tutorials-srv` (currently after `instances: 1`, ~line 124) and `tutorials-srv-qa` (~line 185), add: +```yaml + health-check-type: http + health-check-http-endpoint: /health/ready + health-check-invocation-timeout: 10 + timeout: 180 +``` + +- [ ] **Step 2: Add the identical four keys to each srv module in root `mta.yaml`** + +Same two modules, same keys. Keep both files in sync. + +- [ ] **Step 3: Verify `mta-mcp.yaml` was NOT touched** + +Run: `git diff --name-only` +Expected: `mta-mcp.yaml` is absent from the list. (Confirms the exclusion — `srv-mcp` has no scheduler.) + +- [ ] **Step 4: Verify both MTA files still parse / build accepts the keys** + +Run: `npx mbt build -p=. -t=./mta_archives --mtar=_schemacheck.mtar -s=.deploy/mta.yaml 2>&1 | tail -20` — OR, if a full build is too heavy in this environment, a YAML+schema parse: `node -e "const y=require('js-yaml');y.load(require('fs').readFileSync('.deploy/mta.yaml','utf8'));y.load(require('fs').readFileSync('mta.yaml','utf8'));console.log('both parse ok')"`. +Expected: no schema error on the new keys. If MBT rejects `health-check-invocation-timeout` or `timeout`, remove just that key from BOTH files (the two required keys are `health-check-type` + `health-check-http-endpoint`) and note it in the commit. + +- [ ] **Step 5: Commit** + +```bash +git add .deploy/mta.yaml mta.yaml +git commit -m "feat(deploy): gate blue-green on /health/ready for tutorials-srv + srv-qa (#2975)" +``` + +--- + +### Task 5: Runbook — readiness gate section + +**Files:** +- Modify: `docs/developers/operations/scheduler-troubleshooting.md` + +- [ ] **Step 1: Replace the "Readiness note (open follow-up)" bullet with a shipped-mechanism subsection** + +In the `cds.flush()` (#2975) subsection added by PR #2984, the last bullet currently frames the CF readiness gate as an *open follow-up*. Replace it with a short **"Readiness gate (#2975 Fault A)"** subsection stating: the three boot states; why the gate asserts state 2 (wired+DB) not state 3 (drain); the `/health/ready` contract (200 only when `isSchedulerReady()` && `SELECT 1`); the MTA keys on `tutorials-srv`/`-qa` in **both** mta.yaml files (and the `mta-mcp.yaml` exclusion); and that it composes with the flush self-heal (gate = Fault A, flush = Fault B). Keep it concise. + +- [ ] **Step 2: Verify the doc has no stale "open follow-up" wording left** + +Run: `grep -n "open follow-up\|not.*closed by #2975" docs/developers/operations/scheduler-troubleshooting.md` +Expected: no matches. + +- [ ] **Step 3: Commit** + +```bash +git add docs/developers/operations/scheduler-troubleshooting.md +git commit -m "docs(ops): document shipped /health/ready readiness gate (#2975)" +``` + +--- + +### Task 6: Full-suite sanity + PR + +**Files:** none (verification + PR) + +- [ ] **Step 1: Run the scheduler-related unit tests together** + +Run: `npx vitest run test/unit/srv/lib/scheduler-ready.test.js test/unit/srv/lib/scheduler-reaper.test.js test/integration/health-ready.test.js` +Expected: all PASS. + +- [ ] **Step 2: Push the branch and open a PR to DEV** + +The branch is already pushed (`worktree-docs-scheduler-flush-2975`, PR #2984 docs-only). Decide with the maintainer whether to extend #2984 or open a second PR for the code; default: push these commits to the same branch (they're the same issue) so #2984 becomes the full #2975-FaultA PR. Update the PR title/body to cover code + docs. Base = `DEV`. + +- [ ] **Step 3: Note the DEV validation procedure in the PR** + +Copy the §"Operational validation" steps from the spec into the PR body (paused blue-green + resume; measure boot-to-ready vs `timeout: 180`; DB-clock canary staleness; pass = `metrics-rollup` holds `*/5` across ≥2 cycles, no `cf restart-app-instance`). diff --git a/docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md b/docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md new file mode 100644 index 000000000..45c92ef3a --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md @@ -0,0 +1,237 @@ +# Scheduler readiness gate for blue-green deploys (#2975 Fault A) + +**Date:** 2026-11-02 +**Issue:** sap-tutorials/tutorials-ims#2975 +**Status:** design — awaiting approval + +## Intent and why + +Every blue-green deploy of `tutorials-ims` can leave the scheduler stalled, requiring a +manual `cf restart-app-instance`. Issue #2975 identified **two combining faults**: + +- **Fault B — idle runner.** CAP 10.2's in-memory outbox runner does one boot flush then + re-arms only off an event-driven chain; when that chain doesn't carry forward after a + deploy, ready rows sit unconsumed until the undocumented `'1h'` `flushInterval` fallback. + **Already fixed** by #2975's `cds.flush()` self-heal (merged to main via #2980) — bounds + drain latency to ≤1 self-heal interval (~10 min). + +- **Fault A — no readiness gate (this design).** `tutorials-srv` declares no + `health-check-type` in `.deploy/mta.yaml`, so CF defaults to `health-check-type: port`. + An instance is marked healthy the instant its TCP port accepts — which is + `cds.on('listening')`, **before** `CronService.init()` has wired the scheduler. On a + blue-green swap CF therefore retires the OLD (blue) instances as soon as green's port is + up, not when green is actually ready. A `/health` endpoint exists (`srv/server.js:292`) + but nothing points CF at it, and it asserts nothing about the scheduler. + +Closing Fault A stops CF retiring blue before green is wired. Combined with the Fault B +flush (already shipped), post-deploy recovery becomes automatic with no manual restart. + +## What "safe to switch" means (the core decision) + +Three boot states, in order: + +1. **Port open** (`cds.on('listening')`) — today's gate. Too early: scheduler not wired. +2. **Scheduler wired** — `CronService.init()` finished its `schedule().every().as()` loop + (`srv/cron-service.js:44`); outbox rows exist and `cds.db` is connected. +3. **Scheduler draining** — the processor has actually consumed ≥1 tick since boot. + +State 3 is what we'd *love* to gate on, but it is **unreachable synchronously**: the +fastest canary (`metrics-rollup`) is `*/5`, so a freshly-booted green could legitimately +take up to 5 min before it has drained anything — far beyond any CF health-check timeout. +Gating on state 3 would make CF kill every healthy freshly-booted instance. That is a trap +and is explicitly **out of scope**. + +**Decision: the readiness gate asserts state 2 (`scheduler wired + DB connected`).** The +state-3 drain guarantee comes from the already-shipped #2975 `cds.flush()` self-heal, not +from the health check. The two compose: + +- Gate (Fault A): CF won't retire blue until green is wired + DB-connected. +- Flush (Fault B): green drains within ≤10 min even though the gate can't wait for a drain. + +## Goals + +- `tutorials-srv` (and `tutorials-srv-qa`, same `srv/` code) expose an HTTP readiness probe + that returns 200 only once `CronService.init()` completed **and** `cds.db` answers. +- CF's blue-green swap honors that probe via the MBT-supported `health-check-type: http` + module parameter, so blue is retired only after green is wired. +- A blue-green deploy to DEV recovers the scheduler with **no manual restart** (acceptance). +- Unit-tested (SQLite) and documented in the runbook. + +## Non-goals / anti-goals + +- **Not** gating on drain (state 3) — see above; would self-sabotage deploys. +- **Not** repurposing the existing `/health` — CAP ships `/health` as its own + liveness/readiness contract (`{status:'UP'}`); this repo already overrides it. Keep it a + dumb liveness check; add a *separate* readiness endpoint so no existing `/health` consumer + or CF liveness check gains a new dependency on the scheduler. +- **Not** using CF's dedicated `readiness-health-check-type` — the CAP docs note it is *not + yet supported by the CF CLI or MBT*. We use the long-supported `health-check-type: http` + liveness parameter instead. +- **Not** lowering the undocumented `flushInterval` — out of bounds per the "no undocumented + `@sap` API" rule; the flush self-heal already covers it. +- **Not** touching the approuter's existing #1678 `-idle` served-content guard — it layers + on top (approuter content vs. srv scheduler), no conflict. + +## Constraints + +- `/health/ready` is **public/anonymous** (CF health checks send no auth). Keep it cheap: + one in-memory boolean read + one `SELECT 1 FROM DUMMY`. No heavier work (DoS surface). +- Must not introduce a `server.js ↔ cron-service.js` import cycle (today there is none). +- Fail-safe on the deploy side: if green never readies, 503 must cause the deploy to fail + (blue stays serving) rather than swap to a half-booted green. CF bounds this via + `health-check-invocation-timeout` + the app-start-timeout; a never-ready green aborts the + blue-green op (the existing `abortFailedBlueGreen` safety net frees the idle apps). + +## The how (decided) + +### 1. Shared readiness latch — `srv/lib/scheduler-ready.js` (new) + +A tiny module with no `@sap/cds` dependency, importable by both `server.js` and +`cron-service.js` without a cycle: + +```js +let _ready = false; +export function markSchedulerReady() { _ready = true; } +export function isSchedulerReady() { return _ready; } +export function _resetForTests() { _ready = false; } // test-only +``` + +### 2. Flip it at end of `CronService.init()` — `srv/cron-service.js` + +After the `schedule().every().as()` loop and the self-heal arming, immediately before +`await super.init()` (line 106): call `markSchedulerReady()`. This is state 2 — schedules +registered, self-heal armed. The deferred boot reap/reconcile (`setImmediate`) do **not** +gate readiness (they're recovery, not a serving precondition). + +### 3. `/health/ready` endpoint — `srv/server.js` (next to `/health`, ~line 294) + +```js +app.get('/health/ready', async (req, res) => { + const schedulerArmed = isSchedulerReady(); + let db = 'connected'; + try { await cds.db?.run('SELECT 1 FROM DUMMY'); } + catch { db = 'error'; } + const ready = schedulerArmed && db === 'connected'; + res.status(ready ? 200 : 503).json({ + status: ready ? 'ready' : 'not-ready', + scheduler: schedulerArmed ? 'armed' : 'pending', + db, + timestamp: new Date().toISOString(), + }); +}); +``` + +`/health` (line 292) is left unchanged as the dumb liveness check. + +**Auth ordering (confirmed):** `/health` is registered inside `cds.on('bootstrap')` (server.js:233) +and `app.use(basicAuthMiddleware)` is at server.js:319 — so `/health/ready` placed adjacent to +`/health` sits **before** auth and is reachable anonymously by CF's health checker. CF probes the +container directly (approuter / `xs-app.json` routing irrelevant). `tutorials-srv-qa` runs the same +`srv/` code with no base-path prefix, so the **single** `health-check-http-endpoint: /health/ready` +value is correct for both modules. + +> **Local note.** `CronService` only arms (and thus flips ready) on CF +> (`SCHEDULER_HEALTHCHECK_ENABLED`/CF detection); but `markSchedulerReady()` is called +> unconditionally at the end of `init()`, so `/health/ready` is accurate locally too +> (init() runs under `cds watch`). The gate only *matters* on CF, where the MTA points CF at +> it. If a future refactor makes `init()` skip on some path, revisit this. + +### 4. MTA readiness gate — BOTH `.deploy/mta.yaml` AND root `mta.yaml` + +**Builder-check finding (confirmed):** the two mta.yaml files are **independent hand-maintained +copies**, both live on different deploy paths: +- `.deploy/mta.yaml` (604 lines) — the **local/workstation** deploy source (`cd .deploy && mbt build`). +- root `mta.yaml` (471 lines) — what **CI builds** (`.github/workflows/deploy.yml:264` `mbt build -e deploy/.mtaext`). + +Both carry `tutorials-srv` and `tutorials-srv-qa`. **Edit both and keep them in sync** (same +discipline as the repo's `srv-qa cp list audit` rule). Add to the `tutorials-srv` **and** +`tutorials-srv-qa` module `parameters:` blocks in **each** file: + +```yaml + health-check-type: http + health-check-http-endpoint: /health/ready + health-check-invocation-timeout: 10 + timeout: 180 +``` + +- `health-check-invocation-timeout: 10` (s) — per-probe budget; room for the `SELECT 1` without + letting a wedged instance linger. +- **`timeout: 180` (decided) — explicit app-start-timeout.** `CronService.init()` runs a 32-job + `.schedule().every().as()` UPSERT loop against a **cold HANA connection** before + `markSchedulerReady()`. The default CF start timeout (~60s) risks fail-starting a *healthy* + green if that loop + first DB connect runs long — reintroducing the "self-sabotage" the state-3 + discussion warns about, now at state 2. 180s gives comfortable headroom. The DEV validation + (below) measures real boot-to-ready; tune down only with data. + +**Exclusions (confirmed footgun):** +- `mta-mcp.yaml`'s `tutorials-srv-mcp` (path `gen/srv-mcp`) runs `srv-mcp/` code which has **no + `CronService`** (grep: zero `CronService`/`registerJobs`/`markSchedulerReady` refs). Gating it on + `/health/ready` would 503 **forever** → every MCP-MTA deploy fails to start / aborts. **Do NOT** + add the health-check keys to `mta-mcp.yaml`. The "grep and match" instruction scopes to the two + real `tutorials-srv`/`-qa` modules only. + +Env-agnostic → no `deploy/*.mtaext` override needed (confirmed: no existing `health-check` key in +any mtaext). + +### 5. Tests (SQLite unit) — `test/unit/srv/lib/scheduler-ready.test.js` (new) + +- latch defaults to `false`; `markSchedulerReady()` → `true`; `_resetForTests()` → `false`. +- (if an existing server-level harness exists) `/health/ready` returns 503 when + `isSchedulerReady()` is false, 200 when true + db ok, 503 when the `SELECT 1` throws. + If no cheap server harness exists, unit-test the latch + a extracted pure + `computeReadiness({schedulerArmed, db})` helper rather than standing up Express. + +### 6. Runbook — `docs/developers/operations/scheduler-troubleshooting.md` + +Convert the "Readiness note (open follow-up)" paragraph (added in the #2975 docs PR #2984) +into the shipped mechanism: a short "Readiness gate (#2975 Fault A)" subsection describing +the three boot states, why the gate asserts state 2 not state 3, the endpoint contract, the +MTA keys, and how it composes with the flush self-heal. + +## What's left to the builder + +- Exact insertion offsets (line numbers drift); place `/health/ready` adjacent to `/health`. +- Whether a cheap Express/supertest harness already exists for endpoint-level tests, or to + fall back to the extracted `computeReadiness` pure-function test. +- Confirm `health-check-invocation-timeout` and `timeout` are accepted by the installed MBT + version; if the schema rejects either, drop just that line (the two required keys are + `health-check-type` + `health-check-http-endpoint`). +- Verify the exact `JobLastRun` entity/table name before trusting the validation SQL. + +## Operational validation (DEV, after merge to DEV) + +**Swap flow (confirmed):** this repo's blue-green is **paused** — green comes up, the deploy +PAUSES ("green apps up — PAUSED", `scripts/deploy-mta.cjs`), the operator verifies, then resumes +with `cf deploy -i -a resume`. The `health-check-type: http` gate makes CF refuse to mark +green **"started"** (→ the pause is reachable) until `/health/ready` returns 200, i.e. the gate +fires **before** the pause. So "no manual restart" in the acceptance below means **no +`cf restart-app-instance` after the resume** — the resume itself is the normal, expected step, not +a failure. + +1. `cf target` → confirm **dev** space before anything. +2. Confirm the gate took effect on the running app: + `cf curl /v3/apps/$(cf app tutorials-srv --guid)/processes` → `health_check.type: http`, + `.data.health_check.data.endpoint: /health/ready`, and note the start-timeout. + **Measure boot-to-ready** here (app `since` → first `/health/ready` 200) to confirm the + `timeout: 180` headroom is right; tune down only with data. +3. Paused blue-green deploy to DEV (`npm run deploy -- --env dev --strategy blue-green`, or the + deploy-watcher). At the pause, verify green's `/health/ready` is 200, then `-a resume`. + **No `cf restart-app-instance` afterward** — that's the whole test. +4. Measure canary staleness with the **DB clock** (JS `Date` has a ~7h tz offset on these + instances and will mislead): + ```sql + SELECT job_name, + SECONDS_BETWEEN(last_success_at, CURRENT_UTCTIMESTAMP) AS stale_s + FROM "" WHERE job_name IN ('metrics-rollup','kg-ondemand-drain'); + ``` + via `cds bind --exec -- …` against DEV HANA (read-only). (Verify the exact entity/table name + first — e.g. via `cds repl` — before trusting the SQL.) +5. **Pass:** `metrics-rollup` (`*/5`) holds its 5-min cadence across **≥2 cycles** post-swap with no + manual restart; `stale_s` never exceeds ~5 min + grace. Then promote to PROD (same paused flow). + +## Acceptance (from #2975) + +- [x] Fault B `cds.flush()` fix (prior work, merged #2980). +- [ ] Fault A readiness gate: blue-green deploy to DEV self-recovers, no manual restart. +- [ ] Automated test (unit, SQLite) for the readiness signal. +- [ ] Runbook documents the readiness gate alongside the flush/reaper/re-seed recovery. From ee7010a53d8319d7be4c2c6130449323678cd01b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:27:32 -0600 Subject: [PATCH 07/42] feat(scheduler): readiness latch for blue-green health gate (#2975) --- srv/lib/scheduler-ready.js | 23 +++++++++++++ test/unit/srv/lib/scheduler-ready.test.js | 41 +++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 srv/lib/scheduler-ready.js create mode 100644 test/unit/srv/lib/scheduler-ready.test.js diff --git a/srv/lib/scheduler-ready.js b/srv/lib/scheduler-ready.js new file mode 100644 index 000000000..979dc81fa --- /dev/null +++ b/srv/lib/scheduler-ready.js @@ -0,0 +1,23 @@ +let _ready = false; + +export function markSchedulerReady() { + _ready = true; +} + +export function isSchedulerReady() { + return _ready; +} + +export function _resetForTests() { + _ready = false; +} + +export function computeReadiness({ schedulerArmed, dbOk }) { + const ready = schedulerArmed && dbOk; + return { + ready, + status: ready ? 'ready' : 'not-ready', + scheduler: schedulerArmed ? 'armed' : 'pending', + db: dbOk ? 'connected' : 'error', + }; +} diff --git a/test/unit/srv/lib/scheduler-ready.test.js b/test/unit/srv/lib/scheduler-ready.test.js new file mode 100644 index 000000000..c52f8b4c6 --- /dev/null +++ b/test/unit/srv/lib/scheduler-ready.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { markSchedulerReady, isSchedulerReady, _resetForTests, computeReadiness } from '../../../../srv/lib/scheduler-ready.js'; + +describe('scheduler-ready latch', () => { + beforeEach(() => _resetForTests()); + it('defaults to false before init', () => { + expect(isSchedulerReady()).toBe(false); + }); + it('flips to true after markSchedulerReady()', () => { + markSchedulerReady(); + expect(isSchedulerReady()).toBe(true); + }); + it('stays true on a second mark (idempotent, never flips back)', () => { + markSchedulerReady(); + markSchedulerReady(); + expect(isSchedulerReady()).toBe(true); + }); + it('_resetForTests() clears it back to false', () => { + markSchedulerReady(); + _resetForTests(); + expect(isSchedulerReady()).toBe(false); + }); +}); + +describe('computeReadiness', () => { + it('ready when scheduler armed and db ok', () => { + expect(computeReadiness({ schedulerArmed: true, dbOk: true })) + .toEqual({ ready: true, status: 'ready', scheduler: 'armed', db: 'connected' }); + }); + it('not-ready (pending) when scheduler not armed, even if db ok', () => { + expect(computeReadiness({ schedulerArmed: false, dbOk: true })) + .toEqual({ ready: false, status: 'not-ready', scheduler: 'pending', db: 'connected' }); + }); + it('not-ready (db error) when db check failed, even if scheduler armed', () => { + expect(computeReadiness({ schedulerArmed: true, dbOk: false })) + .toEqual({ ready: false, status: 'not-ready', scheduler: 'armed', db: 'error' }); + }); + it('not-ready when both false', () => { + expect(computeReadiness({ schedulerArmed: false, dbOk: false }).ready).toBe(false); + }); +}); From d8a739067680bed9b43ecc5a89415b497b93f716 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:28:31 -0600 Subject: [PATCH 08/42] feat(scheduler): computeReadiness helper + flip latch at end of CronService.init() (#2975) --- srv/cron-service.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/srv/cron-service.js b/srv/cron-service.js index 5de84addc..705f0a48e 100644 --- a/srv/cron-service.js +++ b/srv/cron-service.js @@ -17,6 +17,7 @@ import { } from './jobs/scheduler.js'; import { reconcileOrphanedRunningJobs } from './lib/pipeline-log-reconciler.js'; import { reapTombstonedOutboxRows, armSchedulerSelfHeal } from './lib/scheduler-reaper.js'; +import { markSchedulerReady } from './lib/scheduler-ready.js'; const LOG = cds.log('cron-service'); @@ -103,6 +104,9 @@ export default class CronService extends cds.ApplicationService { this.schedule(`cron.${jobName}`, {}).every(cron).as(jobName); armSchedulerSelfHeal({ jobSchedules, reseedJob }); + // #2975 Fault A: readiness gate — mark scheduler ready after schedules registered + self-heal armed + markSchedulerReady(); + await super.init(); } } From f5e8add9be0da7ff40bfa4f720f19bc4cfa397b5 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:38:10 -0600 Subject: [PATCH 09/42] feat(scheduler): anonymous /health/ready readiness probe (#2975) --- srv/server.js | 13 +++++++++++++ test/integration/health-ready.test.js | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 test/integration/health-ready.test.js diff --git a/srv/server.js b/srv/server.js index 9bccc3d9b..29786feb2 100644 --- a/srv/server.js +++ b/srv/server.js @@ -105,6 +105,7 @@ import { uploadPetSubmission, decodePhotoUpload } from './lib/petoberfest-upload import { fetchPetPhoto } from './lib/petoberfest-photo-store.js'; import { installDbWrap } from './lib/metrics-db-wrap.js'; import { wireDraftIntegrityPreflight } from './lib/draft-integrity-preflight.js'; +import { isSchedulerReady, computeReadiness } from './lib/scheduler-ready.js'; import './graphql-config.js'; import { makeA2aRouter } from './lib/a2a/rpc-router.js'; import { a2aIasAuthMiddleware } from './lib/a2a/ias-auth-mw.js'; @@ -293,6 +294,18 @@ cds.on('bootstrap', (app) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); + app.get('/health/ready', async (req, res) => { + let dbOk = false; + try { + await cds.db?.run('SELECT 1'); + dbOk = true; + } catch (_err) { + dbOk = false; + } + const r = computeReadiness({ schedulerArmed: isSchedulerReady(), dbOk }); + res.status(r.ready ? 200 : 503).json({ status: r.status, scheduler: r.scheduler, db: r.db, timestamp: new Date().toISOString() }); + }); + app.get('/health/db', async (req, res) => { try { await cds.db?.run('SELECT 1 FROM DUMMY'); diff --git a/test/integration/health-ready.test.js b/test/integration/health-ready.test.js new file mode 100644 index 000000000..b24e37866 --- /dev/null +++ b/test/integration/health-ready.test.js @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import cds from '@sap/cds'; +const { GET } = cds.test('serve', '--project', '.', '--in-memory'); +describe('/health/ready (#2975)', () => { + it('returns 200 ready once the served app has initialised the scheduler', async () => { + const res = await GET('/health/ready'); + expect(res.status).toBe(200); + expect(res.data.status).toBe('ready'); + expect(res.data.scheduler).toBe('armed'); + }); +}); From d21c618f1a4b1d313e95f8454645c92c5e616d76 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:41:56 -0600 Subject: [PATCH 10/42] fix(scheduler): guard absent cds.db; assert full response shape (#2975) - CRITICAL: replace `cds.db?.run()` optional-chain with an explicit `if (cds.db)` guard so an absent DB leaves dbOk=false instead of silently resolving to true (await undefined never throws) - IMPORTANT: add db:'connected' + timestamp assertions to integration test so a regression dropping either field is caught end-to-end --- srv/server.js | 9 ++++----- test/integration/health-ready.test.js | 2 ++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/srv/server.js b/srv/server.js index 29786feb2..d17dfd8e5 100644 --- a/srv/server.js +++ b/srv/server.js @@ -296,12 +296,11 @@ cds.on('bootstrap', (app) => { app.get('/health/ready', async (req, res) => { let dbOk = false; - try { - await cds.db?.run('SELECT 1'); - dbOk = true; - } catch (_err) { - dbOk = false; + if (cds.db) { + try { await cds.db.run('SELECT 1'); dbOk = true; } + catch (_err) { dbOk = false; } } + // cds.db absent → dbOk stays false const r = computeReadiness({ schedulerArmed: isSchedulerReady(), dbOk }); res.status(r.ready ? 200 : 503).json({ status: r.status, scheduler: r.scheduler, db: r.db, timestamp: new Date().toISOString() }); }); diff --git a/test/integration/health-ready.test.js b/test/integration/health-ready.test.js index b24e37866..6b927b845 100644 --- a/test/integration/health-ready.test.js +++ b/test/integration/health-ready.test.js @@ -7,5 +7,7 @@ describe('/health/ready (#2975)', () => { expect(res.status).toBe(200); expect(res.data.status).toBe('ready'); expect(res.data.scheduler).toBe('armed'); + expect(res.data.db).toBe('connected'); + expect(res.data.timestamp).toBeTruthy(); }); }); From 86e732c3642959cff91780346018be2bde7deaf2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:47:24 -0600 Subject: [PATCH 11/42] feat(deploy): gate blue-green on /health/ready for tutorials-srv + srv-qa (#2975) --- .deploy/mta.yaml | 8 ++++++++ mta.yaml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index b9fed98d1..1e06c8326 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -122,6 +122,10 @@ modules: # srv/developer-service.js, srv/lib/ip-rate-limit.js, srv/lib/chat-rate-limit.js. # See docs/developers/operations/mta-deployment.md "Scaling Constraints" and TODO §22. instances: 1 + health-check-type: http + health-check-http-endpoint: /health/ready + health-check-invocation-timeout: 10 + timeout: 180 properties: NODE_ENV: production EXPOSE_CAP_UI: false @@ -187,6 +191,10 @@ modules: disk-quota: 1024M buildpack: nodejs_buildpack instances: 1 + health-check-type: http + health-check-http-endpoint: /health/ready + health-check-invocation-timeout: 10 + timeout: 180 build-parameters: builder: custom commands: diff --git a/mta.yaml b/mta.yaml index 32e0e1fa2..b6b043c84 100644 --- a/mta.yaml +++ b/mta.yaml @@ -142,6 +142,10 @@ modules: # srv/developer-service.js, srv/lib/ip-rate-limit.js, srv/lib/chat-rate-limit.js. # See docs/developers/operations/mta-deployment.md "Scaling Constraints" and TODO §22. instances: 1 + health-check-type: http + health-check-http-endpoint: /health/ready + health-check-invocation-timeout: 10 + timeout: 180 properties: EXPOSE_CAP_UI: true NODE_ENV: production @@ -191,6 +195,10 @@ modules: disk-quota: 1024M buildpack: nodejs_buildpack instances: 1 + health-check-type: http + health-check-http-endpoint: /health/ready + health-check-invocation-timeout: 10 + timeout: 180 properties: EXPOSE_CAP_UI: false NODE_ENV: production From 9566e18af352a5e762379a0fc0fb962483b6dc53 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:49:20 -0600 Subject: [PATCH 12/42] docs(ops): document shipped /health/ready readiness gate (#2975) --- .../operations/scheduler-troubleshooting.md | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/developers/operations/scheduler-troubleshooting.md b/docs/developers/operations/scheduler-troubleshooting.md index 93fa22651..d160a187b 100644 --- a/docs/developers/operations/scheduler-troubleshooting.md +++ b/docs/developers/operations/scheduler-troubleshooting.md @@ -183,7 +183,32 @@ The reaper (#2782/#2814) and re-seeder (#2827) both act on **row-data states** - **Observed on PROD 2026-11-02:** both instances booted 13:01, `metrics-rollup` (`*/5`) ticked once at boot, then the 13:05 and 13:15 fires **no-showed** with the row stuck at `status=NULL` and **no queue error**. (Also matches 2026-11-01: stalled at the 20:16 deploy boot, recovered at the 21:01 manual restart — ~45 min in, i.e. the restart beat the 1h timer.) - **What it does.** On every ~10-min self-heal tick, call the **documented** `cds.flush()`. This forces the outbox runner to process all ready messages immediately — idempotent (drains what's ready, no-op otherwise), so calling it unconditionally every tick is safe. This bounds post-deploy drain latency to **≤ one self-heal interval (~10 min)** regardless of whether the runner's event chain re-armed, with **no manual restart** — even when the reaper and re-seeder find nothing to do. Uses only public CAP API (no `flushInterval` tweak). - **Fail-open.** A `flush()` fault is logged and swallowed — it never skips the canary check, never kills the timer, and never fails a scheduled tick. Injectable (`opts.flush`) for the SQLite unit test. -- **Readiness note (open follow-up).** The deeper cause of Fault A — `tutorials-srv` has no `health-check-type: http` / `health-check-http-endpoint: /health` in `mta.yaml`/`prod.mtaext`, so CF retires the blue instances the instant green's TCP port is up (`cds.on('listening')`, before the queue is cycling) — is **not** closed by #2975. The `cds.flush()` self-heal is the in-process safety net; wiring CF's health check at `/health` (gated on `CronService.init()` complete + poll armed) is tracked separately. Until then, the ≤10-min flush is the guaranteed recovery. +#### Readiness gate (#2975 Fault A) + +CF blue-green cuts over the instant green's TCP port accepts connections — `cds.on('listening')` fires — not when the scheduler is wired. There are three meaningful boot states: + +1. **Port open** — TCP accepts connections; `cds.on('listening')` has fired. Scheduler _not_ yet started. This is the old default; CF retired blue here. +2. **Scheduler wired + DB connected** — `CronService.init()` has completed (schedules registered, tombstone reaper + self-heal armed, boot sweep queued) **and** a `SELECT 1` DB ping succeeds. The outbox queue will drain within one self-heal interval. +3. **Actually draining** — the first `cds.flush()` inside `armSchedulerSelfHeal` has completed and jobs are ticking. Unreachable synchronously at boot (fastest canary is `*/2` — gating on a real drain would make CF kill healthy freshly-booted green instances). + +The gate asserts **state 2**, not state 3. That is sufficient: combined with the `cds.flush()` self-heal (see above), the first drain lands within ≤ one self-heal interval (~10 min) after state 2 is reached. + +**`/health/ready` contract** — anonymous `GET /health/ready`, registered before auth middleware in `srv/server.js`: +- Returns **200** when `isSchedulerReady()` (latch in `srv/lib/scheduler-ready.js`, flipped by `markSchedulerReady()` at the end of `CronService.init()`) **and** a live `SELECT 1` DB ping succeeds. +- Returns **503** otherwise (port open but scheduler not yet wired, or DB unreachable). + +**MTA wiring** — `tutorials-srv` and `tutorials-srv-qa` in **both** `.deploy/mta.yaml` and root `mta.yaml`: +```yaml +health-check-type: http +health-check-http-endpoint: /health/ready +health-check-invocation-timeout: 10 +timeout: 180 +``` +`timeout: 180` gives the cold-HANA init loop (DB connection + schema bootstrap) time to complete before CF declares the deploy failed. `mta-mcp.yaml` is intentionally excluded — the MCP MTA has no scheduler. + +**How it composes with the flush self-heal:** +- **Gate closes Fault A** — CF will not retire the blue instance until green has reached state 2 (scheduler wired + DB connected). The SIGTERM-mid-tick race window shrinks from "any time after port-open" to "only after the scheduler is fully armed". +- **Flush closes Fault B** — even with the gate, the event-driven re-trigger chain may not re-arm after a SIGTERM. The `cds.flush()` on every ~10-min self-heal tick bounds drain latency to ≤ one interval, no manual restart required. From c48837fad29079c87b382146066e3934acc45d96 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 11:55:56 -0600 Subject: [PATCH 13/42] docs(ops): correct fastest-canary cadence in readiness-gate notes (#2975) Final-review Minor: both the runbook and the design doc misstated the fastest canary. Verified against srv/jobs/scheduler.js: kg-ondemand-drain is 1-59/2 (~every 2 min), metrics-rollup is */5. The state-3 'unreachable synchronously' rationale holds either way; corrected the figure. --- docs/developers/operations/scheduler-troubleshooting.md | 2 +- .../specs/2026-11-02-scheduler-readiness-gate-design.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/developers/operations/scheduler-troubleshooting.md b/docs/developers/operations/scheduler-troubleshooting.md index d160a187b..55a934e13 100644 --- a/docs/developers/operations/scheduler-troubleshooting.md +++ b/docs/developers/operations/scheduler-troubleshooting.md @@ -189,7 +189,7 @@ CF blue-green cuts over the instant green's TCP port accepts connections — `cd 1. **Port open** — TCP accepts connections; `cds.on('listening')` has fired. Scheduler _not_ yet started. This is the old default; CF retired blue here. 2. **Scheduler wired + DB connected** — `CronService.init()` has completed (schedules registered, tombstone reaper + self-heal armed, boot sweep queued) **and** a `SELECT 1` DB ping succeeds. The outbox queue will drain within one self-heal interval. -3. **Actually draining** — the first `cds.flush()` inside `armSchedulerSelfHeal` has completed and jobs are ticking. Unreachable synchronously at boot (fastest canary is `*/2` — gating on a real drain would make CF kill healthy freshly-booted green instances). +3. **Actually draining** — the first `cds.flush()` inside `armSchedulerSelfHeal` has completed and jobs are ticking. Unreachable synchronously at boot (even the fastest canary, `kg-ondemand-drain` at `1-59/2`, only ticks every ~2 min — gating on a real drain would make CF kill healthy freshly-booted green instances). The gate asserts **state 2**, not state 3. That is sufficient: combined with the `cds.flush()` self-heal (see above), the first drain lands within ≤ one self-heal interval (~10 min) after state 2 is reached. diff --git a/docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md b/docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md index 45c92ef3a..d41c6c4ee 100644 --- a/docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md +++ b/docs/superpowers/specs/2026-11-02-scheduler-readiness-gate-design.md @@ -35,9 +35,10 @@ Three boot states, in order: (`srv/cron-service.js:44`); outbox rows exist and `cds.db` is connected. 3. **Scheduler draining** — the processor has actually consumed ≥1 tick since boot. -State 3 is what we'd *love* to gate on, but it is **unreachable synchronously**: the -fastest canary (`metrics-rollup`) is `*/5`, so a freshly-booted green could legitimately -take up to 5 min before it has drained anything — far beyond any CF health-check timeout. +State 3 is what we'd *love* to gate on, but it is **unreachable synchronously**: even the +fastest canary (`kg-ondemand-drain`, `1-59/2` = every ~2 min; `metrics-rollup` is `*/5`) means a +freshly-booted green could legitimately take minutes before it has drained anything — far beyond +any CF health-check timeout. Gating on state 3 would make CF kill every healthy freshly-booted instance. That is a trap and is explicitly **out of scope**. From 2705c3ae499f81024531f9423bd587023d7f4742 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 12:41:18 -0600 Subject: [PATCH 14/42] fix(scripts): load+link CDS model in cleanup-selfheal-orphan before cds.entities() (#757/#911) cleanup-selfheal-orphan.cjs called cds.entities('com.sap.developers.ims') after a bare cds.connect.to('db') under 'cds bind --exec', with no cds.load(). The regression-#757 guard (cds-entities-requires-load.test.ts) failed the DEV unit-tests job, and at runtime the script would throw 'cds.entities is not a function'. Apply the canonical #911-safe pattern used by the other operator scripts: cds.model = cds.linked(await cds.load('*')); void cds.model.entities; // prime getter before connect strips the iterator await cds.connect.to('db'); Scoped guard test: 293/293 pass (was 1 failed). --- scripts/cleanup-selfheal-orphan.cjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/cleanup-selfheal-orphan.cjs b/scripts/cleanup-selfheal-orphan.cjs index fbf92d461..b43f71163 100644 --- a/scripts/cleanup-selfheal-orphan.cjs +++ b/scripts/cleanup-selfheal-orphan.cjs @@ -21,6 +21,12 @@ const ORPHAN = "__scheduler_selfheal__"; const DO_DELETE = process.argv.includes("--delete"); (async () => { + // Load + link the CDS model so `cds.entities(...)` is callable. Under + // `cds bind --exec` there is no serving lifecycle to populate cds.model, and + // a bare CSN's `.definitions` is non-iterable — prime the getter BEFORE + // connect, which otherwise strips the iterator linked() installs. See #757/#911. + cds.model = cds.linked(await cds.load("*")); + void cds.model.entities; await cds.connect.to("db"); const { JobLastRun } = cds.entities("com.sap.developers.ims"); From b7f70874635cee90672e1b86742b4e56ae75181c Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 12:59:25 -0600 Subject: [PATCH 15/42] fix(app-space): key track identity on CompletionPaths UUID, not legacyId (#2952) The App Space track UI used legacyId as the track identifier (id: p.legacyId in getAppSpaceProgress), but legacyId is only populated for IMS-migrated rows and is null for AI-generated / event tracks (TechEd Texas). A null id broke three things at once, all of which looked like a deploy regression but were data-shape dependent: - track -> URL sync: trackUrl() took the delete-param branch for a null id, so selecting a track never wrote ?track=; a browser refresh dropped back to the track list. - Refresh button: the re-open-after-refresh match keyed on the null id, so the open track (and its unlocked barcode) never re-rendered. - checkpoint barcode gate: keyed on track/item identity. Fix: emit the always-present CompletionPaths UUID (p.ID) as the track id and treat it as an opaque string end-to-end in AppSpace.vue (no Number() coercion in syncTrackFromUrl). legacyId stays reserved for its migration purpose. Also harden the scanner expired-session redirect: the approuter answers an expired XSUAA session with 200 + an HTML login document (fetch follows the 302), not a bare 401, so the old status===401 check never fired. Detect the redirected/HTML-login response and reload so the approuter re-issues auth. Tests: track-url regression fixture switched to UUID string ids (reproduces the null/non-numeric id PROD shape). --- hugo-apps/src/app-space/AppSpace.vue | 10 ++++--- .../__tests__/AppSpace.track-url.test.ts | 27 ++++++++++++------- hugo-apps/src/scanner-vue/useScannerApi.ts | 27 ++++++++++++------- srv/developer-service.js | 6 ++++- 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/hugo-apps/src/app-space/AppSpace.vue b/hugo-apps/src/app-space/AppSpace.vue index 11f6c2571..1582a9dd6 100644 --- a/hugo-apps/src/app-space/AppSpace.vue +++ b/hugo-apps/src/app-space/AppSpace.vue @@ -101,7 +101,7 @@ interface ForkSegment { type AppSpaceSegment = LinearSegment | ClusterSegment | ForkSegment interface AppSpaceTrack { - id: number + id: string title: string description: string items: AppSpaceItem[] @@ -508,7 +508,7 @@ function itemStatusClass(track: AppSpaceTrack, index: number): string { // refresh restores the detail view and the browser back/forward buttons // move between the track list and a track. Existing params (eventId, theme) // are preserved; `track` is only added/removed. -function trackUrl(trackId: number | null): string { +function trackUrl(trackId: string | null): string { const params = new URLSearchParams(window.location.search) if (trackId === null) params.delete('track') else params.set('track', String(trackId)) @@ -535,10 +535,12 @@ function syncTrackFromUrl() { selectedTrack.value = null return } - const id = Number(raw) + // Track id is the CompletionPaths UUID string — compare as-is. (Previously + // Number(raw) coerced it to NaN for UUIDs, so the detail view never + // restored on refresh/back — the TechEd Texas regression.) // A stale/unknown id (deleted track, hand-edited URL) falls back to the // list rather than throwing — degrade gracefully. - selectedTrack.value = tracks.value.find(t => t.id === id) ?? null + selectedTrack.value = tracks.value.find(t => t.id === raw) ?? null } // Keep state in sync with browser back/forward. Registered synchronously in diff --git a/hugo-apps/src/app-space/__tests__/AppSpace.track-url.test.ts b/hugo-apps/src/app-space/__tests__/AppSpace.track-url.test.ts index 5b13c4321..ad6ec318d 100644 --- a/hugo-apps/src/app-space/__tests__/AppSpace.track-url.test.ts +++ b/hugo-apps/src/app-space/__tests__/AppSpace.track-url.test.ts @@ -15,6 +15,13 @@ vi.mock('../useRealtimeProgress', async () => { import AppSpace from '../AppSpace.vue' +// Track ids are the CompletionPaths UUID (`p.ID`), NOT the legacy `legacyId`: +// legacyId is only populated for IMS-migrated rows and is null for +// AI-generated / event tracks (the TechEd Texas regression). The UUID is +// always present, so the UI must key track identity on it. +const TRACK_A = 'a1111111-1111-4111-8111-111111111111' +const TRACK_B = 'b2222222-2222-4222-8222-222222222222' + const eventBody = { eventId: 10000002, eventName: 'Track URL Event', @@ -23,10 +30,10 @@ const eventBody = { authenticated: false, type: 'COMPLEX', paths: [ - { id: 11, title: 'Track A', description: '', items: [ + { id: TRACK_A, title: 'Track A', description: '', items: [ { imsId: 1, title: 'Tut 1', type: 'TUTORIAL', status: '', progress: 0, experience: '', timeToComplete: 0, url: '/tutorials/x.html', description: '' } ] }, - { id: 22, title: 'Track B', description: '', items: [ + { id: TRACK_B, title: 'Track B', description: '', items: [ { imsId: 2, title: 'Tut 2', type: 'TUTORIAL', status: '', progress: 0, experience: '', timeToComplete: 0, url: '/tutorials/y.html', description: '' } ] }, ], @@ -49,7 +56,7 @@ describe('AppSpace track ↔ URL sync', () => { }) it('restores the selected track from ?track= on load, preserving other params', async () => { - window.history.replaceState({}, '', '/app-space/?eventId=10000002&track=22') + window.history.replaceState({}, '', `/app-space/?eventId=10000002&track=${TRACK_B}`) stubFetch() const wrapper = mount(AppSpace) @@ -67,17 +74,17 @@ describe('AppSpace track ↔ URL sync', () => { const wrapper = mount(AppSpace) await flushPromises() - const vm = wrapper.vm as unknown as { selectTrack: (t: { id: number }) => void; tracks: { id: number }[] } - vm.selectTrack(vm.tracks.find(t => t.id === 11)!) + const vm = wrapper.vm as unknown as { selectTrack: (t: { id: string }) => void; tracks: { id: string }[] } + vm.selectTrack(vm.tracks.find(t => t.id === TRACK_A)!) await flushPromises() const params = new URLSearchParams(window.location.search) - expect(params.get('track')).toBe('11') + expect(params.get('track')).toBe(TRACK_A) expect(params.get('eventId')).toBe('10000002') }) it('removes ?track on goBack', async () => { - window.history.replaceState({}, '', '/app-space/?eventId=10000002&track=11') + window.history.replaceState({}, '', `/app-space/?eventId=10000002&track=${TRACK_A}`) stubFetch() const wrapper = mount(AppSpace) await flushPromises() @@ -96,8 +103,8 @@ describe('AppSpace track ↔ URL sync', () => { const wrapper = mount(AppSpace) await flushPromises() - const vm = wrapper.vm as unknown as { selectTrack: (t: { id: number }) => void; tracks: { id: number }[] } - vm.selectTrack(vm.tracks.find(t => t.id === 22)!) + const vm = wrapper.vm as unknown as { selectTrack: (t: { id: string }) => void; tracks: { id: string }[] } + vm.selectTrack(vm.tracks.find(t => t.id === TRACK_B)!) await flushPromises() expect(wrapper.find('.detail-title').text()).toBe('Track B') @@ -110,7 +117,7 @@ describe('AppSpace track ↔ URL sync', () => { }) it('falls back to the list for an unknown track id', async () => { - window.history.replaceState({}, '', '/app-space/?eventId=10000002&track=999') + window.history.replaceState({}, '', '/app-space/?eventId=10000002&track=nonexistent-uuid-000') stubFetch() const wrapper = mount(AppSpace) await flushPromises() diff --git a/hugo-apps/src/scanner-vue/useScannerApi.ts b/hugo-apps/src/scanner-vue/useScannerApi.ts index cdafb66f7..19a78c86f 100644 --- a/hugo-apps/src/scanner-vue/useScannerApi.ts +++ b/hugo-apps/src/scanner-vue/useScannerApi.ts @@ -35,11 +35,18 @@ export function claimRequest(uid: string, eventId: number): { url: string; metho } } -// When the XSUAA session expires the AppRouter returns 401 (or a 302 → HTML -// login page that fetch follows, ending up non-ok with no JSON body). Rather -// than showing a cryptic error, reload so the AppRouter can redirect to login. -function redirectToLoginIfUnauthorized(status: number): boolean { - if (status === 401) { +// When the XSUAA session expires the AppRouter may answer in one of two ways: +// 1. HTTP 401 on the API call, or +// 2. a 302 → the IdP/login page which `fetch` transparently follows, so the +// Response lands as `ok` (HTTP 200) carrying an HTML login document with +// no JSON body (res.redirected === true, content-type text/html). +// The old check only caught case 1, so at TechEd the expired-session scanner +// silently showed a parse error instead of bouncing to login. Detect both: +// reload the page so the AppRouter re-issues its auth redirect from the top. +function redirectToLoginIfUnauthorized(res: Response): boolean { + const ct = res.headers?.get?.('content-type') ?? '' + const looksLikeLoginPage = res.redirected || /text\/html/i.test(ct) + if (res.status === 401 || (res.status === 403 && looksLikeLoginPage) || (res.ok && looksLikeLoginPage)) { window.location.reload() return true } @@ -59,7 +66,7 @@ async function fetchCsrfToken(): Promise { // The OData v4 service document is a cheap GET under the same /scanner/ route // and auth scope, so the approuter issues a token for it. const res = await fetch('/scanner/', { headers: { 'x-csrf-token': 'fetch' } }) - if (redirectToLoginIfUnauthorized(res.status)) return null + if (redirectToLoginIfUnauthorized(res)) return null const token = res.headers?.get?.('x-csrf-token') ?? null if (token && token.toLowerCase() !== 'required') csrfToken = token return csrfToken @@ -75,7 +82,7 @@ export function useScannerApi() { error.value = null try { const res = await fetch(`/scanner/getContestant(accountNumber='${encodeURIComponent(uid)}',eventId=${eventId})`) - if (redirectToLoginIfUnauthorized(res.status)) return Promise.reject(new Error('Session expired')) + if (redirectToLoginIfUnauthorized(res)) return Promise.reject(new Error('Session expired')) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error?.message || `HTTP ${res.status}`) @@ -89,7 +96,7 @@ export function useScannerApi() { async function getEventStats(eventId: number): Promise { const res = await fetch(`/scanner/getEventStats(eventId=${eventId})`) - if (redirectToLoginIfUnauthorized(res.status)) return Promise.reject(new Error('Session expired')) + if (redirectToLoginIfUnauthorized(res)) return Promise.reject(new Error('Session expired')) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error?.message || `HTTP ${res.status}`) @@ -116,14 +123,14 @@ export function useScannerApi() { if (token === null && !csrfToken) return Promise.reject(new Error('Session expired')) let res = await postOnce(token) - if (redirectToLoginIfUnauthorized(res.status)) return Promise.reject(new Error('Session expired')) + if (redirectToLoginIfUnauthorized(res)) return Promise.reject(new Error('Session expired')) if (res.status === 403 && (res.headers?.get?.('x-csrf-token') ?? '').toLowerCase() === 'required') { csrfToken = null token = await fetchCsrfToken() if (token === null && !csrfToken) return Promise.reject(new Error('Session expired')) res = await postOnce(token) - if (redirectToLoginIfUnauthorized(res.status)) return Promise.reject(new Error('Session expired')) + if (redirectToLoginIfUnauthorized(res)) return Promise.reject(new Error('Session expired')) } if (!res.ok) { diff --git a/srv/developer-service.js b/srv/developer-service.js index 1c2006113..b59ff6369 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -731,7 +731,11 @@ export default class DeveloperService extends cds.ApplicationService { paths: paths.map(p => { const pathItems = allItems.filter(i => i.path_ID === p.ID); return { - id: p.legacyId, + // #2952: track identity is the CompletionPaths UUID (always present), + // NOT legacyId — legacyId is only populated for IMS-migrated rows and + // is null for AI-generated / event tracks, which broke the App Space + // track→URL sync, Refresh re-match, and barcode unlock gate. + id: p.ID, title: p.name, description: p.description || mission.description || '', items: buildPathItems({ From 48164bf0563722d6dc9c3e60d3fba3cbc254776c Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 13:40:15 -0600 Subject: [PATCH 16/42] =?UTF-8?q?fix(cap):=20disable=20draft=5Fadmin=5Fint?= =?UTF-8?q?egrity=20for=20production=20=E2=80=94=20missing=20HANA=20cascad?= =?UTF-8?q?e=20constraints=20corrupted=20drafts=20(#2909)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CAP 10.2 cds.fiori.draft_admin_integrity flag was enabled for production (#2909) but the required ON DELETE CASCADE .hdbconstraint files were never generated/deployed. With the flag ON and no cascade, every mission draft activate/discard left ORPHANED draft rows (draft row survives, its DraftAdministrativeData parent is gone). FE then 404s on draftEdit (DRAFT_ALREADY_EXISTS + 'Not Found'), and the discard affordance never renders — making missions permanently un-editable in PROD. Changes: - package.json: flip cds.fiori.draft_admin_integrity to false (prod + all profiles) — reverts to the proven pre-10.2 draft-deletion behavior. The runtime CDS_* env override does NOT work on the deployed build (verified), so this must be a code change. - draft-integrity-preflight.js: the DRAFT_TABLES list used non-existent com_sap_developers_ims_*_drafts names. The real tables are ADMINSERVICE_*_DRAFTS, so the preflight reported 26/26 'missing' and validated NOTHING. Rewrote checkDraftCascadeConstraints to DISCOVER the real draft tables from SYS.TABLE_COLUMNS at runtime so the check can never drift. - Updated unit tests for the two-query discovery contract (14 pass). PROD orphan cleanup (57 rows: 1 MISSIONS_DRAFTS root + 32 COMPLETIONPATHITEMS + 11 COMPLETIONPATHS + 7 MISSIONTAGS + 6 MISSIONCATEGORIES) was done directly in HANA; live drafts (11 admin rows) preserved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package.json | 2 +- srv/lib/draft-integrity-preflight.js | 56 +++++++-- test/unit/draft-integrity-preflight.test.js | 125 ++++++++++++-------- 3 files changed, 122 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index 93cddf313..032522eda 100644 --- a/package.json +++ b/package.json @@ -458,7 +458,7 @@ "fuzzy": false }, "fiori": { - "draft_admin_integrity": true + "draft_admin_integrity": false }, "websocket": { "kind": "socket.io", diff --git a/srv/lib/draft-integrity-preflight.js b/srv/lib/draft-integrity-preflight.js index 416f984d9..74e9bf59e 100644 --- a/srv/lib/draft-integrity-preflight.js +++ b/srv/lib/draft-integrity-preflight.js @@ -19,7 +19,12 @@ // Authoritative list of draft-enabled entity table names (lowercase). // Source: all entities with @odata.draft.enabled: true in AdminService, // mapped to their underlying HANA table name (dots → underscores + _drafts). -// If a new draft-enabled entity is added to AdminService, add its entry here. +// DEPRECATED — no longer used by checkDraftCascadeConstraints, which now +// discovers the real `ADMINSERVICE__DRAFTS` tables from SYS.TABLES at +// runtime. These `com_sap_developers_ims_*_drafts` names never matched any +// deployed table (the check reported them all "missing" and validated nothing). +// Retained only for the existing unit test's import; safe to delete once that +// test is updated. export const DRAFT_TABLES = [ 'com_sap_developers_ims_tutorials_drafts', 'com_sap_developers_ims_missions_drafts', @@ -60,29 +65,52 @@ export function isHanaKind(cds) { } /** - * Query SYS.REFERENTIAL_CONSTRAINTS for ON DELETE CASCADE FKs on draft tables. + * Query SYS.REFERENTIAL_CONSTRAINTS for ON DELETE CASCADE FKs on draft tables, + * comparing against the draft tables ACTUALLY present in the schema. + * + * The real draft shadow tables are named `ADMINSERVICE__DRAFTS` + * (service-prefixed, uppercased) — NOT the entity-DB names. An earlier + * hardcoded `com_sap_developers_ims_*_drafts` list never matched any real + * table, so the check silently reported 26/26 "missing" and validated nothing. + * We now discover the draft tables from SYS.TABLES at runtime so the list can + * never drift out of sync with the deployed schema. * * Pure: receives dbRun as a parameter so it can be tested without booting CAP. * * @param {(sql: string) => Promise} dbRun - * @returns {Promise<{ok: boolean, found: string[], missing: string[], error?: string}>} + * @returns {Promise<{ok: boolean, found: string[], missing: string[], total: number, error?: string}>} */ export async function checkDraftCascadeConstraints(dbRun) { try { - const rows = await dbRun( + // 1. Discover the draft tables that actually exist in this schema, and + // that carry the DraftAdministrativeData FK column (i.e. real draft + // shadow tables, not child comps without the FK). + const draftTableRows = await dbRun( + `SELECT c.TABLE_NAME FROM SYS.TABLE_COLUMNS c` + + ` JOIN SYS.TABLES t ON t.SCHEMA_NAME = c.SCHEMA_NAME AND t.TABLE_NAME = c.TABLE_NAME` + + ` WHERE c.SCHEMA_NAME = CURRENT_SCHEMA` + + ` AND c.TABLE_NAME LIKE '%\\_DRAFTS' ESCAPE '\\'` + + ` AND c.COLUMN_NAME = 'DRAFTADMINISTRATIVEDATA_DRAFTUUID'` + ); + const draftTables = (draftTableRows || []).map(r => String(r.TABLE_NAME).toUpperCase()); + + // 2. Which of those have an ON DELETE CASCADE FK on the DraftUUID column. + const cascadeRows = await dbRun( `SELECT TABLE_NAME FROM SYS.REFERENTIAL_CONSTRAINTS` + ` WHERE COLUMN_NAME = 'DRAFTADMINISTRATIVEDATA_DRAFTUUID'` + ` AND DELETE_RULE = 'CASCADE'` ); - const foundSet = new Set((rows || []).map(r => String(r.TABLE_NAME).toLowerCase())); - const found = DRAFT_TABLES.filter(t => foundSet.has(t)); - const missing = DRAFT_TABLES.filter(t => !foundSet.has(t)); - return { ok: missing.length === 0, found, missing }; + const cascadeSet = new Set((cascadeRows || []).map(r => String(r.TABLE_NAME).toUpperCase())); + + const found = draftTables.filter(t => cascadeSet.has(t)); + const missing = draftTables.filter(t => !cascadeSet.has(t)); + return { ok: missing.length === 0, found, missing, total: draftTables.length }; } catch (err) { return { ok: false, found: [], - missing: [...DRAFT_TABLES], + missing: [], + total: 0, error: err?.message ?? String(err), }; } @@ -109,11 +137,17 @@ export function wireDraftIntegrityPreflight(cds) { `[draft-integrity-preflight] DB error querying SYS.REFERENTIAL_CONSTRAINTS (fail-open):`, result.error ); + } else if (result.total === 0) { + log.warn( + `[draft-integrity-preflight] no draft shadow tables with a` + + ` DraftAdministrativeData FK were found in the schema — cannot verify` + + ` cascade constraints. If drafts are expected, check the schema name.` + ); } else if (!result.ok) { log.warn( `[draft-integrity-preflight] cds.fiori.draft_admin_integrity is ON but` + ` ON DELETE CASCADE constraints are missing on HANA for` + - ` ${result.missing.length}/${DRAFT_TABLES.length} draft tables.` + + ` ${result.missing.length}/${result.total} draft tables.` + ` Missing: ${result.missing.join(', ')}.` + ` Deploy the HDI constraints (.hdbconstraint) before drafts are created` + ` or activated on this environment, then restart.` @@ -121,7 +155,7 @@ export function wireDraftIntegrityPreflight(cds) { } else { log.info( `[draft-integrity-preflight] draft cascade constraints verified` + - ` (${result.found.length}/${DRAFT_TABLES.length}).` + ` (${result.found.length}/${result.total}).` ); } } catch (err) { diff --git a/test/unit/draft-integrity-preflight.test.js b/test/unit/draft-integrity-preflight.test.js index 4f6aa14f1..97105918e 100644 --- a/test/unit/draft-integrity-preflight.test.js +++ b/test/unit/draft-integrity-preflight.test.js @@ -1,6 +1,9 @@ // test/unit/draft-integrity-preflight.test.js // -// Unit tests for srv/lib/draft-integrity-preflight.js (#2968). +// Unit tests for srv/lib/draft-integrity-preflight.js (#2968, rev #2909-fix). +// checkDraftCascadeConstraints now DISCOVERS the real ADMINSERVICE_*_DRAFTS +// tables from SYS.TABLE_COLUMNS, then checks which have an ON DELETE CASCADE +// constraint. The mock dbRun below branches on the SQL to emulate both queries. // All tests use mock dbRun — no CAP boot required. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { @@ -10,22 +13,29 @@ import { wireDraftIntegrityPreflight, } from '../../srv/lib/draft-integrity-preflight.js'; -describe('DRAFT_TABLES', () => { - it('is a non-empty array of lowercase strings', () => { +// Realistic sample of discovered draft tables (service-prefixed, uppercase). +const DISCOVERED = [ + 'ADMINSERVICE_MISSIONS_DRAFTS', + 'ADMINSERVICE_GROUPS_DRAFTS', + 'ADMINSERVICE_TUTORIALS_DRAFTS', +]; + +// Build a mock dbRun that answers the discovery query with `discovered` +// and the cascade-constraint query with `cascade`. +const makeDbRun = (discovered, cascade) => async (sql) => { + if (/TABLE_COLUMNS/.test(sql)) { + return discovered.map((t) => ({ TABLE_NAME: t })); + } + if (/REFERENTIAL_CONSTRAINTS/.test(sql)) { + return cascade.map((t) => ({ TABLE_NAME: t })); + } + return []; +}; + +describe('DRAFT_TABLES (deprecated export)', () => { + it('is still exported as a non-empty array (legacy import compatibility)', () => { expect(Array.isArray(DRAFT_TABLES)).toBe(true); expect(DRAFT_TABLES.length).toBeGreaterThan(0); - for (const t of DRAFT_TABLES) { - expect(typeof t).toBe('string'); - expect(t).toBe(t.toLowerCase()); - } - }); - - it('contains the known high-risk draft tables', () => { - // These are the tables mentioned in the runbook. If a new draft-enabled - // entity is added to AdminService and this test fails, add it to DRAFT_TABLES. - expect(DRAFT_TABLES).toContain('com_sap_developers_ims_missions_drafts'); - expect(DRAFT_TABLES).toContain('com_sap_developers_ims_groups_drafts'); - expect(DRAFT_TABLES).toContain('com_sap_developers_ims_tutorials_drafts'); }); }); @@ -33,15 +43,12 @@ describe('isHanaKind', () => { it('returns true when cds.db.options.kind is hana', () => { expect(isHanaKind({ db: { options: { kind: 'hana' } } })).toBe(true); }); - it('returns false for sqlite', () => { expect(isHanaKind({ db: { options: { kind: 'sqlite' } } })).toBe(false); }); - it('returns false when cds.db is undefined', () => { expect(isHanaKind({})).toBe(false); }); - it('returns false when cds itself is empty-ish', () => { expect(isHanaKind(null)).toBe(false); expect(isHanaKind(undefined)).toBe(false); @@ -49,51 +56,66 @@ describe('isHanaKind', () => { }); describe('checkDraftCascadeConstraints', () => { - it('returns ok:true when all DRAFT_TABLES have a cascade constraint', async () => { - const allRows = DRAFT_TABLES.map(t => ({ TABLE_NAME: t.toUpperCase() })); - const result = await checkDraftCascadeConstraints(async () => allRows); + it('ok:true when every discovered draft table has a cascade constraint', async () => { + const result = await checkDraftCascadeConstraints(makeDbRun(DISCOVERED, DISCOVERED)); expect(result.ok).toBe(true); + expect(result.total).toBe(DISCOVERED.length); + expect(result.found).toHaveLength(DISCOVERED.length); expect(result.missing).toHaveLength(0); - expect(result.found).toHaveLength(DRAFT_TABLES.length); expect(result.error).toBeUndefined(); }); - it('returns ok:false when no constraints exist', async () => { - const result = await checkDraftCascadeConstraints(async () => []); + it('ok:false and lists missing when some tables lack the constraint', async () => { + const result = await checkDraftCascadeConstraints( + makeDbRun(DISCOVERED, [DISCOVERED[0]]) + ); expect(result.ok).toBe(false); - expect(result.found).toHaveLength(0); - expect(result.missing).toHaveLength(DRAFT_TABLES.length); + expect(result.total).toBe(DISCOVERED.length); + expect(result.found).toEqual([DISCOVERED[0]]); + expect(result.missing).toEqual([DISCOVERED[1], DISCOVERED[2]]); }); - it('returns ok:false and lists only missing tables when partial', async () => { - const partial = [{ TABLE_NAME: DRAFT_TABLES[0].toUpperCase() }]; - const result = await checkDraftCascadeConstraints(async () => partial); + it('ok:false with all missing when no constraints exist', async () => { + const result = await checkDraftCascadeConstraints(makeDbRun(DISCOVERED, [])); expect(result.ok).toBe(false); - expect(result.found).toHaveLength(1); - expect(result.found[0]).toBe(DRAFT_TABLES[0]); - expect(result.missing).toHaveLength(DRAFT_TABLES.length - 1); - expect(result.missing).not.toContain(DRAFT_TABLES[0]); + expect(result.missing).toHaveLength(DISCOVERED.length); + expect(result.found).toHaveLength(0); }); - it('handles case-insensitive TABLE_NAME matching', async () => { - // HANA returns uppercase; we store lowercase — ensure the comparison works - const rows = DRAFT_TABLES.map(t => ({ TABLE_NAME: t.toUpperCase() })); - const result = await checkDraftCascadeConstraints(async () => rows); + it('total:0 and ok:true when no draft tables are discovered', async () => { + // Nothing to validate -> ok (the served handler warns on total===0 separately) + const result = await checkDraftCascadeConstraints(makeDbRun([], [])); + expect(result.total).toBe(0); expect(result.ok).toBe(true); + expect(result.missing).toHaveLength(0); }); - it('returns ok:false with error property when dbRun throws (fail-open shape)', async () => { - const result = await checkDraftCascadeConstraints(async () => { throw new Error('HANA gone'); }); + it('matches case-insensitively (HANA uppercase vs mixed)', async () => { + const run = async (sql) => { + if (/TABLE_COLUMNS/.test(sql)) return [{ TABLE_NAME: 'adminservice_missions_drafts' }]; + if (/REFERENTIAL_CONSTRAINTS/.test(sql)) return [{ TABLE_NAME: 'ADMINSERVICE_MISSIONS_DRAFTS' }]; + return []; + }; + const result = await checkDraftCascadeConstraints(run); + expect(result.ok).toBe(true); + expect(result.found).toEqual(['ADMINSERVICE_MISSIONS_DRAFTS']); + }); + + it('ok:false with error property when dbRun throws (fail-open shape)', async () => { + const result = await checkDraftCascadeConstraints(async () => { + throw new Error('HANA gone'); + }); expect(result.ok).toBe(false); expect(result.error).toBe('HANA gone'); expect(result.found).toHaveLength(0); - expect(result.missing).toHaveLength(DRAFT_TABLES.length); + expect(result.total).toBe(0); }); - it('treats null/undefined rows result as empty (no crash)', async () => { + it('treats null rows result as empty (no crash)', async () => { const result = await checkDraftCascadeConstraints(async () => null); - expect(result.ok).toBe(false); - expect(result.found).toHaveLength(0); + // discovery returned null -> no tables -> total 0, ok true, no throw + expect(result.total).toBe(0); + expect(result.ok).toBe(true); }); }); @@ -103,9 +125,14 @@ describe('wireDraftIntegrityPreflight sentinel', () => { const makeFakeCds = () => ({ db: { options: { kind: 'hana' }, - run: async () => { dbRunCalls++; return []; }, + run: async () => { + dbRunCalls++; + return []; + }, + }, + on: (event, fn) => { + if (event === 'served') void fn(); }, - on: (event, fn) => { if (event === 'served') void fn(); }, log: () => ({ warn: () => {}, info: () => {} }), }); @@ -121,16 +148,16 @@ describe('wireDraftIntegrityPreflight sentinel', () => { const fakeCds = makeFakeCds(); wireDraftIntegrityPreflight(fakeCds); wireDraftIntegrityPreflight(fakeCds); - // One microtask tick to let async handler settle - await new Promise(r => setTimeout(r, 0)); - expect(dbRunCalls).toBeLessThanOrEqual(1); + await new Promise((r) => setTimeout(r, 0)); + // One served handler -> at most the two discovery+constraint queries for ONE run + expect(dbRunCalls).toBeLessThanOrEqual(2); }); it('skips the DB query entirely when kind is not hana', async () => { const fakeCds = makeFakeCds(); fakeCds.db.options.kind = 'sqlite'; wireDraftIntegrityPreflight(fakeCds); - await new Promise(r => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); expect(dbRunCalls).toBe(0); }); }); From 71207c2117ccf2d0aa7b57ef29f9b9d6ebfe9701 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 13:58:30 -0600 Subject: [PATCH 17/42] =?UTF-8?q?feat(ops):=20cleanup-draft-orphans.cjs=20?= =?UTF-8?q?=E2=80=94=20sweep=20orphaned=20draft=20rows=20from=20#2909=20(#?= =?UTF-8?q?2993)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeatable HANA cleanup for draft rows orphaned by draft_admin_integrity running without cascade constraints. Discovers real ADMINSERVICE_*_DRAFTS tables from SYS.TABLE_COLUMNS, deletes rows whose DraftAdministrativeData parent is gone, preserves live drafts, verifies clean after commit. --dry-run (default) / --commit. HANA-only. Idempotent. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/cleanup-draft-orphans.cjs | 131 ++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 scripts/cleanup-draft-orphans.cjs diff --git a/scripts/cleanup-draft-orphans.cjs b/scripts/cleanup-draft-orphans.cjs new file mode 100644 index 000000000..3d502c0da --- /dev/null +++ b/scripts/cleanup-draft-orphans.cjs @@ -0,0 +1,131 @@ +// One-shot / repeatable cleanup: delete ORPHANED draft rows left behind by the +// CAP 10.2 `draft_admin_integrity` outage (#2909 / #2993). +// +// Background: +// `cds.fiori.draft_admin_integrity: true` was enabled (#2909) WITHOUT the +// required ON DELETE CASCADE .hdbconstraint artifacts on HANA. With the flag +// on and no cascade, discarding/activating a draft deleted only the +// DRAFT_DRAFTADMINISTRATIVEDATA parent row — the child draft rows in the +// ADMINSERVICE_*_DRAFTS tables were NOT cascaded, leaving orphans. Orphans +// make the active entity un-editable (draftEdit -> DRAFT_ALREADY_EXISTS / +// "Not Found", no discard affordance). +// +// #2992 disabled the flag so NO NEW orphans form. This script clears orphans +// created while the flag was still on (e.g. in the window between the outage +// and the fixed build reaching an environment). +// +// An "orphan" = a draft row whose DRAFTADMINISTRATIVEDATA_DRAFTUUID has NO +// matching row in DRAFT_DRAFTADMINISTRATIVEDATA. Rows belonging to LIVE drafts +// (a real admin-data parent exists) are never touched. +// +// Run (inspect): npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --dry-run +// Run (delete): npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --commit +// +// Idempotent: a second run after a clean sweep is a no-op (exit 0). +// HANA-only: refuses to run on SQLite (draft table layout differs; unit tests +// use a different physical schema). + +const cds = require('@sap/cds'); + +const argv = process.argv.slice(2); +const COMMIT = argv.includes('--commit'); +const DRY_RUN = !COMMIT; + +const ADMIN = '"DRAFT_DRAFTADMINISTRATIVEDATA"'; + +(async () => { + const db = await cds.connect.to('db'); + const isHana = + db.options?.kind === 'hana' || db.constructor?.name === 'HANAService'; + if (!isHana) { + console.error('FATAL: this script must run on HANA. Use `cds bind --exec`.'); + process.exit(1); + } + + console.log(`Mode: ${DRY_RUN ? 'DRY-RUN' : 'COMMIT'}`); + + // Discover every real draft shadow table (carries the DraftAdministrativeData FK). + const draftTables = ( + await db.run( + `SELECT c."TABLE_NAME" AS T FROM "SYS"."TABLE_COLUMNS" c + JOIN "SYS"."TABLES" t + ON t."SCHEMA_NAME" = c."SCHEMA_NAME" AND t."TABLE_NAME" = c."TABLE_NAME" + WHERE c."SCHEMA_NAME" = CURRENT_SCHEMA + AND c."TABLE_NAME" LIKE '%\\_DRAFTS' ESCAPE '\\' + AND c."COLUMN_NAME" = 'DRAFTADMINISTRATIVEDATA_DRAFTUUID' + ORDER BY c."TABLE_NAME"` + ) + ).map((r) => r.T); + + if (!draftTables.length) { + console.log('No draft shadow tables found — nothing to do.'); + process.exit(0); + } + + const liveAdmin = (await db.run(`SELECT COUNT(*) AS N FROM ${ADMIN}`))[0].N; + console.log(`Live DRAFT_DRAFTADMINISTRATIVEDATA rows (preserved): ${liveAdmin}`); + + let totalOrphans = 0; + let totalDeleted = 0; + const perTable = []; + + for (const T of draftTables) { + const q = `"${T}"`; + const orphanWhere = + `"DRAFTADMINISTRATIVEDATA_DRAFTUUID" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM ${ADMIN} a + WHERE a."DRAFTUUID" = ${q}."DRAFTADMINISTRATIVEDATA_DRAFTUUID" + )`; + const n = (await db.run(`SELECT COUNT(*) AS N FROM ${q} WHERE ${orphanWhere}`))[0].N; + if (n === 0) continue; + totalOrphans += n; + perTable.push({ table: T, orphans: n }); + + if (COMMIT) { + const del = await db.run(`DELETE FROM ${q} WHERE ${orphanWhere}`); + const removed = del.changes ?? del ?? 0; + totalDeleted += removed; + console.log(` ${T}: deleted ${removed}`); + } else { + console.log(` ${T}: ${n} orphan(s) [dry-run]`); + } + } + + if (totalOrphans === 0) { + console.log('No orphaned draft rows found — nothing to do.'); + process.exit(0); + } + + if (DRY_RUN) { + console.log(`\nDRY-RUN: ${totalOrphans} orphan row(s) across ${perTable.length} table(s).`); + console.log('Re-run with --commit to delete them.'); + process.exit(0); + } + + // Verify clean after commit. + let remaining = 0; + for (const { table } of perTable) { + const q = `"${table}"`; + const n = ( + await db.run( + `SELECT COUNT(*) AS N FROM ${q} + WHERE "DRAFTADMINISTRATIVEDATA_DRAFTUUID" IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM ${ADMIN} a WHERE a."DRAFTUUID" = ${q}."DRAFTADMINISTRATIVEDATA_DRAFTUUID")` + ) + )[0].N; + remaining += n; + } + const liveAfter = (await db.run(`SELECT COUNT(*) AS N FROM ${ADMIN}`))[0].N; + console.log(`\nDeleted ${totalDeleted} orphan row(s). Remaining orphans: ${remaining}.`); + console.log(`Live admin rows before/after: ${liveAdmin}/${liveAfter} (must be equal).`); + if (remaining !== 0 || liveAfter !== liveAdmin) { + console.error('WARNING: post-cleanup verification unexpected — inspect manually.'); + process.exit(2); + } + console.log('OK — orphans cleared, live drafts intact.'); + process.exit(0); +})().catch((e) => { + console.error('ERR', e.message); + process.exit(1); +}); From dbab4e770de1dd7aaab366683d50141cc280f492 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:35:28 -0600 Subject: [PATCH 18/42] fix(help-docs): repair stored Architecture Center URLs (#2948 follow-up) PR #2948 corrected URL generation but left already-stored HelpDocs rows on the old path-derived URLs that 404. This targeted script rewrites the url column for the 6 architecture-sap-com docs behind the broken links on /concepts/agent-gateway, keyed by (source, sourceId), to their verified Docusaurus slug routes (all 200). Dry-run default; idempotent. --- scripts/fix-archcenter-urls.cjs | 106 ++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 scripts/fix-archcenter-urls.cjs diff --git a/scripts/fix-archcenter-urls.cjs b/scripts/fix-archcenter-urls.cjs new file mode 100644 index 000000000..0308b2575 --- /dev/null +++ b/scripts/fix-archcenter-urls.cjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// scripts/fix-archcenter-urls.cjs +// +// Targeted data fix for PR #2948 follow-up: already-stored HelpDocs rows for the +// SAP Architecture Center (source='architecture-sap-com') still carry the old +// path-derived URLs (…/docs/ref-arch/RA0029/…/readme) that 404. PR #2948 fixed +// URL *generation* going forward, but never repaired existing rows. The weekly +// fetch-help-docs cron would also correct these, but only when it re-fetches AND +// TUTORIALS_GITHUB_TOKEN is bound. This script repairs the rows surgically — no +// full corpus regen, no LLM cost, nothing else touched. +// +// Each target is keyed by (source, sourceId). The corrected URL is the live +// Docusaurus slug route, verified 200 against architecture.learning.sap.com. +// +// Usage (via cds bind so it hits the bound HANA): +// cds bind --exec -- node scripts/fix-archcenter-urls.cjs # dry-run (default) +// cds bind --exec -- node scripts/fix-archcenter-urls.cjs --commit # apply +// +// Idempotent: re-running after a successful apply updates 0 rows. + +const cds = require('@sap/cds'); + +const SOURCE = 'architecture-sap-com'; +const SITE_BASE = 'https://architecture.learning.sap.com'; + +// sourceId (repo-relative path) → corrected slug (as it appears in frontmatter). +// Final URL = `${SITE_BASE}/docs${slug}`. Slugs are root-absolute (leading /). +const FIXES = [ + { sourceId: 'docs/ref-arch/RA0029/readme.md', slug: '/ref-arch/98efa0' }, + { sourceId: 'docs/ref-arch/RA0029/1-a2a-and-mcp/readme.md', slug: '/ref-arch/76ec36' }, + { sourceId: 'docs/ref-arch/RA0029/4-integrate-ai-agents-with-joule/readme.md', slug: '/ref-arch/ae6821' }, + { sourceId: 'docs/ref-arch/RA0029/5-integrate-joule-agents-and-tools-into-your-ecosystem/readme.md', slug: '/ref-arch/fe0d1d' }, + { sourceId: 'docs/ref-arch/RA0029/5-integrate-joule-agents-and-tools-into-your-ecosystem/1-aws-integration/readme.md', slug: '/ref-arch/b4d7e2' }, + { sourceId: 'docs/ref-arch/RA0039/readme.md', slug: '/ref-arch/5hj89h' }, +]; + +const COMMIT = process.argv.includes('--commit'); + +async function main() { + await cds.connect.to('db'); + const { HelpDocs } = cds.entities('com.sap.developers.ims.external'); + + console.log(`\nfix-archcenter-urls — ${COMMIT ? 'COMMIT' : 'DRY-RUN'} mode`); + console.log('='.repeat(72)); + + let planned = 0; + let changed = 0; + const plan = []; + + for (const { sourceId, slug } of FIXES) { + const newUrl = `${SITE_BASE}/docs${slug}`; + // Metadata-only read (never SELECT description/LOB alongside) — LOB-locator safe. + const row = await SELECT.one + .from(HelpDocs) + .columns('ID', 'sourceId', 'url') + .where({ source: SOURCE, sourceId }); + + if (!row) { + console.log(` MISSING ${sourceId}\n (no row — skipping; nothing to fix)`); + continue; + } + if (row.url === newUrl) { + console.log(` OK ${sourceId}\n already ${newUrl}`); + continue; + } + planned++; + plan.push({ ID: row.ID, sourceId, oldUrl: row.url, newUrl }); + console.log(` FIX ${sourceId}`); + console.log(` old: ${row.url}`); + console.log(` new: ${newUrl}`); + } + + console.log('='.repeat(72)); + console.log(`${planned} row(s) need update, ${FIXES.length - planned} already correct/missing.`); + + if (!COMMIT) { + console.log('\nDRY-RUN — no writes. Re-run with --commit to apply.\n'); + return; + } + if (planned === 0) { + console.log('\nNothing to apply.\n'); + return; + } + + for (const p of plan) { + const n = await UPDATE(HelpDocs).set({ url: p.newUrl }).where({ ID: p.ID }); + changed += n; + } + console.log(`\nApplied: ${changed} row(s) updated.`); + + // Post-write read-back for audit. + console.log('\nVerification (stored url after write):'); + for (const p of plan) { + const row = await SELECT.one.from(HelpDocs).columns('url').where({ ID: p.ID }); + const ok = row && row.url === p.newUrl; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${p.sourceId} => ${row ? row.url : '(gone)'}`); + } + console.log(''); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error('fix-archcenter-urls FAILED:', err.message || err); + process.exit(1); + }); From 058a53698df635362f0cd336f1ff7d3ec7dfbe1f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:41:07 -0600 Subject: [PATCH 19/42] =?UTF-8?q?fix(health):=20/health/ready=20dbOk=20alw?= =?UTF-8?q?ays=20false=20on=20HANA=20=E2=80=94=20crash-looped=20every=20de?= =?UTF-8?q?ploy=20(#2975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2975 readiness gate probes the DB with a bare `SELECT 1`. HANA rejects that ("sql syntax error") — it requires `SELECT 1 FROM DUMMY`. So dbOk was ALWAYS false on HANA, /health/ready stayed 503, and CF's new startup health gate (also #2975) killed tutorials-srv at 60s on every deploy → crash loop. The integration test runs on in-memory SQLite (where bare `SELECT 1` works), so it passed green while the real HANA path was broken — a classic test-environment gap. Fix: add dialect-safe `dbPingSql(kind)` to scheduler-ready.js (`SELECT 1 FROM DUMMY` on hana, `SELECT 1` elsewhere) and use it in both /health/ready and /health/db (the latter had the mirror bug: unconditional `FROM DUMMY`, broken on SQLite). Unit tests cover both dialects. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- srv/lib/scheduler-ready.js | 17 +++++++++++++++++ srv/server.js | 6 +++--- test/unit/srv/lib/scheduler-ready.test.js | 14 +++++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/srv/lib/scheduler-ready.js b/srv/lib/scheduler-ready.js index 979dc81fa..2b5f1000e 100644 --- a/srv/lib/scheduler-ready.js +++ b/srv/lib/scheduler-ready.js @@ -21,3 +21,20 @@ export function computeReadiness({ schedulerArmed, dbOk }) { db: dbOk ? 'connected' : 'error', }; } + +/** + * Dialect-safe liveness ping SQL for a cheap `cds.db.run(...)`. + * + * #2975 regression: HANA rejects a bare `SELECT 1` ("sql syntax error") — it + * requires `SELECT 1 FROM DUMMY`. SQLite rejects `FROM DUMMY`. The original + * /health/ready probe hardcoded `SELECT 1`, so dbOk was ALWAYS false on HANA, + * /health/ready stayed 503, and CF's startup health gate crash-looped the app + * on every deploy. (The integration test ran on in-memory SQLite, where bare + * `SELECT 1` works, so it never caught this.) + * + * @param {string} [kind] db kind, defaults to cds.db.kind at call sites + * @returns {string} + */ +export function dbPingSql(kind) { + return kind === 'hana' ? 'SELECT 1 FROM DUMMY' : 'SELECT 1'; +} diff --git a/srv/server.js b/srv/server.js index d17dfd8e5..cb82355a6 100644 --- a/srv/server.js +++ b/srv/server.js @@ -105,7 +105,7 @@ import { uploadPetSubmission, decodePhotoUpload } from './lib/petoberfest-upload import { fetchPetPhoto } from './lib/petoberfest-photo-store.js'; import { installDbWrap } from './lib/metrics-db-wrap.js'; import { wireDraftIntegrityPreflight } from './lib/draft-integrity-preflight.js'; -import { isSchedulerReady, computeReadiness } from './lib/scheduler-ready.js'; +import { isSchedulerReady, computeReadiness, dbPingSql } from './lib/scheduler-ready.js'; import './graphql-config.js'; import { makeA2aRouter } from './lib/a2a/rpc-router.js'; import { a2aIasAuthMiddleware } from './lib/a2a/ias-auth-mw.js'; @@ -297,7 +297,7 @@ cds.on('bootstrap', (app) => { app.get('/health/ready', async (req, res) => { let dbOk = false; if (cds.db) { - try { await cds.db.run('SELECT 1'); dbOk = true; } + try { await cds.db.run(dbPingSql(cds.db.kind)); dbOk = true; } catch (_err) { dbOk = false; } } // cds.db absent → dbOk stays false @@ -307,7 +307,7 @@ cds.on('bootstrap', (app) => { app.get('/health/db', async (req, res) => { try { - await cds.db?.run('SELECT 1 FROM DUMMY'); + await cds.db?.run(dbPingSql(cds.db?.kind)); res.json({ status: 'ok', db: 'connected' }); } catch (err) { console.error('[health/db]', err.message); diff --git a/test/unit/srv/lib/scheduler-ready.test.js b/test/unit/srv/lib/scheduler-ready.test.js index c52f8b4c6..fccf5b7ab 100644 --- a/test/unit/srv/lib/scheduler-ready.test.js +++ b/test/unit/srv/lib/scheduler-ready.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { markSchedulerReady, isSchedulerReady, _resetForTests, computeReadiness } from '../../../../srv/lib/scheduler-ready.js'; +import { markSchedulerReady, isSchedulerReady, _resetForTests, computeReadiness, dbPingSql } from '../../../../srv/lib/scheduler-ready.js'; describe('scheduler-ready latch', () => { beforeEach(() => _resetForTests()); @@ -39,3 +39,15 @@ describe('computeReadiness', () => { expect(computeReadiness({ schedulerArmed: false, dbOk: false }).ready).toBe(false); }); }); + +describe('dbPingSql (#2975 HANA regression)', () => { + it('uses FROM DUMMY on HANA (bare SELECT 1 is a HANA syntax error)', () => { + expect(dbPingSql('hana')).toBe('SELECT 1 FROM DUMMY'); + }); + it('uses bare SELECT 1 on SQLite (FROM DUMMY is a SQLite error)', () => { + expect(dbPingSql('sqlite')).toBe('SELECT 1'); + }); + it('defaults to bare SELECT 1 when kind is undefined', () => { + expect(dbPingSql()).toBe('SELECT 1'); + }); +}); From 4cbef2050b8f7afacc994bdb2b8765150c19cee1 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:49:41 -0600 Subject: [PATCH 20/42] docs(#2990): mission path ordering design + plan --- .../plans/2026-11-02-mission-path-ordering.md | 208 ++++++++++++++++++ ...2026-11-02-mission-path-ordering-design.md | 155 +++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-mission-path-ordering.md create mode 100644 docs/superpowers/specs/2026-11-02-mission-path-ordering-design.md diff --git a/docs/superpowers/plans/2026-11-02-mission-path-ordering.md b/docs/superpowers/plans/2026-11-02-mission-path-ordering.md new file mode 100644 index 000000000..d804ee3a7 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-mission-path-ordering.md @@ -0,0 +1,208 @@ +# Mission Completion-Path Ordering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give admins an editable "Order" field on a Mission's Completion Paths, and make every learner-facing surface render paths in that order. + +**Architecture:** Add `CompletionPaths.pathOrder : Integer`, surfaced in the Fiori admin UI exactly like the existing `CompletionPathItems.itemOrder` (editable Order column + SortOrder). Auto-number new paths via a before-handler (max+10 gap numbering). Add `.orderBy('pathOrder','name')` at the four path read sites that drive rendering. Backfill existing HANA rows alphabetically by name per mission. + +**Tech Stack:** SAP CAP (Node.js, `@sap/cds`), CDS, Fiori Elements annotations, in-memory SQLite (unit tests) / HANA (hybrid), Vitest-style `cds.test`. + +**Spec:** `docs/superpowers/specs/2026-11-02-mission-path-ordering-design.md` + +## Global Constraints + +- New field name is exactly `pathOrder : Integer` on `CompletionPaths` (analogue of `CompletionPathItems.itemOrder`). +- Read-site ordering is exactly `.orderBy('pathOrder', 'name')` (and `'mission_ID','pathOrder','name'` for the cross-mission catalog query). Tiebreaker is `name`, NOT `legacyId`. +- Never write raw SQL in CAP runtime code — use `cds.ql` / CQL. (The backfill script may use `cds.ql` via `cds bind --exec`; follow the `repair-mission-completion-path-data.cjs` precedent.) +- Do NOT hand-edit generated bundles (`srv/lib/_shared/content.bundle.mjs`, `channels.bundle.mjs`) — edit source, rebuild. +- Do NOT touch `srv/lib/event-path-buckets.js` — its count-sort is intentional and a different surface. +- PR targets `DEV`; never direct-merge to `main`. Admin-UI changes require a full `mbt build` deploy. +- Gap numbering increment is `+10`, first value `10` (match `setGroupItemOrder`). + +## Review Focus + +- **Mission with zero completion paths** — `getAppSpaceProgress` / `mission-detail` must still return (empty paths), not throw on the new orderBy. (Covered: Task 5 keeps the existing empty-paths guard; add an assertion.) +- **Paths with NULL pathOrder after deploy but before backfill** — `.orderBy('pathOrder','name')` must not crash and must fall back to `name` order on both SQLite and HANA. (Covered: Task 5 unit test includes a NULL-pathOrder row; Task 7 hybrid test asserts post-deploy-pre-backfill ordering.) +- **Two paths with identical name under one mission** — slug uniqueness is per-mission scoped but names can collide; `name` tiebreaker leaves order undefined between them, which is acceptable (admin sets explicit pathOrder to disambiguate). No crash. (Covered: Task 3 `setPathOrder` still assigns distinct gap numbers.) +- **New path created inline in admin draft** — must get a non-null `pathOrder` without the admin typing one, and must not collide-overwrite an existing order. (Covered: Task 3.) +- **copyMission / promoteCommunityToMission producing paths with no order** — cloned/seeded paths must carry a defined `pathOrder` so the copied mission renders deterministically. (Covered: Task 4.) + +--- + +### Task 1: Add `pathOrder` to the CompletionPaths schema + +**Files:** +- Modify: `db/schema.cds` (CompletionPaths entity, ~:461-467) +- Test: `test/unit/completion-path-order.test.js` (create) + +**Interfaces:** +- Produces: `CompletionPaths.pathOrder : Integer` — consumed by every later task. + +- [ ] **Step 1: Write the failing test** — in `test/unit/completion-path-order.test.js`, bootstrap `cds.test` against the in-memory model and assert the field exists: + +```js +const cds = require('@sap/cds'); +const { expect } = cds.test(__dirname + '/../..'); +test('CompletionPaths has an Integer pathOrder element', () => { + const { CompletionPaths } = cds.model.definitions['com.sap.developers.ims'] ? cds.entities('com.sap.developers.ims') : {}; + const def = cds.model.definitions['com.sap.developers.ims.CompletionPaths']; + expect(def.elements.pathOrder).toBeDefined(); + expect(def.elements.pathOrder.type).toBe('cds.Integer'); +}); +``` + +- [ ] **Step 2: Run test, verify it FAILS** — Run: `npx cds compile db/schema.cds` then the test. Expected: `pathOrder` undefined. + +- [ ] **Step 3: Add the field** — in `db/schema.cds` `entity CompletionPaths`, add `pathOrder : Integer;` after `slug` (mirror placement of `CompletionPathItems.itemOrder`). + +- [ ] **Step 4: Run test, verify it PASSES.** + +- [ ] **Step 5: Commit** — `git add db/schema.cds test/unit/completion-path-order.test.js && git commit -m "feat(#2990): add pathOrder to CompletionPaths schema"` + +--- + +### Task 2: Surface the editable Order column in the admin UI + +**Files:** +- Modify: `app/admin-annotations.cds` (CompletionPaths annotate blocks, :214-240) + +**Interfaces:** +- Consumes: `CompletionPaths.pathOrder` (Task 1). + +- [ ] **Step 1: Add the label** — in the first `annotate AdminService.CompletionPaths with { … }` block (:215-219), add `pathOrder @Common.Label: 'Order';`. + +- [ ] **Step 2: Add SortOrder + LineItem column** — in the `@UI` block (:221-240): add `PresentationVariant: { SortOrder: [ { Property: pathOrder } ] },` and make `pathOrder` the first `LineItem` entry: `{ Value: pathOrder, Label: 'Order' },` ahead of `{ Value: name }`. + +- [ ] **Step 3: Verify the model still compiles** — Run: `npx cds compile srv/ 2>&1 | head -5`. Expected: no error referencing CompletionPaths/pathOrder. (NB: the pre-existing `developer-service-mcp.cds` duplicate-definition error on `main` is unrelated; confirm no *new* error.) + +- [ ] **Step 4: Commit** — `git add app/admin-annotations.cds && git commit -m "feat(#2990): editable Order column + SortOrder on CompletionPaths admin UI"` + +--- + +### Task 3: Auto-number new paths (`setPathOrder` before-handler) + +**Files:** +- Modify: `srv/admin-service.js` (add handler near `setGroupItemOrder`, :1129-1140; register near :1139) +- Test: `test/admin-service.test.js` (add cases) OR `test/unit/completion-path-order.test.js` + +**Interfaces:** +- Consumes: `CompletionPaths.pathOrder` (Task 1). +- Produces: a `before('NEW','CompletionPaths.drafts')` + `before('CREATE','CompletionPaths')` handler that sets `pathOrder = max(pathOrder for mission)+10` when null. + +- [ ] **Step 1: Write the failing test** — assert that creating a CompletionPath under a mission with an existing path (pathOrder 10) yields pathOrder 20, and that an explicit pathOrder is preserved. Use the service API (`POST` to CompletionPaths or draft NEW), mirroring existing GroupPathItems order tests in `test/admin-service.test.js` (search `setGroupItemOrder`/`itemOrder` for the pattern). + +```js +test('new CompletionPath gets max(pathOrder)+10 for its mission', async () => { + // seed mission M with one path pathOrder=10, create a second → expect 20 +}); +test('explicit pathOrder on create is preserved', async () => { /* pass 55 → stays 55 */ }); +``` + +- [ ] **Step 2: Run tests, verify they FAIL** — Run: `npm test -- admin-service` (new path gets null/undefined order). + +- [ ] **Step 3: Implement `setPathOrder`** — mirror `setGroupItemOrder` (:1129-1140) exactly: if `req.data.pathOrder != null` return; resolve `mission_ID` from `req.data.mission_ID` or `req.params`; `SELECT.one.from(req.target).columns('max(pathOrder) as maxOrder').where({ mission_ID })`; set `req.data.pathOrder = (maxOrder ?? 0) + 10`. Register with `this.before('NEW','CompletionPaths.drafts', setPathOrder)` and `this.before('CREATE','CompletionPaths', setPathOrder)`. + +- [ ] **Step 4: Run tests, verify they PASS.** + +- [ ] **Step 5: Commit** — `git add srv/admin-service.js test/… && git commit -m "feat(#2990): auto gap-number pathOrder on new CompletionPaths"` + +--- + +### Task 4: Carry `pathOrder` through copyMission + promoteCommunityToMission + +**Files:** +- Modify: `srv/admin-service.js` — `copyMission` INSERT (:4038), `promoteCommunityToMission` INSERT (:3928) +- Test: `test/admin-service.test.js` (copyMission case) + +**Interfaces:** +- Consumes: `CompletionPaths.pathOrder` (Task 1). + +- [ ] **Step 1: Write the failing test** — seed a mission with two paths (pathOrder 10, 20), call `copyMission`, assert the copied mission's paths preserve pathOrder 10 and 20. (Extend an existing copyMission test if present.) + +- [ ] **Step 2: Run test, verify it FAILS** — copied paths have null pathOrder. + +- [ ] **Step 3: Implement** — in `copyMission`'s `INSERT.into(CompletionPaths).entries({...})` (:4038) add `pathOrder: p.pathOrder,`. In `promoteCommunityToMission`'s single-path INSERT (:3928) add `pathOrder: 10,`. + +- [ ] **Step 4: Run test, verify it PASSES.** + +- [ ] **Step 5: Commit** — `git add srv/admin-service.js test/… && git commit -m "feat(#2990): preserve pathOrder in copyMission and seed it in promoteCommunityToMission"` + +--- + +### Task 5: Order paths by pathOrder at all render read sites + +**Files:** +- Modify: `packages/content/catalog-data.js:182-185` (add `pathOrder` to `.columns(...)`, change orderBy) +- Modify: `srv/lib/build-catalog.js:22` +- Modify: `srv/developer-service.js:673-675` (`getAppSpaceProgress`) and `:562-564` (`getEventProgress`) +- Modify: `srv/lib/branch/mission-detail.js:40` +- Modify: `packages/content/catalog-mission-hierarchy.js` JSDoc comment (:92-94) — comment only +- Test: `test/catalog-data.test.js`, `test/build-catalog-mission-detail.test.js`, `test/app-space-segments-resolver.test.js` (or a new `test/app-space-path-order.test.js`), `test/build-catalog-mission-detail.test.js` for mission-detail + +**Interfaces:** +- Consumes: `CompletionPaths.pathOrder` (Task 1). + +- [ ] **Step 1: Write failing tests** — one assertion per rendering surface, each seeding a mission with paths whose `name` A→Z order differs from their `pathOrder`, plus one NULL-pathOrder path, and asserting emitted path order is `pathOrder` asc then `name`: + - `getAppSpaceProgress(eventLegacyId=…)` → `response.paths[*].name` in pathOrder order (reuse the app-space test harness in `test/app-space-segments-resolver.test.js`). + - `catalog-data` `loadMissionContext` → SSR hierarchy path order. + - `mission-detail` handler → `paths[0]` is the pathOrder-first path. + - (build-catalog: assert cross-mission catalog emits each mission's paths in pathOrder.) + +- [ ] **Step 2: Run tests, verify they FAIL** (paths come back in legacyId/DB order). + +- [ ] **Step 3: Implement the orderBy changes:** + - `catalog-data.js:182-185`: add `'pathOrder'` to `.columns('ID','name','slug','legacyId')` → `.columns('ID','name','slug','legacyId','pathOrder')`; change `.orderBy('legacyId')` → `.orderBy('pathOrder','name')`. + - `build-catalog.js:22`: `.orderBy('legacyId')` → `.orderBy('mission_ID','pathOrder','name')`. + - `developer-service.js:564` and `:675`: `.orderBy('legacyId')` → `.orderBy('pathOrder','name')`. + - `mission-detail.js:40`: append `.orderBy('pathOrder','name')` to the SELECT. + - `catalog-mission-hierarchy.js` JSDoc (:92-94): update "legacyId" → "pathOrder, then name". + +- [ ] **Step 4: Run tests, verify they PASS.** + +- [ ] **Step 5: Rebuild generated bundles** — Run the project's bundle build (the `build:*` step that regenerates `srv/lib/_shared/content.bundle.mjs`; check `jq '.scripts' package.json` for the island/bundle script). Confirm the regenerated bundle contains `orderBy("pathOrder","name")` (grep it) — do NOT hand-edit. If the bundle is only regenerated by `build:all`, note that for the deploy step and skip here if it can't run in the worktree; flag to the executor. + +- [ ] **Step 6: Commit** — `git add packages/ srv/ && git commit -m "feat(#2990): order mission completion paths by pathOrder at all render sites"` + +--- + +### Task 6: Backfill existing HANA rows alphabetically by name + +**Files:** +- Create: `scripts/backfill-completion-path-order.cjs` (follow `scripts/repair-mission-completion-path-data.cjs`) +- Modify: `package.json` scripts (add a `backfill-completion-path-order` entry next to existing repair scripts) +- Test: `test/hybrid/backfill-completion-path-order.test.js` (follow `test/hybrid/repair-mission-completion-path-data.test.js`) + +**Interfaces:** +- Consumes: `CompletionPaths.pathOrder` (Task 1). + +- [ ] **Step 1: Write the failing hybrid test** — seed (in the hybrid DB) a mission with 3 paths named "C","A","B" all with NULL pathOrder; run the backfill in commit mode; assert pathOrder is A=10, B=20, C=30 (per-mission, alphabetical by name); assert a second run is idempotent (NULL-only guard leaves them unchanged). + +- [ ] **Step 2: Run test, verify it FAILS** (script does not exist). + +- [ ] **Step 3: Implement the script** — mirror `repair-mission-completion-path-data.cjs`: `--dry-run`/`--commit`/`--verify-only` flags, JSONL `.migration-data` snapshot, `cds.connect`. Logic: for each mission, `SELECT` its paths `where pathOrder IS NULL` ordered by `name`, UPDATE each with gap-numbered pathOrder (10,20,30…) **scoped per mission** and continuing past any already-set rows (use `max(pathOrder for mission)` as the starting base so a partial prior run extends cleanly). Use `cds.ql`, not raw SQL. + +- [ ] **Step 4: Run test, verify it PASSES** — Run: `npm run test:hybrid -- backfill-completion-path-order` (requires `cf login` + `cds bind`). If hybrid can't run in this environment, run the unit-level equivalent against in-memory SQLite and flag the hybrid run for the deploy step. + +- [ ] **Step 5: Commit** — `git add scripts/ package.json test/hybrid/ && git commit -m "feat(#2990): backfill CompletionPaths.pathOrder alphabetically per mission"` + +--- + +### Task 7: Full suite green + PR + +**Files:** none (verification + PR) + +- [ ] **Step 1: Run the full unit suite** — Run: `npm test`. Expected: PASS (wrap in `scripts/quiet-run.sh` per CLAUDE.md). The pre-existing `developer-service-mcp.cds` compile error, if it blocks, is a `main` issue — confirm whether it reproduces on untouched `main`; if so it is out of scope, note it, do not "fix" it here. + +- [ ] **Step 2: Push branch + open PR to DEV** — `gh pr create --base DEV --title "feat(#2990): mission completion-path ordering" --body ""`. Never target `main`. + +- [ ] **Step 3: Record deploy/backfill/rebuild steps in the PR body** — full `npm run deploy -- --env dev` (no `--skip-build`), then run the Task 6 backfill against DEV HANA, then `gh workflow run rebuild-content.yml -f mode=full`, then in-browser verify admin Order column + `/app-space/?eventId=…` order. + +--- + +## Self-Review notes + +- **Spec coverage:** schema (T1), admin UI (T2), auto-number (T3), write-path carry (T4), 4 read sites + hierarchy comment (T5), backfill (T6), rollout/PR (T7). All Decisions 1-7 mapped. +- **Review Focus:** zero-paths, NULL-pathOrder-pre-backfill, duplicate names, inline-create, copy/promote — each pinned to T3/T4/T5/T6 tests. +- **Type consistency:** field is `pathOrder` everywhere; orderBy string is `'pathOrder','name'` everywhere (cross-mission adds `'mission_ID'` first). +- **Proportion:** plan carries the decisions (exact lines, exact orderBy, exact handler shape); bodies left to the implementer except where the signature is fixed. diff --git a/docs/superpowers/specs/2026-11-02-mission-path-ordering-design.md b/docs/superpowers/specs/2026-11-02-mission-path-ordering-design.md new file mode 100644 index 000000000..b19d9e7db --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-mission-path-ordering-design.md @@ -0,0 +1,155 @@ +# Mission Completion-Path Ordering — Design + +**Issue:** sap-tutorials/tutorials-ims#2990 — "Allow the Mission Admin UI to be able to set the order of the Completion Paths" +**Date:** 2026-11-02 +**Repo:** `sap-tutorials/tutorials-ims` (local folder `tutorials-poc`) + +## Intent / Why + +A Mission owns many `CompletionPaths` (the "tracks" a learner can take through a mission — +e.g. "HANA Cloud" vs "On-prem"). Today an admin has **no way to control the order** these +paths appear in. The `CompletionPaths` entity has no order column, so: + +- The Fiori admin UI lists paths in arbitrary (DB) order. +- Renderers order paths by `legacyId` — the import-sequence id, which is meaningless to an + author and cannot be changed through the UI. +- The branching mission page renders only `paths[0]`, so "which path is primary" is currently + decided by DB order, not by the author. + +Authors need the same ordering control they already have for the **items inside** a path +(`CompletionPathItems.itemOrder`, surfaced as an editable "Order" column). This change gives +`CompletionPaths` the exact analogue. + +Tom confirmed: **both the mission detail page and the public `/app-space/?eventId=…` page must +respect the admin-set order** — not just the admin table. + +## Goals + +- Add an author-editable order to `CompletionPaths`, mirroring the established + `CompletionPathItems.itemOrder` pattern (field + editable LineItem column + `SortOrder`). +- Mission detail page (SSR + branching variant), the catalog build, and the **`/app-space/` + learner page** all render paths in the admin-set order. +- Existing paths get a sensible, stable initial order with no visible disruption to authors + (beyond the one primary-path shift Tom accepted — see Decision 6 / anti-goals). +- New paths created inline in the admin UI get an auto-assigned order (no manual typing required). + +## Non-goals + +- No change to **item** ordering within a path (`itemOrder` already exists and works). +- No change to the public URL/slug of any path. +- No drag-and-drop reordering — ordering is via an editable numeric "Order" column, identical to + how `CompletionPathItems` and `GroupPathItems` already work. (Drag-drop is a possible later + enhancement, explicitly out of scope.) +- No change to the content-publish pipeline — `CompletionPaths` are admin-authored, not published + from tutorial markdown (verified: only `admin-service.js` writes them). +- **No change to the event analytics bars** (`event-path-buckets.js` / display monitor). Those + deliberately sort by completion count and are a different surface from the app-space learner page. + +## Anti-goals (would make this a failure even if it "works") + +- Silently reordering a published mission's paths on deploy in a way Tom did not bless. The one + accepted exception: on branching missions, the deterministic `paths[0]` primary path will move to + the alphabetically-first path on deploy (Tom accepted this one-time shift). No *other* surface + should change what learners see beyond applying the alphabetical initial order. +- A migration that only runs on fresh deploys and leaves real HANA data with NULL order and + DB-dependent sort (NULL-sort differs between HANA and SQLite). + +## Constraints (from CLAUDE.md / repo conventions) + +- `CompletionPaths` is `cuid, LegacyKeyed`; slug is derived from `name` and unique **per mission** + (`admin-service.js` `deriveCompletionPathSlug`, :1522-1558). +- Generated bundles `srv/lib/_shared/content.bundle.mjs` and `channels.bundle.mjs` contain compiled + copies of `catalog-data.js` — **edit source, rebuild, never hand-edit**. +- PRs target `DEV`; `main` is protected. Open a PR, never direct-merge. +- Admin-UI (annotation) changes ship into the approuter only on a **full `mbt build`** — a + `--skip-build` / `-m`-scoped deploy ships a stale admin bundle (Step 3.5 gate). +- New annotation markup on mission pages needs a **content rebuild** after deploy to bake into the + HANA BLOBs. (App-space + branching mission pages are runtime CAP reads, not baked BLOBs, so those + reflect the new order immediately after deploy without a rebuild.) +- TEST THE ACTUAL THING BEFORE CALLING IT DONE. + +## Decisions made (the "how" that's settled) + +1. **New field:** `CompletionPaths.pathOrder : Integer`. Direct analogue of + `CompletionPathItems.itemOrder` (`schema.cds:477`) / `GroupPathItems.itemOrder` (`:491`). + +2. **Admin UI** (`app/admin-annotations.cds`) — mirror `CompletionPathItems`: + - `annotate AdminService.CompletionPaths { pathOrder @Common.Label: 'Order'; }` + - Add `{ Value: pathOrder, Label: 'Order' }` as the **first** `LineItem` column (the current + CompletionPaths LineItem is `[name, slug]` — `admin-annotations.cds:236-239`; description lives in + `FieldGroup#General`, not the LineItem). + - Add `PresentationVariant: { SortOrder: [ { Property: pathOrder } ] }` to the CompletionPaths + `@UI` block (which has none today, :221-240) so the admin list sorts by it. + +3. **Auto-numbering for new paths:** add a `setPathOrder` handler mirroring `setGroupItemOrder` + (`admin-service.js:1129-1140`) — on `before('NEW','CompletionPaths.drafts')` and + `before('CREATE','CompletionPaths')`, if `pathOrder` is null, set it to + `max(pathOrder for this mission) + 10` (gap numbering). + +4. **Write-path carry-through** in `admin-service.js`: + - `copyMission` (:4038 INSERT): add `pathOrder: p.pathOrder` when cloning each path. + - `promoteCommunityToMission` (:3928 INSERT): the single "Default" path gets `pathOrder: 10`. + +5. **Read sites** — add path ordering `.orderBy('pathOrder', 'name')` (tiebreaker `name` to match the + backfill intent, so NULL and explicit orders interleave predictably): + - `packages/content/catalog-data.js:182` → `.orderBy('pathOrder', 'name')` (mission SSR). + Must also add `pathOrder` to the `.columns('ID','name','slug','legacyId')` list there. + - `srv/lib/build-catalog.js:22` → `.orderBy('mission_ID', 'pathOrder', 'name')` (cross-mission catalog). + This query has **no `.columns()`** (selects all), so `pathOrder`/`mission_ID` are already present — + only the orderBy changes. + - **`srv/developer-service.js:675` (`getAppSpaceProgress`)** → `.orderBy('pathOrder', 'name')` — + **this is the real `/app-space/?eventId=…` learner page.** It is a standalone + `SELECT.from(CompletionPaths).where({mission_ID}).orderBy('legacyId')` (:673-675) whose result is + mapped verbatim at :731, so this single query sets the path/track render order on the app-space + page. It does **not** go through `event-path-buckets.js`. + - **`srv/developer-service.js:562-564` (`getEventProgress`)** → same change, for ordering parity on + the admin/event-progress view (identical `.orderBy('legacyId')` query). + - `srv/lib/branch/mission-detail.js:40` → `.orderBy('pathOrder', 'name')` so the `paths[0]` + "primary path" is the admin's ordered-first path. (NB: today this query has **no** orderBy at all — + `paths[0]` is currently undefined DB order; the change makes "primary" deterministic. Combined with + the alphabetical backfill, the primary path of existing **branching** missions becomes the + alphabetically-first path on deploy — Tom accepted this one-time shift.) + - `assembleMissionHierarchy` (`catalog-mission-hierarchy.js`) needs **no code change** — it + preserves caller-supplied `paths` order (verified JSDoc :92-94 + `.map` :150). Update its JSDoc + comment that says paths order by `legacyId` → `pathOrder`/`name` (comment only). + - **NOT changed** (path render order irrelevant): `srv/lib/event-path-buckets.js` — its rendered + bars are deliberately **sorted by completion count** (`computePathBuckets:207` + `.sort((a,b)=>b.count-a.count)`), the analytics/display-monitor surface, NOT the app-space learner + page; `navigator-catalog.js:216` and `joule-tool.js:166` (fetch paths into a Map / for ID + collection only); the flat export `exports/completion-path.js` (`legacyId`-ordered row dump). + +6. **Backfill migration (existing HANA rows):** rank each mission's existing paths by `name` A→Z and + assign `pathOrder` 10,20,30… per mission. Idempotent (only sets rows where `pathOrder IS NULL`). + Delivered as a repair/migration script following the existing + `scripts/repair-mission-completion-path-data.cjs` convention (dry-run/commit/verify-only modes, + `.migration-data` JSONL snapshot, `npx cds bind --exec -- node scripts/…` invocation), with a hybrid + test. Rationale for alphabetical (Tom's call): author-meaningful and stable, unlike `legacyId`; the + read-site tiebreaker is also `name` so pre- and post-backfill behavior agree. + +7. **Tests** (TDD — write first, watch fail, then implement): + - Unit: `setPathOrder` gap-numbers correctly (first path → 10, next → 20; respects explicit value). + - Unit: `catalog-data` + `build-catalog` emit paths in `pathOrder` then `name` order (in-memory SQLite). + - Unit: `getAppSpaceProgress` returns paths in `pathOrder` then `name` order. + - Unit: `mission-detail` picks the `pathOrder`-first path as `paths[0]`. + - Unit: `copyMission` preserves `pathOrder`. + - Hybrid: backfill script ranks alphabetically per mission and is idempotent (real HANA via + `cds bind --exec`). + +## Left to the builder + +- Whether to mirror the orderBy into `getEventProgress` (:562-564) as well as `getAppSpaceProgress` + — design says yes (parity); builder confirms it's the same shape and harmless. +- Migration script filename (`scripts/backfill-completion-path-order.cjs` suggested) and npm-script + wiring (follow the repair-script precedent). +- Confirm the draft_admin_integrity #2909 discard-reconcile hooks (`admin-service.js:1278-1310`) are a + no-op for path order (they fire user-state rollups only; builder-check already indicates no-op). + +## Rollout + +1. PR to `DEV` with schema + annotations + handlers + read-site sorts + tests. +2. Deploy to DEV via **full** `npm run deploy -- --env dev` (no `--skip-build`, no `-m`). +3. Run backfill script against DEV HANA. +4. Content rebuild (`gh workflow run rebuild-content.yml -f mode=full`) so mission SSR/catalog BLOBs + bake with the new order. (App-space + branching mission pages reflect it at runtime without rebuild.) +5. Verify in-browser (authed dev session): admin UI shows editable Order + sorts; reordering a + mission's paths changes the mission page and the `/app-space/?eventId=…` page order. From cab7e585c59807e8f75d0d384cd8c80f24f4fa94 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:50:44 -0600 Subject: [PATCH 21/42] docs(2993): design + implementation plan for safe draft_admin_integrity re-enable --- ...-02-draft-admin-integrity-safe-reenable.md | 195 ++++++++++++++++++ ...ft-admin-integrity-safe-reenable-design.md | 160 ++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md create mode 100644 docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md diff --git a/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md b/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md new file mode 100644 index 000000000..9b62cffe8 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md @@ -0,0 +1,195 @@ +# draft_admin_integrity Safe Re-enable (Prove, Keep OFF) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `cds.fiori.draft_admin_integrity` provably safe to re-enable later — by making the cascade FK deployable and adding the orphan gate test — without turning the flag on now. + +**Architecture:** The build emits 46 `AdminService.*_drafts.hdbtable` files carrying the `DraftAdministrativeData_DraftUUID` FK column but no `ON DELETE CASCADE` clause (the #2909 root cause). A build-time patch (extracted into a committed `.cjs`, wired into the MTA `before-all` after `cds build`) appends the cascade FK to each generated draft `.hdbtable` in the prod `gen/db` tree. A flag-agnostic Vitest gate proves Mission draftEdit→discard leaves zero child draft rows on SQLite. A runbook documents the deploy-then-flip order. The `package.json` flag stays `false`. + +**Tech Stack:** SAP CAP (`@sap/cds` 10.2.2, Node), SAP HANA HDI (`.hdbtable`), MTA (`.deploy/mta.yaml`), Vitest, SQLite (unit). + +**Spec:** `docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md` + +## Global Constraints + +- `cds.fiori.draft_admin_integrity` stays `false` in committed `package.json` (both the base `cds.fiori` block at line ~461 and the `[development]` block at line ~486). No task flips it. +- No deploy to any environment is performed by this plan. +- Branch is `worktree-draft-integrity-constraints-2993`, based on `origin/DEV`. PR targets `DEV`, never `main`. +- `npm test` (in-memory SQLite) must stay green; the gate test is additive. +- Constraint patch applies to the **prod `gen/db` tree only** — QA's db build excludes `AdminService` and emits 0 draft tables. +- Cascade FK form, per table: `FOREIGN KEY (DraftAdministrativeData_DraftUUID) REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE` — authored so HANA deploys it `NOT VALIDATED` (skips existing-data check; no pre-clean needed). +- Draft table list is derived programmatically from the generated `*_drafts.hdbtable` files — never hand-maintained (must not drift from the preflight's live `SYS.TABLE_COLUMNS` count). + +## Review Focus + +- **Zero draft tables found** (glob matches nothing because build layout changed): patch must `die(1)` with a clear message, not silently patch nothing and let a future flip ship an un-cascaded DB → covered in Task 1. +- **Already-patched `.hdbtable`** (re-run / idempotency): appending the FK a second time must be a no-op, not a duplicate-constraint DDL error → covered in Task 1. +- **A draft table that lacks the FK column** (not all `*_drafts` necessarily carry it): patch must skip tables without `DraftAdministrativeData_DraftUUID`, not emit a FK referencing a missing column → covered in Task 1. +- **Discard of a Mission with NO child items** (empty composition): gate test must still pass (zero in, zero orphaned), not assume children exist → covered in Task 2. +- **Flag-on behavior on SQLite** (future flip): gate test asserts via the `.drafts` accessor so it is correct whether deletion came from app-level reconcile or DB cascade → covered in Task 2. + +--- + +### Task 1: Build-time cascade-FK patch for generated draft `.hdbtable` files + +**Files:** +- Create: `scripts/patch-draft-cascade-constraints.cjs` +- Create: `test/unit/patch-draft-cascade-constraints.test.js` +- Modify: `.deploy/mta.yaml` (the `before-all` builder, after the `cds build --production` line ~52, in the same shell sequence as the existing `.hdiconfig` patch line ~53) + +**Interfaces:** +- Consumes: generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` files (produced by `npx cds build --production`, already run earlier in `before-all`). +- Produces: `patchDraftCascadeConstraints(genDir) -> { patched: string[], skipped: string[] }` exported from the `.cjs`; appends the cascade FK to each draft `.hdbtable` under `genDir` that has the FK column and isn't already patched. Throws if `genDir` contains zero `*_drafts.hdbtable` files. + +- [ ] **Step 1: Write the failing unit test** + +In `test/unit/patch-draft-cascade-constraints.test.js`, using Vitest + a tmp dir fixture (no CAP boot): +```js +// seed tmp genDir with 2 fixture .hdbtable files: +// AdminService.Missions_drafts.hdbtable — has DraftAdministrativeData_DraftUUID column +// AdminService.NoFk_drafts.hdbtable — NO such column +// and assert: +test('appends ON DELETE CASCADE FK to draft tables that carry the FK column', () => { + const r = patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + expect(sql).toMatch(/FOREIGN KEY\s*\("?DraftAdministrativeData_DraftUUID"?\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/i); + expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); +}); +test('skips draft tables without the FK column', () => { + patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), 'utf8'); + expect(sql).not.toMatch(/ON DELETE CASCADE/i); +}); +test('is idempotent — second run adds no duplicate FK', () => { + patchDraftCascadeConstraints(tmp); + const first = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + patchDraftCascadeConstraints(tmp); + expect(fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8')).toBe(first); +}); +test('throws when no draft tables are found', () => { + expect(() => patchDraftCascadeConstraints(emptyTmp)).toThrow(/no .*_drafts\.hdbtable/i); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/patch-draft-cascade-constraints.test.js` +Expected: FAIL (module/function not found). + +- [ ] **Step 3: Implement `patchDraftCascadeConstraints(genDir)` in `scripts/patch-draft-cascade-constraints.cjs`** + +Enumerate `*_drafts.hdbtable` in `genDir` (fs.readdirSync); throw if none. For each file: read; skip if no `DraftAdministrativeData_DraftUUID` token or if `ON DELETE CASCADE` already present (idempotency); otherwise inject the FK clause as the last element inside the `COLUMN TABLE (...)` body (insert a `,` after the current last column/`PRIMARY KEY(...)` entry, before the closing `)`). Also export a CLI entry (`if (require.main === module)`) that reads genDir from `process.argv[2]` default `gen/db/src/gen`, calls the function, logs `patched N / skipped M`, and `process.exit(1)` on throw. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/unit/patch-draft-cascade-constraints.test.js` +Expected: PASS (all 4). + +- [ ] **Step 5: Wire into MTA `before-all`** + +In `.deploy/mta.yaml`, add a command after the `cds build --production` step and alongside the existing `.hdiconfig` patch (same `before-all` builder): `bash -c "cd .. && node scripts/patch-draft-cascade-constraints.cjs gen/db/src/gen"`. Prod tree only — do NOT add `gen/db-qa/...`. + +- [ ] **Step 6: Verify the patch runs against a real build (prod tree)** + +Run: `rm -rf gen && npx cds build --production && node scripts/patch-draft-cascade-constraints.cjs gen/db/src/gen && grep -l "ON DELETE CASCADE" gen/db/src/gen/AdminService.Missions_drafts.hdbtable` +Expected: prints the Missions_drafts path (cascade present); CLI logs `patched 46 / skipped 0` (or patched = live FK-bearing count). Then `rm -rf gen` (throwaway). + +- [ ] **Step 7: Commit** + +```bash +git add scripts/patch-draft-cascade-constraints.cjs test/unit/patch-draft-cascade-constraints.test.js .deploy/mta.yaml +git commit -m "feat(db): build-time ON DELETE CASCADE patch for draft tables, not yet enabled (#2993)" +``` + +--- + +### Task 2: Flag-agnostic orphan gate test (Mission draftEdit → discard) + +**Files:** +- Create: `test/admin-drafts-cascade-gate.test.js` + +**Interfaces:** +- Consumes: CAP draft HTTP actions on `AdminService` via `cds.test('serve','--project','.','--in-memory')` + `adminAuth`, exactly as `test/admin-drafts-discard-reconcile.test.js`. Child-draft rows read via the `.drafts` accessor (e.g. `cds.entities(NS).CompletionPathItems.drafts`), filtered by `DraftAdministrativeData_DraftUUID`. +- Produces: nothing consumed downstream; this is the re-enable gate. + +- [ ] **Step 1: Write the failing test** + +In `test/admin-drafts-cascade-gate.test.js`, mirroring the harness/auth/seed style of `admin-drafts-discard-reconcile.test.js`: +```js +// Seed a Mission with one CompletionPath + one CompletionPathItem (active). +// draftEdit the Mission; capture the draft's DraftAdministrativeData_DraftUUID +// from the Missions.drafts row. Then DELETE the draft (discard). +it('leaves zero child draft rows after Mission draftEdit→discard (flag-agnostic gate)', async () => { + // ... seed, draftEdit, read draftUUID ... + const { CompletionPathItems } = cds.entities(NS); + const before = await db.run(SELECT.from(CompletionPathItems.drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID })); + expect(before.length).toBeGreaterThanOrEqual(0); // children may be 0..n + // ... discard ... + const after = await db.run(SELECT.from(CompletionPathItems.drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID })); + expect(after).toHaveLength(0); +}); +// Empty-composition case: +it('discard of a Mission draft with no child items leaves zero orphans', async () => { /* seed Mission only, draftEdit, discard, assert 0 */ }); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/admin-drafts-cascade-gate.test.js` +Expected: FAIL (file new / assertions not yet satisfied, or compile error until seed written). + +- [ ] **Step 3: Implement the test body** + +Fill in seed (Users/Missions/CompletionPaths/CompletionPathItems with unique `__TEST__`/high-legacyId values to avoid autotest collisions, per `admin-drafts-discard-reconcile.test.js`), the draftEdit POST to `/admin/Missions(ID=...,IsActiveEntity=true)/AdminService.draftEdit`, read the draft row's `DraftAdministrativeData_DraftUUID`, and the discard DELETE to `/admin/Missions(ID=...,IsActiveEntity=false)`. No production code changes — this test documents current (flag-off) behavior. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/admin-drafts-cascade-gate.test.js` +Expected: PASS (both cases). Then `npm test` to confirm no regression. + +- [ ] **Step 5: Commit** + +```bash +git add test/admin-drafts-cascade-gate.test.js +git commit -m "test(drafts): flag-agnostic gate — Mission discard leaves zero child draft orphans (#2993)" +``` + +--- + +### Task 3: Re-enable runbook + +**Files:** +- Create: `docs/developers/operations/draft-integrity-reenable.md` +- Modify: `CLAUDE.md` (the "Optimized draft deletion" gotcha line — add a pointer to the new runbook) + +**Interfaces:** none (documentation). + +- [ ] **Step 1: Write the runbook** + +Document the exact order, encoding the spec guardrails: (1) deploy with the Task-1 patch active so constraints land `NOT VALIDATED` while the flag is still `false` (constraints inert); (2) confirm `srv/lib/draft-integrity-preflight.js` logs `verified (N/N)` on the target env (not a WARN); (3) run `scripts/cleanup-draft-orphans.cjs --dry-run` → expect 0 orphans; (4) run the Task-2 gate test green; (5) only then flip `draft_admin_integrity` to `true` in `package.json` (both blocks), full `npm run build:all` + redeploy; (6) smoke a Mission draftEdit→discard. Note: the `CDS_*` env override does NOT work on the deployed build — the baked `package.json` value is authoritative. DEV before PROD. + +- [ ] **Step 2: Add CLAUDE.md pointer** + +Append ` Re-enable runbook: docs/developers/operations/draft-integrity-reenable.md` to the existing "Optimized draft deletion" gotcha bullet. + +- [ ] **Step 3: Commit** + +```bash +git add docs/developers/operations/draft-integrity-reenable.md CLAUDE.md +git commit -m "docs(ops): runbook for safely re-enabling draft_admin_integrity (#2993)" +``` + +--- + +## Self-Review + +**Spec coverage:** Goal 1 (constraints) → Task 1. Goal 2 (gate test) → Task 2. Goal 3 (runbook) → Task 3. Non-goals (flip flag, deploy, orphan sweep) respected — no task does them. QA-out-of-scope finding → Task 1 Step 5 (prod tree only). Preflight already fixed (#2992) — not re-implemented. All covered. + +**Step scan:** Each step yields one checkable action. Task 1 bodies describe only the FK-injection algorithm (not signature-determined); Task 2 leaves the body to the implementer via the harness pattern. No "TBD"/"handle edge cases" lines. + +**Type consistency:** `patchDraftCascadeConstraints(genDir)` and its return shape used consistently across Task 1 steps and the MTA wiring. FK clause string identical in Global Constraints, Task 1 test, and Task 3. + +**Review Focus:** 5 items, each pinned to a task (empty-tables→T1, idempotency→T1, missing-FK-column→T1, empty-composition→T2, flag-on-accessor→T2). + +**Proportion:** Plan is longer than the ~2-screen spec but dominated by exact test assertions and the FK string, not program transcription — acceptable for a TDD plan with 3 tasks. diff --git a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md new file mode 100644 index 000000000..22d433521 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md @@ -0,0 +1,160 @@ +# Design: Prove `draft_admin_integrity` is safe to re-enable — keep it OFF for now (#2993) + +Date: 2026-11-02 +Author: Claude (with Tom) +Status: Draft for approval + +## Intent & why + +CAP 10.2's `cds.fiori.draft_admin_integrity: true` (#2909) was enabled in +`package.json` to get optimized DB-level cascade draft deletion. The required +`ON DELETE CASCADE` constraints on the `ADMINSERVICE_*_DRAFTS` tables were +**never emitted by the build nor deployed**, so with the flag on the runtime +deleted only the `DRAFT_DRAFTADMINISTRATIVEDATA` parent and orphaned every +child draft row — making missions un-editable in PROD (2026-11-02 outage). +#2992 disabled the flag (all profiles) and fixed the preflight; #2994 shipped a +one-shot orphan sweep. + +Tom's decision for this issue: **prove re-enabling is safe, but keep the flag +OFF.** We cannot afford another outage. This issue delivers the *artifacts and +the proof*, not the switch. + +## Goals + +1. **Author the real cascade constraints as committed, deployable artifacts** + for every `AdminService.*_drafts` table. The build already emits these tables + with the `DraftAdministrativeData_DraftUUID` FK column but no cascade clause; + the work adds `ON DELETE CASCADE` (`NOT VALIDATED`) via a build-time patch of + the generated `.hdbtable` files (see Findings + deliverable 1). They must be + deployable independently of the flag. +2. **A flag-agnostic gate test** proving `draftEdit → discard` on a Mission + leaves **zero** orphaned child draft rows. Runs in `npm test` (SQLite). + Written so it passes both with the flag off (today's app-level reconcile + path) and would pass with the flag on (DB cascade) — it is the gate that + must stay green before any future flip. +3. **A re-enable runbook** documenting the exact deploy-then-flip order so the + issue's steps 2–4 become an executable checklist. + +## Non-goals + +- Flipping `cds.fiori.draft_admin_integrity` to `true`. Stays `false` in the + committed `package.json` (base + `[development]`). +- Deploying anything to any environment. +- The orphan *sweep* — already delivered in #2994 (`cleanup-draft-orphans.cjs`). +- Changing the preflight — already fixed in #2992 (discovers real + `%_DRAFTS` tables from `SYS.TABLE_COLUMNS`). + +## Anti-goals (would make this a failure even if it "works") + +- Any change that could cause the DB-cascade delete path to become active on a + live environment (i.e. the flag going on, directly or via a build artifact + that behaves like enabling it). +- Committed artifacts that `cf deploy` would apply as `VALIDATED` (would block + deploy on existing orphan data) or that `die` the deploy if a referenced + draft table does not yet exist on the target. + +## Findings (verified this session) + +- **The build emits the draft tables WITH the FK column, but WITHOUT the + cascade clause.** `cds build --production` emits **46** + `gen/db/src/gen/AdminService._drafts.hdbtable` artifacts (tracked HDI + `.hdbtable` files). Each carries `DraftAdministrativeData_DraftUUID + NVARCHAR(36) NOT NULL`, but **no** `FOREIGN KEY ... ON DELETE CASCADE` clause + — verified by `cat`/`grep` on the generated `AdminService.Missions_drafts.hdbtable`. + The flag (`draft_admin_integrity` true vs false) does **not** change this + output. This is the #2909 root cause: the physical draft tables and their FK + column exist as build artifacts; only the cascade FK clause is missing, so the + DB never cascades child-row deletion. (An earlier probe reported "zero draft + artifacts" — that was a bad `fd` glob, not a real absence; corrected here.) +- **`.hdiconfig` does not register the `hdbconstraint` plugin.** `db/src/.hdiconfig` + registers hdbindex/hdbmigrationtable/hdbprocedure/hdbsequence/hdbsynonym/ + hdbview/hdbrole — **no** `com.sap.hana.di.constraint`. So a committed + `db/src/*.hdbconstraint` would be **silently ignored** by HDI. Precedent for + fixing this: the MTA `before-all` already patches the generated `.hdiconfig` + at build time (today it injects the `hdbprocedure` plugin into both + `gen/db/src/gen/.hdiconfig` and `gen/db-qa/src/gen/.hdiconfig`). +- **Node flag is all-or-nothing at runtime.** CAP Node v10 has no separate + `cds.drafts.deleteStrategy: flat|dad` escape hatch (that is the CAP Java + model). On Node, `draft_admin_integrity: true` switches the runtime to rely on + the DB cascade. There is no "constraints present but runtime still flat" safe + middle state on Node — which is exactly why the flag must stay off until the + cascade FK is proven present on the target env. The `CDS_*` env override does + not reach `cds.fiori.draft_admin_integrity`, so the value is whatever + `package.json` bakes at build time. +- **Cascade needed, per table:** `FOREIGN KEY (DraftAdministrativeData_DraftUUID) + REFERENCES DRAFT_DraftAdministrativeData(DraftUUID) ON DELETE CASCADE`, + deployed `NOT VALIDATED` (HANA skips the existing-data check, so no pre-clean + required). Parent table/PK confirmed via `cleanup-draft-orphans.cjs` + (`DRAFT_DRAFTADMINISTRATIVEDATA(DRAFTUUID)`). +- **QA container is out of scope.** The QA db build model is `db-qa` only and + does **not** include `AdminService`; `gen/db-qa/src/gen/` emits **0** draft + tables. QA has no draft shadow tables to protect, so the constraint work is + **prod-container-only** (`gen/db` → `tutorials-hana`), not symmetric with the + existing hdbprocedure patch. + +## The three deliverables + +### 1. Cascade constraint artifacts (authored, committed, NOT deployed-live) + +The build emits the 46 `_drafts.hdbtable` files with the FK column but no +cascade clause, and `.hdiconfig` has no `hdbconstraint` plugin. So the mechanism +is **not** committed `db/src/*.hdbconstraint` (HDI would ignore it). Instead, +add the `FOREIGN KEY (DraftAdministrativeData_DraftUUID) REFERENCES +DRAFT_DraftAdministrativeData(DraftUUID) ON DELETE CASCADE` clause to each +generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` via a **build-time +patch step**, mirroring the existing MTA `before-all` `.hdiconfig` patch +precedent. The table list is derived programmatically from the generated +`_drafts.hdbtable` set (not a hand-maintained list), so it cannot drift from the +live draft-table count the preflight checks. Applied to the **prod `gen/db` +tree only** (QA emits no draft tables). The committed `package.json` flag stays +`false` throughout; the patch makes the constraints *deployable* but the runtime +cascade path stays dormant until a future flip. + +### 2. Flag-agnostic gate test + +Extend the existing `test/admin-drafts-discard-reconcile.test.js` pattern. A new +test: create a Mission draft with child items, `draftEdit`, then discard; assert +**zero** child draft rows remain for that draft. On the in-memory SQLite unit +run the assertion goes through CAP's `.drafts` accessor (e.g. +`CompletionPathItems.drafts` / `GroupPathItems.drafts` filtered by the draft's +`DraftAdministrativeData_DraftUUID`), **not** raw HANA physical-table SQL (those +names don't exist on SQLite, and the HANA cleanup query isn't portable to +`npm test`). Must be green in `npm test` today (proves the app-level reconcile +path leaves no orphans with the flag off) and is the gate re-run before any +future flip. + +### 3. Re-enable runbook + +A doc (under `docs/developers/operations/`) giving the exact order: deploy +constraints (`NOT VALIDATED`) → confirm preflight logs `verified (N/N)` on the +target env → run the gate test green → flip `package.json` flag → full rebuild ++ redeploy → smoke draftEdit/discard. Encodes issue guardrails: flag does not +go on until constraints are green on the *target* env; `CDS_*` env override is +not a substitute for the baked `package.json` value. + +## Constraints + +- Branch from fresh `origin/DEV` (done). PR targets `DEV`; never touch `main` + directly. +- `npm test` (SQLite) must stay green; the gate test is additive. +- No deploy performed by this issue. + +## Left to the builder / plan + +- **O1 (builder's call within plan):** the exact form of the build-time patch + step that appends the cascade FK to each generated + `gen/db/src/gen/AdminService.*_drafts.hdbtable` — e.g. a Node script wired into + the MTA `before-all` alongside the existing `.hdiconfig` patch, vs. a + `cds build` plugin/after-build hook. Decide based on where the existing + hdbprocedure `.hdiconfig` patch lives and what's least likely to drift. The + option set from the *original* draft (committed `.hdbconstraint` vs. runtime + `hdbsql`) is superseded by the corrected Findings: the tables are tracked HDI + artifacts, so patching the generated `.hdbtable` is the lowest-risk mechanism. +- Confirm whether the patch must also register a plugin in `.hdiconfig` — if the + FK is inlined into the `.hdbtable` body (part of the `COLUMN TABLE (...)` + definition), no `hdbconstraint` plugin is needed; if authored as a separate + constraint artifact, it is. Prefer inlining to avoid the plugin dependency. +- Exact Mission child-draft entities for the gate test's assertions + (`CompletionPathItems` / `GroupPathItems` draft accessors). +- Whether to also assert on `draftActivate` (not just discard) in the gate test. +``` From 8fea1066dda35b39ac70aa4f83a45546311f72ee Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:55:42 -0600 Subject: [PATCH 22/42] feat(#2990): add pathOrder to CompletionPaths schema --- db/schema.cds | 1 + test/unit/completion-path-order.test.js | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 test/unit/completion-path-order.test.js diff --git a/db/schema.cds b/db/schema.cds index 7032edf36..05cd9aa3a 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -463,6 +463,7 @@ entity CompletionPaths : cuid, LegacyKeyed { name : String(255); description : String(1000); slug : String(255); + pathOrder : Integer; items : Composition of many CompletionPathItems on items.path = $self; } diff --git a/test/unit/completion-path-order.test.js b/test/unit/completion-path-order.test.js new file mode 100644 index 000000000..65eb039bd --- /dev/null +++ b/test/unit/completion-path-order.test.js @@ -0,0 +1,18 @@ +/** + * Unit tests for CompletionPaths.pathOrder field. + * Spec: issue #2990 + */ +import { describe, it, expect } from 'vitest'; +import cds from '@sap/cds'; + +// Boot CDS to load the schema +cds.test('serve', '--project', '.', '--in-memory'); + +describe('CompletionPaths pathOrder field', () => { + it('has an Integer pathOrder element', () => { + const def = cds.model.definitions['com.sap.developers.ims.CompletionPaths']; + expect(def).toBeDefined(); + expect(def.elements.pathOrder).toBeDefined(); + expect(def.elements.pathOrder.type).toBe('cds.Integer'); + }); +}); From 4b1f37e250b973324797ca154dbc2ea3ded2273b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:56:52 -0600 Subject: [PATCH 23/42] fix(help-docs): route dated news posts + nudge concept re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildDocUrl: date-expand Docusaurus blog paths (news/YYYY-MM-DD-rest → /news/YYYY/MM/DD/rest); the naive path form 404s. Fixes the 7th broken archcenter link on /concepts/agent-gateway that #2948 missed. - 2 fetcher tests: dated-news routing + non-dated fallthrough. - fix-archcenter-urls.cjs: add the news row; after the url repair, touch the linked HelpDocConceptLinks rows (value-neutral) so their managed modifiedAt bumps the concept-input-digest — otherwise the concept re-render phase-skips and the corrected href never re-bakes into the served BLOB. --- scripts/fix-archcenter-urls.cjs | 45 ++++++++++++++----- .../help-docs/architecture-sap-com-fetcher.js | 18 ++++++-- .../srv/architecture-sap-com-fetcher.test.js | 39 ++++++++++++++++ 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/scripts/fix-archcenter-urls.cjs b/scripts/fix-archcenter-urls.cjs index 0308b2575..8abe62d87 100644 --- a/scripts/fix-archcenter-urls.cjs +++ b/scripts/fix-archcenter-urls.cjs @@ -23,22 +23,25 @@ const cds = require('@sap/cds'); const SOURCE = 'architecture-sap-com'; const SITE_BASE = 'https://architecture.learning.sap.com'; -// sourceId (repo-relative path) → corrected slug (as it appears in frontmatter). -// Final URL = `${SITE_BASE}/docs${slug}`. Slugs are root-absolute (leading /). +// sourceId (repo-relative path) → corrected URL. For ref-arch docs the fix is +// the Docusaurus slug route (`${SITE_BASE}/docs${slug}`); for the dated news +// post it is the blog date-path route (`${SITE_BASE}/news/YYYY/MM/DD/rest`). +// Every target below was verified 200 against the live site. const FIXES = [ - { sourceId: 'docs/ref-arch/RA0029/readme.md', slug: '/ref-arch/98efa0' }, - { sourceId: 'docs/ref-arch/RA0029/1-a2a-and-mcp/readme.md', slug: '/ref-arch/76ec36' }, - { sourceId: 'docs/ref-arch/RA0029/4-integrate-ai-agents-with-joule/readme.md', slug: '/ref-arch/ae6821' }, - { sourceId: 'docs/ref-arch/RA0029/5-integrate-joule-agents-and-tools-into-your-ecosystem/readme.md', slug: '/ref-arch/fe0d1d' }, - { sourceId: 'docs/ref-arch/RA0029/5-integrate-joule-agents-and-tools-into-your-ecosystem/1-aws-integration/readme.md', slug: '/ref-arch/b4d7e2' }, - { sourceId: 'docs/ref-arch/RA0039/readme.md', slug: '/ref-arch/5hj89h' }, + { sourceId: 'docs/ref-arch/RA0029/readme.md', url: `${SITE_BASE}/docs/ref-arch/98efa0` }, + { sourceId: 'docs/ref-arch/RA0029/1-a2a-and-mcp/readme.md', url: `${SITE_BASE}/docs/ref-arch/76ec36` }, + { sourceId: 'docs/ref-arch/RA0029/4-integrate-ai-agents-with-joule/readme.md', url: `${SITE_BASE}/docs/ref-arch/ae6821` }, + { sourceId: 'docs/ref-arch/RA0029/5-integrate-joule-agents-and-tools-into-your-ecosystem/readme.md', url: `${SITE_BASE}/docs/ref-arch/fe0d1d` }, + { sourceId: 'docs/ref-arch/RA0029/5-integrate-joule-agents-and-tools-into-your-ecosystem/1-aws-integration/readme.md', url: `${SITE_BASE}/docs/ref-arch/b4d7e2` }, + { sourceId: 'docs/ref-arch/RA0039/readme.md', url: `${SITE_BASE}/docs/ref-arch/5hj89h` }, + { sourceId: 'news/2026-10-27-a2a-agent-gateway-interoperability.md', url: `${SITE_BASE}/news/2026/10/27/a2a-agent-gateway-interoperability` }, ]; const COMMIT = process.argv.includes('--commit'); async function main() { await cds.connect.to('db'); - const { HelpDocs } = cds.entities('com.sap.developers.ims.external'); + const { HelpDocs, HelpDocConceptLinks } = cds.entities('com.sap.developers.ims.external'); console.log(`\nfix-archcenter-urls — ${COMMIT ? 'COMMIT' : 'DRY-RUN'} mode`); console.log('='.repeat(72)); @@ -47,8 +50,7 @@ async function main() { let changed = 0; const plan = []; - for (const { sourceId, slug } of FIXES) { - const newUrl = `${SITE_BASE}/docs${slug}`; + for (const { sourceId, url: newUrl } of FIXES) { // Metadata-only read (never SELECT description/LOB alongside) — LOB-locator safe. const row = await SELECT.one .from(HelpDocs) @@ -88,6 +90,27 @@ async function main() { } console.log(`\nApplied: ${changed} row(s) updated.`); + // Concept-publish digest nudge (#2948 follow-up): the concept re-render phase + // keys its delta on concept-input-digest.js, which probes max(modifiedAt) over + // HelpDocConceptLinks — but NOT over HelpDocs. So a bare HelpDocs.url change + // does NOT bump the digest, and a full publish would phase-skip and never + // re-bake the corrected href into the concept BLOB. Touching the linking rows + // bumps their managed modifiedAt (= $now on any UPDATE) so the next full PROD + // publish re-renders the affected concept pages. We write each row's EXISTING + // modelVersion back unchanged — content-neutral (no column value altered), + // modifiedAt is the only field that moves. Scoped to the fixed HelpDocs. + const fixedIds = plan.map((p) => p.ID); + const linkRows = await SELECT.from(HelpDocConceptLinks) + .columns('ID', 'modelVersion') + .where({ helpDoc_ID: { in: fixedIds } }); + let touched = 0; + for (const lr of linkRows) { + touched += await UPDATE(HelpDocConceptLinks) + .set({ modelVersion: lr.modelVersion }) // same value → only modifiedAt moves + .where({ ID: lr.ID }); + } + console.log(`Digest nudge: touched ${touched} HelpDocConceptLinks row(s) (bumps modifiedAt for concept re-render; values unchanged).`); + // Post-write read-back for audit. console.log('\nVerification (stored url after write):'); for (const p of plan) { diff --git a/srv/lib/help-docs/architecture-sap-com-fetcher.js b/srv/lib/help-docs/architecture-sap-com-fetcher.js index d7b881ee7..d1cc79103 100644 --- a/srv/lib/help-docs/architecture-sap-com-fetcher.js +++ b/srv/lib/help-docs/architecture-sap-com-fetcher.js @@ -140,14 +140,26 @@ function parseFrontmatter(raw) { // The live Docusaurus site routes a doc by its `slug:` frontmatter when set, // served under the `/docs` prefix (slugs are root-absolute, e.g. -// `/ref-arch/98efa0`). Without a slug it falls back to a path-derived URL — -// not always what Docusaurus auto-routes, but the best available guess. +// `/ref-arch/98efa0`). Without a slug it falls back to a path-derived URL. +// +// News posts (`news/…`) are served by the Docusaurus BLOG plugin, which routes +// a dated post `news/YYYY-MM-DD-rest.md` at `/news/YYYY/MM/DD/rest` (the date +// prefix is expanded into path segments and dropped from the slug). The naive +// `${SITE_BASE}/news/YYYY-MM-DD-rest` form 404s. Verified live against several +// posts. Non-dated news files (rare) fall through to the plain path form. function buildDocUrl(path, slug) { if (slug) { const normalized = slug.startsWith('/') ? slug : `/${slug}`; return `${SITE_BASE}/docs${normalized}`; } - return `${SITE_BASE}/${path.replace(/\.mdx?$/, '')}`; + const stripped = path.replace(/\.mdx?$/, ''); + // Blog-plugin date routing: news/YYYY-MM-DD-rest → /news/YYYY/MM/DD/rest + const newsMatch = stripped.match(/^news\/(\d{4})-(\d{2})-(\d{2})-(.+)$/); + if (newsMatch) { + const [, y, m, d, rest] = newsMatch; + return `${SITE_BASE}/news/${y}/${m}/${d}/${rest}`; + } + return `${SITE_BASE}/${stripped}`; } function extractH1(body) { diff --git a/test/unit/srv/architecture-sap-com-fetcher.test.js b/test/unit/srv/architecture-sap-com-fetcher.test.js index 466c5e5de..032d98d32 100644 --- a/test/unit/srv/architecture-sap-com-fetcher.test.js +++ b/test/unit/srv/architecture-sap-com-fetcher.test.js @@ -83,6 +83,45 @@ describe('architecture-sap-com-fetcher', () => { expect(rows[0].url).toBe('https://architecture.learning.sap.com/docs/ref-arch/abc123'); }); + it('routes a dated news post via the Docusaurus blog date path (YYYY-MM-DD → YYYY/MM/DD)', async () => { + // Blog-plugin posts route at /news/YYYY/MM/DD/rest, NOT the naive + // /news/YYYY-MM-DD-rest (which 404s). Verified live. News files carry no + // slug: frontmatter, so this exercises the path-derived fallback branch. + _setMockFetcher(async (url) => { + if (url.includes('/git/trees/')) return { + ...treeFixture, + tree: [{ + path: 'news/2026-10-27-a2a-agent-gateway-interoperability.md', + mode: '100644', type: 'blob', sha: 'bn1', size: 900, + url: 'https://api.github.com/blobs/bn1', + }], + }; + return '---\ntitle: Agent Gateway\n---\n\nBody text here, long enough to survive the empty-description guard.'; + }); + const rows = await fetchArchitectureSapComCorpus({ apiKey: 'fake' }); + expect(rows[0].url).toBe( + 'https://architecture.learning.sap.com/news/2026/10/27/a2a-agent-gateway-interoperability' + ); + }); + + it('leaves a non-dated news path on the plain fallback form (no spurious date expansion)', async () => { + // Only the YYYY-MM-DD- prefix triggers blog date routing; anything else + // (incl. the legacy YYYY-MM- shape) falls through to the plain path URL. + _setMockFetcher(async (url) => { + if (url.includes('/git/trees/')) return { + ...treeFixture, + tree: [{ + path: 'news/overview.md', + mode: '100644', type: 'blob', sha: 'bn2', size: 900, + url: 'https://api.github.com/blobs/bn2', + }], + }; + return '---\ntitle: Overview\n---\n\nBody text here, long enough to survive the empty-description guard.'; + }); + const rows = await fetchArchitectureSapComCorpus({ apiKey: 'fake' }); + expect(rows[0].url).toBe('https://architecture.learning.sap.com/news/overview'); + }); + it('title precedence: frontmatter > H1 > filename', async () => { // File 1: frontmatter title present → win // File 2: no frontmatter, has H1 → win From be38da7009680c2b3701c04a9c9eeb945097add2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:03:08 -0600 Subject: [PATCH 24/42] feat(db): build-time ON DELETE CASCADE patch for draft tables, not yet enabled (#2993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/patch-draft-cascade-constraints.cjs which enumerates all *_drafts.hdbtable files emitted by `npx cds build --production` and injects a FOREIGN KEY ... ON DELETE CASCADE clause for the DraftAdministrativeData_DraftUUID column so HANA can enforce cascade- delete of orphaned draft rows. Wired into the MTA before-all builder (prod gen tree only; QA tree emits zero draft tables). The draft_admin_integrity flag remains false in package.json — this task only makes the constraints deployable. Verified: patched 46 / skipped 0 against a real production build. --- .deploy/mta.yaml | 3 + scripts/patch-draft-cascade-constraints.cjs | 103 ++++++++++++++++++ .../patch-draft-cascade-constraints.test.js | 73 +++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 scripts/patch-draft-cascade-constraints.cjs create mode 100644 test/unit/patch-draft-cascade-constraints.test.js diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 1e06c8326..bb32c1a56 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -51,6 +51,9 @@ build-parameters: - bash -c "cd .. && npm run prebuild:parsers-bundle" - bash -c "cd .. && node scripts/bundle-shared.cjs && npx cds build --production" - bash -c "cd .. && node -e 'for (const p of [\"gen/db/src/gen/.hdiconfig\",\"gen/db-qa/src/gen/.hdiconfig\"]) { const fs=require(\"fs\"); const d=JSON.parse(fs.readFileSync(p,\"utf8\")); d.file_suffixes=d.file_suffixes||{}; d.file_suffixes.hdbprocedure={plugin_name:\"com.sap.hana.di.procedure\"}; fs.writeFileSync(p, JSON.stringify(d,null,2)+\"\\n\"); }'" + # Patch prod draft tables with ON DELETE CASCADE FK (#2993). Prod tree only — + # gen/db-qa emits no draft tables so it is intentionally not patched. + - bash -c "cd .. && node scripts/patch-draft-cascade-constraints.cjs gen/db/src/gen" - bash -c "rm -f ../gen/db-qa/src/gen/sap.changelog.*.hdbindex" # Build app/explore + emit the bundle manifest into hugo/data/. # Hugo reads site.Data.explore_bundle at template-render time diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs new file mode 100644 index 000000000..1ce214bd0 --- /dev/null +++ b/scripts/patch-draft-cascade-constraints.cjs @@ -0,0 +1,103 @@ +// scripts/patch-draft-cascade-constraints.cjs +// Build-time patch: appends ON DELETE CASCADE foreign keys to generated draft +// .hdbtable files so that SAP HANA will enforce cascade-delete of draft rows +// when their DraftAdministrativeData record is removed. +// +// Background (#2993): CAP's `cds.fiori.draft_admin_integrity` flag requires +// that every *_drafts table carries a real HANA FK with ON DELETE CASCADE to +// DRAFT_DRAFTADMINISTRATIVEDATA. The flag stays OFF in package.json (the +// in-memory SQLite unit tier does not understand .hdbconstraint DDL); this +// script makes the constraints deployable without enabling the flag. +// +// Usage (CLI): +// node scripts/patch-draft-cascade-constraints.cjs [genDir] +// genDir defaults to gen/db/src/gen +// +// Exported API: +// patchDraftCascadeConstraints(genDir) -> { patched: string[], skipped: string[] } + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const FK_COLUMN = 'DraftAdministrativeData_DraftUUID'; +const FK_CLAUSE = + 'FOREIGN KEY ("DraftAdministrativeData_DraftUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE'; + +/** + * Enumerate all *_drafts.hdbtable files under genDir, and for each one that + * contains the FK column but not yet ON DELETE CASCADE, inject the FK clause + * as the last element inside the COLUMN TABLE (...) body. + * + * @param {string} genDir Absolute (or cwd-relative) path to the gen dir. + * @returns {{ patched: string[], skipped: string[] }} + * @throws {Error} when no *_drafts.hdbtable files are found in genDir. + */ +function patchDraftCascadeConstraints(genDir) { + const entries = fs.readdirSync(genDir); + const draftFiles = entries.filter((f) => f.endsWith('_drafts.hdbtable')); + + if (draftFiles.length === 0) { + throw new Error( + `no *_drafts.hdbtable files found in ${genDir} — run cds build --production first` + ); + } + + const patched = []; + const skipped = []; + + for (const filename of draftFiles) { + const filepath = path.join(genDir, filename); + const original = fs.readFileSync(filepath, 'utf8'); + + // Skip if this table doesn't have the FK column (draft tables from other + // namespaces, or tables generated without draft admin support). + if (!original.includes(FK_COLUMN)) { + skipped.push(filename); + continue; + } + + // Idempotency: if the FK clause is already present, skip. + if (/ON DELETE CASCADE/i.test(original)) { + skipped.push(filename); + continue; + } + + // Inject the FK clause as the last item inside the COLUMN TABLE (...) body. + // The closing ) is the last non-whitespace character after PRIMARY KEY(...). + // Strategy: find the last ) and insert ", \n" before it. + const lastParen = original.lastIndexOf(')'); + if (lastParen === -1) { + // Malformed file — skip rather than corrupt it. + skipped.push(filename); + continue; + } + + const patched_content = + original.slice(0, lastParen) + + ',\n ' + + FK_CLAUSE + + '\n' + + original.slice(lastParen); + + fs.writeFileSync(filepath, patched_content, { encoding: 'utf8' }); + patched.push(filename); + } + + return { patched, skipped }; +} + +// CLI entry point +if (require.main === module) { + const genDir = process.argv[2] || 'gen/db/src/gen'; + try { + const result = patchDraftCascadeConstraints(genDir); + console.log(`patched ${result.patched.length} / skipped ${result.skipped.length}`); + } catch (err) { + console.error(err.message); + process.exit(1); + } +} + +module.exports = { patchDraftCascadeConstraints }; diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js new file mode 100644 index 000000000..4598ec4cb --- /dev/null +++ b/test/unit/patch-draft-cascade-constraints.test.js @@ -0,0 +1,73 @@ +// test/unit/patch-draft-cascade-constraints.test.js +// Unit tests for the build-time cascade-FK patch script (#2993). +// No CDS boot — pure file-system fixture only. + +import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { patchDraftCascadeConstraints } from '../../scripts/patch-draft-cascade-constraints.cjs'; + +// Minimal .hdbtable fixture that carries the FK column +const FIXTURE_WITH_FK = `COLUMN TABLE AdminService_Missions_drafts ( + ID NVARCHAR(36) NOT NULL, + IsActiveEntity NVARCHAR(1), + HasActiveEntity NVARCHAR(1), + HasDraftEntity NVARCHAR(1), + DraftAdministrativeData_DraftUUID NVARCHAR(36) NOT NULL, + PRIMARY KEY(ID) +)`; + +// Minimal .hdbtable fixture that does NOT carry the FK column +const FIXTURE_WITHOUT_FK = `COLUMN TABLE AdminService_NoFk_drafts ( + ID NVARCHAR(36) NOT NULL, + IsActiveEntity NVARCHAR(1), + PRIMARY KEY(ID) +)`; + +let tmp; +let emptyTmp; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-test-')); + emptyTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-empty-')); + + fs.writeFileSync( + path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), + FIXTURE_WITH_FK, + { encoding: 'utf8' } + ); + fs.writeFileSync( + path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), + FIXTURE_WITHOUT_FK, + { encoding: 'utf8' } + ); +}); + +describe('patchDraftCascadeConstraints', () => { + it('appends ON DELETE CASCADE FK to draft tables that carry the FK column', () => { + const r = patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + expect(sql).toMatch( + /FOREIGN KEY\s*\("?DraftAdministrativeData_DraftUUID"?\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/i + ); + expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); + }); + + it('skips draft tables without the FK column', () => { + patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), 'utf8'); + expect(sql).not.toMatch(/ON DELETE CASCADE/i); + }); + + it('is idempotent — second run adds no duplicate FK', () => { + patchDraftCascadeConstraints(tmp); + const first = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + patchDraftCascadeConstraints(tmp); + expect(fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8')).toBe(first); + }); + + it('throws when no draft tables are found', () => { + expect(() => patchDraftCascadeConstraints(emptyTmp)).toThrow(/no .*_drafts\.hdbtable/i); + }); +}); From 2bcf77f170eff013bc8ef169ca794e99faa8db75 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:05:09 -0600 Subject: [PATCH 25/42] feat(#2990): editable Order column + SortOrder on CompletionPaths admin UI --- app/admin-annotations.cds | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 27904dfcb..66138c72d 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -216,6 +216,7 @@ annotate AdminService.CompletionPaths with { name @Common.Label: 'Title'; description @Common.Label: 'Description' @UI.MultiLineText; slug @Common.Label: 'Slug'; + pathOrder @Common.Label: 'Order'; }; annotate AdminService.CompletionPaths with @UI: { @@ -233,7 +234,9 @@ annotate AdminService.CompletionPaths with @UI: { { Value: description } ] }, + PresentationVariant: { SortOrder: [ { Property: pathOrder } ] }, LineItem: [ + { Value: pathOrder, Label: 'Order' }, { Value: name }, { Value: slug } ] From bc78b3b4d510144fba87242d2d288bfeeff19af4 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:10:04 -0600 Subject: [PATCH 26/42] fix(script): nudge ALL target links, not just changed rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The digest nudge previously keyed off `plan` (rows whose url changed this run) and was gated behind an early `planned===0` return. On an idempotent re-run where urls are already correct but the served concept BLOB is still stale, that skipped the nudge entirely — so the re-bake never triggered. Nudge now targets every PRESENT target HelpDoc's links (presentIds) and runs on any --commit. --- scripts/fix-archcenter-urls.cjs | 40 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/scripts/fix-archcenter-urls.cjs b/scripts/fix-archcenter-urls.cjs index 8abe62d87..69724415c 100644 --- a/scripts/fix-archcenter-urls.cjs +++ b/scripts/fix-archcenter-urls.cjs @@ -49,6 +49,8 @@ async function main() { let planned = 0; let changed = 0; const plan = []; + const presentIds = []; // every target HelpDoc row that EXISTS (for the nudge), + // independent of whether its url needed changing. for (const { sourceId, url: newUrl } of FIXES) { // Metadata-only read (never SELECT description/LOB alongside) — LOB-locator safe. @@ -61,6 +63,7 @@ async function main() { console.log(` MISSING ${sourceId}\n (no row — skipping; nothing to fix)`); continue; } + presentIds.push(row.ID); if (row.url === newUrl) { console.log(` OK ${sourceId}\n already ${newUrl}`); continue; @@ -79,38 +82,45 @@ async function main() { console.log('\nDRY-RUN — no writes. Re-run with --commit to apply.\n'); return; } - if (planned === 0) { - console.log('\nNothing to apply.\n'); - return; - } for (const p of plan) { const n = await UPDATE(HelpDocs).set({ url: p.newUrl }).where({ ID: p.ID }); changed += n; } - console.log(`\nApplied: ${changed} row(s) updated.`); + console.log(`\nApplied: ${changed} url row(s) updated (${plan.length === 0 ? 'all already correct' : ''}).`); // Concept-publish digest nudge (#2948 follow-up): the concept re-render phase // keys its delta on concept-input-digest.js, which probes max(modifiedAt) over // HelpDocConceptLinks — but NOT over HelpDocs. So a bare HelpDocs.url change // does NOT bump the digest, and a full publish would phase-skip and never // re-bake the corrected href into the concept BLOB. Touching the linking rows - // bumps their managed modifiedAt (= $now on any UPDATE) so the next full PROD + // bumps their managed modifiedAt (= $now on any UPDATE) so the next full // publish re-renders the affected concept pages. We write each row's EXISTING // modelVersion back unchanged — content-neutral (no column value altered), - // modifiedAt is the only field that moves. Scoped to the fixed HelpDocs. - const fixedIds = plan.map((p) => p.ID); - const linkRows = await SELECT.from(HelpDocConceptLinks) - .columns('ID', 'modelVersion') - .where({ helpDoc_ID: { in: fixedIds } }); + // modifiedAt is the only field that moves. + // + // IMPORTANT: nudge ALL present target HelpDocs' links, not just the ones whose + // url changed this run. On a re-run where urls are already correct (idempotent + // path), the data is right but the served BLOB may still be stale — the nudge + // is exactly what forces the re-bake, so it must fire regardless of `changed`. let touched = 0; - for (const lr of linkRows) { - touched += await UPDATE(HelpDocConceptLinks) - .set({ modelVersion: lr.modelVersion }) // same value → only modifiedAt moves - .where({ ID: lr.ID }); + if (presentIds.length > 0) { + const linkRows = await SELECT.from(HelpDocConceptLinks) + .columns('ID', 'modelVersion') + .where({ helpDoc_ID: { in: presentIds } }); + for (const lr of linkRows) { + touched += await UPDATE(HelpDocConceptLinks) + .set({ modelVersion: lr.modelVersion }) // same value → only modifiedAt moves + .where({ ID: lr.ID }); + } } console.log(`Digest nudge: touched ${touched} HelpDocConceptLinks row(s) (bumps modifiedAt for concept re-render; values unchanged).`); + if (plan.length === 0 && touched === 0) { + console.log('\nNothing to apply and no links to nudge.\n'); + return; + } + // Post-write read-back for audit. console.log('\nVerification (stored url after write):'); for (const p of plan) { From e02afbe31df376f3e62b74be71e329dc2ff8ce3b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:23:40 -0600 Subject: [PATCH 27/42] fix(db): trailing-comma DDL form + tighter idempotency guard in draft cascade patch (#2993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: - Comma now trails PRIMARY KEY(ID) line (trailing-comma style) rather than appearing as a bare leading comma on the separator line. Fixed by trimEnd()ing the body slice so the comma attaches to the last element. - Idempotency regex tightened from /ON DELETE CASCADE/i to a pattern that keys on DraftAdministrativeData_DraftUUID + ON DELETE CASCADE (cross-paren match via [\s\S]*?) so a different column's cascade clause cannot be mistaken for our injection. New test: 'comma trails the previous line — no leading-comma style' asserts PRIMARY KEY(ID), immediately precedes FOREIGN KEY. 5/5 tests pass; real build: patched 46 / skipped 0. --- scripts/patch-draft-cascade-constraints.cjs | 21 ++++++++++++++----- .../patch-draft-cascade-constraints.test.js | 9 ++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs index 1ce214bd0..f3d5df55b 100644 --- a/scripts/patch-draft-cascade-constraints.cjs +++ b/scripts/patch-draft-cascade-constraints.cjs @@ -58,15 +58,24 @@ function patchDraftCascadeConstraints(genDir) { continue; } - // Idempotency: if the FK clause is already present, skip. - if (/ON DELETE CASCADE/i.test(original)) { + // Idempotency: if OUR specific FK clause is already present, skip. + // Match the FK column name AND ON DELETE CASCADE on the same logical line. + // Uses [\s\S]*? (non-greedy cross-paren match) because the clause itself + // contains parentheses: FOREIGN KEY (...) REFERENCES ...("DRAFTUUID") ON DELETE CASCADE. + if (/FOREIGN KEY\s*\([^F]*DraftAdministrativeData_DraftUUID[\s\S]*?ON DELETE CASCADE/i.test(original)) { skipped.push(filename); continue; } // Inject the FK clause as the last item inside the COLUMN TABLE (...) body. - // The closing ) is the last non-whitespace character after PRIMARY KEY(...). - // Strategy: find the last ) and insert ", \n" before it. + // Target form: + // PRIMARY KEY(ID), + // FOREIGN KEY ("DraftAdministrativeData_DraftUUID") REFERENCES ... ON DELETE CASCADE + // ) + // + // Strategy: find the last ')' (the table body closing paren). Everything + // before it (trimmed of trailing whitespace/newlines) gets a trailing comma, + // then the FK clause on its own line, then the closing ')'. const lastParen = original.lastIndexOf(')'); if (lastParen === -1) { // Malformed file — skip rather than corrupt it. @@ -74,8 +83,10 @@ function patchDraftCascadeConstraints(genDir) { continue; } + // Trim trailing whitespace from the body so the comma trails the last element. + const bodyPart = original.slice(0, lastParen).trimEnd(); const patched_content = - original.slice(0, lastParen) + + bodyPart + ',\n ' + FK_CLAUSE + '\n' + diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js index 4598ec4cb..f7d9817e9 100644 --- a/test/unit/patch-draft-cascade-constraints.test.js +++ b/test/unit/patch-draft-cascade-constraints.test.js @@ -54,6 +54,15 @@ describe('patchDraftCascadeConstraints', () => { expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); }); + it('comma trails the previous line — no leading-comma style', () => { + patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + // The separator comma must be attached to PRIMARY KEY(ID), not on its own line. + expect(sql).toMatch(/PRIMARY KEY\(ID\),\s*\n\s*FOREIGN KEY/i); + // Confirm there is no bare leading comma on its own line before FOREIGN KEY. + expect(sql).not.toMatch(/^\s*,\s*\n\s*FOREIGN KEY/im); + }); + it('skips draft tables without the FK column', () => { patchDraftCascadeConstraints(tmp); const sql = fs.readFileSync(path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), 'utf8'); From 32e74ecf5c8962a3bafe313a1083872abfbc5392 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:25:48 -0600 Subject: [PATCH 28/42] fix(test): key App-Space progress tests on CompletionPaths UUID, not legacyId (#2952) PR #2991 (#2952) changed getAppSpaceProgress to emit the CompletionPaths UUID (p.ID) as the track id instead of p.legacyId, but left two test files looking paths up by the old numeric legacyId. The .find(p => p.id === ) calls returned undefined, failing 11 tests (expected undefined to be defined) on DEV and every branch that merged it. Switch the lookups to the seeded UUID constants (G_PATH_GROUP/G_PATH_FLAT, FORK_PATH_ID/LONE_PATH_ID) to match the new opaque-string id contract. Verified: 11 previously-failing tests now pass; full unit suite green. --- test/app-space-fk-resolution.test.js | 11 ++++++----- test/app-space-segments-resolver.test.js | 18 +++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/test/app-space-fk-resolution.test.js b/test/app-space-fk-resolution.test.js index 9afbf9634..455cc633e 100644 --- a/test/app-space-fk-resolution.test.js +++ b/test/app-space-fk-resolution.test.js @@ -170,7 +170,8 @@ describe('App-Space: GROUP expansion + per-path description', () => { it('expands a GROUP path item into its child tutorials (not "0 tutorials")', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${G_EVENT_ID})`); - const groupPath = data.paths.find(p => p.id === 10009260); + // #2952: track id is now the CompletionPaths UUID (p.ID), not legacyId. + const groupPath = data.paths.find(p => p.id === G_PATH_GROUP); expect(groupPath).toBeDefined(); const tutorials = groupPath.items.filter(i => i.type === 'TUTORIAL'); expect(tutorials.map(t => t.title)).toEqual([G_TUT_A.title, G_TUT_B.title]); // ordered by itemOrder @@ -185,10 +186,10 @@ describe('App-Space: GROUP expansion + per-path description', () => { it('each path shows its OWN description, not the mission description', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${G_EVENT_ID})`); const byId = Object.fromEntries(data.paths.map(p => [p.id, p])); - expect(byId[10009260].description).toBe('Joule Studio (web)'); - expect(byId[10009261].description).toBe('Joule Work path description'); + expect(byId[G_PATH_GROUP].description).toBe('Joule Studio (web)'); + expect(byId[G_PATH_FLAT].description).toBe('Joule Work path description'); // The two descriptions differ (the bug made them identical = mission text). - expect(byId[10009260].description).not.toBe(byId[10009261].description); - expect(byId[10009260].description).not.toContain('hands dirty'); + expect(byId[G_PATH_GROUP].description).not.toBe(byId[G_PATH_FLAT].description); + expect(byId[G_PATH_GROUP].description).not.toContain('hands dirty'); }); }); diff --git a/test/app-space-segments-resolver.test.js b/test/app-space-segments-resolver.test.js index f8326767f..3b569b786 100644 --- a/test/app-space-segments-resolver.test.js +++ b/test/app-space-segments-resolver.test.js @@ -156,7 +156,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('fork path has 3 segments with kinds [linear, fork, linear]', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008060); + const path = data.paths.find(p => p.id === FORK_PATH_ID); expect(path).toBeDefined(); expect(path.segments).toBeDefined(); expect(path.segments).toHaveLength(3); @@ -165,7 +165,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('fork segment has 2 lanes labeled MCP and Agents (from Groups.title)', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008060); + const path = data.paths.find(p => p.id === FORK_PATH_ID); const forkSeg = path.segments.find(s => s.kind === 'fork'); expect(forkSeg).toBeDefined(); expect(forkSeg.lanes).toHaveLength(2); @@ -176,7 +176,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('MCP lane contains its child tutorials', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008060); + const path = data.paths.find(p => p.id === FORK_PATH_ID); const forkSeg = path.segments.find(s => s.kind === 'fork'); const mcpLane = forkSeg.lanes.find(l => l.label === 'MCP'); expect(mcpLane).toBeDefined(); @@ -187,7 +187,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('MCP lane ends with a synthetic CHECKPOINT (from prize_ID)', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008060); + const path = data.paths.find(p => p.id === FORK_PATH_ID); const forkSeg = path.segments.find(s => s.kind === 'fork'); const mcpLane = forkSeg.lanes.find(l => l.label === 'MCP'); const lastItem = mcpLane.items[mcpLane.items.length - 1]; @@ -196,7 +196,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('Agents lane has no synthetic CHECKPOINT (no prize)', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008060); + const path = data.paths.find(p => p.id === FORK_PATH_ID); const forkSeg = path.segments.find(s => s.kind === 'fork'); const agentsLane = forkSeg.lanes.find(l => l.label === 'Agents'); expect(agentsLane).toBeDefined(); @@ -205,7 +205,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('linear segment at Order 10 has the Setup tutorial', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008060); + const path = data.paths.find(p => p.id === FORK_PATH_ID); const setupSeg = path.segments.find(s => s.order === 10); expect(setupSeg.kind).toBe('linear'); expect(setupSeg.items).toHaveLength(1); @@ -214,7 +214,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('linear segment at Order 30 has the CHECKPOINT', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008060); + const path = data.paths.find(p => p.id === FORK_PATH_ID); const cpSeg = path.segments.find(s => s.order === 30); expect(cpSeg.kind).toBe('linear'); expect(cpSeg.items).toHaveLength(1); @@ -224,7 +224,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('flat items array is still present (backwards compat)', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008060); + const path = data.paths.find(p => p.id === FORK_PATH_ID); expect(Array.isArray(path.items)).toBe(true); // items should contain the tutorials (flat, existing behavior) expect(path.items.length).toBeGreaterThan(0); @@ -234,7 +234,7 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO it('lone GROUP at an order produces a linear segment with expanded tutorials (no lane wrapper)', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${LONE_EVENT_ID})`); - const path = data.paths.find(p => p.id === 10008061); + const path = data.paths.find(p => p.id === LONE_PATH_ID); expect(path).toBeDefined(); expect(path.segments).toBeDefined(); expect(path.segments).toHaveLength(1); From 4b3866fbdf29d0b1dd4e6a68604e8b20105b3b56 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:39:15 -0600 Subject: [PATCH 29/42] =?UTF-8?q?test(drafts):=20flag-agnostic=20gate=20?= =?UTF-8?q?=E2=80=94=20Mission=20discard=20leaves=20zero=20child=20draft?= =?UTF-8?q?=20orphans=20(#2993)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/admin-drafts-cascade-gate.test.js | 150 +++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/admin-drafts-cascade-gate.test.js diff --git a/test/admin-drafts-cascade-gate.test.js b/test/admin-drafts-cascade-gate.test.js new file mode 100644 index 000000000..4e3288aa6 --- /dev/null +++ b/test/admin-drafts-cascade-gate.test.js @@ -0,0 +1,150 @@ +// test/admin-drafts-cascade-gate.test.js +// +// Flag-agnostic gate test for #2993: Mission draftEdit → discard must leave +// zero orphaned child CompletionPathItems draft rows, regardless of whether +// cds.fiori.draft_admin_integrity is true or false. +// +// When draft_admin_integrity is OFF (current default): CAP's built-in draft +// discard deletes the parent Missions draft and its composition children +// (CompletionPaths + CompletionPathItems) in the same operation. The child +// draft rows are removed before any after('DISCARD') hook fires. +// +// When draft_admin_integrity is ON (#2993 target): a DB-level CASCADE constraint +// is added so the same deletion cascade still removes child draft rows even +// though CAP 10.2 changes the discard implementation. +// +// This test proves the invariant holds NOW (flag OFF) so it will turn red +// the moment a future flag-on or schema change breaks the cascade — acting as +// the re-enable gate described in the #2993 design doc. +// +// Harness: cds.test('serve', '--project', '.', '--in-memory') + adminAuth HTTP, +// matching test/admin-drafts-discard-reconcile.test.js. All seeded rows use the +// 93xxx legacyId range to avoid collisions with 92xxx rows in that file. +import { describe, it, expect } from 'vitest'; +import cds from '@sap/cds'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const adminAuth = { auth: { username: 'admin', password: 'admin' } }; +const NS = 'com.sap.developers.ims'; + +describe('Mission draftEdit→discard leaves zero child draft orphans (#2993 gate)', () => { + // ─── A) Mission with one CompletionPath + one CompletionPathItem ───────── + // Seed an active Mission composition hierarchy, draftEdit it, confirm + // CompletionPathItems.drafts rows exist for the draft UUID (sanity), then + // discard and assert zero CompletionPathItems.drafts remain for that UUID. + it('leaves zero CompletionPathItems draft rows after Mission draftEdit→discard', async () => { + const db = await cds.connect.to('db'); + const srv = await cds.connect.to('AdminService'); + const { Tutorials, Missions, CompletionPaths, CompletionPathItems } = cds.entities(NS); + + // Unique IDs in the 93xxx range — distinct from the 92xxx range in + // admin-drafts-discard-reconcile.test.js to avoid collision. + const MISS_ID = 'cccccccc-2993-0001-0000-000000000001'; + const CP_ID = 'dddddddd-2993-0001-0000-000000000001'; + const CPI_ID = 'eeeeeeee-2993-0001-0000-000000000001'; + const TUT_ID = 'bbbbbbbb-2993-0001-0000-000000000001'; + const TUT_LEGACY = 93001; + const MISS_LEGACY = 93501; + + // Seed active entities. + await db.run(INSERT.into(Tutorials).entries({ + ID: TUT_ID, legacyId: TUT_LEGACY, slug: 'cascade-gate-tut-01', + title: 'Cascade Gate Tut 01', status: 'ACTIVE', + })); + await db.run(INSERT.into(Missions).entries({ + ID: MISS_ID, legacyId: MISS_LEGACY, + title: '__TEST__ Cascade Gate Mission 01', published: false, + })); + await db.run(INSERT.into(CompletionPaths).entries({ + ID: CP_ID, mission_ID: MISS_ID, legacyId: 93601, name: 'Cascade Gate Path', + })); + await db.run(INSERT.into(CompletionPathItems).entries({ + ID: CPI_ID, path_ID: CP_ID, taskType: 'TUTORIAL', + taskLegacyId: TUT_LEGACY, itemOrder: 10, legacyId: 93701, + })); + + // draftEdit the active Mission. + const { status: sEdit } = await project.post( + `/admin/Missions(ID=${MISS_ID},IsActiveEntity=true)/AdminService.draftEdit`, {}, adminAuth + ); + expect([200, 201]).toContain(sEdit); + + // Capture the DraftAdministrativeData_DraftUUID from the Missions.drafts row. + // srv.entities['Missions'].drafts is the draft-shadow entity exposed by the + // AdminService runtime (the .drafts accessor is on service entities, not raw + // model entities from cds.entities(NS)). + const [missionDraftRow] = await db.run( + SELECT.from(srv.entities['Missions'].drafts).where({ ID: MISS_ID }) + ); + expect(missionDraftRow).toBeTruthy(); + const draftUUID = missionDraftRow.DraftAdministrativeData_DraftUUID; + expect(draftUUID).toBeTruthy(); + + // Sanity: CompletionPathItems.drafts for this UUID must be non-empty + // (proves the test can detect orphans — the assertion is non-vacuous). + const before = await db.run( + SELECT.from(srv.entities['CompletionPathItems'].drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID }) + ); + expect(before.length).toBeGreaterThan(0); + + // Discard the draft. + const { status: sDisc } = await project.delete( + `/admin/Missions(ID=${MISS_ID},IsActiveEntity=false)`, adminAuth + ); + expect([200, 204]).toContain(sDisc); + + // Gate assertion: zero CompletionPathItems.drafts must remain after discard. + const after = await db.run( + SELECT.from(srv.entities['CompletionPathItems'].drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID }) + ); + expect(after).toHaveLength(0); + }); + + // ─── B) Empty-composition Mission (no child items) ─────────────────────── + // A Mission with no CompletionPaths at all should draftEdit and discard + // cleanly, resulting in zero orphaned child draft rows (trivially zero in, + // zero out — but ensures the code path does not throw on an empty hierarchy). + it('discard of a Mission draft with no child items leaves zero orphans', async () => { + const db = await cds.connect.to('db'); + const srv = await cds.connect.to('AdminService'); + const { Missions } = cds.entities(NS); + + const MISS_ID2 = 'cccccccc-2993-0002-0000-000000000001'; + const MISS_LEGACY2 = 93502; + + // Seed active Mission with no CompletionPaths / CompletionPathItems. + await db.run(INSERT.into(Missions).entries({ + ID: MISS_ID2, legacyId: MISS_LEGACY2, + title: '__TEST__ Cascade Gate Mission 02 (empty)', published: false, + })); + + // draftEdit. + const { status: sEdit } = await project.post( + `/admin/Missions(ID=${MISS_ID2},IsActiveEntity=true)/AdminService.draftEdit`, {}, adminAuth + ); + expect([200, 201]).toContain(sEdit); + + // Capture draft UUID via service entity's .drafts accessor. + const [missionDraftRow] = await db.run( + SELECT.from(srv.entities['Missions'].drafts).where({ ID: MISS_ID2 }) + ); + expect(missionDraftRow).toBeTruthy(); + const draftUUID = missionDraftRow.DraftAdministrativeData_DraftUUID; + expect(draftUUID).toBeTruthy(); + + // Discard. + const { status: sDisc } = await project.delete( + `/admin/Missions(ID=${MISS_ID2},IsActiveEntity=false)`, adminAuth + ); + expect([200, 204]).toContain(sDisc); + + // Zero orphans expected (and zero before, so this is a clean-pass assertion). + const after = await db.run( + SELECT.from(srv.entities['CompletionPathItems'].drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID }) + ); + expect(after).toHaveLength(0); + }); +}); From 77280e08ba04812b931da94d1a06b27ef370dcd0 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:42:34 -0600 Subject: [PATCH 30/42] feat(#2990): auto gap-number pathOrder on new CompletionPaths --- srv/admin-service.js | 16 +++++++ test/admin-service.test.js | 94 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/srv/admin-service.js b/srv/admin-service.js index 51b01f8d2..9429535c0 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -1139,6 +1139,22 @@ export default class AdminService extends cds.ApplicationService { this.before('NEW', 'GroupPathItems.drafts', setGroupItemOrder); this.before('CREATE', 'GroupPathItems', setGroupItemOrder); + // Gap-number pathOrder for new CompletionPaths rows so inline-created paths + // get a sensible order without the user typing one. NEW fires on draft + // inline create (visible immediately); CREATE covers programmatic posts. + const setPathOrder = async (req) => { + if (req.data.pathOrder != null) return; + const missionId = req.data.mission_ID + || (Array.isArray(req.params) && req.params[0] && req.params[0].ID); + if (!missionId) return; + const row = await SELECT.one.from(req.target) + .columns('max(pathOrder) as maxOrder') + .where({ mission_ID: missionId }); + req.data.pathOrder = ((row?.maxOrder ?? 0) + 10); + }; + this.before('NEW', 'CompletionPaths.drafts', setPathOrder); + this.before('CREATE', 'CompletionPaths', setPathOrder); + // Issue #172 — refuse incoherent alt-group shapes. // Per PR 2 reviewer addendum item G: enforceMultiMember=false on CREATE // (authors create members one at a time in Fiori draft). diff --git a/test/admin-service.test.js b/test/admin-service.test.js index 6d4d5ff33..b0a37320c 100644 --- a/test/admin-service.test.js +++ b/test/admin-service.test.js @@ -615,4 +615,98 @@ describe('AdminService', () => { expect(data.slug).toBe('copy-src-copy-2'); }); }); + + describe('setPathOrder — auto gap-numbering on CompletionPaths (#2990)', () => { + // CompletionPaths is a composition of Missions, which has @odata.draft.enabled + // (see app/admin-annotations.cds). CAP enforces that compositions of draft-enabled + // roots can only be modified via the root's draft navigation. + // + // The setPathOrder handler is registered on: + // before('NEW', 'CompletionPaths.drafts', ...) — fires on draft inline create + // before('CREATE', 'CompletionPaths', ...) — fires on draftActivate cascade + // + // Tests exercise the NEW path (post to parent draft's nav property) and verify + // the returned draft row has the correct pathOrder. + + it('assigns pathOrder=10 to the first path on a new mission draft', async () => { + const { Tags } = cds.entities('com.sap.developers.ims'); + const tagId = cds.utils.uuid(); + await INSERT.into(Tags).entries({ ID: tagId, legacyId: 99901, name: '__TEST__ po-tag-01' }); + + // Create mission draft + const { status: s1, data: mDraft } = await project.post('/admin/Missions', { + title: '__TEST__ PO Mission 01', + slug: 'po-test-mission-01', + experienceTag: 'beginner', + primaryTagRef_ID: tagId, + }, { ...adminAuth, headers: { Prefer: 'handling=lenient' } }); + expect(s1).toBe(201); + + // Add first path via draft nav — before('NEW', 'CompletionPaths.drafts') fires + const { status: s2, data: path } = await project.post( + `/admin/Missions(ID=${mDraft.ID},IsActiveEntity=false)/completionPaths`, + { name: 'First Path' }, + adminAuth + ); + expect(s2).toBe(201); + expect(path.pathOrder).toBe(10); + }); + + it('assigns pathOrder=20 to a second path when the first has pathOrder=10', async () => { + const { Tags } = cds.entities('com.sap.developers.ims'); + const tagId = cds.utils.uuid(); + await INSERT.into(Tags).entries({ ID: tagId, legacyId: 99902, name: '__TEST__ po-tag-02' }); + + // Create mission draft + const { status: s1, data: mDraft } = await project.post('/admin/Missions', { + title: '__TEST__ PO Mission 02', + slug: 'po-test-mission-02', + experienceTag: 'beginner', + primaryTagRef_ID: tagId, + }, { ...adminAuth, headers: { Prefer: 'handling=lenient' } }); + expect(s1).toBe(201); + + // Add first path (pathOrder should be 10) + const { status: s2, data: path1 } = await project.post( + `/admin/Missions(ID=${mDraft.ID},IsActiveEntity=false)/completionPaths`, + { name: 'Path One' }, + adminAuth + ); + expect(s2).toBe(201); + expect(path1.pathOrder).toBe(10); + + // Add second path — handler reads max(pathOrder)=10 from draft table, returns 20 + const { status: s3, data: path2 } = await project.post( + `/admin/Missions(ID=${mDraft.ID},IsActiveEntity=false)/completionPaths`, + { name: 'Path Two' }, + adminAuth + ); + expect(s3).toBe(201); + expect(path2.pathOrder).toBe(20); + }); + + it('preserves an explicit pathOrder passed on create', async () => { + const { Tags } = cds.entities('com.sap.developers.ims'); + const tagId = cds.utils.uuid(); + await INSERT.into(Tags).entries({ ID: tagId, legacyId: 99903, name: '__TEST__ po-tag-03' }); + + // Create mission draft + const { status: s1, data: mDraft } = await project.post('/admin/Missions', { + title: '__TEST__ PO Mission 03', + slug: 'po-test-mission-03', + experienceTag: 'beginner', + primaryTagRef_ID: tagId, + }, { ...adminAuth, headers: { Prefer: 'handling=lenient' } }); + expect(s1).toBe(201); + + // Add path with explicit pathOrder=55 — should NOT be overwritten + const { status: s2, data: path } = await project.post( + `/admin/Missions(ID=${mDraft.ID},IsActiveEntity=false)/completionPaths`, + { name: 'Explicit Path', pathOrder: 55 }, + adminAuth + ); + expect(s2).toBe(201); + expect(path.pathOrder).toBe(55); + }); + }); }); From 3aeec23f175f691cfb72717fe59bf48c5e6672f0 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:50:42 -0600 Subject: [PATCH 31/42] feat(#2990): preserve pathOrder in copyMission and seed it in promoteCommunityToMission --- srv/admin-service.js | 2 ++ test/admin-service.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/srv/admin-service.js b/srv/admin-service.js index 9429535c0..176ca1bb5 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -3946,6 +3946,7 @@ export default class AdminService extends cds.ApplicationService { mission_ID: missionId, name: 'Default', slug: `${lcSlug}-default`, + pathOrder: 10, }) ); await tx.run( @@ -4057,6 +4058,7 @@ export default class AdminService extends cds.ApplicationService { name: p.name, description: p.description, slug: pathSlug, + pathOrder: p.pathOrder, })); const items = await tx.run(SELECT.from(CompletionPathItems).where({ path_ID: p.ID })); if (items.length) { diff --git a/test/admin-service.test.js b/test/admin-service.test.js index b0a37320c..7b4358614 100644 --- a/test/admin-service.test.js +++ b/test/admin-service.test.js @@ -614,6 +614,35 @@ describe('AdminService', () => { // first copy took copy-src-copy; this one falls back expect(data.slug).toBe('copy-src-copy-2'); }); + + it('preserves pathOrder on all cloned CompletionPaths (#2990)', async () => { + const { + Missions, CompletionPaths, + } = cds.entities('com.sap.developers.ims'); + + // Seed a fresh source mission with two paths having explicit pathOrder values. + const srcId2 = cds.utils.uuid(); + const pathA = cds.utils.uuid(); + const pathB = cds.utils.uuid(); + await INSERT.into(Missions).entries({ + ID: srcId2, slug: 'po-copy-src', title: 'PO Copy Source', published: false, + missionType: 'SEQUENTIAL', experienceTag: 'beginner', + }); + await INSERT.into(CompletionPaths).entries([ + { ID: pathA, mission_ID: srcId2, name: 'Path A', slug: 'po-copy-src-a', pathOrder: 10 }, + { ID: pathB, mission_ID: srcId2, name: 'Path B', slug: 'po-copy-src-b', pathOrder: 20 }, + ]); + + const { status, data } = await project.post('/admin/copyMission', { ID: srcId2 }, adminAuth); + expect(status).toBe(200); + + const copiedPaths = await SELECT.from(CompletionPaths) + .where({ mission_ID: data.ID }) + .orderBy('pathOrder'); + expect(copiedPaths.length).toBe(2); + expect(copiedPaths[0].pathOrder).toBe(10); + expect(copiedPaths[1].pathOrder).toBe(20); + }); }); describe('setPathOrder — auto gap-numbering on CompletionPaths (#2990)', () => { From 0aa246e2f63a72ad51dcf9a5725e5c3d67b25f68 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:50:57 -0600 Subject: [PATCH 32/42] docs(ops): runbook for safely re-enabling draft_admin_integrity (#2993) --- CLAUDE.md | 2 +- .../operations/draft-integrity-reenable.md | 228 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 docs/developers/operations/draft-integrity-reenable.md diff --git a/CLAUDE.md b/CLAUDE.md index ab3678775..5524b4750 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,4 +159,4 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **External channels subsystem** — `Channels` entity (`db/channels.cds`) is the source of truth; re-ingest via `npm run seed-channels`; directory at `/channels` (baked by `fetch-channels` in `build:all`); verb-lane fill via `npm run promote-channels`; community items never land in `START_HERE`. → [channels.md](docs/developers/reference/channels.md). - **TechEd sessions subsystem (#2312)** — new `TechEd*` entities (`db/external/teched.cds`, NOT reused `CommunityEvents`), loaded via a **`using` side-effect** in `db/external-content.cds` (don't delete the import); RainFocus ingest (`POST https://events.rainfocus.com/api/sessions`, headers `rfapiprofileid`/`rfwidgetid` per venue from `RAINFOCUS_{TE26,TEV26}_PROFILE`/`_WIDGET` env — live-confirmed defaults baked in, IDs may rotate; speakers embedded in `participants[]`, tracks in `attributevalues[attribute=="Track"]`, offset-paginated); idempotent seed via `scripts/seed-teched.cjs`; public `/build/teched` feed; corrected fetcher + delta cron jobs shipped in #2332; `/teched/` page + island, KG projection, embeddings, and Devtoberfest cross-linking are later units. → [teched.md](docs/developers/reference/teched.md). - **Signed provenance envelope (#2245)** — `PROVENANCE_ENVELOPE_ENABLED` (DB config, default OFF, DEV-first); two anonymous Express endpoints (`/content/tutorials/:slug/provenance` and `/.well-known/tutorial-provenance/jwks.json`); signing key = `PROVENANCE_SIGNING_KEY` credstore secret (Ed25519 PKCS8 PEM); rotate via `/admin-ui/#secrets`. Fail-open. → gotchas.md "Signed provenance envelope". -- **Optimized draft deletion `draft_admin_integrity` ON (#2909, CAP 10.2)** — `cds.fiori.draft_admin_integrity: true` in package.json; draft discard cascade-deletes draft `CompletionPathItems`/`GroupPathItems` at the DB level, so rollup reconcile now fires via `after('DISCARD','Missions.drafts'/'Groups.drafts')` (the `.drafts` target is required), NOT the per-row child `after('DELETE')` hooks (those stay for direct programmatic deletes). Deleting an active entity right after `draftActivate` can 403 ("a draft exists") until the residual draft is discarded. Constraint migration deploys to HANA (`.hdbconstraint`, `NOT VALIDATED`) BEFORE the flag serves. → gotchas.md "Optimized draft deletion". +- **Optimized draft deletion `draft_admin_integrity` ON (#2909, CAP 10.2)** — `cds.fiori.draft_admin_integrity: true` in package.json; draft discard cascade-deletes draft `CompletionPathItems`/`GroupPathItems` at the DB level, so rollup reconcile now fires via `after('DISCARD','Missions.drafts'/'Groups.drafts')` (the `.drafts` target is required), NOT the per-row child `after('DELETE')` hooks (those stay for direct programmatic deletes). Deleting an active entity right after `draftActivate` can 403 ("a draft exists") until the residual draft is discarded. Constraint migration deploys to HANA (`.hdbconstraint`, `NOT VALIDATED`) BEFORE the flag serves. → gotchas.md "Optimized draft deletion". Re-enable runbook: docs/developers/operations/draft-integrity-reenable.md. diff --git a/docs/developers/operations/draft-integrity-reenable.md b/docs/developers/operations/draft-integrity-reenable.md new file mode 100644 index 000000000..f51c4b8f9 --- /dev/null +++ b/docs/developers/operations/draft-integrity-reenable.md @@ -0,0 +1,228 @@ +# Safely Re-enabling `draft_admin_integrity` on Production + +**Status:** DEFERRED — This runbook documents the SAFE re-enable procedure for `cds.fiori.draft_admin_integrity`. The flag is currently **disabled** (`false` in `package.json`). Re-enable ONLY after all green checks — another outage is unacceptable. This runbook exists so it CAN be done safely when the decision is made; it is NOT an instruction to do it now. + +**Context:** +- Issue #2909 (CAP 10.2): draft_admin_integrity ON + missing HANA cascade constraints orphaned all Mission drafts (PROD outage). +- Issue #2992 (Task 1 fix): added preflight check `srv/lib/draft-integrity-preflight.js`; issue #2968 expanded it. +- Issue #2994 (Task 2 fix): added orphan cleanup script `scripts/cleanup-draft-orphans.cjs`. +- Issue #2993 (this work): added build-time FK injection `scripts/patch-draft-cascade-constraints.cjs` + gate test + re-enable runbook. + +**Key facts:** +- Constraints are built `NOT VALIDATED` (no pre-clean needed); they remain **inert** until `draft_admin_integrity: true` is set. +- `CDS_*` env-var overrides do NOT reach `cds.fiori.draft_admin_integrity` on deployed builds — only the baked `package.json` value counts. +- Preflight is HANA-only and fail-open; SQLite unit tests do not require cascade FKs. + +--- + +## Prerequisites + +- [ ] Git branch is up-to-date with `origin/main` (or `origin/DEV` for DEV environments). +- [ ] You have `cf login` credentials for the target environment (DEV first, then PROD). +- [ ] Node.js 22+ and `npm` are installed locally. +- [ ] `@sap/cds-dk` is globally available (`cds --version`). + +--- + +## Step 1: Deploy with Cascade Constraints (Flag Still `false`) + +The Task 1 patch `scripts/patch-draft-cascade-constraints.cjs` already injects `ON DELETE CASCADE` foreign keys into the 46 draft tables in the generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` files. + +**On the target environment (DEV or PROD):** + +1. Ensure the MTA includes the latest `cds build --production` output with the patched constraints. +2. Deploy the MTA as usual (e.g., `npm run deploy -- --env dev` or via CI/CD): + ```bash + npm run build:all + cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f + ``` +3. The HANA schema will accept the constraints as `NOT VALIDATED` — existing orphaned rows are unaffected while the flag is `false`. +4. Verify deployment success and CAP service startup. + +**Note:** The constraints land on every build already (while `draft_admin_integrity` is `false`). This step merely ensures they have deployed to the target environment HANA schema. + +--- + +## Step 2: Verify Preflight Check + +After deployment, the CAP service logs startup messages. Look for the preflight result from `srv/lib/draft-integrity-preflight.js`. + +**Expected log:** +``` +[draft-integrity-preflight] verified (N/N) +``` +where N is the count of verified draft tables (typically 46 for Admin/Mission/Group/Author services + compositions). + +**If you see a WARN instead:** +``` +[draft-integrity-preflight] ⚠ missing: +``` +**STOP.** The constraints are incomplete or not deployed. Do not proceed to Step 3. Investigate: +- Is the MTA's `gen/db` module serving the patched HDbtable files? +- Did the HANA HDI deployment succeed (check `cf logs --recent`)? +- Run `cds build --production` locally and verify `gen/db/src/gen/AdminService.*_drafts.hdbtable` files contain the `ON DELETE CASCADE` clause. + +--- + +## Step 3: Sweep Orphaned Drafts (Dry-Run First) + +Orphaned draft rows are draft-only children whose parent active entity was deleted or reassigned. The cleanup script `scripts/cleanup-draft-orphans.cjs` identifies and removes them. + +**Locally, with credentials bound to the target environment:** + +1. Set up the service binding (one-time): + ```bash + npm run bind:setup # if this is your first hybrid run + ``` + +2. Run a dry-run to inspect any orphans: + ```bash + npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --dry-run + ``` + +3. **Expected output:** Zero orphans found: + ``` + Scanning for orphaned drafts... + Found 0 orphaned draft rows. (All clean.) + ``` + +4. **If orphans are found**, inspect them (the script prints details) and decide whether to proceed. If you expect orphans from the #2909 outage: + - Commit/note the findings. + - Run WITHOUT `--dry-run` to delete: + ```bash + npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --commit + ``` + - Verify the count matches the dry-run scan. + +--- + +## Step 4: Run the Gate Test (Mission Draft Lifecycle) + +The gate test `test/admin-drafts-cascade-gate.test.js` verifies that Mission draftEdit + discard leaves zero orphaned child drafts. + +**Locally:** +```bash +npx vitest run test/admin-drafts-cascade-gate.test.js +``` + +**Expected:** All tests pass (green), including: +- Draft creation, edit, and discard lifecycle. +- Zero orphaned `Missions.drafts` rows after discard. +- Zero orphaned `CompletionPathItems.drafts` rows after parent discard. + +**If the test fails,** re-examine Step 1–3. Do NOT proceed to Step 5 until this test is green. + +--- + +## Step 5: Flip the Flag in `package.json` + +**ONLY after Steps 1–4 are green on the target environment**, update the flag: + +1. Edit `package.json`: + - Line ~461: find the `"cds": { "fiori": { ... } }` block and set `"draft_admin_integrity": true`. + - Line ~486: find the `"[development]": { "cds": { "fiori": { ... } } }` block and set `"draft_admin_integrity": true`. + +2. Example (before): + ```json + "cds": { + "fiori": { + "draft_admin_integrity": false + } + } + ``` + Example (after): + ```json + "cds": { + "fiori": { + "draft_admin_integrity": true + } + } + ``` + +3. Commit and push the change: + ```bash + git add package.json + git commit -m "feat(config): enable draft_admin_integrity globally (#2993)" + git push origin + ``` + +4. Run a fresh build with the flag ON: + ```bash + npm run build:all + ``` + +5. Deploy to the target environment: + ```bash + cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f + ``` + +--- + +## Step 6: Smoke Test – Mission Discard on Deployed Environment + +After the re-deployed service is running with the flag `true`, verify the behavior end-to-end: + +1. **Via Admin UI** (`/admin-ui/#/admin/missions`): + - Create or open an existing Mission draft. + - Edit it (add a step, change the title, etc.). + - Click **Discard** (or equivalent button). + - Expected: Discard succeeds; no orphaned rows left in `Missions.drafts` or child composition tables. + +2. **Verify no errors in the server logs:** + ```bash + cf logs --recent | grep -i error + ``` + +3. **If discard fails or errors appear,** rollback the flag to `false`, redeploy, and investigate the error. Escalate to the team. + +--- + +## Rollback Plan (Emergency) + +If any step fails or the deployed service encounters errors: + +1. Revert the flag to `false` in `package.json` (Steps 1–2): + ```json + "draft_admin_integrity": false + ``` + +2. Commit, push, and redeploy: + ```bash + git add package.json + git commit -m "revert(config): disable draft_admin_integrity (#2993)" + git push origin + npm run build:all + cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/.mtaext -f + ``` + +3. The constraints remain in the schema (`NOT VALIDATED`, inert); they cause no harm. The flag controls their enforcement. + +4. Investigate the root cause before attempting re-enable again. Involve the team. + +--- + +## Environment Order + +**Always test DEV first, then PROD.** + +- **DEV:** Lower risk; catch any logic gaps before production. +- **PROD:** Only after DEV smoke test passes for at least 2–4 hours without incident. + +--- + +## Related Issues + +- **#2909:** CAP 10.2 draft_admin_integrity ON + missing cascade constraints → PROD outage. +- **#2968:** Preflight check discovered missing cascade FK columns on draft tables. +- **#2992:** Task 1 fix: build-time FK injection + preflight expansion. +- **#2994:** Task 2 fix: orphan cleanup script. +- **#2993:** This work: gate test + re-enable runbook. + +--- + +## Contacts & Escalation + +For questions or issues: +- Check [docs/developers/reference/tutorials-ims-gotchas.md](../reference/tutorials-ims-gotchas.md) for known draft-related gotchas. +- Review the related issues linked above for context. +- Escalate failures to the team via Slack or the issue tracker. From 7beacdf5f926377fb99304c13ffd6bbd8b53bb45 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:54:02 -0600 Subject: [PATCH 33/42] fix(docs): correct issue cross-references in draft-integrity-reenable runbook (#2993) --- .../operations/draft-integrity-reenable.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/developers/operations/draft-integrity-reenable.md b/docs/developers/operations/draft-integrity-reenable.md index f51c4b8f9..8e26fcd9c 100644 --- a/docs/developers/operations/draft-integrity-reenable.md +++ b/docs/developers/operations/draft-integrity-reenable.md @@ -3,10 +3,11 @@ **Status:** DEFERRED — This runbook documents the SAFE re-enable procedure for `cds.fiori.draft_admin_integrity`. The flag is currently **disabled** (`false` in `package.json`). Re-enable ONLY after all green checks — another outage is unacceptable. This runbook exists so it CAN be done safely when the decision is made; it is NOT an instruction to do it now. **Context:** -- Issue #2909 (CAP 10.2): draft_admin_integrity ON + missing HANA cascade constraints orphaned all Mission drafts (PROD outage). -- Issue #2992 (Task 1 fix): added preflight check `srv/lib/draft-integrity-preflight.js`; issue #2968 expanded it. -- Issue #2994 (Task 2 fix): added orphan cleanup script `scripts/cleanup-draft-orphans.cjs`. -- Issue #2993 (this work): added build-time FK injection `scripts/patch-draft-cascade-constraints.cjs` + gate test + re-enable runbook. +- Issue #2909: introduced the flag (CAP 10.2) + missing cascade constraints → PROD outage. +- Issue #2968: added the preflight check `srv/lib/draft-integrity-preflight.js`. +- Issue #2992: disabled the flag for all profiles and fixed the preflight (it had queried non-existent `com_sap_developers_ims_*_drafts` names instead of the real `ADMINSERVICE_*_DRAFTS` discovered from `SYS.TABLE_COLUMNS`). +- Issue #2994: added the orphan cleanup sweep `scripts/cleanup-draft-orphans.cjs`. +- Issue #2993 (this work): added the build-time FK injection `scripts/patch-draft-cascade-constraints.cjs`, the gate test `test/admin-drafts-cascade-gate.test.js`, and this runbook. **Key facts:** - Constraints are built `NOT VALIDATED` (no pre-clean needed); they remain **inert** until `draft_admin_integrity: true` is set. @@ -212,11 +213,11 @@ If any step fails or the deployed service encounters errors: ## Related Issues -- **#2909:** CAP 10.2 draft_admin_integrity ON + missing cascade constraints → PROD outage. -- **#2968:** Preflight check discovered missing cascade FK columns on draft tables. -- **#2992:** Task 1 fix: build-time FK injection + preflight expansion. -- **#2994:** Task 2 fix: orphan cleanup script. -- **#2993:** This work: gate test + re-enable runbook. +- **#2909:** Introduced the flag (CAP 10.2) + missing cascade constraints → PROD outage. +- **#2968:** Added the preflight check `srv/lib/draft-integrity-preflight.js`. +- **#2992:** Disabled the flag for all profiles and fixed the preflight (queried non-existent `com_sap_developers_ims_*_drafts` instead of real `ADMINSERVICE_*_DRAFTS` from `SYS.TABLE_COLUMNS`). +- **#2994:** Added the orphan cleanup sweep `scripts/cleanup-draft-orphans.cjs`. +- **#2993:** Added the build-time FK injection `scripts/patch-draft-cascade-constraints.cjs`, the gate test `test/admin-drafts-cascade-gate.test.js`, and this runbook. --- From dcf5e1b2fa5f043f6818fc6daff15b52b06cdf91 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:11:27 -0600 Subject: [PATCH 34/42] fix(db): uppercase FK local column + enforced-FK docs correction (#2993) Critical fix: local column in FK clause changed from quoted mixed-case ("DraftAdministrativeData_DraftUUID") to quoted UPPERCASE ("DRAFTADMINISTRATIVEDATA_DRAFTUUID"). The column is declared bare (unquoted) in the generated .hdbtable, so HANA folds it to uppercase; a quoted mixed-case reference is case-sensitive and would not resolve. Confirmed by srv/lib/draft-integrity-preflight.js:93,100 and scripts/cleanup-draft-orphans.cjs:55 both using the uppercase name. Idempotency regex updated to match the uppercase token. Test updated: FK-match assertion now requires "DRAFTADMINISTRATIVEDATA_DRAFTUUID" (exact uppercase, not /i on the column name) and adds a regression guard that fails on the mixed-case quoted form. Docs corrected across runbook, spec, and plan: inline FK is ENFORCED (NOT VALIDATED is an ALTER TABLE affordance only), so orphan sweep must come BEFORE deploying the constraint-bearing build. Runbook reordered: Step 1 = sweep, Step 2 = deploy (with enforced-FK precondition noted). All "NOT VALIDATED / no pre-clean needed" claims removed. Real build: patched 46 / skipped 0; PRIMARY KEY(ID), FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") ... ON DELETE CASCADE confirmed. 5/5 tests pass. --- .../operations/draft-integrity-reenable.md | 81 ++++++++++--------- ...-02-draft-admin-integrity-safe-reenable.md | 4 +- ...ft-admin-integrity-safe-reenable-design.md | 48 ++++++----- scripts/patch-draft-cascade-constraints.cjs | 15 ++-- .../patch-draft-cascade-constraints.test.js | 7 +- 5 files changed, 86 insertions(+), 69 deletions(-) diff --git a/docs/developers/operations/draft-integrity-reenable.md b/docs/developers/operations/draft-integrity-reenable.md index 8e26fcd9c..16dac409d 100644 --- a/docs/developers/operations/draft-integrity-reenable.md +++ b/docs/developers/operations/draft-integrity-reenable.md @@ -10,7 +10,7 @@ - Issue #2993 (this work): added the build-time FK injection `scripts/patch-draft-cascade-constraints.cjs`, the gate test `test/admin-drafts-cascade-gate.test.js`, and this runbook. **Key facts:** -- Constraints are built `NOT VALIDATED` (no pre-clean needed); they remain **inert** until `draft_admin_integrity: true` is set. +- The inline FK (`FOREIGN KEY ... ON DELETE CASCADE` inside the `COLUMN TABLE (...)` body) is created **ENFORCED** by HANA — `NOT VALIDATED` is an `ALTER TABLE` affordance only and cannot be specified inline. This means orphaned draft rows **must be swept BEFORE the constraint-bearing HDbtable is deployed**, or HANA will reject the deploy. - `CDS_*` env-var overrides do NOT reach `cds.fiori.draft_admin_integrity` on deployed builds — only the baked `package.json` value counts. - Preflight is HANA-only and fail-open; SQLite unit tests do not require cascade FKs. @@ -25,11 +25,44 @@ --- -## Step 1: Deploy with Cascade Constraints (Flag Still `false`) +## Step 1: Sweep Orphaned Drafts (Dry-Run First) + +The inline FK is ENFORCED — HANA will reject deploying the constraint if orphaned draft rows already exist in the schema. Sweep orphans **before** deploying the constraint-bearing build. + +**Locally, with credentials bound to the target environment:** + +1. Set up the service binding (one-time): + ```bash + npm run bind:setup # if this is your first hybrid run + ``` + +2. Run a dry-run to inspect any orphans: + ```bash + npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --dry-run + ``` + +3. **Expected output:** Zero orphans found: + ``` + Scanning for orphaned drafts... + Found 0 orphaned draft rows. (All clean.) + ``` + +4. **If orphans are found**, inspect them (the script prints details) and decide whether to proceed. If you expect orphans from the #2909 outage: + - Commit/note the findings. + - Run WITHOUT `--dry-run` to delete: + ```bash + npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --commit + ``` + - Verify the count matches the dry-run scan. + - Re-run dry-run to confirm zero before proceeding. + +--- + +## Step 2: Deploy with Cascade Constraints (Flag Still `false`) The Task 1 patch `scripts/patch-draft-cascade-constraints.cjs` already injects `ON DELETE CASCADE` foreign keys into the 46 draft tables in the generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` files. -**On the target environment (DEV or PROD):** +**On the target environment (DEV or PROD), after Step 1 confirms zero orphans:** 1. Ensure the MTA includes the latest `cds build --production` output with the patched constraints. 2. Deploy the MTA as usual (e.g., `npm run deploy -- --env dev` or via CI/CD): @@ -37,14 +70,14 @@ The Task 1 patch `scripts/patch-draft-cascade-constraints.cjs` already injects ` npm run build:all cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f ``` -3. The HANA schema will accept the constraints as `NOT VALIDATED` — existing orphaned rows are unaffected while the flag is `false`. +3. The FK is enforced — a clean schema (zero orphans from Step 1) is the precondition for the deploy to succeed. 4. Verify deployment success and CAP service startup. -**Note:** The constraints land on every build already (while `draft_admin_integrity` is `false`). This step merely ensures they have deployed to the target environment HANA schema. +**Note:** The constraints land on every build already (while `draft_admin_integrity` is `false`). This step ensures they have deployed to the target environment HANA schema with a clean baseline. --- -## Step 2: Verify Preflight Check +## Step 3: Verify Preflight Check After deployment, the CAP service logs startup messages. Look for the preflight result from `srv/lib/draft-integrity-preflight.js`. @@ -58,45 +91,13 @@ where N is the count of verified draft tables (typically 46 for Admin/Mission/Gr ``` [draft-integrity-preflight] ⚠ missing: ``` -**STOP.** The constraints are incomplete or not deployed. Do not proceed to Step 3. Investigate: +**STOP.** The constraints are incomplete or not deployed. Do not proceed to Step 4. Investigate: - Is the MTA's `gen/db` module serving the patched HDbtable files? - Did the HANA HDI deployment succeed (check `cf logs --recent`)? - Run `cds build --production` locally and verify `gen/db/src/gen/AdminService.*_drafts.hdbtable` files contain the `ON DELETE CASCADE` clause. --- -## Step 3: Sweep Orphaned Drafts (Dry-Run First) - -Orphaned draft rows are draft-only children whose parent active entity was deleted or reassigned. The cleanup script `scripts/cleanup-draft-orphans.cjs` identifies and removes them. - -**Locally, with credentials bound to the target environment:** - -1. Set up the service binding (one-time): - ```bash - npm run bind:setup # if this is your first hybrid run - ``` - -2. Run a dry-run to inspect any orphans: - ```bash - npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --dry-run - ``` - -3. **Expected output:** Zero orphans found: - ``` - Scanning for orphaned drafts... - Found 0 orphaned draft rows. (All clean.) - ``` - -4. **If orphans are found**, inspect them (the script prints details) and decide whether to proceed. If you expect orphans from the #2909 outage: - - Commit/note the findings. - - Run WITHOUT `--dry-run` to delete: - ```bash - npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --commit - ``` - - Verify the count matches the dry-run scan. - ---- - ## Step 4: Run the Gate Test (Mission Draft Lifecycle) The gate test `test/admin-drafts-cascade-gate.test.js` verifies that Mission draftEdit + discard leaves zero orphaned child drafts. @@ -196,7 +197,7 @@ If any step fails or the deployed service encounters errors: cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/.mtaext -f ``` -3. The constraints remain in the schema (`NOT VALIDATED`, inert); they cause no harm. The flag controls their enforcement. +3. The enforced constraints remain in the schema but the flag controls whether the runtime relies on them. With the flag `false` the app-level reconcile path runs instead, so the deployed constraints are dormant but harmless. 4. Investigate the root cause before attempting re-enable again. Involve the team. diff --git a/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md b/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md index 9b62cffe8..d4c72370f 100644 --- a/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md +++ b/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md @@ -17,7 +17,7 @@ - Branch is `worktree-draft-integrity-constraints-2993`, based on `origin/DEV`. PR targets `DEV`, never `main`. - `npm test` (in-memory SQLite) must stay green; the gate test is additive. - Constraint patch applies to the **prod `gen/db` tree only** — QA's db build excludes `AdminService` and emits 0 draft tables. -- Cascade FK form, per table: `FOREIGN KEY (DraftAdministrativeData_DraftUUID) REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE` — authored so HANA deploys it `NOT VALIDATED` (skips existing-data check; no pre-clean needed). +- Cascade FK form, per table: `FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE` — the local column is declared bare/unquoted in the generated DDL, so HANA folds it to uppercase; the FK reference must quote the uppercase name. The inline FK is ENFORCED (not `NOT VALIDATED` — that is an ALTER TABLE affordance only); orphaned rows must be swept BEFORE deploying the constraint-bearing build. - Draft table list is derived programmatically from the generated `*_drafts.hdbtable` files — never hand-maintained (must not drift from the preflight's live `SYS.TABLE_COLUMNS` count). ## Review Focus @@ -167,7 +167,7 @@ git commit -m "test(drafts): flag-agnostic gate — Mission discard leaves zero - [ ] **Step 1: Write the runbook** -Document the exact order, encoding the spec guardrails: (1) deploy with the Task-1 patch active so constraints land `NOT VALIDATED` while the flag is still `false` (constraints inert); (2) confirm `srv/lib/draft-integrity-preflight.js` logs `verified (N/N)` on the target env (not a WARN); (3) run `scripts/cleanup-draft-orphans.cjs --dry-run` → expect 0 orphans; (4) run the Task-2 gate test green; (5) only then flip `draft_admin_integrity` to `true` in `package.json` (both blocks), full `npm run build:all` + redeploy; (6) smoke a Mission draftEdit→discard. Note: the `CDS_*` env override does NOT work on the deployed build — the baked `package.json` value is authoritative. DEV before PROD. +Document the exact order, encoding the spec guardrails: (1) sweep orphans first — `scripts/cleanup-draft-orphans.cjs --dry-run` then `--commit` if needed — the enforced inline FK will reject the deploy if orphaned rows are present; (2) deploy with the Task-1 patch active so constraints land (enforced, schema must be clean); (3) confirm `srv/lib/draft-integrity-preflight.js` logs `verified (N/N)` on the target env (not a WARN); (4) run the Task-2 gate test green; (5) only then flip `draft_admin_integrity` to `true` in `package.json` (both blocks), full `npm run build:all` + redeploy; (6) smoke a Mission draftEdit→discard. Note: the `CDS_*` env override does NOT work on the deployed build — the baked `package.json` value is authoritative. DEV before PROD. - [ ] **Step 2: Add CLAUDE.md pointer** diff --git a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md index 22d433521..3a1c25e27 100644 --- a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md +++ b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md @@ -49,8 +49,9 @@ the proof*, not the switch. - Any change that could cause the DB-cascade delete path to become active on a live environment (i.e. the flag going on, directly or via a build artifact that behaves like enabling it). -- Committed artifacts that `cf deploy` would apply as `VALIDATED` (would block - deploy on existing orphan data) or that `die` the deploy if a referenced +- Committed artifacts that `cf deploy` would reject because of existing orphan data + (the enforced inline FK fails if orphaned draft rows are present — sweep first + using `cleanup-draft-orphans.cjs`) or that `die` the deploy if a referenced draft table does not yet exist on the target. ## Findings (verified this session) @@ -81,10 +82,14 @@ the proof*, not the switch. cascade FK is proven present on the target env. The `CDS_*` env override does not reach `cds.fiori.draft_admin_integrity`, so the value is whatever `package.json` bakes at build time. -- **Cascade needed, per table:** `FOREIGN KEY (DraftAdministrativeData_DraftUUID) - REFERENCES DRAFT_DraftAdministrativeData(DraftUUID) ON DELETE CASCADE`, - deployed `NOT VALIDATED` (HANA skips the existing-data check, so no pre-clean - required). Parent table/PK confirmed via `cleanup-draft-orphans.cjs` +- **Cascade needed, per table:** `FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") + REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE`, + deployed ENFORCED (inline FK inside `COLUMN TABLE (...)` is always enforced — + `NOT VALIDATED` is an `ALTER TABLE`-only affordance). This means orphaned draft + rows must be swept BEFORE deploying the constraint, or HANA rejects the deploy. + Local column is bare/unquoted in the generated DDL, so HANA folds it to + `DRAFTADMINISTRATIVEDATA_DRAFTUUID`; the FK reference must quote that uppercase + name. Parent table/PK confirmed via `cleanup-draft-orphans.cjs` (`DRAFT_DRAFTADMINISTRATIVEDATA(DRAFTUUID)`). - **QA container is out of scope.** The QA db build model is `db-qa` only and does **not** include `AdminService`; `gen/db-qa/src/gen/` emits **0** draft @@ -99,16 +104,18 @@ the proof*, not the switch. The build emits the 46 `_drafts.hdbtable` files with the FK column but no cascade clause, and `.hdiconfig` has no `hdbconstraint` plugin. So the mechanism is **not** committed `db/src/*.hdbconstraint` (HDI would ignore it). Instead, -add the `FOREIGN KEY (DraftAdministrativeData_DraftUUID) REFERENCES -DRAFT_DraftAdministrativeData(DraftUUID) ON DELETE CASCADE` clause to each +add the `FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES +"DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE` clause to each generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` via a **build-time patch step**, mirroring the existing MTA `before-all` `.hdiconfig` patch -precedent. The table list is derived programmatically from the generated -`_drafts.hdbtable` set (not a hand-maintained list), so it cannot drift from the -live draft-table count the preflight checks. Applied to the **prod `gen/db` -tree only** (QA emits no draft tables). The committed `package.json` flag stays -`false` throughout; the patch makes the constraints *deployable* but the runtime -cascade path stays dormant until a future flip. +precedent. The FK is enforced (inline DDL); orphaned rows must be swept before +the constraint-bearing build is deployed. The table list is derived +programmatically from the generated `_drafts.hdbtable` set (not a hand-maintained +list), so it cannot drift from the live draft-table count the preflight checks. +Applied to the **prod `gen/db` tree only** (QA emits no draft tables). The +committed `package.json` flag stays `false` throughout; the patch makes the +constraints *deployable* but the runtime cascade path stays dormant until a +future flip. ### 2. Flag-agnostic gate test @@ -125,12 +132,13 @@ future flip. ### 3. Re-enable runbook -A doc (under `docs/developers/operations/`) giving the exact order: deploy -constraints (`NOT VALIDATED`) → confirm preflight logs `verified (N/N)` on the -target env → run the gate test green → flip `package.json` flag → full rebuild -+ redeploy → smoke draftEdit/discard. Encodes issue guardrails: flag does not -go on until constraints are green on the *target* env; `CDS_*` env override is -not a substitute for the baked `package.json` value. +A doc (under `docs/developers/operations/`) giving the exact order: sweep orphans +first (`cleanup-draft-orphans.cjs --dry-run` then `--commit` if needed) → deploy +constraints (enforced inline FK — clean schema required) → confirm preflight +logs `verified (N/N)` on the target env → run the gate test green → flip +`package.json` flag → full rebuild + redeploy → smoke draftEdit/discard. Encodes +issue guardrails: flag does not go on until constraints are green on the *target* +env; `CDS_*` env override is not a substitute for the baked `package.json` value. ## Constraints diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs index f3d5df55b..306173299 100644 --- a/scripts/patch-draft-cascade-constraints.cjs +++ b/scripts/patch-draft-cascade-constraints.cjs @@ -22,8 +22,11 @@ const fs = require('fs'); const path = require('path'); const FK_COLUMN = 'DraftAdministrativeData_DraftUUID'; +// Local column is declared BARE (unquoted) in the generated .hdbtable, so HANA +// stores its name folded to UPPERCASE: DRAFTADMINISTRATIVEDATA_DRAFTUUID. +// The FK reference must therefore quote the UPPERCASE name to resolve correctly. const FK_CLAUSE = - 'FOREIGN KEY ("DraftAdministrativeData_DraftUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE'; + 'FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE'; /** * Enumerate all *_drafts.hdbtable files under genDir, and for each one that @@ -59,10 +62,10 @@ function patchDraftCascadeConstraints(genDir) { } // Idempotency: if OUR specific FK clause is already present, skip. - // Match the FK column name AND ON DELETE CASCADE on the same logical line. - // Uses [\s\S]*? (non-greedy cross-paren match) because the clause itself - // contains parentheses: FOREIGN KEY (...) REFERENCES ...("DRAFTUUID") ON DELETE CASCADE. - if (/FOREIGN KEY\s*\([^F]*DraftAdministrativeData_DraftUUID[\s\S]*?ON DELETE CASCADE/i.test(original)) { + // Match DRAFTADMINISTRATIVEDATA_DRAFTUUID (the HANA-folded uppercase name we + // emit) + ON DELETE CASCADE. Uses [\s\S]*? because the clause contains + // parentheses that [^)]* cannot cross. + if (/FOREIGN KEY\s*\([^F]*DRAFTADMINISTRATIVEDATA_DRAFTUUID[\s\S]*?ON DELETE CASCADE/i.test(original)) { skipped.push(filename); continue; } @@ -70,7 +73,7 @@ function patchDraftCascadeConstraints(genDir) { // Inject the FK clause as the last item inside the COLUMN TABLE (...) body. // Target form: // PRIMARY KEY(ID), - // FOREIGN KEY ("DraftAdministrativeData_DraftUUID") REFERENCES ... ON DELETE CASCADE + // FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES ... ON DELETE CASCADE // ) // // Strategy: find the last ')' (the table body closing paren). Everything diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js index f7d9817e9..d8786e0d8 100644 --- a/test/unit/patch-draft-cascade-constraints.test.js +++ b/test/unit/patch-draft-cascade-constraints.test.js @@ -48,9 +48,14 @@ describe('patchDraftCascadeConstraints', () => { it('appends ON DELETE CASCADE FK to draft tables that carry the FK column', () => { const r = patchDraftCascadeConstraints(tmp); const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + // Local column must be quoted UPPERCASE (HANA folds bare column names to upper). + // A mixed-case quoted reference ("DraftAdministrativeData_DraftUUID") would be + // case-sensitive and fail to resolve against the stored UPPERCASE name. expect(sql).toMatch( - /FOREIGN KEY\s*\("?DraftAdministrativeData_DraftUUID"?\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/i + /FOREIGN KEY\s*\("DRAFTADMINISTRATIVEDATA_DRAFTUUID"\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/ ); + // Regression guard: must NOT emit the mixed-case quoted form. + expect(sql).not.toMatch(/FOREIGN KEY\s*\("DraftAdministrativeData_DraftUUID"\)/); expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); }); From d61f64bc7d8f3558ff4541b0d1535c01a43d2412 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:15:00 -0600 Subject: [PATCH 35/42] feat(#2990): order mission completion paths by pathOrder at all render sites - packages/content/catalog-data.js: add pathOrder to columns, orderBy('pathOrder','name') - srv/lib/build-catalog.js: orderBy('mission_ID','pathOrder','name') for cross-mission query - srv/developer-service.js: orderBy('pathOrder','name') in getEventProgress and getAppSpaceProgress - srv/lib/branch/mission-detail.js: append orderBy('pathOrder','name') to paths SELECT - packages/content/catalog-mission-hierarchy.js: update JSDoc comment - vitest.config.ts: add @tutorials/content alias to resolve worktree src in unit tests - test/: add pathOrder-ordering assertions in catalog-data, build-catalog-mission-detail, and app-space-segments-resolver (NULL pathOrder sorts first on SQLite per observed behavior) --- packages/content/catalog-data.js | 4 +- packages/content/catalog-mission-hierarchy.js | 2 +- srv/developer-service.js | 4 +- srv/lib/branch/mission-detail.js | 2 +- srv/lib/build-catalog.js | 2 +- test/app-space-segments-resolver.test.js | 62 +++++++++++++++++++ test/build-catalog-mission-detail.test.js | 58 +++++++++++++++++ test/catalog-data.test.js | 51 +++++++++++++++ vitest.config.ts | 5 ++ 9 files changed, 183 insertions(+), 7 deletions(-) diff --git a/packages/content/catalog-data.js b/packages/content/catalog-data.js index eec2b7ee5..0d1515a3b 100644 --- a/packages/content/catalog-data.js +++ b/packages/content/catalog-data.js @@ -181,8 +181,8 @@ export async function loadMissionContext(slug) { const paths = await SELECT.from(CompletionPaths) .where({ mission_ID: mission.ID }) - .columns('ID', 'name', 'slug', 'legacyId') - .orderBy('legacyId'); + .columns('ID', 'name', 'slug', 'legacyId', 'pathOrder') + .orderBy('pathOrder', 'name'); const pathIds = paths.map(p => p.ID); // [#382 phase F1] Fetch BOTH taskType variants. Direct TUTORIAL items diff --git a/packages/content/catalog-mission-hierarchy.js b/packages/content/catalog-mission-hierarchy.js index 74454f235..8bfad6f3c 100644 --- a/packages/content/catalog-mission-hierarchy.js +++ b/packages/content/catalog-mission-hierarchy.js @@ -91,7 +91,7 @@ export function collectAltGroups(items, resolveTutorialIdentity) { * @param {object} args.mission - The Missions row (`.ID` at minimum) * @param {Array} args.paths - CompletionPaths for this mission, in * the order they should appear. Caller is responsible for ordering - * (build-catalog.js orders by legacyId; catalog-data.js orders by legacyId). + * (build-catalog.js orders by pathOrder, then name; catalog-data.js orders by pathOrder, then name). * @param {Array} args.items - CompletionPathItems for any path in * `paths`. The helper filters per-path internally — passing extras is * harmless; passing missing items means those items simply don't appear. diff --git a/srv/developer-service.js b/srv/developer-service.js index 1c2006113..af32b6486 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -561,7 +561,7 @@ export default class DeveloperService extends cds.ApplicationService { const paths = await SELECT.from(dbPaths) .where({ mission_ID: mission.ID }) - .orderBy('legacyId'); + .orderBy('pathOrder', 'name'); const pathIds = paths.map(p => p.ID); const allItems = pathIds.length > 0 @@ -672,7 +672,7 @@ export default class DeveloperService extends cds.ApplicationService { const paths = await SELECT.from(dbPaths) .where({ mission_ID: mission.ID }) - .orderBy('legacyId'); + .orderBy('pathOrder', 'name'); const pathIds = paths.map(p => p.ID); const allItems = pathIds.length > 0 diff --git a/srv/lib/branch/mission-detail.js b/srv/lib/branch/mission-detail.js index 37f6b89b1..6350d8c9c 100644 --- a/srv/lib/branch/mission-detail.js +++ b/srv/lib/branch/mission-detail.js @@ -37,7 +37,7 @@ export async function missionDetailHandler(req, res) { const mission = await SELECT.one.from(Missions).where({ slug }); if (!mission) return res.status(404).json({ error: 'mission_not_found' }); - const paths = await SELECT.from(CompletionPaths).where({ mission_ID: mission.ID }); + const paths = await SELECT.from(CompletionPaths).where({ mission_ID: mission.ID }).orderBy('pathOrder', 'name'); if (paths.length === 0) return res.json({ missionSlug: slug, items: [] }); // For v1 we render the FIRST path only (matches today's mission-side-nav rendering). diff --git a/srv/lib/build-catalog.js b/srv/lib/build-catalog.js index 9e8097430..988032ee3 100644 --- a/srv/lib/build-catalog.js +++ b/srv/lib/build-catalog.js @@ -19,7 +19,7 @@ export async function buildCatalogHandler(req, res) { try { const missions = await SELECT.from(Missions).where({ published: true }); - const paths = await SELECT.from(CompletionPaths).orderBy('legacyId'); + const paths = await SELECT.from(CompletionPaths).orderBy('mission_ID', 'pathOrder', 'name'); const items = await SELECT.from(CompletionPathItems).orderBy('itemOrder'); const tutorials = await SELECT.from(Tutorials) .columns('ID', 'legacyId', 'slug', 'title', 'description') diff --git a/test/app-space-segments-resolver.test.js b/test/app-space-segments-resolver.test.js index f8326767f..22eddb3e9 100644 --- a/test/app-space-segments-resolver.test.js +++ b/test/app-space-segments-resolver.test.js @@ -251,3 +251,65 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO expect(seg.items.every(i => i.type === 'TUTORIAL')).toBe(true); }); }); + +// #2990 — getAppSpaceProgress returns paths in pathOrder, then name order. +// Three paths: name order (Z→M→A) differs from pathOrder (3→null→1). +// SQLite NULLs sort FIRST so expected title order is: +// Z Path (pathOrder=null), A Path (pathOrder=1), M Path (pathOrder=3) +// (NULL < 1 < 3 in SQLite ASC ordering). +describe('App-Space path ordering by pathOrder (#2990)', () => { + const PO_EVENT_ID = 10002991; + const PO_MISSION_ID = '11111111-2990-aaaa-0000-000000000001'; + const PO_EVENT_UUID = 'eeeeeeee-2990-aaaa-0000-000000000001'; + const PO_PATH_A_ID = '22222222-2990-aaaa-0000-000000000001'; // name='A Path', pathOrder=1 + const PO_PATH_M_ID = '22222222-2990-aaaa-0000-000000000002'; // name='M Path', pathOrder=3 + const PO_PATH_Z_ID = '22222222-2990-aaaa-0000-000000000003'; // name='Z Path', pathOrder=null + const PO_TUT_ID = 'cccccccc-2990-aaaa-0000-000000000001'; + + beforeAll(async () => { + const { Events, Missions, CompletionPaths, CompletionPathItems, Tutorials } = + cds.entities('com.sap.developers.ims'); + + await INSERT.into(Tutorials).entries({ + ID: PO_TUT_ID, legacyId: 10002980, slug: 'po-tut-2990', title: 'PO Tutorial', + }); + await INSERT.into(Missions).entries({ + ID: PO_MISSION_ID, legacyId: 10002990, title: 'PO Mission', + slug: 'po-mission-2990', description: 'Path order mission', published: true, + }); + await INSERT.into(Events).entries({ + ID: PO_EVENT_UUID, legacyId: PO_EVENT_ID, + name: 'PO Test Event', eventType: 'OTHER', mission_ID: PO_MISSION_ID, + }); + await INSERT.into(CompletionPaths).entries([ + { ID: PO_PATH_A_ID, legacyId: 10002991, name: 'A Path', mission_ID: PO_MISSION_ID, pathOrder: 1 }, + { ID: PO_PATH_M_ID, legacyId: 10002992, name: 'M Path', mission_ID: PO_MISSION_ID, pathOrder: 3 }, + { ID: PO_PATH_Z_ID, legacyId: 10002993, name: 'Z Path', mission_ID: PO_MISSION_ID, pathOrder: null }, + ]); + // One item per path so all three paths appear in the response. + await INSERT.into(CompletionPathItems).entries([ + { ID: 'dddddddd-2990-aaaa-0000-000000000001', path_ID: PO_PATH_A_ID, itemOrder: 1, taskType: 'TUTORIAL', tutorial_ID: PO_TUT_ID }, + { ID: 'dddddddd-2990-aaaa-0000-000000000002', path_ID: PO_PATH_M_ID, itemOrder: 1, taskType: 'TUTORIAL', tutorial_ID: PO_TUT_ID }, + { ID: 'dddddddd-2990-aaaa-0000-000000000003', path_ID: PO_PATH_Z_ID, itemOrder: 1, taskType: 'TUTORIAL', tutorial_ID: PO_TUT_ID }, + ]); + }); + + afterAll(async () => { + const { Events, Missions, CompletionPaths, CompletionPathItems, Tutorials } = + cds.entities('com.sap.developers.ims'); + await DELETE.from(CompletionPathItems).where({ + path_ID: { in: [PO_PATH_A_ID, PO_PATH_M_ID, PO_PATH_Z_ID] } + }); + await DELETE.from(CompletionPaths).where({ ID: { in: [PO_PATH_A_ID, PO_PATH_M_ID, PO_PATH_Z_ID] } }); + await DELETE.from(Events).where({ legacyId: PO_EVENT_ID }); + await DELETE.from(Missions).where({ ID: PO_MISSION_ID }); + await DELETE.from(Tutorials).where({ ID: PO_TUT_ID }); + }); + + it('returns paths ordered by pathOrder asc then name; NULL pathOrder sorts first on SQLite', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${PO_EVENT_ID})`); + expect(data.paths).toHaveLength(3); + // SQLite NULLs first: Z Path (null), A Path (1), M Path (3) + expect(data.paths.map(p => p.title)).toEqual(['Z Path', 'A Path', 'M Path']); + }); +}); diff --git a/test/build-catalog-mission-detail.test.js b/test/build-catalog-mission-detail.test.js index 52759e777..c15e73c55 100644 --- a/test/build-catalog-mission-detail.test.js +++ b/test/build-catalog-mission-detail.test.js @@ -112,3 +112,61 @@ describe('/build/mission/:slug — alt-group grouping', () => { await DELETE.from(BranchDecisions).where({ missionSlug: '__test__-mission' }); }); }); + +// #2990 — mission-detail /build/mission/:slug returns paths[0] as the +// pathOrder-first path, not insertion order. +// Three paths: name order (Z→M→A) differs from pathOrder (3→NULL→1). +// SQLite NULLs sort FIRST so order is: M Path (null), A Path (1), Z Path (3). +describe('/build/mission/:slug — paths ordered by pathOrder then name (#2990)', () => { + const MDP_MISSION_ID = '11111111-2990-dc00-0000-000000000001'; + // Three paths; name Z→M→A, pathOrder 3→null→1 + const MDP_PATH_A_ID = '22222222-2990-dc00-0000-000000000001'; // name='A Path', pathOrder=1 + const MDP_PATH_M_ID = '22222222-2990-dc00-0000-000000000002'; // name='M Path', pathOrder=null + const MDP_PATH_Z_ID = '22222222-2990-dc00-0000-000000000003'; // name='Z Path', pathOrder=3 + const MDP_TUT_ID = '33333333-2990-dc00-0000-000000000001'; + + beforeAll(async () => { + const { Missions, CompletionPaths, CompletionPathItems, Tutorials } = cds.entities('com.sap.developers.ims'); + await INSERT.into(Missions).entries({ + ID: MDP_MISSION_ID, legacyId: 299020, title: '__TEST__ MDP Mission', + slug: '__test__-mdp-mission', published: true, + }); + await INSERT.into(CompletionPaths).entries([ + { ID: MDP_PATH_A_ID, legacyId: 299021, mission_ID: MDP_MISSION_ID, name: 'A Path', slug: '__test__-mdp-path-a', pathOrder: 1 }, + { ID: MDP_PATH_M_ID, legacyId: 299022, mission_ID: MDP_MISSION_ID, name: 'M Path', slug: '__test__-mdp-path-m', pathOrder: null }, + { ID: MDP_PATH_Z_ID, legacyId: 299023, mission_ID: MDP_MISSION_ID, name: 'Z Path', slug: '__test__-mdp-path-z', pathOrder: 3 }, + ]); + await INSERT.into(Tutorials).entries({ + ID: MDP_TUT_ID, legacyId: 299031, slug: '__test__-mdp-tut', title: 'MDP Tutorial', status: 'ACTIVE', + }); + // Only path A has items — so the handler picks the pathOrder-first path (not insertion order). + // After the fix, paths[0] should be M Path (null sorts first), so we add an item on M Path. + // That way if the old legacyId order were used (A=1 < M=2 < Z=3), we'd get A Path instead. + await INSERT.into(CompletionPathItems).entries({ + ID: '44444444-2990-dc00-0000-000000000001', legacyId: 299041, + path_ID: MDP_PATH_M_ID, taskType: 'TUTORIAL', tutorial_ID: MDP_TUT_ID, itemOrder: 0, + }); + __resetCacheForTest(); + }); + + afterAll(async () => { + const { Missions, CompletionPaths, CompletionPathItems, Tutorials, ChatSettings } = cds.entities('com.sap.developers.ims'); + await DELETE.from(CompletionPathItems).where({ path_ID: { in: [MDP_PATH_A_ID, MDP_PATH_M_ID, MDP_PATH_Z_ID] } }); + await DELETE.from(CompletionPaths).where({ ID: { in: [MDP_PATH_A_ID, MDP_PATH_M_ID, MDP_PATH_Z_ID] } }); + await DELETE.from(Missions).where({ ID: MDP_MISSION_ID }); + await DELETE.from(Tutorials).where({ ID: MDP_TUT_ID }); + __resetCacheForTest(); + }); + + it('paths[0] is the path with the lowest pathOrder (NULL < 1 < 3 on SQLite)', async () => { + // M Path has pathOrder=null → sorts FIRST on SQLite → becomes paths[0]. + // The handler uses paths[0] exclusively; items should come from M Path. + const { status, data } = await project.get('/build/mission/__test__-mdp-mission?nocache=1'); + expect(status).toBe(200); + // missionSlug canonicalized + expect(data.missionSlug).toBe('__test__-mdp-mission'); + // M Path has the one tutorial item → items should be non-empty + expect(data.items).toHaveLength(1); + expect(data.items[0].slug).toBe('__test__-mdp-tut'); + }); +}); diff --git a/test/catalog-data.test.js b/test/catalog-data.test.js index 007d6532e..39b97482b 100644 --- a/test/catalog-data.test.js +++ b/test/catalog-data.test.js @@ -288,3 +288,54 @@ describe('catalog-data — mission with direct-TUTORIAL CompletionPathItems', () expect(ctx.groups[0].slug).toBe('__test__-cd-group'); }); }); + +// #2990 — loadMissionContext orders paths by pathOrder, then name. +// Three paths: name order (Z, M, A) differs from pathOrder (3, 1, NULL). +// SQLite orders NULLs FIRST, so expected path title order is: +// "Z Path" (pathOrder=null, name='Z Path' — NULL sorts first on SQLite) +// "A Path" (pathOrder=1) +// "M Path" (pathOrder=3) +// This documents the observed SQLite NULL-first behavior; the controller +// can decide whether a NULL-last clause is needed. +describe('catalog-data — loadMissionContext orders paths by pathOrder then name (#2990)', () => { + const NS = 'com.sap.developers.ims'; + const TUT_PO_ID = 'cccccccc-2990-cd00-0000-000000000001'; + const MISSION_PO_ID = 'mmmmmmmm-2990-cd00-0000-000000000001'; + // Three paths: name order A→M→Z differs from pathOrder 1→3→NULL + const PATH_PO_A_ID = 'eeeeeeee-2990-cd00-0000-000000000001'; // name='A Path', pathOrder=1 + const PATH_PO_M_ID = 'eeeeeeee-2990-cd00-0000-000000000002'; // name='M Path', pathOrder=3 + const PATH_PO_Z_ID = 'eeeeeeee-2990-cd00-0000-000000000003'; // name='Z Path', pathOrder=NULL + + beforeAll(async () => { + const { Tutorials, Missions, CompletionPaths, CompletionPathItems } = cds.entities(NS); + await INSERT.into(Tutorials).entries({ + ID: TUT_PO_ID, slug: '__test__-cd-po-tut', title: '__TEST__ PO Tut', + description: 'po', status: 'ACTIVE', + }); + await INSERT.into(Missions).entries({ + ID: MISSION_PO_ID, legacyId: 299010, slug: '__test__-cd-po-mission', + title: '__TEST__ PO Mission', description: 'po-desc', + published: true, status: 'ACTIVE', + }); + await INSERT.into(CompletionPaths).entries([ + { ID: PATH_PO_A_ID, mission_ID: MISSION_PO_ID, name: 'A Path', slug: '__test__-cd-po-path-a', legacyId: 299011, pathOrder: 1 }, + { ID: PATH_PO_M_ID, mission_ID: MISSION_PO_ID, name: 'M Path', slug: '__test__-cd-po-path-m', legacyId: 299012, pathOrder: 3 }, + { ID: PATH_PO_Z_ID, mission_ID: MISSION_PO_ID, name: 'Z Path', slug: '__test__-cd-po-path-z', legacyId: 299013, pathOrder: null }, + ]); + // Each path has one direct TUTORIAL item so it produces a synthetic group card. + await INSERT.into(CompletionPathItems).entries([ + { path_ID: PATH_PO_A_ID, tutorial_ID: TUT_PO_ID, taskType: 'TUTORIAL', itemOrder: 1 }, + { path_ID: PATH_PO_M_ID, tutorial_ID: TUT_PO_ID, taskType: 'TUTORIAL', itemOrder: 1 }, + { path_ID: PATH_PO_Z_ID, tutorial_ID: TUT_PO_ID, taskType: 'TUTORIAL', itemOrder: 1 }, + ]); + }); + + it('emits paths ordered by pathOrder asc then name; NULL pathOrder sorts first on SQLite', async () => { + const ctx = await loadMissionContext('__test__-cd-po-mission'); + expect(ctx).not.toBeNull(); + // One synthetic group card per path → 3 total + expect(ctx.groups).toHaveLength(3); + // SQLite NULLs first: Z Path (null), A Path (1), M Path (3) + expect(ctx.groups.map(g => g.title)).toEqual(['Z Path', 'A Path', 'M Path']); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1b9b8f216..65b421071 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -105,6 +105,11 @@ export default defineConfig({ // @shared/ProgressRing.vue, etc. The unit project doesn't load // hugo-apps/vite.config.ts so the alias is redeclared here. '@shared': fileURLToPath(new URL('./hugo-apps/src/shared', import.meta.url)), + // Worktree isolation (#2990): force @tutorials/content to resolve from + // this worktree's packages/content directory rather than the parent + // repo's npm-workspace symlink, which still points to the parent's + // unmodified source when running tests from a git worktree. + '@tutorials/content': fileURLToPath(new URL('./packages/content', import.meta.url)), }, }, }, From 1f1341f11a64729cc089340c72869b8afc0d1150 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:15:06 -0600 Subject: [PATCH 36/42] docs(spec): remove stale (NOT VALIDATED) parenthetical from Goals section (#2993) --- .../2026-11-02-draft-admin-integrity-safe-reenable-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md index 3a1c25e27..9b1ff849c 100644 --- a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md +++ b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md @@ -24,7 +24,7 @@ the proof*, not the switch. 1. **Author the real cascade constraints as committed, deployable artifacts** for every `AdminService.*_drafts` table. The build already emits these tables with the `DraftAdministrativeData_DraftUUID` FK column but no cascade clause; - the work adds `ON DELETE CASCADE` (`NOT VALIDATED`) via a build-time patch of + the work adds `ON DELETE CASCADE` via a build-time patch of the generated `.hdbtable` files (see Findings + deliverable 1). They must be deployable independently of the flag. 2. **A flag-agnostic gate test** proving `draftEdit → discard` on a Mission From 286f8d68bc056a58c73f02bcf19de8f3f912b4c2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:33:22 -0600 Subject: [PATCH 37/42] fix(#2990): sort NULL pathOrder last on both HANA and SQLite (coalesce sentinel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use tagged template literal .orderBy\`coalesce(pathOrder, 999999), name\` at all five CompletionPaths render sites. Plain .orderBy('pathOrder','name') sorts NULLs FIRST on SQLite but LAST on HANA — coalesce(pathOrder, 999999) forces NULL-last on both engines. Tests updated to assert NULL-last ordering. Fixes the { func: 'coalesce', args: [...] } object form which is not valid as a fluent .orderBy() positional arg — the orders() function treats plain objects without .ref as {field: sort} pairs, turning 'func' into a field reference lookup that fails. The tagged template literal form goes through cds.parse._select and produces the correct CQL func/args AST. --- packages/content/catalog-data.js | 2 +- srv/developer-service.js | 4 +-- srv/lib/branch/mission-detail.js | 2 +- srv/lib/build-catalog.js | 2 +- test/app-space-segments-resolver.test.js | 17 +++++------ test/build-catalog-mission-detail.test.js | 35 ++++++++++++----------- test/catalog-data.test.js | 17 +++++------ 7 files changed, 42 insertions(+), 37 deletions(-) diff --git a/packages/content/catalog-data.js b/packages/content/catalog-data.js index 0d1515a3b..f90e3c9db 100644 --- a/packages/content/catalog-data.js +++ b/packages/content/catalog-data.js @@ -182,7 +182,7 @@ export async function loadMissionContext(slug) { const paths = await SELECT.from(CompletionPaths) .where({ mission_ID: mission.ID }) .columns('ID', 'name', 'slug', 'legacyId', 'pathOrder') - .orderBy('pathOrder', 'name'); + .orderBy`coalesce(pathOrder, 999999), name`; const pathIds = paths.map(p => p.ID); // [#382 phase F1] Fetch BOTH taskType variants. Direct TUTORIAL items diff --git a/srv/developer-service.js b/srv/developer-service.js index af32b6486..dbd019cec 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -561,7 +561,7 @@ export default class DeveloperService extends cds.ApplicationService { const paths = await SELECT.from(dbPaths) .where({ mission_ID: mission.ID }) - .orderBy('pathOrder', 'name'); + .orderBy`coalesce(pathOrder, 999999), name`; const pathIds = paths.map(p => p.ID); const allItems = pathIds.length > 0 @@ -672,7 +672,7 @@ export default class DeveloperService extends cds.ApplicationService { const paths = await SELECT.from(dbPaths) .where({ mission_ID: mission.ID }) - .orderBy('pathOrder', 'name'); + .orderBy`coalesce(pathOrder, 999999), name`; const pathIds = paths.map(p => p.ID); const allItems = pathIds.length > 0 diff --git a/srv/lib/branch/mission-detail.js b/srv/lib/branch/mission-detail.js index 6350d8c9c..fa639bb7b 100644 --- a/srv/lib/branch/mission-detail.js +++ b/srv/lib/branch/mission-detail.js @@ -37,7 +37,7 @@ export async function missionDetailHandler(req, res) { const mission = await SELECT.one.from(Missions).where({ slug }); if (!mission) return res.status(404).json({ error: 'mission_not_found' }); - const paths = await SELECT.from(CompletionPaths).where({ mission_ID: mission.ID }).orderBy('pathOrder', 'name'); + const paths = await SELECT.from(CompletionPaths).where({ mission_ID: mission.ID }).orderBy`coalesce(pathOrder, 999999), name`; if (paths.length === 0) return res.json({ missionSlug: slug, items: [] }); // For v1 we render the FIRST path only (matches today's mission-side-nav rendering). diff --git a/srv/lib/build-catalog.js b/srv/lib/build-catalog.js index 988032ee3..56761ba19 100644 --- a/srv/lib/build-catalog.js +++ b/srv/lib/build-catalog.js @@ -19,7 +19,7 @@ export async function buildCatalogHandler(req, res) { try { const missions = await SELECT.from(Missions).where({ published: true }); - const paths = await SELECT.from(CompletionPaths).orderBy('mission_ID', 'pathOrder', 'name'); + const paths = await SELECT.from(CompletionPaths).orderBy`mission_ID, coalesce(pathOrder, 999999), name`; const items = await SELECT.from(CompletionPathItems).orderBy('itemOrder'); const tutorials = await SELECT.from(Tutorials) .columns('ID', 'legacyId', 'slug', 'title', 'description') diff --git a/test/app-space-segments-resolver.test.js b/test/app-space-segments-resolver.test.js index 22eddb3e9..3bf733118 100644 --- a/test/app-space-segments-resolver.test.js +++ b/test/app-space-segments-resolver.test.js @@ -252,12 +252,12 @@ describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GRO }); }); -// #2990 — getAppSpaceProgress returns paths in pathOrder, then name order. +// #2990 — getAppSpaceProgress returns paths in pathOrder (NULL last via coalesce), then name. // Three paths: name order (Z→M→A) differs from pathOrder (3→null→1). -// SQLite NULLs sort FIRST so expected title order is: -// Z Path (pathOrder=null), A Path (pathOrder=1), M Path (pathOrder=3) -// (NULL < 1 < 3 in SQLite ASC ordering). -describe('App-Space path ordering by pathOrder (#2990)', () => { +// Coalesce sentinel (NULL→999999): A Path (1), M Path (3), Z Path (null→last). +// This assertion pins NULL-last behavior — it FAILS under plain .orderBy('pathOrder','name') +// on SQLite where NULLs sort first, proving the coalesce fix is needed. +describe('App-Space path ordering by pathOrder, NULL last (#2990)', () => { const PO_EVENT_ID = 10002991; const PO_MISSION_ID = '11111111-2990-aaaa-0000-000000000001'; const PO_EVENT_UUID = 'eeeeeeee-2990-aaaa-0000-000000000001'; @@ -306,10 +306,11 @@ describe('App-Space path ordering by pathOrder (#2990)', () => { await DELETE.from(Tutorials).where({ ID: PO_TUT_ID }); }); - it('returns paths ordered by pathOrder asc then name; NULL pathOrder sorts first on SQLite', async () => { + it('returns paths ordered by pathOrder asc then name; NULL pathOrder sorts LAST (coalesce sentinel)', async () => { const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${PO_EVENT_ID})`); expect(data.paths).toHaveLength(3); - // SQLite NULLs first: Z Path (null), A Path (1), M Path (3) - expect(data.paths.map(p => p.title)).toEqual(['Z Path', 'A Path', 'M Path']); + // Coalesce: A Path (1), M Path (3), Z Path (null → sorts last) + // Would be ['Z Path','A Path','M Path'] under plain orderBy on SQLite — wrong on HANA. + expect(data.paths.map(p => p.title)).toEqual(['A Path', 'M Path', 'Z Path']); }); }); diff --git a/test/build-catalog-mission-detail.test.js b/test/build-catalog-mission-detail.test.js index c15e73c55..3a25a3345 100644 --- a/test/build-catalog-mission-detail.test.js +++ b/test/build-catalog-mission-detail.test.js @@ -114,15 +114,17 @@ describe('/build/mission/:slug — alt-group grouping', () => { }); // #2990 — mission-detail /build/mission/:slug returns paths[0] as the -// pathOrder-first path, not insertion order. -// Three paths: name order (Z→M→A) differs from pathOrder (3→NULL→1). -// SQLite NULLs sort FIRST so order is: M Path (null), A Path (1), Z Path (3). -describe('/build/mission/:slug — paths ordered by pathOrder then name (#2990)', () => { +// pathOrder-first path (NULL last via coalesce sentinel), not insertion order. +// Three paths: name order (Z→M→A) differs from pathOrder (3→null→1). +// Coalesce sentinel: A Path (1), Z Path (3), M Path (null→999999 = last). +// Only A Path has items — so if NULL sorted first (old bug), paths[0] would +// be M Path (empty items → items:[]). With coalesce, paths[0] = A Path → items non-empty. +describe('/build/mission/:slug — paths ordered by pathOrder then name, NULL last (#2990)', () => { const MDP_MISSION_ID = '11111111-2990-dc00-0000-000000000001'; // Three paths; name Z→M→A, pathOrder 3→null→1 - const MDP_PATH_A_ID = '22222222-2990-dc00-0000-000000000001'; // name='A Path', pathOrder=1 - const MDP_PATH_M_ID = '22222222-2990-dc00-0000-000000000002'; // name='M Path', pathOrder=null - const MDP_PATH_Z_ID = '22222222-2990-dc00-0000-000000000003'; // name='Z Path', pathOrder=3 + const MDP_PATH_A_ID = '22222222-2990-dc00-0000-000000000001'; // name='A Path', pathOrder=1 → paths[0] + const MDP_PATH_M_ID = '22222222-2990-dc00-0000-000000000002'; // name='M Path', pathOrder=null → paths[2] + const MDP_PATH_Z_ID = '22222222-2990-dc00-0000-000000000003'; // name='Z Path', pathOrder=3 → paths[1] const MDP_TUT_ID = '33333333-2990-dc00-0000-000000000001'; beforeAll(async () => { @@ -139,12 +141,12 @@ describe('/build/mission/:slug — paths ordered by pathOrder then name (#2990)' await INSERT.into(Tutorials).entries({ ID: MDP_TUT_ID, legacyId: 299031, slug: '__test__-mdp-tut', title: 'MDP Tutorial', status: 'ACTIVE', }); - // Only path A has items — so the handler picks the pathOrder-first path (not insertion order). - // After the fix, paths[0] should be M Path (null sorts first), so we add an item on M Path. - // That way if the old legacyId order were used (A=1 < M=2 < Z=3), we'd get A Path instead. + // Only A Path (pathOrder=1) has items. With the coalesce fix, paths[0] = A Path → items + // are returned. With plain .orderBy (SQLite NULL-first bug) paths[0] would be M Path + // (null) → empty items → this test would fail, proving the coalesce fix is required. await INSERT.into(CompletionPathItems).entries({ ID: '44444444-2990-dc00-0000-000000000001', legacyId: 299041, - path_ID: MDP_PATH_M_ID, taskType: 'TUTORIAL', tutorial_ID: MDP_TUT_ID, itemOrder: 0, + path_ID: MDP_PATH_A_ID, taskType: 'TUTORIAL', tutorial_ID: MDP_TUT_ID, itemOrder: 0, }); __resetCacheForTest(); }); @@ -158,14 +160,15 @@ describe('/build/mission/:slug — paths ordered by pathOrder then name (#2990)' __resetCacheForTest(); }); - it('paths[0] is the path with the lowest pathOrder (NULL < 1 < 3 on SQLite)', async () => { - // M Path has pathOrder=null → sorts FIRST on SQLite → becomes paths[0]. - // The handler uses paths[0] exclusively; items should come from M Path. + it('paths[0] is the lowest explicit pathOrder (A Path=1); NULL-pathOrder path sorts last (coalesce sentinel)', async () => { + // A Path has pathOrder=1 → sorts FIRST via coalesce → becomes paths[0]. + // The handler uses paths[0] exclusively; items come from A Path → non-empty. + // Under the old plain .orderBy('pathOrder','name') SQLite puts M Path (null) first, + // so items would be empty — this assertion pins the NULL-last behavior. const { status, data } = await project.get('/build/mission/__test__-mdp-mission?nocache=1'); expect(status).toBe(200); - // missionSlug canonicalized expect(data.missionSlug).toBe('__test__-mdp-mission'); - // M Path has the one tutorial item → items should be non-empty + // A Path has the tutorial item → items must be non-empty expect(data.items).toHaveLength(1); expect(data.items[0].slug).toBe('__test__-mdp-tut'); }); diff --git a/test/catalog-data.test.js b/test/catalog-data.test.js index 39b97482b..f2b49f6c0 100644 --- a/test/catalog-data.test.js +++ b/test/catalog-data.test.js @@ -289,14 +289,13 @@ describe('catalog-data — mission with direct-TUTORIAL CompletionPathItems', () }); }); -// #2990 — loadMissionContext orders paths by pathOrder, then name. +// #2990 — loadMissionContext orders paths by pathOrder (NULL last), then name. // Three paths: name order (Z, M, A) differs from pathOrder (3, 1, NULL). -// SQLite orders NULLs FIRST, so expected path title order is: -// "Z Path" (pathOrder=null, name='Z Path' — NULL sorts first on SQLite) +// The coalesce sentinel (coalesce(pathOrder, 999999)) ensures NULLs sort LAST +// on both SQLite and HANA so DEV/PROD behavior matches tests. Expected order: // "A Path" (pathOrder=1) // "M Path" (pathOrder=3) -// This documents the observed SQLite NULL-first behavior; the controller -// can decide whether a NULL-last clause is needed. +// "Z Path" (pathOrder=null → treated as 999999, sorts last) describe('catalog-data — loadMissionContext orders paths by pathOrder then name (#2990)', () => { const NS = 'com.sap.developers.ims'; const TUT_PO_ID = 'cccccccc-2990-cd00-0000-000000000001'; @@ -330,12 +329,14 @@ describe('catalog-data — loadMissionContext orders paths by pathOrder then nam ]); }); - it('emits paths ordered by pathOrder asc then name; NULL pathOrder sorts first on SQLite', async () => { + it('emits paths ordered by pathOrder asc then name; NULL pathOrder sorts LAST (coalesce sentinel)', async () => { const ctx = await loadMissionContext('__test__-cd-po-mission'); expect(ctx).not.toBeNull(); // One synthetic group card per path → 3 total expect(ctx.groups).toHaveLength(3); - // SQLite NULLs first: Z Path (null), A Path (1), M Path (3) - expect(ctx.groups.map(g => g.title)).toEqual(['Z Path', 'A Path', 'M Path']); + // Coalesce sentinel: A Path (1), M Path (3), Z Path (null→last) + // This assertion would FAIL under plain .orderBy('pathOrder','name') on SQLite + // because SQLite puts NULLs first by default. + expect(ctx.groups.map(g => g.title)).toEqual(['A Path', 'M Path', 'Z Path']); }); }); From a18bbf25d958b42e0fbb4c678607dfcc6e00eaec Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:44:17 -0600 Subject: [PATCH 38/42] feat(#2990): backfill CompletionPaths.pathOrder alphabetically per mission --- package.json | 1 + scripts/backfill-completion-path-order.cjs | 269 ++++++++++++++++++ .../backfill-completion-path-order.test.js | 114 ++++++++ .../backfill-completion-path-order.test.js | 197 +++++++++++++ 4 files changed, 581 insertions(+) create mode 100644 scripts/backfill-completion-path-order.cjs create mode 100644 test/hybrid/backfill-completion-path-order.test.js create mode 100644 test/unit/backfill-completion-path-order.test.js diff --git a/package.json b/package.json index 93cddf313..ca521bced 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "export:advocates": "cds bind --exec -- node scripts/export-advocates.cjs", "import:advocates": "cds bind --exec -- node scripts/import-advocates.cjs", "backfill-categories": "node scripts/backfill-categories.cjs", + "backfill-completion-path-order": "cds bind --exec -- node scripts/backfill-completion-path-order.cjs", "kg:reextract": "cross-env KG_EXTRACT_BUILD_CAP=10000 cds bind --exec -- node scripts/kg-reextract.cjs", "seed-channels": "cds bind --exec -- node scripts/seed-channels.cjs", "promote-channels": "cds bind --exec -- node scripts/promote-channels-to-shelves.cjs", diff --git a/scripts/backfill-completion-path-order.cjs b/scripts/backfill-completion-path-order.cjs new file mode 100644 index 000000000..2e42238de --- /dev/null +++ b/scripts/backfill-completion-path-order.cjs @@ -0,0 +1,269 @@ +/* eslint-disable no-console */ +/** + * One-shot backfill: assign CompletionPaths.pathOrder for rows where it is NULL, + * ordering paths alphabetically by name within each mission (issue #2990). + * + * For each mission that has CompletionPaths with NULL pathOrder: + * 1. Find the current max(pathOrder) among non-null paths in that mission (base). + * 2. Sort the NULL-order paths by name ascending (localeCompare, deterministic). + * 3. Assign gap-numbered pathOrder values starting above base: + * first = floor(base/10)*10 + 10 + * i-th = floor(base/10)*10 + 10*(i+1) + * Examples: + * base=0 → 10, 20, 30, … + * base=25 → 30, 40, 50, … (floor(25/10)*10 = 20, then 20+10=30) + * This ensures a clean multiple-of-10 start above any existing max. + * + * Modes: + * --dry-run (default) — print plan, no writes + * --commit — snapshot JSONL first, then write + * --verify-only — exit 0 if no NULL pathOrder rows remain, exit 2 if work remains + * + * Run via: + * npx cds bind --exec -- node scripts/backfill-completion-path-order.cjs [--commit] + * + * npm script: npm run backfill-completion-path-order + */ + +'use strict'; + +const cds = require('@sap/cds'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SNAPSHOT_DIR = path.resolve(__dirname, '..', '.migration-data'); +const SNAPSHOT_PATH = path.join( + SNAPSHOT_DIR, + `completion-path-order-backfill-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl` +); +let snapshotInited = false; +function appendSnapshot(record) { + if (!snapshotInited) { + fs.mkdirSync(SNAPSHOT_DIR, { recursive: true }); + snapshotInited = true; + } + fs.appendFileSync(SNAPSHOT_PATH, JSON.stringify(record) + '\n'); +} + +/** + * Resolve namespaced CDS entities robustly in both cds bind --exec and + * unit-test (in-memory SQLite) contexts. Passing a bare string to SELECT.from + * fails against HANA because it resolves to the unqualified table name. + * + * @param {typeof import('@sap/cds')} cdsInstance + * @returns {Promise>} + */ +async function resolveImsEntities(cdsInstance) { + if (typeof cdsInstance.entities === 'function' && cdsInstance.model) { + return cdsInstance.entities('com.sap.developers.ims'); + } + const linked = cdsInstance.linked(await cdsInstance.load('*')); + return linked.entities('com.sap.developers.ims'); +} + +/** + * Compute the set of per-mission pathOrder backfill assignments without writing. + * + * Returns an array of { missionId, paths: [{ id, name, newPathOrder }] } + * for every mission that has at least one CompletionPath with NULL pathOrder. + * + * @param {object} db - CDS database service + * @param {object} ims - resolved IMS entities + * @returns {Promise }>>} + */ +async function planBackfill(db, ims) { + const { CompletionPaths } = ims; + + // Find all missions that have at least one NULL pathOrder path. + const nullPaths = await db.run( + SELECT.from(CompletionPaths) + .columns('ID', 'mission_ID', 'name', 'pathOrder') + .where({ pathOrder: null }) + .orderBy('mission_ID', 'name') + ); + + if (nullPaths.length === 0) return []; + + // Group by mission. + const byMission = new Map(); + for (const p of nullPaths) { + const mid = p.mission_ID; + if (!byMission.has(mid)) byMission.set(mid, []); + byMission.get(mid).push(p); + } + + const plan = []; + + for (const [missionId, paths] of byMission) { + // Find max existing pathOrder for this mission (among non-null rows). + const existingRows = await db.run( + SELECT.from(CompletionPaths) + .columns('pathOrder') + .where({ mission_ID: missionId }) + .where('pathOrder IS NOT NULL') + ); + const base = existingRows.length > 0 + ? Math.max(...existingRows.map(r => r.pathOrder)) + : 0; + + // Sort NULL-order paths alphabetically by name (localeCompare, deterministic). + const sorted = [...paths].sort((a, b) => + (a.name || '').localeCompare(b.name || '') + ); + + // Assign gap-numbered pathOrder values starting above base. + // Formula: floor(base/10)*10 + 10*(i+1) + // base=0 → 10, 20, 30 + // base=25 → 30, 40, 50 (floor(25/10)*10 = 20, so start = 30) + const startBase = Math.floor(base / 10) * 10; + const assignments = sorted.map((p, i) => ({ + id: p.ID, + name: p.name, + newPathOrder: startBase + 10 * (i + 1), + })); + + plan.push({ missionId, paths: assignments }); + } + + return plan; +} + +/** + * Execute the backfill: snapshot, then UPDATE each CompletionPath individually. + * + * @param {object} db - CDS database service + * @param {object} ims - resolved IMS entities + * @param {Array} plan - output of planBackfill() + * @param {boolean} doSnapshot - whether to write snapshot JSONL + * @returns {Promise<{ updated: number, failed: number }>} + */ +async function applyBackfill(db, ims, plan, doSnapshot) { + const { CompletionPaths } = ims; + let updated = 0; + let failed = 0; + + for (const { missionId, paths } of plan) { + for (const { id, name, newPathOrder } of paths) { + try { + if (doSnapshot) { + appendSnapshot({ + kind: 'completion-path-order-before', + table: 'CompletionPaths', + id, + mission_ID: missionId, + name, + newPathOrder, + }); + } + + await db.run( + UPDATE(CompletionPaths) + .set({ pathOrder: newPathOrder }) + .where({ ID: id }) + .where({ pathOrder: null }) + ); + + updated++; + console.log(` ✓ Path ${id.slice(0, 8)} "${name ?? ''}"` + + ` mission=${missionId.slice(0, 8)} pathOrder → ${newPathOrder}`); + } catch (err) { + failed++; + console.error(` ✗ Path ${id.slice(0, 8)} failed: ${err.message}`); + } + } + } + + return { updated, failed }; +} + +/** + * Core backfill logic — callable from tests as well as from main(). + * + * @param {object} options + * @param {typeof import('@sap/cds')} options.cdsInstance - cds module to use + * @param {boolean} [options.commit=false] - actually write; snapshot first + * @param {boolean} [options.verifyOnly=false] - report + exit 0/2, no writes + * @param {boolean} [options.dryRun=false] - print plan, no writes (default when all false) + * @param {boolean} [options.hanaOnly=false] - if true, abort when db is not HANA + * @returns {Promise<{ planRows: number, updated: number, failed: number, clean: boolean }>} + */ +async function backfill({ cdsInstance = cds, commit = false, verifyOnly = false, dryRun = false, hanaOnly = false } = {}) { + const db = await cdsInstance.connect.to('db'); + + if (hanaOnly) { + const isHana = db.options?.kind === 'hana' || db.constructor?.name === 'HANAService'; + if (!isHana) { + throw new Error('FATAL: --hana-only flag set but DB is not HANA. Use `cds bind --exec`.'); + } + } + + const ims = await resolveImsEntities(cdsInstance); + const plan = await planBackfill(db, ims); + + const totalPlanRows = plan.reduce((s, m) => s + m.paths.length, 0); + + if (verifyOnly) { + console.log(`CompletionPaths with NULL pathOrder: ${totalPlanRows}`); + const clean = totalPlanRows === 0; + console.log(clean ? 'CLEAN — no backfill needed.' : 'WORK REMAINS — re-run with --commit.'); + return { planRows: totalPlanRows, updated: 0, failed: 0, clean }; + } + + // Print plan regardless of mode. + console.log(`\n--- CompletionPaths with NULL pathOrder: ${totalPlanRows} (across ${plan.length} missions) ---`); + for (const { missionId, paths } of plan) { + console.log(`\n Mission ${missionId.slice(0, 8)} (${paths.length} paths to assign):`); + for (const { id, name, newPathOrder } of paths) { + console.log(` ${id.slice(0, 8)} "${name ?? ''}"` + + ` → pathOrder = ${newPathOrder}`); + } + } + + if (!commit) { + console.log('\nDry-run complete. Re-run with --commit to apply.'); + return { planRows: totalPlanRows, updated: 0, failed: 0, clean: totalPlanRows === 0 }; + } + + console.log(`\nSnapshot will be written to: ${SNAPSHOT_PATH}\n`); + + const { updated, failed } = await applyBackfill(db, ims, plan, true); + + console.log('\n=== SUMMARY ==='); + console.log(JSON.stringify({ planRows: totalPlanRows, updated, failed }, null, 2)); + + if (failed > 0) { + throw new Error(`Backfill completed with ${failed} failures — check output above.`); + } + + return { planRows: totalPlanRows, updated, failed, clean: totalPlanRows === 0 }; +} + +// ── CLI entry point ────────────────────────────────────────────────────────── + +const argv = process.argv.slice(2); +const COMMIT = argv.includes('--commit'); +const VERIFY_ONLY = argv.includes('--verify-only'); +const DRY_RUN = argv.includes('--dry-run'); + +if (COMMIT && VERIFY_ONLY) { + console.error('--commit and --verify-only are mutually exclusive'); + process.exit(1); +} +if (COMMIT && DRY_RUN) { + console.error('--commit and --dry-run are mutually exclusive'); + process.exit(1); +} + +if (require.main === module) { + backfill({ commit: COMMIT, verifyOnly: VERIFY_ONLY, dryRun: DRY_RUN, hanaOnly: false }) + .then(({ clean }) => { + if (VERIFY_ONLY) process.exit(clean ? 0 : 2); + // dry-run and commit both exit 0 on success + }) + .catch(e => { + console.error(e); + process.exit(1); + }); +} + +module.exports = { backfill, planBackfill, resolveImsEntities }; diff --git a/test/hybrid/backfill-completion-path-order.test.js b/test/hybrid/backfill-completion-path-order.test.js new file mode 100644 index 000000000..b968a244e --- /dev/null +++ b/test/hybrid/backfill-completion-path-order.test.js @@ -0,0 +1,114 @@ +// test/hybrid/backfill-completion-path-order.test.js +// Hybrid HANA contract regression for #2990 — exercises the backfill script's +// per-row UPDATE statements directly against HANA via cds bind --exec. +// Mirrors test/hybrid/repair-mission-completion-path-data.test.js (PR #436). + +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { isSafeForWrites } from './_guard.js'; +import { backfill, planBackfill, resolveImsEntities } from '../../scripts/backfill-completion-path-order.cjs'; + +const TEST_PREFIX = '__TEST__2990-backfill-'; + +describe('backfill-completion-path-order (#2990) — HANA', () => { + let db; + let ims; + + beforeAll(async () => { + if (process.env.ALLOW_HYBRID_WRITES !== 'true') { + throw new Error('Hybrid writes require ALLOW_HYBRID_WRITES=true'); + } + if (!isSafeForWrites()) { + throw new Error('Refusing to run hybrid writes against production'); + } + db = await cds.connect.to('db'); + ims = await resolveImsEntities(cds); + }); + + afterAll(async () => { + // Clean up any test missions and their paths. + const { Missions, CompletionPaths } = ims; + const testMissions = await db.run( + SELECT.from(Missions).columns('ID').where(`title like '${TEST_PREFIX}%'`) + ); + for (const m of testMissions) { + await db.run(DELETE.from(CompletionPaths).where({ mission_ID: m.ID })); + } + await db.run(DELETE.from(Missions).where(`title like '${TEST_PREFIX}%'`)); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Core test: C/A/B all NULL → A=10, B=20, C=30 (alphabetical) + // ───────────────────────────────────────────────────────────────────────── + it('assigns pathOrder A=10, B=20, C=30 for paths named "C","A","B" all NULL', async () => { + const { Missions, CompletionPaths } = ims; + const missionId = cds.utils.uuid(); + + await db.run(INSERT.into(Missions).entries({ + ID: missionId, + title: `${TEST_PREFIX}core-alpha`, + status: 'ACTIVE', + })); + + const pathC = cds.utils.uuid(); + const pathA = cds.utils.uuid(); + const pathB = cds.utils.uuid(); + + await db.run(INSERT.into(CompletionPaths).entries( + { ID: pathC, mission_ID: missionId, name: 'C', pathOrder: null }, + { ID: pathA, mission_ID: missionId, name: 'A', pathOrder: null }, + { ID: pathB, mission_ID: missionId, name: 'B', pathOrder: null }, + )); + + // Run backfill in commit mode. + const result = await backfill({ cdsInstance: cds, commit: true }); + expect(result.updated).toBeGreaterThanOrEqual(3); + + // Assert alphabetical assignment. + const rows = await db.run( + SELECT.from(CompletionPaths) + .columns('ID', 'name', 'pathOrder') + .where({ mission_ID: missionId }) + ); + const byName = Object.fromEntries(rows.map(r => [r.name, r.pathOrder])); + expect(byName['A']).toBe(10); + expect(byName['B']).toBe(20); + expect(byName['C']).toBe(30); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Idempotency: second run finds no NULL rows for this mission + // ───────────────────────────────────────────────────────────────────────── + it('is idempotent — second run finds no NULL pathOrder rows for the mission', async () => { + const { Missions, CompletionPaths } = ims; + const missionId = cds.utils.uuid(); + + await db.run(INSERT.into(Missions).entries({ + ID: missionId, + title: `${TEST_PREFIX}idempotency`, + status: 'ACTIVE', + })); + await db.run(INSERT.into(CompletionPaths).entries( + { ID: cds.utils.uuid(), mission_ID: missionId, name: 'C', pathOrder: null }, + { ID: cds.utils.uuid(), mission_ID: missionId, name: 'A', pathOrder: null }, + { ID: cds.utils.uuid(), mission_ID: missionId, name: 'B', pathOrder: null }, + )); + + // First run. + await backfill({ cdsInstance: cds, commit: true }); + + // Second run should find no NULL rows for this mission. + const plan = await planBackfill(db, ims); + const missionPlan = plan.find(m => m.missionId === missionId); + expect(missionPlan).toBeUndefined(); + + // verify-only: this mission contributes 0 rows to the NULL count. + const rows = await db.run( + SELECT.from(CompletionPaths) + .columns('pathOrder') + .where({ mission_ID: missionId }) + .where('pathOrder IS NULL') + ); + expect(rows.length).toBe(0); + }); +}); diff --git a/test/unit/backfill-completion-path-order.test.js b/test/unit/backfill-completion-path-order.test.js new file mode 100644 index 000000000..f5d529001 --- /dev/null +++ b/test/unit/backfill-completion-path-order.test.js @@ -0,0 +1,197 @@ +// test/unit/backfill-completion-path-order.test.js +// +// Unit tests for scripts/backfill-completion-path-order.cjs (#2990). +// Exercises the exported backfill/planBackfill functions against in-memory +// SQLite (the npm test path — no HANA or cf login needed). + +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; + +// Boot CAP with in-memory SQLite once for this file. +cds.test('serve', '--project', '.', '--in-memory'); + +// Import the exported functions from the CJS script. +// Dynamic import works here because vitest handles CJS/ESM interop. +const { backfill, planBackfill } = await import('../../scripts/backfill-completion-path-order.cjs'); + +const NS = 'com.sap.developers.ims'; +const TEST_PREFIX = '__TEST__2990-backfill-'; + +describe('backfill-completion-path-order (#2990) — in-memory SQLite', () => { + let db; + let Missions, CompletionPaths; + + beforeAll(async () => { + db = await cds.connect.to('db'); + ({ Missions, CompletionPaths } = cds.entities(NS)); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // Helpers + // ─────────────────────────────────────────────────────────────────────────── + + async function seedMissionWithPaths(titleSuffix, pathNames, pathOrders) { + const missionId = cds.utils.uuid(); + await INSERT.into(Missions).entries({ + ID: missionId, + title: `${TEST_PREFIX}${titleSuffix}`, + status: 'ACTIVE', + }); + for (let i = 0; i < pathNames.length; i++) { + await INSERT.into(CompletionPaths).entries({ + ID: cds.utils.uuid(), + mission_ID: missionId, + name: pathNames[i], + pathOrder: pathOrders ? pathOrders[i] : null, + }); + } + return missionId; + } + + async function getPathOrders(missionId) { + const rows = await SELECT.from(CompletionPaths) + .columns('name', 'pathOrder') + .where({ mission_ID: missionId }) + .orderBy('name'); + return Object.fromEntries(rows.map(r => [r.name, r.pathOrder])); + } + + async function cleanup(missionId) { + await DELETE.from(CompletionPaths).where({ mission_ID: missionId }); + await DELETE.from(Missions).where({ ID: missionId }); + } + + // ─────────────────────────────────────────────────────────────────────────── + // Core test: C/A/B all NULL → A=10, B=20, C=30 (alphabetical) + // ─────────────────────────────────────────────────────────────────────────── + it('assigns pathOrder alphabetically: A=10, B=20, C=30 for names "C","A","B"', async () => { + const missionId = await seedMissionWithPaths('core-alpha', ['C', 'A', 'B'], null); + try { + const result = await backfill({ cdsInstance: cds, commit: true }); + expect(result.updated).toBeGreaterThanOrEqual(3); + + const orders = await getPathOrders(missionId); + expect(orders['A']).toBe(10); + expect(orders['B']).toBe(20); + expect(orders['C']).toBe(30); + } finally { + await cleanup(missionId); + } + }); + + // ─────────────────────────────────────────────────────────────────────────── + // Idempotency: second run finds no NULL rows, updates nothing + // ─────────────────────────────────────────────────────────────────────────── + it('is idempotent — second run makes no changes', async () => { + const missionId = await seedMissionWithPaths('idempotency', ['C', 'A', 'B'], null); + try { + // First run. + await backfill({ cdsInstance: cds, commit: true }); + + const ordersBefore = await getPathOrders(missionId); + expect(ordersBefore['A']).toBe(10); + + // Second run in commit mode. + const result2 = await backfill({ cdsInstance: cds, commit: true }); + // No new writes targeting our rows (there are 0 NULL pathOrder rows left for this mission). + // The whole-DB updated count may include unrelated missions still NULL from prior tests + // that haven't been cleaned up yet; what matters is our rows are unchanged. + const ordersAfter = await getPathOrders(missionId); + expect(ordersAfter['A']).toBe(10); + expect(ordersAfter['B']).toBe(20); + expect(ordersAfter['C']).toBe(30); + + // verify-only should report clean = true (no NULLs remain for our mission). + const plan = await planBackfill(db, cds.entities(NS)); + const missionPlan = plan.find(m => m.missionId === missionId); + expect(missionPlan).toBeUndefined(); // our mission has no NULL pathOrder rows + } finally { + await cleanup(missionId); + } + }); + + // ─────────────────────────────────────────────────────────────────────────── + // Partial-existing: base=25, new paths extend above it + // ─────────────────────────────────────────────────────────────────────────── + it('extends past existing max(pathOrder): base=25 → new paths start at 30', async () => { + const missionId = cds.utils.uuid(); + await INSERT.into(Missions).entries({ + ID: missionId, + title: `${TEST_PREFIX}partial-existing`, + status: 'ACTIVE', + }); + // One path already has pathOrder=25. + await INSERT.into(CompletionPaths).entries({ + ID: cds.utils.uuid(), + mission_ID: missionId, + name: 'AlreadyOrdered', + pathOrder: 25, + }); + // Two more with NULL. + await INSERT.into(CompletionPaths).entries({ + ID: cds.utils.uuid(), + mission_ID: missionId, + name: 'Zebra', + pathOrder: null, + }); + await INSERT.into(CompletionPaths).entries({ + ID: cds.utils.uuid(), + mission_ID: missionId, + name: 'Apple', + pathOrder: null, + }); + + try { + await backfill({ cdsInstance: cds, commit: true }); + + const orders = await getPathOrders(missionId); + // Formula: floor(25/10)*10 = 20, so start = 30, 40 + expect(orders['Apple']).toBe(30); + expect(orders['Zebra']).toBe(40); + expect(orders['AlreadyOrdered']).toBe(25); // unchanged + } finally { + await DELETE.from(CompletionPaths).where({ mission_ID: missionId }); + await DELETE.from(Missions).where({ ID: missionId }); + } + }); + + // ─────────────────────────────────────────────────────────────────────────── + // dry-run: prints plan, makes no changes + // ─────────────────────────────────────────────────────────────────────────── + it('dry-run mode makes no changes', async () => { + const missionId = await seedMissionWithPaths('dry-run-test', ['B', 'A'], null); + try { + const result = await backfill({ cdsInstance: cds, dryRun: true }); + expect(result.updated).toBe(0); + + const orders = await getPathOrders(missionId); + expect(orders['A']).toBeNull(); + expect(orders['B']).toBeNull(); + } finally { + await cleanup(missionId); + } + }); + + // ─────────────────────────────────────────────────────────────────────────── + // verify-only: returns clean=false when NULLs remain, clean=true when done + // ─────────────────────────────────────────────────────────────────────────── + it('verify-only returns clean=false when NULLs exist, clean=true after commit', async () => { + const missionId = await seedMissionWithPaths('verify-test', ['Z', 'A'], null); + try { + // Before backfill: work remains. + const before = await backfill({ cdsInstance: cds, verifyOnly: true }); + expect(before.clean).toBe(false); + expect(before.planRows).toBeGreaterThanOrEqual(2); + + // Commit. + await backfill({ cdsInstance: cds, commit: true }); + + // After backfill: clean (no NULLs for our mission specifically). + const plan = await planBackfill(db, cds.entities(NS)); + const missionPlan = plan.find(m => m.missionId === missionId); + expect(missionPlan).toBeUndefined(); + } finally { + await cleanup(missionId); + } + }); +}); From a0fb331e0bea2514e08287bb0fbbd25cedc7abc3 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:44:31 -0600 Subject: [PATCH 39/42] =?UTF-8?q?fix(health):=20add=20/health/ready=20to?= =?UTF-8?q?=20srv-qa=20=E2=80=94=20missing=20route=20404-crashed=20QA,=20a?= =?UTF-8?q?borting=20MTA=20deploy=20(#2975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2975 batch set srv-qa's CF startup health check to http /health/ready (mta.yaml:199, parity with tutorials-srv) but never added that route to srv-qa/server.js — it only has /healthz and /health/db. So CF's probe got a 404, srv-qa crash-looped (3 retries), and the ENTIRE MTA deploy aborted even though tutorials-srv itself started fine. (Observed on the 2026-11-02 DEV deploy.) srv-qa has no scheduler, so its readiness is DB-only: ready ⇔ a dialect-safe HANA ping (SELECT 1 FROM DUMMY — a bare SELECT 1 is a HANA syntax error) succeeds. Also made /health/db dialect-safe (it hardcoded FROM DUMMY, which breaks on local SQLite). Unit tests cover both dialects + the down path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- srv-qa/server.js | 22 +++++++- test/srv-qa/health-ready.test.js | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 test/srv-qa/health-ready.test.js diff --git a/srv-qa/server.js b/srv-qa/server.js index 6f404f524..be231004a 100644 --- a/srv-qa/server.js +++ b/srv-qa/server.js @@ -13,9 +13,29 @@ cds.on('bootstrap', (app) => { app.disable('x-powered-by'); app.get('/healthz', (_req, res) => res.json({ status: 'ok', channel: 'qa' })); + // #2975 readiness gate: the MTA sets srv-qa's CF startup health check to + // http /health/ready (parity with tutorials-srv). srv-qa has NO scheduler, + // so readiness here is DB-only: ready ⇔ the HANA connection answers a ping. + // Dialect-safe: HANA rejects a bare `SELECT 1` (needs FROM DUMMY); SQLite + // rejects FROM DUMMY. Without this route CF's probe 404s and srv-qa + // crash-loops, aborting the whole MTA deploy (observed 2026-11-02). + app.get('/health/ready', async (_req, res) => { + let dbOk = false; + if (cds.db) { + const ping = cds.db.kind === 'hana' ? 'SELECT 1 FROM DUMMY' : 'SELECT 1'; + try { await cds.db.run(ping); dbOk = true; } + catch (_err) { dbOk = false; } + } + res.status(dbOk ? 200 : 503).json({ + status: dbOk ? 'ready' : 'not-ready', + channel: 'qa', + db: dbOk ? 'connected' : 'error', + timestamp: new Date().toISOString(), + }); + }); app.get('/health/db', async (_req, res) => { try { - await cds.db?.run('SELECT 1 FROM DUMMY'); + await cds.db?.run(cds.db?.kind === 'hana' ? 'SELECT 1 FROM DUMMY' : 'SELECT 1'); res.json({ status: 'ok', db: 'connected' }); } catch (err) { console.error('[health/db][qa]', err.message); diff --git a/test/srv-qa/health-ready.test.js b/test/srv-qa/health-ready.test.js new file mode 100644 index 000000000..52e2675d3 --- /dev/null +++ b/test/srv-qa/health-ready.test.js @@ -0,0 +1,90 @@ +// test/srv-qa/health-ready.test.js +// +// #2975: srv-qa /health/ready readiness gate. The MTA sets srv-qa's CF startup +// health check to http /health/ready; srv-qa has NO scheduler, so readiness is +// DB-only (ready ⇔ a dialect-safe DB ping succeeds). Without the route CF 404s +// and srv-qa crash-loops, aborting the MTA deploy (observed 2026-11-02). +// +// We don't boot cds here; we mount the structurally-identical handler against a +// fake cds.db, exercising it over real HTTP on an ephemeral port — same pattern +// as test/srv-qa/server.test.js. +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import express from 'express'; + +// Mirror of the /health/ready handler in srv-qa/server.js, parameterised on a +// fake db so we can assert the dialect-safe ping + ready/not-ready mapping. +function buildApp(fakeDb) { + const app = express(); + app.get('/health/ready', async (_req, res) => { + let dbOk = false; + if (fakeDb) { + const ping = fakeDb.kind === 'hana' ? 'SELECT 1 FROM DUMMY' : 'SELECT 1'; + try { await fakeDb.run(ping); dbOk = true; } + catch { dbOk = false; } + } + res.status(dbOk ? 200 : 503).json({ + status: dbOk ? 'ready' : 'not-ready', + channel: 'qa', + db: dbOk ? 'connected' : 'error', + timestamp: new Date().toISOString(), + }); + }); + return app; +} + +function listen(app) { + return new Promise((resolve) => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function get(server, path) { + const { port } = server.address(); + const res = await fetch(`http://127.0.0.1:${port}${path}`); + return { status: res.status, body: await res.json() }; +} + +describe('srv-qa /health/ready (#2975)', () => { + it('200 ready when HANA ping (SELECT 1 FROM DUMMY) succeeds', async () => { + let sql; + const db = { kind: 'hana', run: async (q) => { sql = q; return [{}]; } }; + const server = await listen(buildApp(db)); + try { + const r = await get(server, '/health/ready'); + expect(r.status).toBe(200); + expect(r.body.status).toBe('ready'); + expect(r.body.db).toBe('connected'); + expect(sql).toBe('SELECT 1 FROM DUMMY'); // dialect-safe on HANA + } finally { server.close(); } + }); + + it('uses bare SELECT 1 on sqlite', async () => { + let sql; + const db = { kind: 'sqlite', run: async (q) => { sql = q; return [{}]; } }; + const server = await listen(buildApp(db)); + try { + await get(server, '/health/ready'); + expect(sql).toBe('SELECT 1'); + } finally { server.close(); } + }); + + it('503 not-ready when the DB ping throws', async () => { + const db = { kind: 'hana', run: async () => { throw new Error('db down'); } }; + const server = await listen(buildApp(db)); + try { + const r = await get(server, '/health/ready'); + expect(r.status).toBe(503); + expect(r.body.status).toBe('not-ready'); + expect(r.body.db).toBe('error'); + } finally { server.close(); } + }); + + it('503 not-ready when cds.db is absent', async () => { + const server = await listen(buildApp(null)); + try { + const r = await get(server, '/health/ready'); + expect(r.status).toBe(503); + expect(r.body.db).toBe('error'); + } finally { server.close(); } + }); +}); From c575f3dbdff3a998667ada7947ccd4bfd4d82549 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 17:20:41 -0600 Subject: [PATCH 40/42] build(#2990): stage cds build artifacts for pathOrder (csn + hdbmigrationtable) --- db/last-dev/csn.json | 6 +++++- ...om.sap.developers.ims.CompletionPaths.hdbmigrationtable | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index 60c803022..3ee337640 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -1298,6 +1298,10 @@ "type": "cds.String", "length": 255, "@cds.persistence.name": "SLUG" + }, + "pathOrder": { + "type": "cds.Integer", + "@cds.persistence.name": "PATHORDER" } }, "$tableConstraints": { @@ -4522,7 +4526,7 @@ "flavor": "inferred", "sap.changelog.enhanced": true, "minified": true, - "build": "CDS Build v10.1.0" + "build": "CDS Build v10.2.2" }, "$version": "2.0" } \ No newline at end of file diff --git a/db/src/com.sap.developers.ims.CompletionPaths.hdbmigrationtable b/db/src/com.sap.developers.ims.CompletionPaths.hdbmigrationtable index fc4dc0fae..c629a0a51 100644 --- a/db/src/com.sap.developers.ims.CompletionPaths.hdbmigrationtable +++ b/db/src/com.sap.developers.ims.CompletionPaths.hdbmigrationtable @@ -1,4 +1,4 @@ -== version=3 +== version=4 COLUMN TABLE com_sap_developers_ims_CompletionPaths ( ID NVARCHAR(36) NOT NULL, legacyId INTEGER, @@ -6,9 +6,14 @@ COLUMN TABLE com_sap_developers_ims_CompletionPaths ( name NVARCHAR(255), description NVARCHAR(1000), slug NVARCHAR(255), + pathOrder INTEGER, PRIMARY KEY(ID) ) +== migration=4 +-- generated by cds-compiler version 7.0.1 +ALTER TABLE com_sap_developers_ims_CompletionPaths ADD (pathOrder INTEGER); + == migration=3 -- no-op: description column already added in migration=2 From cd6ee06ed7498199de5db3f88556791a8ac7810a Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 18:18:10 -0600 Subject: [PATCH 41/42] fix(db): emit draft cascade FK as .hdbconstraint, not inline in .hdbtable (#2993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2993 build patch injected FOREIGN KEY ... ON DELETE CASCADE directly into each generated *_drafts.hdbtable. HDI rejects that (error 8250014: 'this artifact type must not have references to other objects') — an .hdbtable cannot carry references. Result: HDI make failed with 96 errors across 46 draft tables, crashing tutorials-db-deployer and aborting the entire MTA deploy (observed on the 2026-11-02 DEV deploy). Fix: generate a standalone *.hdbconstraint artifact per draft table instead (plugin com.sap.hana.di.constraint, already registered in the generated .hdiconfig). Format confirmed against SAP-samples (hana-shine-xsa PO_CONST.hdbconstraint): the bare SQL body with NO ALTER TABLE/ADD verb — CONSTRAINT ON "" FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA" ("DRAFTUUID") ON DELETE CASCADE The .hdbtable is left untouched. Verified against the deployed DEV container that DRAFT_DRAFTADMINISTRATIVEDATA lives in the same HDI container schema with PK DRAFTUUID, so HDI's ALTER TABLE ADD CONSTRAINT resolves the reference at deploy time even though the table is CAP-runtime-created, not an HDI artifact. Generator produces 46 .hdbconstraint files against a real production build; the draft_admin_integrity flag stays OFF in package.json (this only makes the constraints deployable). Unit tests rewritten for the new output. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/patch-draft-cascade-constraints.cjs | 112 +++++++++--------- .../patch-draft-cascade-constraints.test.js | 82 +++++++------ 2 files changed, 99 insertions(+), 95 deletions(-) diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs index 306173299..248971f22 100644 --- a/scripts/patch-draft-cascade-constraints.cjs +++ b/scripts/patch-draft-cascade-constraints.cjs @@ -1,13 +1,27 @@ // scripts/patch-draft-cascade-constraints.cjs -// Build-time patch: appends ON DELETE CASCADE foreign keys to generated draft -// .hdbtable files so that SAP HANA will enforce cascade-delete of draft rows -// when their DraftAdministrativeData record is removed. +// Build-time generator: emits a standalone *.hdbconstraint artifact per Fiori +// draft table so SAP HANA enforces ON DELETE CASCADE of draft rows when their +// DraftAdministrativeData record is removed (the `dad` draft delete strategy / +// cds.fiori.draft_admin_integrity). // -// Background (#2993): CAP's `cds.fiori.draft_admin_integrity` flag requires -// that every *_drafts table carries a real HANA FK with ON DELETE CASCADE to -// DRAFT_DRAFTADMINISTRATIVEDATA. The flag stays OFF in package.json (the -// in-memory SQLite unit tier does not understand .hdbconstraint DDL); this -// script makes the constraints deployable without enabling the flag. +// Background (#2993): the FK must NOT be inlined into the generated +// *_drafts.hdbtable — HDI rejects that with error 8250014 +// ("this artifact type must not have references to other objects"). The correct +// HDI artifact is a separate .hdbconstraint (plugin com.sap.hana.di.constraint, +// already registered in the generated .hdiconfig). +// +// The referenced table DRAFT_DRAFTADMINISTRATIVEDATA is created by the CAP +// runtime, but it lives in the SAME HDI container schema as the draft tables and +// has primary key DRAFTUUID (verified against the deployed DEV container), so +// the ALTER TABLE ... ADD CONSTRAINT that HDI renders at deploy time resolves. +// +// .hdbconstraint file format (confirmed from SAP-samples: hana-shine-xsa +// core-db/src/data/PO_CONST.hdbconstraint and others) — SQL constraint body with +// NO `ALTER TABLE ... ADD CONSTRAINT` verb: +// +// CONSTRAINT +// ON "" FOREIGN KEY ("") +// REFERENCES "" ("") ON DELETE CASCADE // // Usage (CLI): // node scripts/patch-draft-cascade-constraints.cjs [genDir] @@ -21,26 +35,30 @@ const fs = require('fs'); const path = require('path'); -const FK_COLUMN = 'DraftAdministrativeData_DraftUUID'; -// Local column is declared BARE (unquoted) in the generated .hdbtable, so HANA -// stores its name folded to UPPERCASE: DRAFTADMINISTRATIVEDATA_DRAFTUUID. -// The FK reference must therefore quote the UPPERCASE name to resolve correctly. -const FK_CLAUSE = - 'FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE'; +// Bare (unquoted) in the generated .hdbtable → HANA folds to UPPERCASE. +const FK_COLUMN_SRC = 'DraftAdministrativeData_DraftUUID'; +const FK_COLUMN = 'DRAFTADMINISTRATIVEDATA_DRAFTUUID'; +const PARENT_TABLE = 'DRAFT_DRAFTADMINISTRATIVEDATA'; +const PARENT_COLUMN = 'DRAFTUUID'; + +// Extract the physical table name from `COLUMN TABLE (` (first line). +function tableNameOf(hdbtableContent) { + const m = hdbtableContent.match(/COLUMN\s+TABLE\s+("?)([A-Za-z0-9_.]+)\1\s*\(/); + return m ? m[2] : null; +} /** - * Enumerate all *_drafts.hdbtable files under genDir, and for each one that - * contains the FK column but not yet ON DELETE CASCADE, inject the FK clause - * as the last element inside the COLUMN TABLE (...) body. + * For each *_drafts.hdbtable under genDir that carries the DraftAdministrativeData + * FK column, write a sibling *_drafts.hdbconstraint declaring the ON DELETE + * CASCADE foreign key. Leaves the .hdbtable untouched. * - * @param {string} genDir Absolute (or cwd-relative) path to the gen dir. + * @param {string} genDir Path to the gen dir (default gen/db/src/gen). * @returns {{ patched: string[], skipped: string[] }} - * @throws {Error} when no *_drafts.hdbtable files are found in genDir. + * @throws {Error} when no *_drafts.hdbtable files are found. */ function patchDraftCascadeConstraints(genDir) { const entries = fs.readdirSync(genDir); const draftFiles = entries.filter((f) => f.endsWith('_drafts.hdbtable')); - if (draftFiles.length === 0) { throw new Error( `no *_drafts.hdbtable files found in ${genDir} — run cds build --production first` @@ -52,51 +70,39 @@ function patchDraftCascadeConstraints(genDir) { for (const filename of draftFiles) { const filepath = path.join(genDir, filename); - const original = fs.readFileSync(filepath, 'utf8'); + const content = fs.readFileSync(filepath, 'utf8'); - // Skip if this table doesn't have the FK column (draft tables from other - // namespaces, or tables generated without draft admin support). - if (!original.includes(FK_COLUMN)) { + // Only tables that actually carry the draft-admin FK column. + if (!content.includes(FK_COLUMN_SRC)) { skipped.push(filename); continue; } - // Idempotency: if OUR specific FK clause is already present, skip. - // Match DRAFTADMINISTRATIVEDATA_DRAFTUUID (the HANA-folded uppercase name we - // emit) + ON DELETE CASCADE. Uses [\s\S]*? because the clause contains - // parentheses that [^)]* cannot cross. - if (/FOREIGN KEY\s*\([^F]*DRAFTADMINISTRATIVEDATA_DRAFTUUID[\s\S]*?ON DELETE CASCADE/i.test(original)) { + const table = tableNameOf(content); + if (!table) { + // Can't determine the table name — skip rather than emit a broken artifact. skipped.push(filename); continue; } - // Inject the FK clause as the last item inside the COLUMN TABLE (...) body. - // Target form: - // PRIMARY KEY(ID), - // FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES ... ON DELETE CASCADE - // ) - // - // Strategy: find the last ')' (the table body closing paren). Everything - // before it (trimmed of trailing whitespace/newlines) gets a trailing comma, - // then the FK clause on its own line, then the closing ')'. - const lastParen = original.lastIndexOf(')'); - if (lastParen === -1) { - // Malformed file — skip rather than corrupt it. + // Deterministic constraint name, bounded well under HANA's 127-char limit. + const constraintName = `${table}_dadFkCascade`; + const constraintFile = filename.replace(/\.hdbtable$/, '.hdbconstraint'); + const constraintPath = path.join(genDir, constraintFile); + + const body = + `CONSTRAINT ${constraintName}\n` + + `ON "${table}" FOREIGN KEY ("${FK_COLUMN}")\n` + + `REFERENCES "${PARENT_TABLE}" ("${PARENT_COLUMN}") ON DELETE CASCADE\n`; + + // Idempotent: skip rewrite if an identical artifact already exists. + if (fs.existsSync(constraintPath) && fs.readFileSync(constraintPath, 'utf8') === body) { skipped.push(filename); continue; } - // Trim trailing whitespace from the body so the comma trails the last element. - const bodyPart = original.slice(0, lastParen).trimEnd(); - const patched_content = - bodyPart + - ',\n ' + - FK_CLAUSE + - '\n' + - original.slice(lastParen); - - fs.writeFileSync(filepath, patched_content, { encoding: 'utf8' }); - patched.push(filename); + fs.writeFileSync(constraintPath, body, { encoding: 'utf8' }); + patched.push(constraintFile); } return { patched, skipped }; @@ -107,7 +113,7 @@ if (require.main === module) { const genDir = process.argv[2] || 'gen/db/src/gen'; try { const result = patchDraftCascadeConstraints(genDir); - console.log(`patched ${result.patched.length} / skipped ${result.skipped.length}`); + console.log(`wrote ${result.patched.length} .hdbconstraint / skipped ${result.skipped.length}`); } catch (err) { console.error(err.message); process.exit(1); diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js index d8786e0d8..6ddfdc91e 100644 --- a/test/unit/patch-draft-cascade-constraints.test.js +++ b/test/unit/patch-draft-cascade-constraints.test.js @@ -1,6 +1,9 @@ // test/unit/patch-draft-cascade-constraints.test.js -// Unit tests for the build-time cascade-FK patch script (#2993). +// Unit tests for the build-time cascade-constraint generator (#2993). // No CDS boot — pure file-system fixture only. +// +// The generator emits a standalone *.hdbconstraint per draft table (NOT an +// inline FK in the .hdbtable — HDI rejects that, error 8250014). import { describe, it, expect, beforeEach } from 'vitest'; import fs from 'node:fs'; @@ -8,77 +11,72 @@ import path from 'node:path'; import os from 'node:os'; import { patchDraftCascadeConstraints } from '../../scripts/patch-draft-cascade-constraints.cjs'; -// Minimal .hdbtable fixture that carries the FK column const FIXTURE_WITH_FK = `COLUMN TABLE AdminService_Missions_drafts ( ID NVARCHAR(36) NOT NULL, - IsActiveEntity NVARCHAR(1), - HasActiveEntity NVARCHAR(1), - HasDraftEntity NVARCHAR(1), + IsActiveEntity BOOLEAN, + HasActiveEntity BOOLEAN, + HasDraftEntity BOOLEAN, DraftAdministrativeData_DraftUUID NVARCHAR(36) NOT NULL, PRIMARY KEY(ID) )`; -// Minimal .hdbtable fixture that does NOT carry the FK column const FIXTURE_WITHOUT_FK = `COLUMN TABLE AdminService_NoFk_drafts ( ID NVARCHAR(36) NOT NULL, - IsActiveEntity NVARCHAR(1), + IsActiveEntity BOOLEAN, PRIMARY KEY(ID) )`; let tmp; let emptyTmp; +const tablePath = (name) => path.join(tmp, name); +const read = (name) => fs.readFileSync(path.join(tmp, name), 'utf8'); +const exists = (name) => fs.existsSync(path.join(tmp, name)); + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-test-')); emptyTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-empty-')); - - fs.writeFileSync( - path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), - FIXTURE_WITH_FK, - { encoding: 'utf8' } - ); - fs.writeFileSync( - path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), - FIXTURE_WITHOUT_FK, - { encoding: 'utf8' } - ); + fs.writeFileSync(tablePath('AdminService.Missions_drafts.hdbtable'), FIXTURE_WITH_FK, 'utf8'); + fs.writeFileSync(tablePath('AdminService.NoFk_drafts.hdbtable'), FIXTURE_WITHOUT_FK, 'utf8'); }); describe('patchDraftCascadeConstraints', () => { - it('appends ON DELETE CASCADE FK to draft tables that carry the FK column', () => { + it('writes a sibling .hdbconstraint with the ON DELETE CASCADE FK', () => { const r = patchDraftCascadeConstraints(tmp); - const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); - // Local column must be quoted UPPERCASE (HANA folds bare column names to upper). - // A mixed-case quoted reference ("DraftAdministrativeData_DraftUUID") would be - // case-sensitive and fail to resolve against the stored UPPERCASE name. - expect(sql).toMatch( - /FOREIGN KEY\s*\("DRAFTADMINISTRATIVEDATA_DRAFTUUID"\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/ - ); - // Regression guard: must NOT emit the mixed-case quoted form. - expect(sql).not.toMatch(/FOREIGN KEY\s*\("DraftAdministrativeData_DraftUUID"\)/); - expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); + expect(exists('AdminService.Missions_drafts.hdbconstraint')).toBe(true); + expect(r.patched).toContain('AdminService.Missions_drafts.hdbconstraint'); + + const c = read('AdminService.Missions_drafts.hdbconstraint'); + // CONSTRAINT / ON "" FOREIGN KEY (...) / REFERENCES ... CASCADE + expect(c).toMatch(/^CONSTRAINT\s+\S+/m); + expect(c).toMatch(/ON\s+"AdminService_Missions_drafts"\s+FOREIGN KEY\s*\("DRAFTADMINISTRATIVEDATA_DRAFTUUID"\)/); + expect(c).toMatch(/REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\s*\("DRAFTUUID"\)\s+ON DELETE CASCADE/); + // No ALTER TABLE / ADD CONSTRAINT verb — .hdbconstraint is the bare body. + expect(c).not.toMatch(/ALTER\s+TABLE/i); + expect(c).not.toMatch(/ADD\s+CONSTRAINT/i); + // Quoted UPPERCASE column (HANA folds the bare declaration to upper). + expect(c).not.toMatch(/"DraftAdministrativeData_DraftUUID"/); }); - it('comma trails the previous line — no leading-comma style', () => { + it('leaves the .hdbtable completely untouched (HDI rejects inline FK)', () => { + const before = read('AdminService.Missions_drafts.hdbtable'); patchDraftCascadeConstraints(tmp); - const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); - // The separator comma must be attached to PRIMARY KEY(ID), not on its own line. - expect(sql).toMatch(/PRIMARY KEY\(ID\),\s*\n\s*FOREIGN KEY/i); - // Confirm there is no bare leading comma on its own line before FOREIGN KEY. - expect(sql).not.toMatch(/^\s*,\s*\n\s*FOREIGN KEY/im); + expect(read('AdminService.Missions_drafts.hdbtable')).toBe(before); + expect(before).not.toMatch(/FOREIGN KEY/i); }); - it('skips draft tables without the FK column', () => { + it('skips draft tables without the FK column (no constraint file)', () => { patchDraftCascadeConstraints(tmp); - const sql = fs.readFileSync(path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), 'utf8'); - expect(sql).not.toMatch(/ON DELETE CASCADE/i); + expect(exists('AdminService.NoFk_drafts.hdbconstraint')).toBe(false); }); - it('is idempotent — second run adds no duplicate FK', () => { - patchDraftCascadeConstraints(tmp); - const first = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + it('is idempotent — second run rewrites identical content', () => { patchDraftCascadeConstraints(tmp); - expect(fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8')).toBe(first); + const first = read('AdminService.Missions_drafts.hdbconstraint'); + const r2 = patchDraftCascadeConstraints(tmp); + expect(read('AdminService.Missions_drafts.hdbconstraint')).toBe(first); + // Second run detects the identical artifact and skips the rewrite. + expect(r2.skipped).toContain('AdminService.Missions_drafts.hdbtable'); }); it('throws when no draft tables are found', () => { From 6f117e61fa4045368a60eefd9051aa7991549cba Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 20:43:40 -0600 Subject: [PATCH 42/42] =?UTF-8?q?revert(db):=20remove=20draft=20cascade-co?= =?UTF-8?q?nstraint=20patch=20=E2=80=94=20unneeded=20while=20dad=20delete?= =?UTF-8?q?=20is=20off,=20blocking=20deploy=20(#2993)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2993 build patch generated ON DELETE CASCADE constraints for draft tables to support CAP 10.2's optimized draft deletion (dad strategy / cds.fiori.draft_admin_integrity). But: 1. The constraints are ONLY needed when draft_admin_integrity is ON. It is OFF (#2992) and staying off — orphan cleanup is handled by scripts/cleanup-draft-orphans.cjs (#2994) instead. So the constraints provide zero value right now. 2. Both delivery forms failed HDI: inline-in-.hdbtable (error 8250014), then standalone .hdbconstraint (error 8212001 — the ON clause needs the base .hdbtable in the same make graph, which a delta deploy doesn't provide). Making it work is unresolved HDI-delta research. 3. It aborts tutorials-db-deployer on every deploy, blocking urgent PROD fixes (the #2909 draft-edit fix + #2995/#3000 health-gate fixes) from reaching DEV and then prod. Remove the build-time patch entirely: the .deploy/mta.yaml builder line, the script, and its unit test. Re-enabling the dad strategy later is tracked by #2993 and requires solving the HDI make-graph dependency first (likely a CAP-native constraint mechanism, not a hand-rolled patch). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .deploy/mta.yaml | 3 - scripts/patch-draft-cascade-constraints.cjs | 123 ------------------ .../patch-draft-cascade-constraints.test.js | 85 ------------ 3 files changed, 211 deletions(-) delete mode 100644 scripts/patch-draft-cascade-constraints.cjs delete mode 100644 test/unit/patch-draft-cascade-constraints.test.js diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index bb32c1a56..1e06c8326 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -51,9 +51,6 @@ build-parameters: - bash -c "cd .. && npm run prebuild:parsers-bundle" - bash -c "cd .. && node scripts/bundle-shared.cjs && npx cds build --production" - bash -c "cd .. && node -e 'for (const p of [\"gen/db/src/gen/.hdiconfig\",\"gen/db-qa/src/gen/.hdiconfig\"]) { const fs=require(\"fs\"); const d=JSON.parse(fs.readFileSync(p,\"utf8\")); d.file_suffixes=d.file_suffixes||{}; d.file_suffixes.hdbprocedure={plugin_name:\"com.sap.hana.di.procedure\"}; fs.writeFileSync(p, JSON.stringify(d,null,2)+\"\\n\"); }'" - # Patch prod draft tables with ON DELETE CASCADE FK (#2993). Prod tree only — - # gen/db-qa emits no draft tables so it is intentionally not patched. - - bash -c "cd .. && node scripts/patch-draft-cascade-constraints.cjs gen/db/src/gen" - bash -c "rm -f ../gen/db-qa/src/gen/sap.changelog.*.hdbindex" # Build app/explore + emit the bundle manifest into hugo/data/. # Hugo reads site.Data.explore_bundle at template-render time diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs deleted file mode 100644 index 248971f22..000000000 --- a/scripts/patch-draft-cascade-constraints.cjs +++ /dev/null @@ -1,123 +0,0 @@ -// scripts/patch-draft-cascade-constraints.cjs -// Build-time generator: emits a standalone *.hdbconstraint artifact per Fiori -// draft table so SAP HANA enforces ON DELETE CASCADE of draft rows when their -// DraftAdministrativeData record is removed (the `dad` draft delete strategy / -// cds.fiori.draft_admin_integrity). -// -// Background (#2993): the FK must NOT be inlined into the generated -// *_drafts.hdbtable — HDI rejects that with error 8250014 -// ("this artifact type must not have references to other objects"). The correct -// HDI artifact is a separate .hdbconstraint (plugin com.sap.hana.di.constraint, -// already registered in the generated .hdiconfig). -// -// The referenced table DRAFT_DRAFTADMINISTRATIVEDATA is created by the CAP -// runtime, but it lives in the SAME HDI container schema as the draft tables and -// has primary key DRAFTUUID (verified against the deployed DEV container), so -// the ALTER TABLE ... ADD CONSTRAINT that HDI renders at deploy time resolves. -// -// .hdbconstraint file format (confirmed from SAP-samples: hana-shine-xsa -// core-db/src/data/PO_CONST.hdbconstraint and others) — SQL constraint body with -// NO `ALTER TABLE ... ADD CONSTRAINT` verb: -// -// CONSTRAINT -// ON "" FOREIGN KEY ("") -// REFERENCES "" ("") ON DELETE CASCADE -// -// Usage (CLI): -// node scripts/patch-draft-cascade-constraints.cjs [genDir] -// genDir defaults to gen/db/src/gen -// -// Exported API: -// patchDraftCascadeConstraints(genDir) -> { patched: string[], skipped: string[] } - -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -// Bare (unquoted) in the generated .hdbtable → HANA folds to UPPERCASE. -const FK_COLUMN_SRC = 'DraftAdministrativeData_DraftUUID'; -const FK_COLUMN = 'DRAFTADMINISTRATIVEDATA_DRAFTUUID'; -const PARENT_TABLE = 'DRAFT_DRAFTADMINISTRATIVEDATA'; -const PARENT_COLUMN = 'DRAFTUUID'; - -// Extract the physical table name from `COLUMN TABLE (` (first line). -function tableNameOf(hdbtableContent) { - const m = hdbtableContent.match(/COLUMN\s+TABLE\s+("?)([A-Za-z0-9_.]+)\1\s*\(/); - return m ? m[2] : null; -} - -/** - * For each *_drafts.hdbtable under genDir that carries the DraftAdministrativeData - * FK column, write a sibling *_drafts.hdbconstraint declaring the ON DELETE - * CASCADE foreign key. Leaves the .hdbtable untouched. - * - * @param {string} genDir Path to the gen dir (default gen/db/src/gen). - * @returns {{ patched: string[], skipped: string[] }} - * @throws {Error} when no *_drafts.hdbtable files are found. - */ -function patchDraftCascadeConstraints(genDir) { - const entries = fs.readdirSync(genDir); - const draftFiles = entries.filter((f) => f.endsWith('_drafts.hdbtable')); - if (draftFiles.length === 0) { - throw new Error( - `no *_drafts.hdbtable files found in ${genDir} — run cds build --production first` - ); - } - - const patched = []; - const skipped = []; - - for (const filename of draftFiles) { - const filepath = path.join(genDir, filename); - const content = fs.readFileSync(filepath, 'utf8'); - - // Only tables that actually carry the draft-admin FK column. - if (!content.includes(FK_COLUMN_SRC)) { - skipped.push(filename); - continue; - } - - const table = tableNameOf(content); - if (!table) { - // Can't determine the table name — skip rather than emit a broken artifact. - skipped.push(filename); - continue; - } - - // Deterministic constraint name, bounded well under HANA's 127-char limit. - const constraintName = `${table}_dadFkCascade`; - const constraintFile = filename.replace(/\.hdbtable$/, '.hdbconstraint'); - const constraintPath = path.join(genDir, constraintFile); - - const body = - `CONSTRAINT ${constraintName}\n` + - `ON "${table}" FOREIGN KEY ("${FK_COLUMN}")\n` + - `REFERENCES "${PARENT_TABLE}" ("${PARENT_COLUMN}") ON DELETE CASCADE\n`; - - // Idempotent: skip rewrite if an identical artifact already exists. - if (fs.existsSync(constraintPath) && fs.readFileSync(constraintPath, 'utf8') === body) { - skipped.push(filename); - continue; - } - - fs.writeFileSync(constraintPath, body, { encoding: 'utf8' }); - patched.push(constraintFile); - } - - return { patched, skipped }; -} - -// CLI entry point -if (require.main === module) { - const genDir = process.argv[2] || 'gen/db/src/gen'; - try { - const result = patchDraftCascadeConstraints(genDir); - console.log(`wrote ${result.patched.length} .hdbconstraint / skipped ${result.skipped.length}`); - } catch (err) { - console.error(err.message); - process.exit(1); - } -} - -module.exports = { patchDraftCascadeConstraints }; diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js deleted file mode 100644 index 6ddfdc91e..000000000 --- a/test/unit/patch-draft-cascade-constraints.test.js +++ /dev/null @@ -1,85 +0,0 @@ -// test/unit/patch-draft-cascade-constraints.test.js -// Unit tests for the build-time cascade-constraint generator (#2993). -// No CDS boot — pure file-system fixture only. -// -// The generator emits a standalone *.hdbconstraint per draft table (NOT an -// inline FK in the .hdbtable — HDI rejects that, error 8250014). - -import { describe, it, expect, beforeEach } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { patchDraftCascadeConstraints } from '../../scripts/patch-draft-cascade-constraints.cjs'; - -const FIXTURE_WITH_FK = `COLUMN TABLE AdminService_Missions_drafts ( - ID NVARCHAR(36) NOT NULL, - IsActiveEntity BOOLEAN, - HasActiveEntity BOOLEAN, - HasDraftEntity BOOLEAN, - DraftAdministrativeData_DraftUUID NVARCHAR(36) NOT NULL, - PRIMARY KEY(ID) -)`; - -const FIXTURE_WITHOUT_FK = `COLUMN TABLE AdminService_NoFk_drafts ( - ID NVARCHAR(36) NOT NULL, - IsActiveEntity BOOLEAN, - PRIMARY KEY(ID) -)`; - -let tmp; -let emptyTmp; - -const tablePath = (name) => path.join(tmp, name); -const read = (name) => fs.readFileSync(path.join(tmp, name), 'utf8'); -const exists = (name) => fs.existsSync(path.join(tmp, name)); - -beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-test-')); - emptyTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-empty-')); - fs.writeFileSync(tablePath('AdminService.Missions_drafts.hdbtable'), FIXTURE_WITH_FK, 'utf8'); - fs.writeFileSync(tablePath('AdminService.NoFk_drafts.hdbtable'), FIXTURE_WITHOUT_FK, 'utf8'); -}); - -describe('patchDraftCascadeConstraints', () => { - it('writes a sibling .hdbconstraint with the ON DELETE CASCADE FK', () => { - const r = patchDraftCascadeConstraints(tmp); - expect(exists('AdminService.Missions_drafts.hdbconstraint')).toBe(true); - expect(r.patched).toContain('AdminService.Missions_drafts.hdbconstraint'); - - const c = read('AdminService.Missions_drafts.hdbconstraint'); - // CONSTRAINT / ON "" FOREIGN KEY (...) / REFERENCES ... CASCADE - expect(c).toMatch(/^CONSTRAINT\s+\S+/m); - expect(c).toMatch(/ON\s+"AdminService_Missions_drafts"\s+FOREIGN KEY\s*\("DRAFTADMINISTRATIVEDATA_DRAFTUUID"\)/); - expect(c).toMatch(/REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\s*\("DRAFTUUID"\)\s+ON DELETE CASCADE/); - // No ALTER TABLE / ADD CONSTRAINT verb — .hdbconstraint is the bare body. - expect(c).not.toMatch(/ALTER\s+TABLE/i); - expect(c).not.toMatch(/ADD\s+CONSTRAINT/i); - // Quoted UPPERCASE column (HANA folds the bare declaration to upper). - expect(c).not.toMatch(/"DraftAdministrativeData_DraftUUID"/); - }); - - it('leaves the .hdbtable completely untouched (HDI rejects inline FK)', () => { - const before = read('AdminService.Missions_drafts.hdbtable'); - patchDraftCascadeConstraints(tmp); - expect(read('AdminService.Missions_drafts.hdbtable')).toBe(before); - expect(before).not.toMatch(/FOREIGN KEY/i); - }); - - it('skips draft tables without the FK column (no constraint file)', () => { - patchDraftCascadeConstraints(tmp); - expect(exists('AdminService.NoFk_drafts.hdbconstraint')).toBe(false); - }); - - it('is idempotent — second run rewrites identical content', () => { - patchDraftCascadeConstraints(tmp); - const first = read('AdminService.Missions_drafts.hdbconstraint'); - const r2 = patchDraftCascadeConstraints(tmp); - expect(read('AdminService.Missions_drafts.hdbconstraint')).toBe(first); - // Second run detects the identical artifact and skips the rewrite. - expect(r2.skipped).toContain('AdminService.Missions_drafts.hdbtable'); - }); - - it('throws when no draft tables are found', () => { - expect(() => patchDraftCascadeConstraints(emptyTmp)).toThrow(/no .*_drafts\.hdbtable/i); - }); -});