configuration-management 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ Copyright 2025 Adobe. All rights reserved.
4
+ Licensed under the Apache License, Version 2.0
5
+ */
6
+
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+
10
+ const EXTENSION_POINT = 'commerce/backend-ui/1'
11
+ const INCLUDE_REL = 'node_modules/configuration-management/actions/configurations/ext.config.yaml'
12
+ const MARKER = '# configuration-management (auto-linked on npm install)'
13
+ const WEB_BOOTSTRAP_MARKER = 'configuration-management: auto-generated bootstrap'
14
+
15
+ const TEMPLATES_DIR = path.join(__dirname, 'templates')
16
+
17
+ function escapeRe (str) {
18
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
19
+ }
20
+
21
+ function findProjectRoot (startDir) {
22
+ let dir = startDir
23
+ while (dir && dir !== path.dirname(dir)) {
24
+ if (
25
+ fs.existsSync(path.join(dir, 'app.config.yaml')) ||
26
+ fs.existsSync(path.join(dir, 'web-src'))
27
+ ) {
28
+ return dir
29
+ }
30
+ dir = path.dirname(dir)
31
+ }
32
+ return null
33
+ }
34
+
35
+ function resolveProjectRoot () {
36
+ const initCwd = process.env.INIT_CWD
37
+ if (initCwd) {
38
+ const fromInit = findProjectRoot(initCwd)
39
+ if (fromInit) return fromInit
40
+ }
41
+ return findProjectRoot(process.cwd())
42
+ }
43
+
44
+ function readTemplate (name) {
45
+ return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8')
46
+ }
47
+
48
+ function writeIfMissingOrManaged (filePath, content, marker) {
49
+ if (fs.existsSync(filePath)) {
50
+ const existing = fs.readFileSync(filePath, 'utf8')
51
+ if (existing.includes(marker)) {
52
+ const dir = path.dirname(filePath)
53
+ fs.mkdirSync(dir, { recursive: true })
54
+ const changed = existing !== content
55
+ if (changed) {
56
+ fs.writeFileSync(filePath, content, 'utf8')
57
+ }
58
+ return { changed, reason: changed ? 'updated-managed' : 'unchanged-managed', path: filePath }
59
+ }
60
+ return { changed: false, reason: 'skipped-custom-file', path: filePath }
61
+ }
62
+ const dir = path.dirname(filePath)
63
+ fs.mkdirSync(dir, { recursive: true })
64
+ fs.writeFileSync(filePath, content, 'utf8')
65
+ return { changed: true, reason: 'written', path: filePath }
66
+ }
67
+
68
+ function writeWebSrcIndex (projectRoot) {
69
+ const filePath = path.join(projectRoot, 'web-src', 'src', 'index.js')
70
+ const content = readTemplate('web-src-index.js')
71
+
72
+ if (!fs.existsSync(filePath)) {
73
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
74
+ fs.writeFileSync(filePath, content, 'utf8')
75
+ return { changed: true, reason: 'written', path: filePath }
76
+ }
77
+
78
+ const existing = fs.readFileSync(filePath, 'utf8')
79
+ if (existing.includes(WEB_BOOTSTRAP_MARKER)) {
80
+ const changed = existing !== content
81
+ if (changed) {
82
+ fs.writeFileSync(filePath, content, 'utf8')
83
+ }
84
+ return { changed, reason: changed ? 'updated-managed' : 'unchanged-managed', path: filePath }
85
+ }
86
+
87
+ if (existing.includes('configuration-management/web')) {
88
+ fs.writeFileSync(filePath, content, 'utf8')
89
+ return { changed: true, reason: 'migrated-stale-bootstrap', path: filePath }
90
+ }
91
+
92
+ return { changed: false, reason: 'skipped-custom-file', path: filePath }
93
+ }
94
+
95
+ function alreadyLinked (content) {
96
+ return content.includes('configuration-management/actions/configurations/ext.config.yaml')
97
+ }
98
+
99
+ function hasExtensionPoint (content) {
100
+ return /^[ \t]*commerce\/backend-ui\/1:/m.test(content)
101
+ }
102
+
103
+ function buildExtensionBlock () {
104
+ return [
105
+ MARKER,
106
+ 'extensions:',
107
+ ` ${EXTENSION_POINT}:`,
108
+ ` $include: ${INCLUDE_REL}`
109
+ ].join('\n')
110
+ }
111
+
112
+ function updateExistingExtensionBlock (content) {
113
+ const match = content.match(/^([ \t]*)commerce\/backend-ui\/1:/m)
114
+ if (!match) return null
115
+
116
+ const indent = match[1]
117
+ const includeIndent = `${indent} `
118
+ const blockRe = new RegExp(
119
+ `^${escapeRe(indent)}commerce/backend-ui/1:[ \\t]*\\n` +
120
+ `(?:${escapeRe(includeIndent)}\\$include:[^\\n]*\\n)?`,
121
+ 'm'
122
+ )
123
+ const replacement =
124
+ `${indent}${EXTENSION_POINT}:\n${includeIndent}$include: ${INCLUDE_REL}\n`
125
+ const next = content.replace(blockRe, replacement)
126
+ return next !== content ? next : null
127
+ }
128
+
129
+ function patchAppConfig (content) {
130
+ if (alreadyLinked(content)) {
131
+ return { content, changed: false, reason: 'already-linked' }
132
+ }
133
+
134
+ if (hasExtensionPoint(content)) {
135
+ const updated = updateExistingExtensionBlock(content)
136
+ if (updated) {
137
+ return { content: updated, changed: true, reason: 'updated-existing-extension' }
138
+ }
139
+ return { content, changed: false, reason: 'extension-exists-unmodified' }
140
+ }
141
+
142
+ if (/^extensions:[ \t]*\n/m.test(content)) {
143
+ const injection = ` ${EXTENSION_POINT}:\n $include: ${INCLUDE_REL}\n`
144
+ const next = content.replace(/^extensions:[ \t]*\n/m, `extensions:\n${injection}`)
145
+ if (next !== content) {
146
+ return { content: next, changed: true, reason: 'added-under-extensions' }
147
+ }
148
+ }
149
+
150
+ if (!/^extensions:/m.test(content)) {
151
+ const trimmed = content.replace(/\s+$/, '')
152
+ const separator = trimmed.length > 0 ? '\n\n' : ''
153
+ return {
154
+ content: `${trimmed}${separator}${buildExtensionBlock()}\n`,
155
+ changed: true,
156
+ reason: 'appended'
157
+ }
158
+ }
159
+
160
+ return { content, changed: false, reason: 'no-change' }
161
+ }
162
+
163
+ function setupAppConfig (projectRoot) {
164
+ const appConfigPath = path.join(projectRoot, 'app.config.yaml')
165
+ if (!fs.existsSync(appConfigPath)) {
166
+ return { changed: false, reason: 'no-app-config' }
167
+ }
168
+
169
+ const original = fs.readFileSync(appConfigPath, 'utf8')
170
+ const { content, changed, reason } = patchAppConfig(original)
171
+ if (!changed) {
172
+ return { changed: false, reason }
173
+ }
174
+
175
+ fs.writeFileSync(appConfigPath, content, 'utf8')
176
+ return { changed: true, reason, detail: INCLUDE_REL }
177
+ }
178
+
179
+ function setupWebSrc (projectRoot) {
180
+ const webSrcDir = path.join(projectRoot, 'web-src')
181
+ const results = []
182
+
183
+ const indexJs = writeWebSrcIndex(projectRoot)
184
+ results.push(indexJs)
185
+
186
+ const indexHtml = writeIfMissingOrManaged(
187
+ path.join(webSrcDir, 'index.html'),
188
+ readTemplate('web-src-index.html'),
189
+ 'configuration-management'
190
+ )
191
+ results.push(indexHtml)
192
+
193
+ const excRuntime = writeIfMissingOrManaged(
194
+ path.join(webSrcDir, 'src', 'exc-runtime.js'),
195
+ readTemplate('exc-runtime.js'),
196
+ 'Module Runtime: Needs to be within an iframe'
197
+ )
198
+ results.push(excRuntime)
199
+
200
+ const configPath = path.join(webSrcDir, 'src', 'config.json')
201
+ if (!fs.existsSync(configPath)) {
202
+ fs.mkdirSync(path.dirname(configPath), { recursive: true })
203
+ fs.writeFileSync(configPath, '{}\n', 'utf8')
204
+ results.push({ changed: true, reason: 'created-config-json', path: configPath })
205
+ }
206
+
207
+ const changed = results.some((r) => r.changed)
208
+ return { changed, results }
209
+ }
210
+
211
+ function main () {
212
+ if (process.env.CONFIGURATION_MANAGEMENT_SKIP_SETUP === '1') {
213
+ return
214
+ }
215
+
216
+ const projectRoot = resolveProjectRoot()
217
+ if (!projectRoot) {
218
+ console.log(
219
+ '[configuration-management] No App Builder project found — skip setup. ' +
220
+ 'Run `npx configuration-management-setup` from your project root after `aio app init`.'
221
+ )
222
+ return
223
+ }
224
+
225
+ const app = setupAppConfig(projectRoot)
226
+ if (app.changed) {
227
+ console.log(
228
+ `[configuration-management] Updated app.config.yaml (${app.reason}):\n` +
229
+ ` $include: ${app.detail}`
230
+ )
231
+ }
232
+
233
+ const web = setupWebSrc(projectRoot)
234
+ for (const r of web.results || []) {
235
+ if (r.changed) {
236
+ console.log(`[configuration-management] Wrote ${r.path}`)
237
+ }
238
+ }
239
+
240
+ if (!app.changed && !web.changed) {
241
+ console.log('[configuration-management] Project already configured.')
242
+ }
243
+ }
244
+
245
+ if (require.main === module) {
246
+ try {
247
+ main()
248
+ } catch (err) {
249
+ console.error('[configuration-management] setup failed:', err.message)
250
+ process.exitCode = 1
251
+ }
252
+ }
253
+
254
+ module.exports = {
255
+ patchAppConfig,
256
+ setupAppConfig,
257
+ setupWebSrc,
258
+ INCLUDE_REL,
259
+ EXTENSION_POINT
260
+ }
@@ -0,0 +1,14 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software distributed under
8
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ OF ANY KIND, either express or implied. See the License for the specific language
10
+ governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-disable-next-line */
14
+ (function(e,t){if(t.location===t.parent.location)throw new Error("Module Runtime: Needs to be within an iframe!");var o=function(e){var t=new URL(e.location.href).searchParams.get("_mr");return t||!e.EXC_US_HMR?t:e.sessionStorage.getItem("unifiedShellMRScript")}(t);if(!o)throw new Error("Module Runtime: Missing script!");if("https:"!==(o=new URL(decodeURIComponent(o))).protocol)throw new Error("Module Runtime: Must be HTTPS!");if(!/^(exc-unifiedcontent\.)?experience(-qa|-stage|-cdn|-cdn-stage)?\.adobe\.(com|net)$/.test(o.hostname)&&!/localhost\.corp\.adobe\.com$/.test(o.hostname))throw new Error("Module Runtime: Invalid domain!");if(!/\.js$/.test(o.pathname))throw new Error("Module Runtime: Must be a JavaScript file!");t.EXC_US_HMR&&t.sessionStorage.setItem("unifiedShellMRScript",o.toString());var n=e.createElement("script");n.async=1,n.src=o.toString(),n.onload=n.onreadystatechange=function(){n.readyState&&!/loaded|complete/.test(n.readyState)||(n.onload=n.onreadystatechange=null,n=void 0,"EXC_MR_READY"in t&&t.EXC_MR_READY())},e.head.appendChild(n)})(document,window);
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
6
+ <meta name="theme-color" content="#1473e6">
7
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
8
+ <link rel="icon" type="image/svg+xml" href="./favicon.svg">
9
+ <title>Sync Management</title>
10
+ </head>
11
+ <body>
12
+ <noscript>You need to enable JavaScript to run this app.</noscript>
13
+ <div id="root"></div>
14
+ <script src="./src/index.js" async="true" type="module"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,61 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ */
7
+ // configuration-management: auto-generated bootstrap (updated on npm install)
8
+
9
+ import 'core-js/stable'
10
+ import 'regenerator-runtime/runtime'
11
+
12
+ import React from 'react'
13
+ import { createRoot } from 'react-dom/client'
14
+
15
+ import Runtime, { init } from '@adobe/exc-app'
16
+ import { ConfigurationManagementApp as App, configureWeb } from 'configuration-management/web'
17
+ import actions from './config.json'
18
+ import 'configuration-management/web/styles.css'
19
+
20
+ configureWeb({ actionUrls: actions })
21
+
22
+ window.React = React
23
+
24
+ try {
25
+ require('./exc-runtime')
26
+ init(bootstrapInExcShell)
27
+ } catch (e) {
28
+ console.log('application not running in Adobe Experience Cloud Shell')
29
+ bootstrapRaw()
30
+ }
31
+
32
+ function renderApp (runtime, ims) {
33
+ createRoot(document.getElementById('root')).render(
34
+ React.createElement(App, { runtime, ims })
35
+ )
36
+ }
37
+
38
+ function bootstrapRaw () {
39
+ renderApp({ on: () => {} }, {})
40
+ }
41
+
42
+ function bootstrapInExcShell () {
43
+ const runtime = Runtime()
44
+ runtime.favicon = './favicon.svg'
45
+
46
+ runtime.on('ready', ({ imsOrg, imsToken, imsProfile }) => {
47
+ runtime.done()
48
+ renderApp(runtime, {
49
+ profile: imsProfile,
50
+ org: imsOrg,
51
+ token: imsToken
52
+ })
53
+ })
54
+
55
+ runtime.solution = {
56
+ icon: 'AdobeExperienceCloud',
57
+ title: 'Sync Management',
58
+ shortTitle: 'Sync'
59
+ }
60
+ runtime.title = 'Sync Management'
61
+ }
@@ -0,0 +1,241 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ */
7
+
8
+ const { getCommerceOauthClient } = require('./oauth1a')
9
+ const {
10
+ isValidPath,
11
+ toStateKey,
12
+ buildInheritanceChain,
13
+ normalizeScope,
14
+ normalizeScopeId
15
+ } = require('./system-config-shared')
16
+ const { getClient } = require('./abdb-helper')
17
+ const { isEncrypted, decrypt } = require('./system-config-crypto')
18
+
19
+ const COLLECTION = 'system_config_data'
20
+ const CACHE_TTL_MS = 5 * 60 * 1000
21
+
22
+ // Per-lookup result cache.
23
+ const cache = new Map() // key: `${scope}:${scopeId}:${path}` → { value, expiresAt }
24
+
25
+ // Commerce code → numeric id maps. Refreshed at most every CACHE_TTL_MS.
26
+ let websiteCodeToId = null // Map<code, id>
27
+ let websiteCodeToIdAt = 0
28
+ let storeCodeToId = null // Map<code, id> + parentWebsiteId
29
+ let storeCodeToIdAt = 0
30
+
31
+ function maybeParseJson (value) {
32
+ if (typeof value !== 'string') return value
33
+ const trimmed = value.trim()
34
+ if (!trimmed) return value
35
+ if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value
36
+ try { return JSON.parse(trimmed) } catch { return value }
37
+ }
38
+
39
+ async function tryFindOne (collection, query) {
40
+ try {
41
+ const arr = await collection.find(query).limit(1).toArray()
42
+ return arr && arr.length ? arr[0] : null
43
+ } catch (err) {
44
+ const msg = err && err.message ? String(err.message) : String(err)
45
+ if (/not found/i.test(msg)) return null
46
+ throw err
47
+ }
48
+ }
49
+
50
+ function pickCommerceCreds (params) {
51
+ return {
52
+ url: params.COMMERCE_BASE_URL || process.env.COMMERCE_BASE_URL,
53
+ consumerKey: params.COMMERCE_CONSUMER_KEY || process.env.COMMERCE_CONSUMER_KEY,
54
+ consumerSecret: params.COMMERCE_CONSUMER_SECRET || process.env.COMMERCE_CONSUMER_SECRET,
55
+ accessToken: params.COMMERCE_ACCESS_TOKEN || process.env.COMMERCE_ACCESS_TOKEN,
56
+ accessTokenSecret: params.COMMERCE_ACCESS_TOKEN_SECRET || process.env.COMMERCE_ACCESS_TOKEN_SECRET
57
+ }
58
+ }
59
+
60
+ async function loadWebsiteCodeMap (params) {
61
+ const now = Date.now()
62
+ if (websiteCodeToId && (now - websiteCodeToIdAt) < CACHE_TTL_MS) return websiteCodeToId
63
+
64
+ const creds = pickCommerceCreds(params)
65
+ if (!creds.url) return websiteCodeToId || new Map()
66
+
67
+ try {
68
+ const oauth = getCommerceOauthClient(creds, { error: () => {}, info: () => {} })
69
+ const websites = await oauth.get('store/websites')
70
+ const map = new Map()
71
+ if (Array.isArray(websites)) {
72
+ for (const w of websites) {
73
+ if (w && w.code != null && w.id != null) {
74
+ map.set(String(w.code), String(w.id))
75
+ }
76
+ }
77
+ }
78
+ websiteCodeToId = map
79
+ websiteCodeToIdAt = now
80
+ return map
81
+ } catch (_) {
82
+ return websiteCodeToId || new Map()
83
+ }
84
+ }
85
+
86
+ async function loadStoreViewCodeMap (params) {
87
+ const now = Date.now()
88
+ if (storeCodeToId && (now - storeCodeToIdAt) < CACHE_TTL_MS) return storeCodeToId
89
+
90
+ const creds = pickCommerceCreds(params)
91
+ if (!creds.url) return storeCodeToId || new Map()
92
+
93
+ try {
94
+ const oauth = getCommerceOauthClient(creds, { error: () => {}, info: () => {} })
95
+ const stores = await oauth.get('store/storeViews')
96
+ const map = new Map()
97
+ if (Array.isArray(stores)) {
98
+ for (const s of stores) {
99
+ if (s && s.code != null && s.id != null) {
100
+ map.set(String(s.code), { id: String(s.id), websiteId: s.website_id != null ? String(s.website_id) : null })
101
+ }
102
+ }
103
+ }
104
+ storeCodeToId = map
105
+ storeCodeToIdAt = now
106
+ return map
107
+ } catch (_) {
108
+ return storeCodeToId || new Map()
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Resolve a scope code (e.g. website 'ch', store view 'en_ch') to its numeric
114
+ * id via Commerce REST. Returns null when the code can't be resolved AND no
115
+ * verbatim fallback is wanted.
116
+ */
117
+ async function resolveScopeId (scope, code, params) {
118
+ if (!code) return null
119
+ if (scope === 'websites') {
120
+ const map = await loadWebsiteCodeMap(params)
121
+ return map.get(String(code)) || null
122
+ }
123
+ if (scope === 'stores') {
124
+ const map = await loadStoreViewCodeMap(params)
125
+ return map.get(String(code))?.id || null
126
+ }
127
+ return null
128
+ }
129
+
130
+ /**
131
+ * Look up a single config value from ABDB.
132
+ *
133
+ * @param {string} path `<section>/<group>/<field>` (e.g. 'campaign_general/url/url')
134
+ * @param {object} [params] action params containing OAuth + crypto + Commerce creds.
135
+ * Falls back to process.env when omitted.
136
+ * @param {object} [options]
137
+ * @param {string} [options.scope='default'] 'default' | 'websites' | 'stores'
138
+ * @param {string} [options.scopeId] website / store id (numeric string); takes precedence over scopeCode
139
+ * @param {string} [options.scopeCode] website / store-view CODE — resolved to numeric id via Commerce REST
140
+ * @param {string|number} [options.parentWebsiteId] used when scope='stores' to fall back to the parent website
141
+ * @param {string} [options.parentWebsiteCode] same as parentWebsiteId but resolved from a website code
142
+ * @param {boolean} [options.fresh] bypass the cache
143
+ * @returns {Promise<*|null>}
144
+ */
145
+ async function getConfig (path, params = {}, options = {}) {
146
+ if (!isValidPath(path)) return null
147
+
148
+ let scope
149
+ try {
150
+ scope = normalizeScope(options.scope)
151
+ } catch (_) {
152
+ return null
153
+ }
154
+
155
+ // 1. Resolve the active scope id (numeric).
156
+ let resolvedScopeId
157
+ if (scope === 'default') {
158
+ resolvedScopeId = '0'
159
+ } else if (options.scopeId != null && options.scopeId !== '') {
160
+ resolvedScopeId = String(options.scopeId)
161
+ } else if (options.scopeCode) {
162
+ const fromCommerce = await resolveScopeId(scope, options.scopeCode, params)
163
+ // If Commerce isn't reachable, fall back to using the code verbatim — the
164
+ // value still gets queried, and the legacy `getSystemConfig` shim writes
165
+ // under the literal code so this keeps working.
166
+ resolvedScopeId = fromCommerce || String(options.scopeCode)
167
+ } else {
168
+ return null
169
+ }
170
+
171
+ // 2. Resolve the parent website id for store-scope inheritance.
172
+ let parentWebsiteId = options.parentWebsiteId
173
+ if (parentWebsiteId == null && options.parentWebsiteCode) {
174
+ parentWebsiteId =
175
+ await resolveScopeId('websites', options.parentWebsiteCode, params) ||
176
+ String(options.parentWebsiteCode)
177
+ }
178
+ // Auto-derive parentWebsiteId from the store view itself when not given.
179
+ if (parentWebsiteId == null && scope === 'stores' && options.scopeCode) {
180
+ const sMap = await loadStoreViewCodeMap(params)
181
+ parentWebsiteId = sMap.get(String(options.scopeCode))?.websiteId || undefined
182
+ }
183
+
184
+ let normalizedScopeId
185
+ try {
186
+ normalizedScopeId = normalizeScopeId(scope, resolvedScopeId)
187
+ } catch (_) {
188
+ return null
189
+ }
190
+
191
+ const cacheKey = `${scope}:${normalizedScopeId}:${path}`
192
+ const now = Date.now()
193
+ if (!options.fresh) {
194
+ const c = cache.get(cacheKey)
195
+ if (c && c.expiresAt > now) return c.value
196
+ }
197
+
198
+ let handle
199
+ try {
200
+ handle = await getClient(params)
201
+ } catch (_) {
202
+ return null
203
+ }
204
+
205
+ try {
206
+ const collection = await handle.client.collection(COLLECTION)
207
+ const chain = buildInheritanceChain(scope, normalizedScopeId, parentWebsiteId)
208
+
209
+ let resolved = null
210
+ for (const link of chain) {
211
+ const id = toStateKey(link.scope, link.scopeId, path)
212
+ const doc = await tryFindOne(collection, { _id: id })
213
+ if (!doc || doc.value === undefined) continue
214
+ let value = doc.value
215
+ if (isEncrypted(value)) {
216
+ try { value = decrypt(value, params) } catch (_) { /* keep raw */ }
217
+ }
218
+ value = maybeParseJson(value)
219
+ resolved = value
220
+ break
221
+ }
222
+
223
+ cache.set(cacheKey, { value: resolved, expiresAt: now + CACHE_TTL_MS })
224
+ return resolved
225
+ } finally {
226
+ try { await handle.close() } catch (_) { /* noop */ }
227
+ }
228
+ }
229
+
230
+ /** Clear the entire in-process cache (e.g. after a re-migration). */
231
+ function clearAbdbConfigCache () {
232
+ cache.clear()
233
+ websiteCodeToId = null
234
+ storeCodeToId = null
235
+ }
236
+
237
+ module.exports = {
238
+ COLLECTION,
239
+ getConfig,
240
+ clearAbdbConfigCache
241
+ }