External Channels Subsystem
This document covers the P1 foundation of the external-channels subsystem: the Channels source-of-truth entity, re-ingest CLI, /build/channels feed, /channels Hugo directory page, and promote-channels verb-lane fill. P2–P4 work (editorial collections, topic crosswalk, community submissions) are out of scope here and tracked separately.
Data model
Channels entity
- File:
db/channels.cds - Namespace:
com.sap.developers.ims - Persistence: annotated
@cds.persistence.journalindb/persistence.cds— deploys as.hdbmigrationtableso schema evolution usesALTER TABLErather than drop-and-recreate. - Aggregation: pulled into the global model via
using from './channels'indb/schema.cds.
Key design points:
- Unique dedup key:
sourceId(String 40) — theidfield from the raw research dataset;@assert.unique.sourceIdenforces it at DB level. - Array columns:
relatedUrls,aliases,focusAreas,tagsare declared asarray of String(...). On SQLite these come back as native arrays; on HANA they are stored as JSON NCLOBs. The/build/channelsfeed handler (srv/server.js) appliesJSON.parsefor the HANA case;seed-channelscallscds.linked(cds.model ?? ...).entities('com.sap.developers.ims')to resolve the entity through CAP's linked model — thecds.linked()/entities(NS)pattern is required for correct array round-tripping. - Admin-curated columns (never touched by re-ingest):
isPublished,isFeatured,editorialNote,linkStatus,linkStatusOverride,lastChecked. These are preserved across every re-seed so editorial decisions survive data refreshes. - Enum columns:
ownerType(ChannelOwnerType) andstatus(ChannelStatus) both carry@assert.range— invalid values are rejected at the service layer.
Re-ingest CLI (seed-channels)
bash
npm run seed-channels -- --file <path-to-dataset.json> --commit- Script:
scripts/seed-channels.cjs(npm script:cds bind --exec -- node scripts/seed-channels.cjs) - Normalizer:
srv/lib/channels/normalize.js—normalizeChannel(raw, ingestBatch)cleans citation markers, maps free-textownerType/statusto enum values, and computes acontentHash(SHA-256 of source-owned fields in sorted-key canonical JSON).
Behaviour
| Situation | Action |
|---|---|
| Row not in DB | INSERT with a new cds.utils.uuid() as ID |
Row in DB, hash unchanged (no --force) | Skip (skipped++) |
Row in DB, hash changed (or --force) | UPDATE source-owned fields only; curated columns are deleted from the patch before writing |
| Row in DB but absent from this ingest batch | Soft-retire: status = 'Archived'; curated columns untouched |
Flags
| Flag | Effect |
|---|---|
--file <path> | Path to JSON dataset (default: d:/tmp/External-SAP-Channels-Complete.json) |
--commit | Write to DB; omit for dry-run |
--force | Re-process all rows regardless of contentHash match |
The script requires a live DB binding (cds bind --exec). Use npm run seed-channels rather than invoking the script directly.
Directory data path
/build/channels (CAP Express feed)
↓
scripts/fetch-channels.ts → hugo/data/channels.json
↓
/channels Hugo page → channels-directory Vue island/build/channels feed
- Location:
srv/server.jsExpress middleware (around line 418) - Auth: public, unauthenticated;
Cache-Control: public, max-age=60 - Filtering: returns only rows where
isPublished = true; then excludes any where the effectivelinkStatusis'BROKEN'(override wins:linkStatusOverride || linkStatus) - Array parsing:
focusAreas,tags,relatedUrls,aliasesare passed through aparseArrhelper that callsJSON.parsefor HANA string values and passes through native arrays from SQLite - Response shape:
{ channels: Channel[], buildAt: string }
fetch-channels.ts
- File:
scripts/fetch-channels.ts - npm script:
fetch-channels(tsx scripts/fetch-channels.ts) - Wired into
build:all: yes —npm run fetch-channelsis one of the steps in thebuild:allscript inpackage.json - Output:
hugo/data/channels.json(created withmkdirSyncif missing) - Fail-open: if the CAP feed is unreachable (e.g., during a cold build before
cds watchstarts), the script writes an empty-channels payload witherrorset and a warning to stdout — the build continues; the/channelspage renders with zero items
/channels Hugo page
- Content directory:
hugo/content/channels/(_index.mdsets title + description) - Layout:
hugo/layouts/channels/list.html— renders the channel list JSON into a<script id="channels-data" type="application/json">block, mounts<div data-island="channels-directory">, and provides a<noscript>fallback list - Island loading:
<script type="module" src="{{ partial "island-src.html" "channels-directory" }}"></script>— uses theisland-src.htmlpartial (hashed path from the island manifest); never hardcode/js/channels-directory.js
channels-directory Vue island
- Location:
hugo-apps/src/channels-directory/ - Entry:
index.ts - Component:
ChannelsDirectory.vue - Filter logic:
filter.tsexportsfilterChannels(channels, state)wherestateis{ query?, category?, platform?, ownerScope? }. Facets:- category — exact match on
channel.category - platform — exact match on
channel.platform - ownerScope —
'sap'(onlyisSapOwned === true),'community'(onlyisSapOwned !== true), or'all' - query — case-insensitive substring match across
name,purpose, andtags
- category — exact match on
Verb-lane fill (promote-channels)
bash
npm run promote-channels- CLI wrapper:
scripts/promote-channels-to-shelves.cjs - npm script:
cds bind --exec -- node scripts/promote-channels-to-shelves.cjs - Core logic:
srv/lib/channels/promote-to-shelves.js— exportspromoteFeatured(db),mapChannelToShelf(channel),CATEGORY_TO_SHELF,FOCUS_TO_VERB
Mapping rules
mapChannelToShelf converts a channel row to { verb, shelf }:
- shelf from
CATEGORY_TO_SHELF[channel.category](default'REFERENCE') - community / third-party guard: if the computed shelf is
'START_HERE'butchannel.isSapOwned !== true, the shelf is downgraded to'REFERENCE'— community items never land inSTART_HERE - verb from
pickVerb(channel.focusAreas)which walksFOCUS_TO_VERB(ordered priority list of keyword arrays →INTEGRATE / OPERATE / AI / MODEL / BUILD / LEARN); default is'BUILD'
promoteFeatured(db) selects all rows with isFeatured = true, isPublished = true, maps each to a shelf/verb pair, and inserts into HomepageShelves with:
badge: 'THIRD_PARTY'when!isSapOwnedauthoringStatus: 'AI_SEEDED',isExternal: true,isActive: true,sortOrder: 500- Idempotent: upserts on
(verb, url)— existing rows are skipped, not overwritten
Admin surface
- OData projection:
AdminService.Channelsinsrv/admin-service.cds(line 296–297), annotated@odata.draft.enabled - FE app:
app/admin/channels/(UI5 Fiori Elements; bootstrapped frompackage.json+ui5.yaml) - Shell wiring:
app/admin-shell/scripts/admin-shell-overrides.jsregisters'channels'in the component list (explicit ordering) with router prefix'ch'. The shell manifest is generated byapp/admin-shell/scripts/generate-manifest.js; do NOT hand-edit the generatedmanifest.json— run the generator (triggered automatically atnpm run prebuild)
P1 scope / deferred to P2–P4
Items explicitly not in this subsystem yet:
- P2 — Editorial
ChannelCollections: curated groupings (e.g., "Getting Started", "CAP ecosystem") with their own Hugo/island surface - P3 —
ChannelTopicMapcrosswalk: per-topic bands on/topics/<name>pages wiring channels relevant to each topic - P4 —
ChannelSubmissions: community submission form + moderation loop - Nightly link-health extension: the existing link-health job already knows how to check URLs; wiring it to
Channels.url/linkStatusis a follow-up, not yet implemented. P1 already filtersBROKENchannels out of the feed so stale data is not surfaced to users.