@ti-engine/web-framework 1.13.1

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.
Files changed (42) hide show
  1. package/.env +4 -0
  2. package/CHANGELOG.md +274 -0
  3. package/README.md +26 -0
  4. package/bin/build/post-install.js +18 -0
  5. package/bin/localization/web-server-labels.json +28 -0
  6. package/bin/static/.well-known/appspecific/com.chrome.devtools.json +6 -0
  7. package/bin/static/favicon.ico +0 -0
  8. package/bin/static/fragments/components/component-notification-bar.html +21 -0
  9. package/bin/static/fragments/components/component-sidebar-flyout.html +37 -0
  10. package/bin/static/fragments/components/component-sidebar.html +33 -0
  11. package/bin/static/fragments/components/component-tooltip.html +11 -0
  12. package/bin/static/fragments/components/component-topbar.html +6 -0
  13. package/bin/static/fragments/frame-administration.html +3 -0
  14. package/bin/static/fragments/frame-application.html +19 -0
  15. package/bin/static/fragments/frame-dashboard.html +3 -0
  16. package/bin/static/fragments/frame-login.html +105 -0
  17. package/bin/static/fragments/frame-not-found.html +3 -0
  18. package/bin/static/fragments/frame-profile.html +3 -0
  19. package/bin/static/index.html +23 -0
  20. package/bin/static/scripts/lib/alpinejs-csp.min.js +7 -0
  21. package/bin/static/scripts/lib/htmx.min.js +1 -0
  22. package/bin/static/scripts/lib/safe-nonce.min.js +1 -0
  23. package/bin/static/scripts/ti-charts.js +1591 -0
  24. package/bin/static/scripts/ti-framework.css +3195 -0
  25. package/bin/static/scripts/ti-framework.js +1427 -0
  26. package/bin/static/scripts/ti-theme-black-glass.css +216 -0
  27. package/bin/static/scripts/ti-theme-daylight.css +87 -0
  28. package/bin/web-app-manager.js +564 -0
  29. package/bin/web-server.js +604 -0
  30. package/bin/web-server.json +49 -0
  31. package/components/admin-config-handlers.js +92 -0
  32. package/components/auth-manager.js +344 -0
  33. package/components/authorization.js +135 -0
  34. package/components/config-change-notifier.js +98 -0
  35. package/components/config-registry.js +246 -0
  36. package/components/config-service.js +349 -0
  37. package/components/config-store.js +246 -0
  38. package/components/definitions.types.js +26 -0
  39. package/components/session-store.js +111 -0
  40. package/components/user.js +133 -0
  41. package/components/web-handlers.js +765 -0
  42. package/package.json +66 -0
@@ -0,0 +1,246 @@
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ const Ajv = require( "ajv" );
10
+ const exceptions = require( "@ti-engine/core/exceptions" );
11
+
12
+ /**
13
+ * Converts an ajv `instancePath` (a JSON Pointer, e.g. `/competencies/E1-1/name`) into the dot/bracket data path this
14
+ * registry has always exposed on schema issues (e.g. `.competencies.E1-1.name`, array indices rendered as `[0]`).
15
+ * ajv 8 renamed `dataPath` (dot style) to `instancePath` (JSON Pointer); normalizing here keeps the public
16
+ * {@link ConfigValidationIssue} `path` contract stable across the ajv 6 → 8 upgrade.
17
+ *
18
+ * @param {string} [instancePath] The ajv instance path (JSON Pointer), or empty for the document root.
19
+ * @returns {string} The dot/bracket data path, or "" for the document root.
20
+ */
21
+ const instancePathToDataPath = ( instancePath ) => {
22
+ if ( !instancePath ) {
23
+ return "";
24
+ }
25
+ return instancePath
26
+ .split( "/" )
27
+ .slice( 1 )
28
+ .map( ( segment ) => {
29
+ const key = segment.replace( /~1/g, "/" ).replace( /~0/g, "~" );
30
+ return ( /^\d+$/.test( key ) ) ? `[${ key }]` : `.${ key }`;
31
+ } )
32
+ .join( "" );
33
+ };
34
+
35
+ /**
36
+ * @typedef {Object} ConfigValidationIssue
37
+ * @property {string} path A JSON pointer / data path to the offending value (e.g. ".competencies.E1-1.name"), or "".
38
+ * @property {string} message Human-readable problem description.
39
+ * @property {string} code "schema" for JSON-Schema failures, or a validator-supplied code (default "semantic").
40
+ * @property {Object} [params] Optional structured details (e.g. ajv params).
41
+ */
42
+
43
+ /**
44
+ * @typedef {function(Object, Object): (ConfigValidationIssue[]|Promise<ConfigValidationIssue[]>)} SemanticValidator
45
+ * A semantic validator receives the candidate value and a context object (e.g. `{ getConfig(key) }` to read other
46
+ * current configs for cross-document checks) and returns the issues it found (empty array = OK). May be async.
47
+ */
48
+
49
+ /**
50
+ * Registry of editable configuration *documents* and the gate that validates a candidate value against a
51
+ * document's JSON Schema (ajv) plus its semantic validators. The framework stays domain-agnostic: an application
52
+ * registers its config documents (schemas, validators, defaults, editor metadata) at startup; this component knows
53
+ * only "validated, versioned JSON documents". Validation must pass *before* {@link ConfigStore#saveChangeSet}.
54
+ *
55
+ * @class ConfigRegistry
56
+ * @public
57
+ */
58
+ class ConfigRegistry {
59
+
60
+ #ajv;
61
+ #registrations = new Map();
62
+
63
+ constructor() {
64
+ // ajv 8 speaks Draft-07 by default; our schema files annotate Draft 2020-12 only for editor support, so we
65
+ // strip that annotation (see #stripUnsupportedMeta) and skip meta-validation.
66
+ this.#ajv = new Ajv( { allErrors: true, schemaId: "$id", meta: true, validateSchema: false } );
67
+ }
68
+
69
+ /* Public interface */
70
+
71
+ /**
72
+ * Registers an editable configuration document.
73
+ *
74
+ * @method
75
+ * @param {string} configKey Stable identifier for the document (also the ConfigStore key).
76
+ * @param {Object} definition
77
+ * @param {Object} definition.schema A JSON Schema for the document.
78
+ * @param {SemanticValidator[]} [definition.validators] Cross-cutting/semantic checks beyond the schema.
79
+ * @param {Object} [definition.defaultValue] The bootstrap default (seeds an empty store).
80
+ * @param {Object} [definition.metadata] Editor metadata (label, editor type, group, risk class, …).
81
+ * @returns {ConfigRegistry} this (chainable)
82
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} On missing key/schema.
83
+ * @public
84
+ */
85
+ register( configKey, definition ) {
86
+ const { schema, validators = [], defaultValue, metadata = {} } = definition || {};
87
+ if ( !configKey || !schema || typeof schema !== "object" ) {
88
+ throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-config-registration", configKey: configKey } );
89
+ }
90
+ const prepared = this.#stripUnsupportedMeta( schema );
91
+ this.addSchema( prepared );
92
+ this.#registrations.set( configKey, {
93
+ schema: prepared,
94
+ validators: Array.isArray( validators ) ? validators : [],
95
+ defaultValue: defaultValue,
96
+ metadata: metadata || {},
97
+ compiled: null
98
+ } );
99
+ return this;
100
+ }
101
+
102
+ /**
103
+ * Adds a schema that is referenced (via `$ref`/`$id`) by document schemas but is not itself a document.
104
+ *
105
+ * @method
106
+ * @param {Object} schema
107
+ * @returns {ConfigRegistry} this (chainable)
108
+ * @public
109
+ */
110
+ addSchema( schema ) {
111
+ const prepared = this.#stripUnsupportedMeta( schema );
112
+ if ( prepared && prepared.$id && !this.#ajv.getSchema( prepared.$id ) ) {
113
+ this.#ajv.addSchema( prepared );
114
+ }
115
+ return this;
116
+ }
117
+
118
+ /**
119
+ * @method
120
+ * @param {string} configKey
121
+ * @returns {boolean}
122
+ * @public
123
+ */
124
+ has( configKey ) {
125
+ return this.#registrations.has( configKey );
126
+ }
127
+
128
+ /**
129
+ * @method
130
+ * @returns {string[]} All registered configuration keys.
131
+ * @public
132
+ */
133
+ list() {
134
+ return Array.from( this.#registrations.keys() );
135
+ }
136
+
137
+ /**
138
+ * @method
139
+ * @param {string} configKey
140
+ * @returns {Object|undefined} The editor metadata registered for the document.
141
+ * @public
142
+ */
143
+ metadataFor( configKey ) {
144
+ const registration = this.#registrations.get( configKey );
145
+ return registration ? registration.metadata : undefined;
146
+ }
147
+
148
+ /**
149
+ * @method
150
+ * @param {string} configKey
151
+ * @returns {Object|undefined} The bootstrap default value registered for the document.
152
+ * @public
153
+ */
154
+ getDefault( configKey ) {
155
+ const registration = this.#registrations.get( configKey );
156
+ return registration ? registration.defaultValue : undefined;
157
+ }
158
+
159
+ /**
160
+ * Validates a candidate value for a registered document: JSON Schema first, then the semantic validators.
161
+ * Resolves with `{ valid, errors }` (errors is an array of {@link ConfigValidationIssue}).
162
+ *
163
+ * @method
164
+ * @param {string} configKey
165
+ * @param {Object} value
166
+ * @param {Object} [context] Passed to each semantic validator (e.g. `{ getConfig(key) }`).
167
+ * @returns {Promise<{valid: boolean, errors: ConfigValidationIssue[]}>}
168
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
169
+ * @public
170
+ */
171
+ validate( configKey, value, context = {} ) {
172
+ const registration = this.#registrations.get( configKey );
173
+ if ( !registration ) {
174
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKey: configKey } ) );
175
+ }
176
+
177
+ const errors = [];
178
+ const validateSchema = this.#schemaValidator( registration );
179
+ if ( !validateSchema( value ) ) {
180
+ for ( const error of ( validateSchema.errors || [] ) ) {
181
+ errors.push( { path: error.dataPath || instancePathToDataPath( error.instancePath ), message: error.message || "schema violation", code: "schema", params: error.params } );
182
+ }
183
+ }
184
+
185
+ return Promise.all( registration.validators.map( ( validator ) => {
186
+ return Promise.resolve( validator( value, context ) ).then( ( issues ) => ( Array.isArray( issues ) ? issues : [] ) );
187
+ } ) ).then( ( results ) => {
188
+ for ( const issues of results ) {
189
+ for ( const issue of issues ) {
190
+ errors.push( this.#normalizeIssue( issue ) );
191
+ }
192
+ }
193
+ return { valid: errors.length === 0, errors: errors };
194
+ } );
195
+ }
196
+
197
+ /* Private interface */
198
+
199
+ /**
200
+ * @method
201
+ * @param {Object} registration
202
+ * @returns {Function} The compiled (and cached) ajv validate function for the document's schema.
203
+ * @private
204
+ */
205
+ #schemaValidator( registration ) {
206
+ if ( !registration.compiled ) {
207
+ registration.compiled = registration.schema.$id
208
+ ? this.#ajv.getSchema( registration.schema.$id )
209
+ : this.#ajv.compile( registration.schema );
210
+ }
211
+ return registration.compiled;
212
+ }
213
+
214
+ /**
215
+ * @method
216
+ * @param {Object} schema
217
+ * @returns {Object} A shallow copy without the ajv-6-incompatible Draft-2020-12 `$schema` annotation.
218
+ * @private
219
+ */
220
+ #stripUnsupportedMeta( schema ) {
221
+ if ( schema && schema.$schema && String( schema.$schema ).includes( "draft/2020-12" ) ) {
222
+ const clone = { ...schema };
223
+ delete clone.$schema;
224
+ return clone;
225
+ }
226
+ return schema;
227
+ }
228
+
229
+ /**
230
+ * @method
231
+ * @param {ConfigValidationIssue|string} issue
232
+ * @returns {ConfigValidationIssue}
233
+ * @private
234
+ */
235
+ #normalizeIssue( issue ) {
236
+ if ( typeof issue === "string" ) {
237
+ return { path: "", message: issue, code: "semantic" };
238
+ }
239
+ return { path: issue.path || "", message: issue.message || "validation failed", code: issue.code || "semantic", params: issue.params };
240
+ }
241
+
242
+ }
243
+
244
+ const instance = new ConfigRegistry();
245
+ module.exports = ConfigRegistry;
246
+ module.exports.instance = instance;
@@ -0,0 +1,349 @@
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ const exceptions = require( "@ti-engine/core/exceptions" );
10
+
11
+ /**
12
+ * Orchestrates validated, versioned configuration edits on top of {@link ConfigStore} and {@link ConfigRegistry}.
13
+ *
14
+ * Two layers:
15
+ * - **Document level** — {@link ConfigService#applyEdits}: validate every affected document (schema + semantic,
16
+ * with a cross-document context that sees the *pending* values of the same edit) and, only if all pass, commit
17
+ * them as one change-set. Validation failures return `{ ok:false, errors }` and write nothing; a version
18
+ * conflict from the store surfaces as a rejection.
19
+ * - **Entity level** — composite editors registered with `compose(docs)→view` / `decompose(edited, docs)→{key:value}`,
20
+ * so the UI edits a domain entity (e.g. a "competency") that is projected from, and scattered back into, several
21
+ * documents. {@link ConfigService#saveEditorEdit} decomposes the edit and routes it through `applyEdits`.
22
+ *
23
+ * @class ConfigService
24
+ * @public
25
+ */
26
+ class ConfigService {
27
+
28
+ #store;
29
+ #registry;
30
+ #notifier;
31
+ #editors = new Map();
32
+
33
+ /**
34
+ * @constructor
35
+ * @param {Object} [options]
36
+ * @param {ConfigStore} [options.store] Defaults to the ConfigStore singleton.
37
+ * @param {ConfigRegistry} [options.registry] Defaults to the ConfigRegistry singleton.
38
+ * @param {ConfigChangeNotifier} [options.notifier] Defaults to the ConfigChangeNotifier singleton.
39
+ */
40
+ constructor( options = {} ) {
41
+ this.#store = options.store || require( "#config-store" ).instance;
42
+ this.#registry = options.registry || require( "#config-registry" ).instance;
43
+ this.#notifier = options.notifier || require( "#config-change-notifier" ).instance;
44
+ }
45
+
46
+ /* Public interface — document level */
47
+
48
+ /**
49
+ * Validates and commits a set of document edits atomically. Each edit: `{ configKey, value, expectedVersion }`.
50
+ *
51
+ * @method
52
+ * @param {Array<{configKey: string, value: Object, expectedVersion: number}>} edits
53
+ * @param {Object} meta
54
+ * @param {string} meta.adminID
55
+ * @param {string} [meta.note]
56
+ * @returns {Promise<{ok: true, changeSetID: string, versions: Object<string, number>} | {ok: false, errors: Object<string, Array>}>}
57
+ * @public
58
+ */
59
+ applyEdits( edits, meta ) {
60
+ if ( !Array.isArray( edits ) || edits.length === 0 || !meta || !meta.adminID ) {
61
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-apply-input" } ) );
62
+ }
63
+
64
+ // Cross-document validation context: a document being edited is seen at its *pending* value; others are read
65
+ // from the store. This lets a validator on one document check against the post-edit state of its siblings.
66
+ const pending = {};
67
+ for ( const edit of edits ) {
68
+ pending[ edit.configKey ] = edit.value;
69
+ }
70
+ const context = {
71
+ getConfig: ( key ) => {
72
+ if ( Object.prototype.hasOwnProperty.call( pending, key ) ) {
73
+ return Promise.resolve( clone( pending[ key ] ) );
74
+ }
75
+ return this.#store.getCurrent( key ).then( ( current ) => ( current ? current.value : null ) );
76
+ }
77
+ };
78
+
79
+ return Promise.all( edits.map( ( edit ) => {
80
+ return this.#registry.validate( edit.configKey, edit.value, context ).then( ( result ) => ( { configKey: edit.configKey, valid: result.valid, errors: result.errors } ) );
81
+ } ) ).then( ( results ) => {
82
+ const errorsByKey = {};
83
+ for ( const result of results ) {
84
+ if ( !result.valid ) {
85
+ errorsByKey[ result.configKey ] = result.errors;
86
+ }
87
+ }
88
+ if ( Object.keys( errorsByKey ).length > 0 ) {
89
+ return { ok: false, errors: errorsByKey };
90
+ }
91
+ return this.#store.saveChangeSet( edits, meta ).then( ( saved ) => {
92
+ this.#notifier.publish( { changeSetID: saved.changeSetID, configKeys: Object.keys( saved.versions ), adminID: meta.adminID, timestamp: new Date().toISOString() } );
93
+ return { ok: true, changeSetID: saved.changeSetID, versions: saved.versions };
94
+ } );
95
+ } );
96
+ }
97
+
98
+ /* Public interface — entity level (composite editors) */
99
+
100
+ /**
101
+ * Registers a composite editor over one or more documents.
102
+ *
103
+ * @method
104
+ * @param {string} editorKey
105
+ * @param {Object} definition
106
+ * @param {string[]} definition.documents The configKeys this editor spans.
107
+ * @param {function(Object): *} definition.compose Maps `{ [configKey]: value }` → a view for the UI.
108
+ * @param {function(*, Object): Object<string, Object>} definition.decompose Maps `(editedView, currentDocs)` → the
109
+ * full new values for the documents that changed (`{ [configKey]: newValue }`).
110
+ * @param {Object} [definition.metadata]
111
+ * @returns {ConfigService} this (chainable)
112
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS}
113
+ * @public
114
+ */
115
+ registerEditor( editorKey, definition ) {
116
+ const { documents, compose, decompose, metadata = {} } = definition || {};
117
+ if ( !editorKey || !Array.isArray( documents ) || documents.length === 0 || typeof compose !== "function" || typeof decompose !== "function" ) {
118
+ throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-editor-registration", editorKey: editorKey } );
119
+ }
120
+ this.#editors.set( editorKey, { documents: documents.slice(), compose: compose, decompose: decompose, metadata: metadata || {} } );
121
+ return this;
122
+ }
123
+
124
+ /**
125
+ * @method
126
+ * @param {string} editorKey
127
+ * @returns {boolean}
128
+ * @public
129
+ */
130
+ hasEditor( editorKey ) {
131
+ return this.#editors.has( editorKey );
132
+ }
133
+
134
+ /**
135
+ * @method
136
+ * @returns {string[]}
137
+ * @public
138
+ */
139
+ listEditors() {
140
+ return Array.from( this.#editors.keys() );
141
+ }
142
+
143
+ /**
144
+ * Loads the editor's documents and composes them into a view. Returns the view plus the current per-document
145
+ * versions, which the client must echo back on save for optimistic locking.
146
+ *
147
+ * @method
148
+ * @param {string} editorKey
149
+ * @returns {Promise<{rows: *, versions: Object<string, number>}>}
150
+ * @public
151
+ */
152
+ composeView( editorKey ) {
153
+ const editor = this.#editors.get( editorKey );
154
+ if ( !editor ) {
155
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-editor", editorKey: editorKey } ) );
156
+ }
157
+ return this.#loadDocuments( editor.documents ).then( ( { docs, versions } ) => ( { rows: editor.compose( clone( docs ) ), versions: versions } ) );
158
+ }
159
+
160
+ /**
161
+ * Applies an edit made against a composite editor: decompose the edited view into per-document new values, then
162
+ * route through {@link ConfigService#applyEdits} (validate-all → atomic change-set). `expectedVersions` should be
163
+ * the versions returned by {@link ConfigService#composeView} when the edit started.
164
+ *
165
+ * @method
166
+ * @param {string} editorKey
167
+ * @param {*} editedView
168
+ * @param {Object} meta
169
+ * @param {Object<string, number>} [expectedVersions]
170
+ * @returns {Promise<Object>} The {@link ConfigService#applyEdits} result (or `{ ok:true, changeSetID:null }` if nothing changed).
171
+ * @public
172
+ */
173
+ saveEditorEdit( editorKey, editedView, meta, expectedVersions = {} ) {
174
+ const editor = this.#editors.get( editorKey );
175
+ if ( !editor ) {
176
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-editor", editorKey: editorKey } ) );
177
+ }
178
+ return this.#loadDocuments( editor.documents ).then( ( { docs, versions } ) => {
179
+ const newValues = editor.decompose( editedView, clone( docs ) ) || {};
180
+ const edits = Object.keys( newValues ).map( ( key ) => ( {
181
+ configKey: key,
182
+ value: newValues[ key ],
183
+ expectedVersion: ( expectedVersions && expectedVersions[ key ] != null ) ? expectedVersions[ key ] : versions[ key ]
184
+ } ) );
185
+ if ( edits.length === 0 ) {
186
+ return { ok: true, changeSetID: null, versions: {} };
187
+ }
188
+ return this.applyEdits( edits, meta );
189
+ } );
190
+ }
191
+
192
+ /* Public interface — audit, history, and restore */
193
+
194
+ /**
195
+ * Restores a prior change-set through the validated path: rebuild edits from the change-set's historic snapshots
196
+ * and route them through {@link ConfigService#applyEdits} — so the restore is **re-validated against the current
197
+ * schemas/validators** (a snapshot valid when written may be invalid now) and emits `config:changed`. Returns the
198
+ * `applyEdits` result (`{ ok:false, errors }` if a snapshot no longer validates; nothing is written then).
199
+ *
200
+ * @method
201
+ * @param {string} changeSetID
202
+ * @param {Object} meta
203
+ * @param {string} meta.adminID
204
+ * @param {string} [meta.note]
205
+ * @returns {Promise<Object>}
206
+ * @public
207
+ */
208
+ restoreChangeSet( changeSetID, meta ) {
209
+ if ( !meta || !meta.adminID ) {
210
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-restore-input" } ) );
211
+ }
212
+ return this.#store.getChangeSet( changeSetID ).then( ( record ) => {
213
+ if ( !record ) {
214
+ throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-changeset", changeSetID: changeSetID } );
215
+ }
216
+ return Promise.all( record.documents.map( ( doc ) => {
217
+ return Promise.all( [ this.#store.getVersion( doc.configKey, doc.version ), this.#store.getCurrent( doc.configKey ) ] ).then( ( [ historic, current ] ) => ( {
218
+ configKey: doc.configKey,
219
+ value: historic ? historic.snapshot : null,
220
+ expectedVersion: current ? current.version : 0
221
+ } ) );
222
+ } ) ).then( ( edits ) => this.applyEdits( edits, { adminID: meta.adminID, note: meta.note || ( "restored from change-set " + changeSetID ) } ) );
223
+ } );
224
+ }
225
+
226
+ /**
227
+ * @method
228
+ * @param {string} configKey
229
+ * @returns {Promise<Object|null>} The current envelope for a configuration document.
230
+ * @public
231
+ */
232
+ getCurrent( configKey ) {
233
+ return this.#store.getCurrent( configKey );
234
+ }
235
+
236
+ /**
237
+ * @method
238
+ * @param {string} configKey
239
+ * @returns {Promise<Array<Object>>} The document's version history (ascending), each a full snapshot entry.
240
+ * @public
241
+ */
242
+ getHistory( configKey ) {
243
+ return this.#store.listHistory( configKey );
244
+ }
245
+
246
+ /**
247
+ * @method
248
+ * @param {string} changeSetID
249
+ * @returns {Promise<Object|null>} A single change-set record.
250
+ * @public
251
+ */
252
+ getChange( changeSetID ) {
253
+ return this.#store.getChangeSet( changeSetID );
254
+ }
255
+
256
+ /**
257
+ * @method
258
+ * @returns {Promise<Array<Object>>} The cross-document audit feed (change-sets, most-recent first).
259
+ * @public
260
+ */
261
+ listChanges() {
262
+ return this.#store.listChangeSets();
263
+ }
264
+
265
+ /**
266
+ * Builds a downloadable snapshot of the current live configuration — for every registered document, its repo file
267
+ * `path` (from registration metadata, if provided), current `version`, and `value`. This is the one-way export
268
+ * *out* of the store; an admin downloads it and commits the files to git. The store remains the live truth.
269
+ *
270
+ * @method
271
+ * @param {Object} [meta]
272
+ * @param {string} [meta.adminID]
273
+ * @returns {Promise<{exportedAt: string, exportedBy: (string|null), documents: Array<{configKey: string, path: (string|null), version: number, value: Object}>}>}
274
+ * @public
275
+ */
276
+ exportBundle( meta = {} ) {
277
+ const keys = this.#registry.list();
278
+ return Promise.all( keys.map( ( configKey ) => {
279
+ return this.#store.getCurrent( configKey ).then( ( current ) => {
280
+ const metadata = this.#registry.metadataFor( configKey ) || {};
281
+ return {
282
+ configKey: configKey,
283
+ path: metadata.path || null,
284
+ version: current ? current.version : 0,
285
+ value: current ? current.value : null
286
+ };
287
+ } );
288
+ } ) ).then( ( documents ) => ( {
289
+ exportedAt: new Date().toISOString(),
290
+ exportedBy: meta.adminID || null,
291
+ documents: documents
292
+ } ) );
293
+ }
294
+
295
+ /**
296
+ * Seeds a document's default value into the store only if it has never been written (idempotent bootstrap).
297
+ * Used by an application to bring its file defaults into the store at startup before serving live config.
298
+ *
299
+ * @method
300
+ * @param {string} configKey
301
+ * @param {Object} defaultValue
302
+ * @returns {Promise<Object>} The current envelope.
303
+ * @public
304
+ */
305
+ seedDefault( configKey, defaultValue ) {
306
+ return this.#store.seedIfEmpty( configKey, defaultValue );
307
+ }
308
+
309
+ /**
310
+ * Subscribes a listener to `config:changed` events (delegates to the change notifier). Returns an unsubscribe fn.
311
+ *
312
+ * @method
313
+ * @param {function(Object): void} listener
314
+ * @returns {function(): void}
315
+ * @public
316
+ */
317
+ onConfigChanged( listener ) {
318
+ return this.#notifier.subscribe( listener );
319
+ }
320
+
321
+ /* Private interface */
322
+
323
+ /**
324
+ * @method
325
+ * @param {string[]} keys
326
+ * @returns {Promise<{docs: Object<string, Object>, versions: Object<string, number>}>}
327
+ * @private
328
+ */
329
+ #loadDocuments( keys ) {
330
+ return Promise.all( keys.map( ( key ) => this.#store.getCurrent( key ) ) ).then( ( currents ) => {
331
+ const docs = {};
332
+ const versions = {};
333
+ keys.forEach( ( key, index ) => {
334
+ docs[ key ] = currents[ index ] ? currents[ index ].value : null;
335
+ versions[ key ] = currents[ index ] ? currents[ index ].version : 0;
336
+ } );
337
+ return { docs: docs, versions: versions };
338
+ } );
339
+ }
340
+
341
+ }
342
+
343
+ function clone( value ) {
344
+ return value === undefined || value === null ? value : JSON.parse( JSON.stringify( value ) );
345
+ }
346
+
347
+ const instance = new ConfigService();
348
+ module.exports = ConfigService;
349
+ module.exports.instance = instance;