@adobedjangir/commerce-admin-snapshots 0.1.0

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,50 @@
1
+ # Action fragment for the snapshots add-on. $included by app.config.yaml
2
+ # under application.runtimeManifest.packages.Snapshots.
3
+ license: Apache-2.0
4
+ actions:
5
+ system-config-snapshot-create:
6
+ function: system-config-snapshot-create/index.js
7
+ web: 'yes'
8
+ runtime: 'nodejs:20'
9
+ inputs:
10
+ LOG_LEVEL: debug
11
+ OAUTH_CLIENT_ID: $OAUTH_CLIENT_ID
12
+ OAUTH_CLIENT_SECRET: $OAUTH_CLIENT_SECRET
13
+ OAUTH_ORG_ID: $OAUTH_ORG_ID
14
+ OAUTH_SCOPES: $OAUTH_SCOPES
15
+ AIO_DB_REGION: $AIO_DB_REGION
16
+ annotations:
17
+ require-adobe-auth: false
18
+ include-ims-credentials: true
19
+ final: true
20
+ system-config-snapshot-list:
21
+ function: system-config-snapshot-list/index.js
22
+ web: 'yes'
23
+ runtime: 'nodejs:20'
24
+ inputs:
25
+ LOG_LEVEL: debug
26
+ OAUTH_CLIENT_ID: $OAUTH_CLIENT_ID
27
+ OAUTH_CLIENT_SECRET: $OAUTH_CLIENT_SECRET
28
+ OAUTH_ORG_ID: $OAUTH_ORG_ID
29
+ OAUTH_SCOPES: $OAUTH_SCOPES
30
+ AIO_DB_REGION: $AIO_DB_REGION
31
+ annotations:
32
+ require-adobe-auth: false
33
+ include-ims-credentials: true
34
+ final: true
35
+ system-config-snapshot-restore:
36
+ function: system-config-snapshot-restore/index.js
37
+ web: 'yes'
38
+ runtime: 'nodejs:20'
39
+ inputs:
40
+ LOG_LEVEL: debug
41
+ SYSTEM_CONFIG_CRYPT_KEY: $SYSTEM_CONFIG_CRYPT_KEY
42
+ OAUTH_CLIENT_ID: $OAUTH_CLIENT_ID
43
+ OAUTH_CLIENT_SECRET: $OAUTH_CLIENT_SECRET
44
+ OAUTH_ORG_ID: $OAUTH_ORG_ID
45
+ OAUTH_SCOPES: $OAUTH_SCOPES
46
+ AIO_DB_REGION: $AIO_DB_REGION
47
+ annotations:
48
+ require-adobe-auth: false
49
+ include-ims-credentials: true
50
+ final: true
@@ -0,0 +1,108 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Capture a point-in-time snapshot of system_config_data into the
7
+ // system_config_snapshots collection. The snapshot is a single document
8
+ // that contains the entire schema + all value rows, so restore is a clean
9
+ // replay (no merge logic). Sensitive values stay encrypted on the way in.
10
+
11
+ const { Core } = require('@adobe/aio-sdk')
12
+ const { errorResponse } = require('@adobedjangir/commerce-admin-management/actions/utils')
13
+ const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
14
+
15
+ const DATA_COLLECTION = 'system_config_data'
16
+ const SCHEMA_COLLECTION = 'system_config_schema'
17
+ const SCHEMA_DOC_ID = 'v1'
18
+ const SNAPSHOT_COLLECTION = 'system_config_snapshots'
19
+ const SNAPSHOT_MAX = 100
20
+
21
+ async function ensureCollection (client, name) {
22
+ try { await client.createCollection(name) } catch (err) {
23
+ const msg = (err && err.message) ? String(err.message) : String(err)
24
+ if (!/exist|already|duplicate/i.test(msg)) throw err
25
+ }
26
+ }
27
+
28
+ async function tryFindOne (col, query) {
29
+ try {
30
+ const arr = await col.find(query).limit(1).toArray()
31
+ return arr && arr.length ? arr[0] : null
32
+ } catch (err) {
33
+ const msg = err && err.message ? String(err.message) : String(err)
34
+ if (/not found/i.test(msg)) return null
35
+ throw err
36
+ }
37
+ }
38
+
39
+ async function main (params) {
40
+ const logger = Core.Logger('system-config-snapshot-create', { level: params.LOG_LEVEL || 'info' })
41
+ const label = (params.label && String(params.label).trim()) || `Snapshot ${new Date().toISOString()}`
42
+ const createdBy = (params.actor && String(params.actor)) ||
43
+ (params.__ow_headers && (params.__ow_headers['x-gw-ims-org-id'] || params.__ow_headers['x-ims-org-id'])) ||
44
+ 'system'
45
+
46
+ let handle
47
+ try {
48
+ handle = await getClient(params)
49
+ } catch (e) {
50
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
51
+ }
52
+ const { client, close } = handle
53
+
54
+ try {
55
+ await ensureCollection(client, SNAPSHOT_COLLECTION)
56
+ const dataCol = await client.collection(DATA_COLLECTION)
57
+ const schemaCol = await client.collection(SCHEMA_COLLECTION)
58
+ const snapCol = await client.collection(SNAPSHOT_COLLECTION)
59
+
60
+ const [valueDocs, schemaDoc] = await Promise.all([
61
+ dataCol.find({}).toArray().catch(() => []),
62
+ tryFindOne(schemaCol, { _id: SCHEMA_DOC_ID })
63
+ ])
64
+
65
+ const now = new Date().toISOString()
66
+ const snapshot = {
67
+ _id: `snap_${now.replace(/[:.]/g, '-')}_${Math.random().toString(36).slice(2, 8)}`,
68
+ createdAt: now,
69
+ createdBy,
70
+ label,
71
+ schema: schemaDoc && schemaDoc.schema ? schemaDoc.schema : null,
72
+ values: valueDocs.map((d) => ({
73
+ scope: d.scope,
74
+ scope_id: d.scope_id,
75
+ path: d.path,
76
+ value: d.value
77
+ })),
78
+ counts: {
79
+ values: valueDocs.length,
80
+ sections: schemaDoc && schemaDoc.schema ? (schemaDoc.schema.sections || []).length : 0
81
+ }
82
+ }
83
+ await snapCol.insertOne(snapshot)
84
+
85
+ // Compact older snapshots — keep only the most recent SNAPSHOT_MAX.
86
+ try {
87
+ const total = await snapCol.countDocuments({})
88
+ if (total > SNAPSHOT_MAX) {
89
+ const over = total - SNAPSHOT_MAX
90
+ const oldest = await snapCol.find({}).sort({ createdAt: 1 }).limit(over).toArray()
91
+ for (const o of oldest) await snapCol.deleteOne({ _id: o._id })
92
+ }
93
+ } catch (_) { /* compaction best-effort */ }
94
+
95
+ logger.info(`Snapshot ${snapshot._id} created (${snapshot.counts.values} values)`)
96
+ return {
97
+ statusCode: 200,
98
+ body: { ok: true, snapshot: { _id: snapshot._id, label, createdAt: now, createdBy, counts: snapshot.counts } }
99
+ }
100
+ } catch (error) {
101
+ logger.error(error)
102
+ return errorResponse(500, error.message || 'snapshot create failed', logger)
103
+ } finally {
104
+ try { await close() } catch (_) {}
105
+ }
106
+ }
107
+
108
+ exports.main = main
@@ -0,0 +1,47 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Paginated list of snapshots. Returns metadata only (label/createdAt/counts) —
7
+ // the full value array is omitted so the UI list stays light. The restore
8
+ // action loads the full doc on demand.
9
+
10
+ const { Core } = require('@adobe/aio-sdk')
11
+ const { errorResponse } = require('@adobedjangir/commerce-admin-management/actions/utils')
12
+ const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
13
+
14
+ const SNAPSHOT_COLLECTION = 'system_config_snapshots'
15
+
16
+ async function main (params) {
17
+ const logger = Core.Logger('system-config-snapshot-list', { level: params.LOG_LEVEL || 'info' })
18
+ const limit = Math.min(Math.max(parseInt(params.limit, 10) || 50, 1), 500)
19
+ const skip = Math.max(parseInt(params.skip, 10) || 0, 0)
20
+
21
+ let handle
22
+ try { handle = await getClient(params) } catch (e) {
23
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
24
+ }
25
+ const { client, close } = handle
26
+
27
+ try {
28
+ const col = await client.collection(SNAPSHOT_COLLECTION)
29
+ const docs = await col.find({}).sort({ createdAt: -1 }).skip(skip).limit(limit).toArray().catch(() => [])
30
+ // Strip large payloads from the list response.
31
+ const items = docs.map((d) => ({
32
+ _id: d._id,
33
+ label: d.label,
34
+ createdAt: d.createdAt,
35
+ createdBy: d.createdBy,
36
+ counts: d.counts
37
+ }))
38
+ return { statusCode: 200, body: { ok: true, items, limit, skip, returned: items.length } }
39
+ } catch (error) {
40
+ logger.error(error)
41
+ return errorResponse(500, error.message || 'snapshot list failed', logger)
42
+ } finally {
43
+ try { await close() } catch (_) {}
44
+ }
45
+ }
46
+
47
+ exports.main = main
@@ -0,0 +1,154 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Restore the entire system_config_data collection from a snapshot.
7
+ // Strategy: write a snapshot of the CURRENT state first (so the restore
8
+ // itself is auditable + reversible), then wipe + repopulate.
9
+ //
10
+ // Sensitive values in the snapshot are stored as ciphertext (we never
11
+ // decrypt at snapshot time), so restore replays them verbatim — they
12
+ // remain readable as long as SYSTEM_CONFIG_CRYPT_KEY hasn't changed
13
+ // between snapshot and restore.
14
+
15
+ const { Core } = require('@adobe/aio-sdk')
16
+ const { errorResponse, checkMissingRequestInputs } = require('@adobedjangir/commerce-admin-management/actions/utils')
17
+ const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
18
+ const { toStateKey } = require('@adobedjangir/commerce-admin-management/shared')
19
+
20
+ const DATA_COLLECTION = 'system_config_data'
21
+ const SCHEMA_COLLECTION = 'system_config_schema'
22
+ const SCHEMA_DOC_ID = 'v1'
23
+ const SNAPSHOT_COLLECTION = 'system_config_snapshots'
24
+ const AUDIT_COLLECTION = 'system_config_audit'
25
+
26
+ async function ensureCollection (client, name) {
27
+ try { await client.createCollection(name) } catch (err) {
28
+ const msg = (err && err.message) ? String(err.message) : String(err)
29
+ if (!/exist|already|duplicate/i.test(msg)) throw err
30
+ }
31
+ }
32
+
33
+ async function main (params) {
34
+ const logger = Core.Logger('system-config-snapshot-restore', { level: params.LOG_LEVEL || 'info' })
35
+ const missing = checkMissingRequestInputs(params, ['snapshotId'], [])
36
+ if (missing) return errorResponse(400, missing, logger)
37
+ const snapshotId = String(params.snapshotId)
38
+ const actor = (params.actor && String(params.actor)) || 'system'
39
+ const restoreSchema = params.restoreSchema !== false && params.restoreSchema !== 'false'
40
+
41
+ let handle
42
+ try { handle = await getClient(params) } catch (e) {
43
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
44
+ }
45
+ const { client, close } = handle
46
+
47
+ try {
48
+ const snapCol = await client.collection(SNAPSHOT_COLLECTION)
49
+ const snap = await (async () => {
50
+ try { return await snapCol.findOne({ _id: snapshotId }) }
51
+ catch { return null }
52
+ })()
53
+ if (!snap) return errorResponse(404, `Snapshot ${snapshotId} not found`, logger)
54
+
55
+ // 1) Capture a "pre-restore" snapshot of the current state so this op is
56
+ // reversible. Inline rather than reusing the create action.
57
+ const dataCol = await client.collection(DATA_COLLECTION)
58
+ const schemaCol = await client.collection(SCHEMA_COLLECTION)
59
+ const now = new Date().toISOString()
60
+ const currentValues = await dataCol.find({}).toArray().catch(() => [])
61
+ const currentSchemaDoc = await (async () => {
62
+ try { return await schemaCol.findOne({ _id: SCHEMA_DOC_ID }) }
63
+ catch { return null }
64
+ })()
65
+ const preRestoreSnap = {
66
+ _id: `snap_${now.replace(/[:.]/g, '-')}_pre-restore`,
67
+ createdAt: now,
68
+ createdBy: actor,
69
+ label: `Auto: pre-restore of ${snap.label || snapshotId}`,
70
+ schema: currentSchemaDoc && currentSchemaDoc.schema ? currentSchemaDoc.schema : null,
71
+ values: currentValues.map((d) => ({ scope: d.scope, scope_id: d.scope_id, path: d.path, value: d.value })),
72
+ counts: {
73
+ values: currentValues.length,
74
+ sections: currentSchemaDoc && currentSchemaDoc.schema ? (currentSchemaDoc.schema.sections || []).length : 0
75
+ }
76
+ }
77
+ try { await snapCol.insertOne(preRestoreSnap) } catch (e) {
78
+ logger.warn(`pre-restore snapshot failed (continuing): ${e.message}`)
79
+ }
80
+
81
+ // 2) Replace schema if the snapshot has one and the caller wants it.
82
+ if (restoreSchema && snap.schema) {
83
+ try {
84
+ await schemaCol.updateOne(
85
+ { _id: SCHEMA_DOC_ID },
86
+ { $set: { schema: snap.schema, updatedAt: now }, $setOnInsert: { _id: SCHEMA_DOC_ID, createdAt: now } },
87
+ { upsert: true }
88
+ )
89
+ } catch (e) {
90
+ // Fallback for drivers without upsert.
91
+ if (currentSchemaDoc) await schemaCol.updateOne({ _id: SCHEMA_DOC_ID }, { $set: { schema: snap.schema, updatedAt: now } })
92
+ else await schemaCol.insertOne({ _id: SCHEMA_DOC_ID, schema: snap.schema, createdAt: now, updatedAt: now })
93
+ }
94
+ }
95
+
96
+ // 3) Wipe data collection then bulk-insert the snapshot's values.
97
+ for (const d of currentValues) {
98
+ await dataCol.deleteOne({ _id: d._id })
99
+ }
100
+ let inserted = 0
101
+ for (const v of (snap.values || [])) {
102
+ try {
103
+ const id = toStateKey(v.scope, v.scope_id, v.path)
104
+ await dataCol.insertOne({
105
+ _id: id,
106
+ scope: v.scope,
107
+ scope_id: v.scope_id,
108
+ path: v.path,
109
+ value: v.value,
110
+ createdAt: now,
111
+ updatedAt: now
112
+ })
113
+ inserted++
114
+ } catch (e) {
115
+ logger.warn(`failed to restore ${v.path} at ${v.scope}:${v.scope_id}: ${e.message}`)
116
+ }
117
+ }
118
+
119
+ // 4) Audit row for the restore action — a single high-level entry.
120
+ try {
121
+ await ensureCollection(client, AUDIT_COLLECTION)
122
+ const auditCol = await client.collection(AUDIT_COLLECTION)
123
+ await auditCol.insertOne({
124
+ scope: 'default',
125
+ scope_id: '0',
126
+ path: '_system/snapshot/restore',
127
+ action: 'update',
128
+ oldValue: `pre:${preRestoreSnap._id}`,
129
+ newValue: `from:${snapshotId}`,
130
+ changedBy: actor,
131
+ changedAt: now
132
+ })
133
+ } catch (_) {}
134
+
135
+ logger.info(`Restored snapshot ${snapshotId}: deleted ${currentValues.length}, inserted ${inserted}`)
136
+ return {
137
+ statusCode: 200,
138
+ body: {
139
+ ok: true,
140
+ restoredFrom: snapshotId,
141
+ preRestoreSnapshot: preRestoreSnap._id,
142
+ deleted: currentValues.length,
143
+ inserted
144
+ }
145
+ }
146
+ } catch (error) {
147
+ logger.error(error)
148
+ return errorResponse(500, error.message || 'snapshot restore failed', logger)
149
+ } finally {
150
+ try { await close() } catch (_) {}
151
+ }
152
+ }
153
+
154
+ exports.main = main
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@adobedjangir/commerce-admin-snapshots",
3
+ "version": "0.1.0",
4
+ "description": "Snapshot + rollback add-on for @adobedjangir/commerce-admin-management. Capture point-in-time copies of schema + values, restore wholesale (auto pre-restore backup included).",
5
+ "license": "Apache-2.0",
6
+ "author": "Adobe Inc.",
7
+ "keywords": ["adobe-io", "aio", "app-builder", "commerce-admin", "snapshot", "rollback"],
8
+ "main": "./src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./web": "./web/index.js"
12
+ },
13
+ "scripts": {
14
+ "postinstall": "node ./scripts/setup.js"
15
+ },
16
+ "files": ["src", "actions", "web", "scripts", "README.md"],
17
+ "engines": { "node": ">=18" },
18
+ "peerDependencies": {
19
+ "@adobedjangir/commerce-admin-management": ">=0.3.0",
20
+ "@adobedjangir/commerce-admin-get-config": ">=0.0.7"
21
+ }
22
+ }
@@ -0,0 +1,168 @@
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
+ // ── Per-add-on configuration ──
11
+ const PACKAGE_NAME = '@adobedjangir/commerce-admin-snapshots'
12
+ const RUNTIME_KEY = 'Snapshots'
13
+ const INCLUDE_REL = 'node_modules/@adobedjangir/commerce-admin-snapshots/actions/ext.config.yaml'
14
+ const REGISTER_IMPORT = "import registerSnapshots from '@adobedjangir/commerce-admin-snapshots/web'"
15
+ const REGISTER_CALL = 'registerSnapshots()'
16
+ const MARKER = `# ${PACKAGE_NAME} (auto-linked on npm install)`
17
+
18
+ // ── Boilerplate (identical across add-ons; small enough to inline) ──
19
+ function findProjectRoot (startDir) {
20
+ let dir = startDir
21
+ while (dir && dir !== path.dirname(dir)) {
22
+ if (fs.existsSync(path.join(dir, 'app.config.yaml'))) return dir
23
+ dir = path.dirname(dir)
24
+ }
25
+ return null
26
+ }
27
+ function resolveProjectRoot () {
28
+ return (process.env.INIT_CWD && findProjectRoot(process.env.INIT_CWD)) ||
29
+ findProjectRoot(process.cwd())
30
+ }
31
+
32
+ /**
33
+ * Add (or replace) an `application.runtimeManifest.packages.<RUNTIME_KEY>`
34
+ * block that $includes our fragment. Idempotent.
35
+ */
36
+ function patchAppConfig (content) {
37
+ // Look for our own marker to detect prior installs.
38
+ const blockRe = new RegExp(
39
+ String.raw`^[ \t]*${MARKER}\n([ \t]+${RUNTIME_KEY}:[ \t]*\n[ \t]+\$include:[^\n]*\n)`, 'm'
40
+ )
41
+ const desiredBody = ` ${MARKER}\n ${RUNTIME_KEY}:\n $include: ${INCLUDE_REL}\n`
42
+
43
+ if (blockRe.test(content)) {
44
+ // Already linked — leave it.
45
+ return { content, changed: false, reason: 'already-linked' }
46
+ }
47
+
48
+ // 1. application.runtimeManifest.packages exists → append our subkey.
49
+ if (/^application:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+runtimeManifest:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+packages:[ \t]*\n/m.test(content)) {
50
+ const next = content.replace(
51
+ /([ \t]+packages:[ \t]*\n)/m,
52
+ (m) => m + desiredBody
53
+ )
54
+ return { content: next, changed: next !== content, reason: 'appended-under-packages' }
55
+ }
56
+
57
+ // 2. application.runtimeManifest exists but no `packages:` → add one.
58
+ if (/^application:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+runtimeManifest:[ \t]*\n/m.test(content)) {
59
+ const next = content.replace(
60
+ /([ \t]+runtimeManifest:[ \t]*\n)/m,
61
+ (m) => m + ` packages:\n${desiredBody}`
62
+ )
63
+ return { content: next, changed: next !== content, reason: 'added-packages' }
64
+ }
65
+
66
+ // 3. application: exists but no runtimeManifest → add the whole tree.
67
+ if (/^application:[ \t]*\n/m.test(content)) {
68
+ const next = content.replace(
69
+ /^application:[ \t]*\n/m,
70
+ `application:\n runtimeManifest:\n packages:\n${desiredBody}`
71
+ )
72
+ return { content: next, changed: next !== content, reason: 'added-runtimeManifest' }
73
+ }
74
+
75
+ // 4. No application: → append the whole block.
76
+ const trimmed = content.replace(/\s+$/, '')
77
+ const sep = trimmed ? '\n\n' : ''
78
+ return {
79
+ content: `${trimmed}${sep}application:\n runtimeManifest:\n packages:\n${desiredBody}`,
80
+ changed: true,
81
+ reason: 'appended-application'
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Patch the host's web-src/src/index.js to import + call our register fn.
87
+ * We look for a managed `// --- COMMERCE-ADMIN ADDONS ---` block; if it's
88
+ * absent we add one. Idempotent — the registration line is only added once.
89
+ */
90
+ function patchBootstrap (content) {
91
+ const blockStart = '// --- COMMERCE-ADMIN ADDONS (auto-managed) ---'
92
+ const blockEnd = '// --- COMMERCE-ADMIN ADDONS END ---'
93
+ // Already registered?
94
+ if (content.includes(REGISTER_IMPORT) && content.includes(REGISTER_CALL)) {
95
+ return { content, changed: false, reason: 'already-registered' }
96
+ }
97
+
98
+ // Find or create the managed block.
99
+ if (content.includes(blockStart)) {
100
+ // Insert our import + call inside the block.
101
+ const next = content.replace(
102
+ blockStart + '\n',
103
+ blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n'
104
+ )
105
+ return { content: next, changed: true, reason: 'inserted-into-existing-block' }
106
+ }
107
+
108
+ // No block yet — append one after `configureWeb({...})` call.
109
+ const cwRe = /configureWeb\s*\([^)]*\)\s*\n/
110
+ if (cwRe.test(content)) {
111
+ const next = content.replace(
112
+ cwRe,
113
+ (m) => m + '\n' + blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n' + blockEnd + '\n'
114
+ )
115
+ return { content: next, changed: true, reason: 'created-block-after-configureWeb' }
116
+ }
117
+
118
+ // No configureWeb? Append at end as a best effort.
119
+ return {
120
+ content: content + '\n' + blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n' + blockEnd + '\n',
121
+ changed: true,
122
+ reason: 'appended-block-at-end'
123
+ }
124
+ }
125
+
126
+ function main () {
127
+ if (process.env.CONFIGURATION_MANAGEMENT_SKIP_SETUP === '1') return
128
+ const root = resolveProjectRoot()
129
+ if (!root) {
130
+ console.log(`[${PACKAGE_NAME}] No app.config.yaml found — skip setup.`)
131
+ return
132
+ }
133
+
134
+ // 1. app.config.yaml
135
+ const cfg = path.join(root, 'app.config.yaml')
136
+ if (fs.existsSync(cfg)) {
137
+ const before = fs.readFileSync(cfg, 'utf8')
138
+ const { content, changed, reason } = patchAppConfig(before)
139
+ if (changed) {
140
+ fs.writeFileSync(cfg, content, 'utf8')
141
+ console.log(`[${PACKAGE_NAME}] app.config.yaml: ${reason}`)
142
+ } else {
143
+ console.log(`[${PACKAGE_NAME}] app.config.yaml: ${reason}`)
144
+ }
145
+ }
146
+
147
+ // 2. bootstrap (web-src/src/index.js)
148
+ const boot = path.join(root, 'web-src', 'src', 'index.js')
149
+ if (fs.existsSync(boot)) {
150
+ const before = fs.readFileSync(boot, 'utf8')
151
+ const { content, changed, reason } = patchBootstrap(before)
152
+ if (changed) {
153
+ fs.writeFileSync(boot, content, 'utf8')
154
+ console.log(`[${PACKAGE_NAME}] web-src/src/index.js: ${reason}`)
155
+ } else {
156
+ console.log(`[${PACKAGE_NAME}] web-src/src/index.js: ${reason}`)
157
+ }
158
+ }
159
+ }
160
+
161
+ if (require.main === module) {
162
+ try { main() } catch (err) {
163
+ // Never fail npm install on scaffold issues.
164
+ console.error(`[${PACKAGE_NAME}] setup error (install continues):`, err.message)
165
+ }
166
+ }
167
+
168
+ module.exports = { patchAppConfig, patchBootstrap }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Snapshots add-on has no server-side hooks back into core today — the
7
+ // snapshot actions are self-contained. This file is the package's entry
8
+ // point for completeness; consumers don't normally need to import from it.
9
+
10
+ module.exports = {
11
+ SNAPSHOT_COLLECTION: 'system_config_snapshots'
12
+ }
package/web/index.js ADDED
@@ -0,0 +1,26 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ import { configureWeb } from '@adobedjangir/commerce-admin-management/web'
7
+ import SnapshotHistory from './src/SnapshotHistory'
8
+
9
+ export default function registerSnapshots () {
10
+ configureWeb({
11
+ actionKeys: {
12
+ systemConfigSnapshotCreate: 'Snapshots/system-config-snapshot-create',
13
+ systemConfigSnapshotList: 'Snapshots/system-config-snapshot-list',
14
+ systemConfigSnapshotRestore: 'Snapshots/system-config-snapshot-restore'
15
+ },
16
+ extraNav: [{
17
+ id: 'snapshots',
18
+ path: '/snapshots',
19
+ label: 'Snapshots',
20
+ icon: 'Box'
21
+ }],
22
+ extraPages: { snapshots: SnapshotHistory }
23
+ })
24
+ }
25
+
26
+ export { default as SnapshotHistory } from './src/SnapshotHistory'
@@ -0,0 +1,228 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Snapshot history viewer + restore UI. Snapshots are written by either:
7
+ // - the create action (user clicks "Save snapshot now")
8
+ // - the restore action (an auto pre-restore snapshot is taken first)
9
+
10
+ import React, { useCallback, useEffect, useState } from 'react'
11
+ import {
12
+ View, Flex, Heading, Text, Button, TextField, ProgressCircle,
13
+ StatusLight, Well, DialogTrigger, Dialog, Content, Header, ButtonGroup,
14
+ Divider, Switch
15
+ } from '@adobe/react-spectrum'
16
+ import { callAction, resolveActor } from '@adobedjangir/commerce-admin-management/web'
17
+ import { getActionKey } from '@adobedjangir/commerce-admin-management/web'
18
+ import { PALETTE, RADIUS, SHADOW } from '@adobedjangir/commerce-admin-management/web'
19
+
20
+ export default function SnapshotHistory ({ runtime, ims }) {
21
+ const [items, setItems] = useState([])
22
+ const [loading, setLoading] = useState(false)
23
+ const [error, setError] = useState(null)
24
+ const [status, setStatus] = useState({ tone: 'neutral', message: '' })
25
+ const [newLabel, setNewLabel] = useState('')
26
+ const [creating, setCreating] = useState(false)
27
+ const [confirmRow, setConfirmRow] = useState(null)
28
+ const [restoreSchema, setRestoreSchema] = useState(true)
29
+ const [restoring, setRestoring] = useState(false)
30
+
31
+ const fetchList = useCallback(async () => {
32
+ setLoading(true); setError(null)
33
+ try {
34
+ const res = await callAction({ runtime, ims }, getActionKey('systemConfigSnapshotList'), '', { limit: 100 })
35
+ const body = res?.body || res
36
+ setItems(Array.isArray(body?.items) ? body.items : [])
37
+ } catch (e) {
38
+ setError(e.message || 'Failed to load snapshots')
39
+ } finally {
40
+ setLoading(false)
41
+ }
42
+ }, [runtime, ims])
43
+
44
+ useEffect(() => { fetchList() }, [fetchList])
45
+
46
+ const doCreate = async () => {
47
+ setCreating(true); setStatus({ tone: 'notice', message: 'Creating snapshot…' })
48
+ try {
49
+ const res = await callAction({ runtime, ims }, getActionKey('systemConfigSnapshotCreate'), '', {
50
+ label: newLabel.trim() || undefined,
51
+ actor: resolveActor(ims)
52
+ })
53
+ const body = res?.body || res
54
+ if (body?.ok) {
55
+ setStatus({ tone: 'positive', message: `Snapshot saved: ${body.snapshot.label}` })
56
+ setNewLabel('')
57
+ await fetchList()
58
+ } else {
59
+ setStatus({ tone: 'negative', message: body?.error || 'Snapshot failed' })
60
+ }
61
+ } catch (e) {
62
+ setStatus({ tone: 'negative', message: e.message || 'Snapshot failed' })
63
+ } finally {
64
+ setCreating(false)
65
+ }
66
+ }
67
+
68
+ const doRestore = async () => {
69
+ if (!confirmRow) return
70
+ setRestoring(true)
71
+ setStatus({ tone: 'notice', message: 'Restoring…' })
72
+ try {
73
+ const res = await callAction({ runtime, ims }, getActionKey('systemConfigSnapshotRestore'), '', {
74
+ snapshotId: confirmRow._id,
75
+ restoreSchema,
76
+ actor: resolveActor(ims)
77
+ })
78
+ const body = res?.body || res
79
+ if (body?.ok) {
80
+ setStatus({
81
+ tone: 'positive',
82
+ message: `Restored from ${confirmRow.label}. Pre-restore backup saved as ${body.preRestoreSnapshot}.`
83
+ })
84
+ await fetchList()
85
+ } else {
86
+ setStatus({ tone: 'negative', message: body?.error || 'Restore failed' })
87
+ }
88
+ } catch (e) {
89
+ setStatus({ tone: 'negative', message: e.message || 'Restore failed' })
90
+ } finally {
91
+ setRestoring(false)
92
+ setConfirmRow(null)
93
+ }
94
+ }
95
+
96
+ return (
97
+ <View padding="size-400" UNSAFE_style={{ background: PALETTE.bg, minHeight: '100vh' }}>
98
+ <Heading level={2} marginTop={0}>Snapshots</Heading>
99
+ <Text UNSAFE_style={{ color: PALETTE.textMuted }}>
100
+ Each snapshot captures the entire schema + every value row. Restore
101
+ replays a snapshot wholesale — the current state is automatically
102
+ backed up first so a restore is itself reversible.
103
+ </Text>
104
+
105
+ {status.message && (
106
+ <View marginTop="size-150"><StatusLight variant={status.tone}>{status.message}</StatusLight></View>
107
+ )}
108
+ {error && (
109
+ <Well marginTop="size-150" UNSAFE_style={{ borderColor: PALETTE.danger }}>
110
+ <Text UNSAFE_style={{ color: PALETTE.danger }}>{error}</Text>
111
+ </Well>
112
+ )}
113
+
114
+ <View
115
+ marginTop="size-200"
116
+ padding="size-200"
117
+ UNSAFE_style={{ background: PALETTE.surface, border: `1px solid ${PALETTE.border}`, borderRadius: RADIUS.lg, boxShadow: SHADOW.xs }}
118
+ >
119
+ <Flex gap="size-150" alignItems="end" wrap>
120
+ <TextField
121
+ label="Snapshot label (optional)"
122
+ value={newLabel}
123
+ onChange={setNewLabel}
124
+ width="size-4600"
125
+ placeholder="e.g. before-2026-Q2-release"
126
+ />
127
+ <Button variant="cta" onPress={doCreate} isDisabled={creating || loading}>
128
+ {creating ? 'Saving…' : 'Save snapshot now'}
129
+ </Button>
130
+ <Button variant="secondary" onPress={fetchList} isDisabled={loading}>
131
+ Reload
132
+ </Button>
133
+ </Flex>
134
+ </View>
135
+
136
+ <View
137
+ marginTop="size-200"
138
+ UNSAFE_style={{ background: PALETTE.surface, border: `1px solid ${PALETTE.border}`, borderRadius: RADIUS.lg, boxShadow: SHADOW.xs, overflow: 'hidden' }}
139
+ >
140
+ <div style={{
141
+ display: 'grid',
142
+ gridTemplateColumns: 'minmax(180px, 1fr) minmax(140px, 200px) minmax(160px, 220px) 110px 110px 120px',
143
+ padding: '12px 16px',
144
+ gap: 12,
145
+ background: PALETTE.surfaceMuted,
146
+ fontSize: 11,
147
+ fontWeight: 700,
148
+ letterSpacing: 0.6,
149
+ textTransform: 'uppercase',
150
+ color: PALETTE.textMuted,
151
+ borderBottom: `1px solid ${PALETTE.border}`
152
+ }}>
153
+ <div>Label</div>
154
+ <div>Created at</div>
155
+ <div>Created by</div>
156
+ <div>Values</div>
157
+ <div>Sections</div>
158
+ <div>Action</div>
159
+ </div>
160
+ {loading && items.length === 0 ? (
161
+ <Flex justifyContent="center" margin="size-400"><ProgressCircle aria-label="Loading" isIndeterminate /></Flex>
162
+ ) : items.length === 0 ? (
163
+ <View padding="size-400"><Text UNSAFE_style={{ color: PALETTE.textMuted }}>No snapshots yet.</Text></View>
164
+ ) : (
165
+ items.map((row, i) => (
166
+ <div
167
+ key={row._id}
168
+ style={{
169
+ display: 'grid',
170
+ gridTemplateColumns: 'minmax(180px, 1fr) minmax(140px, 200px) minmax(160px, 220px) 110px 110px 120px',
171
+ padding: '12px 16px',
172
+ gap: 12,
173
+ borderBottom: `1px solid ${PALETTE.border}`,
174
+ fontSize: 13,
175
+ background: i % 2 === 0 ? PALETTE.surface : PALETTE.surfaceSubtle,
176
+ alignItems: 'center'
177
+ }}
178
+ >
179
+ <div style={{ wordBreak: 'break-word' }}>
180
+ <div style={{ fontWeight: 600 }}>{row.label}</div>
181
+ <div style={{ color: PALETTE.textMuted, fontSize: 11, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}>{row._id}</div>
182
+ </div>
183
+ <div style={{ color: PALETTE.textMuted, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 }}>
184
+ {row.createdAt ? new Date(row.createdAt).toLocaleString() : ''}
185
+ </div>
186
+ <div style={{ color: PALETTE.textMuted, wordBreak: 'break-word' }}>{row.createdBy || 'system'}</div>
187
+ <div>{row.counts?.values ?? '?'}</div>
188
+ <div>{row.counts?.sections ?? '?'}</div>
189
+ <div>
190
+ <Button variant="secondary" onPress={() => setConfirmRow(row)} isDisabled={restoring}>
191
+ Restore
192
+ </Button>
193
+ </div>
194
+ </div>
195
+ ))
196
+ )}
197
+ </View>
198
+
199
+ <DialogTrigger isOpen={!!confirmRow} onOpenChange={(o) => { if (!o) setConfirmRow(null) }}>
200
+ <div style={{ display: 'none' }} aria-hidden="true">trigger</div>
201
+ <Dialog>
202
+ <Heading>Restore this snapshot?</Heading>
203
+ <Header><Text>{confirmRow?.label}</Text></Header>
204
+ <Divider />
205
+ <Content>
206
+ <Text>
207
+ The current state will be saved as a backup snapshot, then every
208
+ value row will be replaced with the snapshot's contents.
209
+ {' '}{confirmRow?.counts?.values ?? '?'} value rows and
210
+ {' '}{confirmRow?.counts?.sections ?? '?'} schema sections.
211
+ </Text>
212
+ <View marginTop="size-200">
213
+ <Switch isSelected={restoreSchema} onChange={setRestoreSchema}>
214
+ Restore schema too (uncheck to keep current schema, restore values only)
215
+ </Switch>
216
+ </View>
217
+ </Content>
218
+ <ButtonGroup>
219
+ <Button variant="secondary" onPress={() => setConfirmRow(null)} isDisabled={restoring}>Cancel</Button>
220
+ <Button variant="cta" onPress={doRestore} isDisabled={restoring}>
221
+ {restoring ? 'Restoring…' : 'Restore'}
222
+ </Button>
223
+ </ButtonGroup>
224
+ </Dialog>
225
+ </DialogTrigger>
226
+ </View>
227
+ )
228
+ }