Prompt file imported from SPWizard01/spfx-extensions-core (
.github/prompts/plan-configSettingsRuntimeSplit.prompt.md). Fill in{{DEBUG_KEY_APP_PREFIX}},{{APP_VERSION}},{{prefix}}before use. Copyright stays with the author.
Plan: Split config settings from runtime cache + centralize defaults merge
Today SPFxExtensionConfig (one IDB store) and ConfigurationNames (one enum) conflate 9 keys of 4 kinds: 4 real settings, 1 bootstrap primitive (AppCatalogUrl), 3 ensure-caches (SPFxDataSite, ConfiguratorPageData, AppWhiteList), and 1 version marker. MINIMAL_CONFIG_COUNT (=9) only reaches 9 via side effects of 5 unrelated services, while createDefaultListItems seeds only 4 — so it's fragile and the cached vs fresh read paths return different shapes. The fix: settings live in their own store and always resolve as defaults ⊕ API (API wins, defaults fill gaps); the bootstrap primitive + ensure-caches move to a new SPFxRuntimeCache store; the Version marker is removed entirely in favour of a version-suffixed DB name (IDB schema stays at v1); and MINIMAL_CONFIG_COUNT is deleted.
Phase 1 — Types & pure defaults
Files: defaultConfig.ts, configurationList.ts
- Shrink
ConfigurationNamesto settings only (RootCDNLocation,InterceptHistory,EnableAppWhiteList,UsePublicCDNForManifests). This narrowsConfigurationListBaseData["Title"]so the compiler flags every misplaced writer. - Add
RuntimeCacheNames(AppCatalogUrl,SPFxDataSite,ConfiguratorPageData,AppWhiteList) + aRuntimeCacheBaseData/RuntimeCacheDatatype. NoVersionkey — the version marker is dropped entirely (replaced by the version-suffixed DB name in Phase 2). - Replace the mutating
getCoreDefaultConfiguration()with a puregetDefaultSettings(rootCdnDefault)returning a fresh, complete array every call (every setting has a default;RootCDNLocationinjected from the caller). Fixes the shared-array mutation bug and drops the oddVersion: BUILD_DATEenum value.
Phase 2 — IDB store split
Files: coreIdbService.ts + build-time globals in build.ts, serve.ts, vitest.config.ts, globals.d.ts (depends on Phase 1)
- Version-suffix the DB name; keep the schema at v1 (no bump, no migration).
- Set
DBNAME = {{DEBUG_KEY_APP_PREFIX}}COREDB_{{APP_VERSION}}and keepopenDB(DBNAME, 1, …).APP_VERSION= thepackage.jsonversion injected as a build-time global (mirror the existingBUILD_DATEdefinein build.ts / serve.ts / vitest.config.ts and declare it in globals.d.ts). The name is stable during development — it only rolls to a fresh DB when the version is bumped (manual, intentional). A new release ⇒ new DB name ⇒ the existingoldVersion === 0upgrade branch creates all stores fresh. - Add
SPFxRuntimeCacheto the schema /StoreNamesand create it inside that sameoldVersion === 0branch. AddgetRuntimeCacheItem/addOrUpdateRuntimeCache(with eviction) mirroring the config helpers; retype the settings helpers to the settings union. - Expose an
isFreshCoreDBflag (settruewhenupgraderuns witholdVersion === 0) for Phase 3's cache-bust trigger.
- Set
Phase 3 — Move the 4 non-settings + drop the version marker
(depends on Phase 2; files parallel)
- appCatalogService.ts
AppCatalogUrl, configurationWebService.tsSPFxDataSite, pageService.tsConfiguratorPageData, whiteListService.tsAppWhiteList→ switch their get/put to the runtime helpers. - browserCache.ts: rewrite
cleanCacheOnUpgradeto drop theVersionmarker and thegetCoreConfig()call entirely. Trigger thewindow.caches(HTTP asset) bust offisFreshCoreDB— a fresh version-suffixed DB is the “new deployment” signal, so the once-per-version bust is preserved with no stored marker and no heavy config init. (cleanStorageCache(…, true)still only reloads when it actually deletes something, so first-time users don't reload.)
Phase 4 — Centralize merge + delete the count
File: configurationListService.ts (depends on 1–3)
- Add
mergeSettings(items)=getDefaultSettings(SPFX_EXTENSIONS_SITE_URL)overlaid byitems. - Rewrite
getConfigurationListDataCachedto gate on settings-store emptiness (not a magic count): empty → holdCONFIG_BOOTSTRAP_LOCK,ensureConfigurationList(), seed only if it's a new list, read API, cache; then returnmergeSettings. Non-empty →mergeSettings(cache). getConfigurationListData(fresh) returnsmergeSettings(api);commitConfigItemswrites raw to IDB but sets the memo tomergeSettings(raw).createDefaultListItemsusesgetDefaultSettings(one-time, admin-gated seed). DeleteMINIMAL_CONFIG_COUNT.
Phase 5 — Reader cleanup
File: coreConfigService.ts (depends on Phase 4)
getBooleanCoreConfig: drop the IDB self-heal write (merge guarantees presence).getRootCDNLocation: simplify the?? fallbacksince the value is always present.- Verify configWatcherService.ts needs no change (change-token hashing stays on raw API items; merge happens via
commitConfigItems).
Relevant files
- configurationListService.ts —
mergeSettings, gate rewrite, removeMINIMAL_CONFIG_COUNT,commitConfigItemsmerged memo,createDefaultListItems - defaultConfig.ts — split enums, pure
getDefaultSettings - coreIdbService.ts — new store + helpers + version-suffixed DB name (schema v1) +
isFreshCoreDBflag - build.ts / serve.ts / vitest.config.ts / globals.d.ts — inject the
APP_VERSIONbuild-time global - appCatalogService.ts / configurationWebService.ts / pageService.ts / whiteListService.ts / browserCache.ts — move keys to runtime store
- coreConfigService.ts — reader simplification
Verification
bunx tsc -p tsconfig.json(or the errors tool) — the narrowedConfigurationNamesshould surface every writer that must move; zero errors when done.bun run test— full suite green.- Optional new pure unit test:
getDefaultSettingsreturns a fresh array (no cross-call mutation) andmergeSettingsgives API precedence + fills missing keys with defaults (no IDB needed). - Manual: cold load on an uninitialized tenant (list/page/whitelist created + seeded), warm reload (served from cache), edit a setting in the SP list (watcher propagates via
configChange), and confirm a newly added setting key resolves to its default without a list entry. - Manual: bump the
package.jsonversion, rebuild, reload — confirm a fresh…_<newVersion>DB is created and stalewindow.cachesentries are busted (page reloads once).
Decisions
- New
SPFxRuntimeCachestore for non-settings. DB schema stays version 1; the DB name is suffixed with the package version (SPFXEXT_COREDB_{{APP_VERSION}}) so each release gets a fresh DB — no upgrade branches, no migrations. TheVersionmarker is removed; thewindow.cachesbust is triggered by a fresh versioned DB (isFreshCoreDB). - In-memory
defaults ⊕ APImerge for all users; seed writes stay admin-gated (only on new-list creation) — regular users get working defaults without 403s. - Keep both
AppCatalogUrl(discovered bootstrap primitive, control plane) andRootCDNLocation(overridable setting, data plane); they only share a default. - Out of scope: whitelist data model, configurator UI, cleanup of orphaned old-version DBs (see Further considerations).
Context: AppCatalogUrl vs RootCDNLocation
They look redundant because they default to the same folder, but answer two different questions:
AppCatalogUrl→ control plane (where settings live). Discovered from tenant settings (SP_TenantSettings_Current.CorporateCatalogUrl); buildsSPFX_EXTENSIONS_SITE_URL = {AppCatalogUrl}/SPFxExtensionsData, used everywhere for infrastructure (config list, whitelist list, configurator page, request digests). The app catalog is a single tenant-level site — discovered infrastructure, not a knob.RootCDNLocation→ data plane (where apps are served from). A real setting consumed only bygetRootCDNLocation()→{RootCDNLocation}/SPFxExtensions/, the folder scanned for the rootcollectionconfig.json/ manifests (txtAppsService.ts, manifestWatcherService.ts). Overridable so app bundles can live on a different site/library/CDN (pairs withUsePublicCDNForManifests).
Example: config/whitelist always live in /sites/appcatalog/SPFxExtensionsData (from AppCatalogUrl), but an admin could set RootCDNLocation to https://cdn.contoso.com/spfx-apps so JS bundles are served from a CDN. Default: both point at the same SPFxExtensionsData site, hence the apparent overlap.
Current key inventory (SPFxExtensionConfig store, keyed by Title)
| Key | Writer | Nature | Target store |
|---|---|---|---|
RootCDNLocation |
list seed | SETTING | SPFxExtensionConfig |
InterceptHistory |
list seed | SETTING | SPFxExtensionConfig |
EnableAppWhiteList |
list seed / getBooleanCoreConfig |
SETTING | SPFxExtensionConfig |
UsePublicCDNForManifests |
list seed / getBooleanCoreConfig |
SETTING | SPFxExtensionConfig |
AppCatalogUrl |
appCatalogService (tenant API) |
BOOTSTRAP primitive | SPFxRuntimeCache |
SPFxDataSite |
configurationWebService (web API) |
ENSURE-CACHE | SPFxRuntimeCache |
ConfiguratorPageData |
pageService (pages API) |
ENSURE-CACHE | SPFxRuntimeCache |
AppWhiteList |
whiteListService (list ensure resp) |
ENSURE-CACHE | SPFxRuntimeCache |
Version |
browserCache (BUILD_DATE) |
INTERNAL marker | removed (replaced by version-suffixed DB name) |
Further considerations
- Cold-cache re-bootstrap: with the emptiness gate, if the SP list ever returns 0 items the store stays empty and re-bootstraps each memo cycle. Seeding guarantees ≥4 on new lists so it's a non-issue in practice — but a lightweight
Bootstrappedmarker in the runtime store could be added as belt-and-suspenders. Recommend: skip the marker unless desired. ensureConfigurationList()on cold reads: still runs one cheap 404-check GET on every cold read (rare, since the 5-min watcher keeps the cache warm). Recommend: leave as-is; optionally gate behind a runtime-store "ensured" marker later.cleanCacheOnUpgrade()decoupling: it is the first call ininitCoreServices()and currently triggers full config init (web/whitelist/page ensure) just to readVersion. The newisFreshCoreDBtrigger removes both the stored marker and thegetCoreConfig()call — verify no ordering relies on that incidental init (config init is promise-memoized and runs later on demand).- Suffix source — DECIDED: package version (
APP_VERSION). DB name =SPFXEXT_COREDB_{{APP_VERSION}}; it stays stable during development and only rolls to a fresh DB when thepackage.jsonversion is bumped (manual, intentional). (BUILD_DATEand aDEBUGhybrid were considered and rejected to avoid per-build cache churn.) - Dev-time schema evolution: with a package-version suffix, changing the stores without bumping the version leaves the old DB in place (stale schema). Mitigation: bump the version, clear IDB manually, or use the
DEBUG→BUILD_DATEhybrid above. - Orphaned DBs & open tabs: old version-suffixed DBs linger — optionally delete non-current
{{prefix}}COREDB_*viaindexedDB.databases()on open. Also theblockinghandler no longer fires across versions (different DB names don't block each other), so already-open tabs won't get the “new version, reload” prompt until they navigate — acceptable, but noted.
