From 4cbef2050b8f7afacc994bdb2b8765150c19cee1 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:49:41 -0600 Subject: [PATCH 1/9] 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 8fea1066dda35b39ac70aa4f83a45546311f72ee Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:55:42 -0600 Subject: [PATCH 2/9] 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 2bcf77f170eff013bc8ef169ca794e99faa8db75 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:05:09 -0600 Subject: [PATCH 3/9] 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 77280e08ba04812b931da94d1a06b27ef370dcd0 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:42:34 -0600 Subject: [PATCH 4/9] 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 5/9] 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 d61f64bc7d8f3558ff4541b0d1535c01a43d2412 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:15:00 -0600 Subject: [PATCH 6/9] 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 286f8d68bc056a58c73f02bcf19de8f3f912b4c2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:33:22 -0600 Subject: [PATCH 7/9] 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 8/9] 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 c575f3dbdff3a998667ada7947ccd4bfd4d82549 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 17:20:41 -0600 Subject: [PATCH 9/9] 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