@ti-engine/web-framework 1.19.0 → 1.19.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 (38) hide show
  1. package/.env +4 -4
  2. package/CHANGELOG.md +362 -353
  3. package/README.md +73 -73
  4. package/bin/build/post-install.js +18 -18
  5. package/bin/localization/web-server-labels.json +27 -27
  6. package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
  7. package/bin/static/fragments/components/component-notification-bar.html +21 -21
  8. package/bin/static/fragments/components/component-sidebar.html +33 -33
  9. package/bin/static/fragments/components/component-tooltip.html +10 -10
  10. package/bin/static/fragments/components/component-topbar.html +5 -5
  11. package/bin/static/fragments/frame-administration.html +2 -2
  12. package/bin/static/fragments/frame-application.html +18 -18
  13. package/bin/static/fragments/frame-dashboard.html +2 -2
  14. package/bin/static/fragments/frame-login.html +119 -119
  15. package/bin/static/fragments/frame-not-found.html +2 -2
  16. package/bin/static/fragments/frame-profile.html +2 -2
  17. package/bin/static/index.html +22 -22
  18. package/bin/static/scripts/ti-charts.js +1591 -1591
  19. package/bin/static/scripts/ti-framework.css +3194 -3194
  20. package/bin/static/scripts/ti-framework.js +1427 -1427
  21. package/bin/static/scripts/ti-theme-black-glass.css +216 -216
  22. package/bin/static/scripts/ti-theme-daylight.css +87 -87
  23. package/bin/web-app-manager.js +663 -663
  24. package/bin/web-server.js +936 -936
  25. package/bin/web-server.json +48 -48
  26. package/components/admin-config-handlers.js +92 -92
  27. package/components/auth-manager.js +441 -441
  28. package/components/authorization.js +135 -135
  29. package/components/config-change-notifier.js +98 -98
  30. package/components/config-registry.js +260 -260
  31. package/components/config-service.js +360 -360
  32. package/components/config-store.js +246 -246
  33. package/components/definitions.types.js +26 -26
  34. package/components/session-store.js +110 -110
  35. package/components/user.js +132 -132
  36. package/components/web-config-env.js +85 -85
  37. package/components/web-handlers.js +800 -800
  38. package/package.json +76 -67
@@ -1,360 +1,360 @@
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 {@link ValidatorContext} whose `getConfig` sees the *pending* values of the same edit —
17
- * letting a validator check a sibling document's post-edit state — while `getStoredConfig` always returns the
18
- * committed value, even for the document currently under validation) and, only if all pass, commit them as one
19
- * change-set. Validation failures return `{ ok:false, errors }` and write nothing; a version conflict from the
20
- * store surfaces as a rejection.
21
- * - **Entity level** — composite editors registered with `compose(docs)→view` / `decompose(edited, docs)→{key:value}`,
22
- * so the UI edits a domain entity (e.g. a "competency") that is projected from, and scattered back into, several
23
- * documents. {@link ConfigService#saveEditorEdit} decomposes the edit and routes it through `applyEdits`.
24
- *
25
- * @class ConfigService
26
- * @public
27
- */
28
- class ConfigService {
29
-
30
- #store;
31
- #registry;
32
- #notifier;
33
- #editors = new Map();
34
-
35
- /**
36
- * @constructor
37
- * @param {Object} [options]
38
- * @param {ConfigStore} [options.store] Defaults to the ConfigStore singleton.
39
- * @param {ConfigRegistry} [options.registry] Defaults to the ConfigRegistry singleton.
40
- * @param {ConfigChangeNotifier} [options.notifier] Defaults to the ConfigChangeNotifier singleton.
41
- */
42
- constructor( options = {} ) {
43
- this.#store = options.store || require( "#config-store" ).instance;
44
- this.#registry = options.registry || require( "#config-registry" ).instance;
45
- this.#notifier = options.notifier || require( "#config-change-notifier" ).instance;
46
- }
47
-
48
- /* Public interface — document level */
49
-
50
- /**
51
- * Validates and commits a set of document edits atomically. Each edit: `{ configKey, value, expectedVersion }`.
52
- *
53
- * @method
54
- * @param {Array<{configKey: string, value: Object, expectedVersion: number}>} edits
55
- * @param {Object} meta
56
- * @param {string} meta.adminID
57
- * @param {string} [meta.note]
58
- * @returns {Promise<{ok: true, changeSetID: string, versions: Object<string, number>} | {ok: false, errors: Object<string, Array>}>}
59
- * @public
60
- */
61
- applyEdits( edits, meta ) {
62
- if ( !Array.isArray( edits ) || edits.length === 0 || !meta || !meta.adminID ) {
63
- return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-apply-input" } ) );
64
- }
65
-
66
- // Cross-document validation context: a document being edited is seen at its *pending* value via getConfig —
67
- // even when that document is the one currently under validation, so calling getConfig on "yourself" just
68
- // hands back the same incoming value already passed as the validator's first argument, not its prior state.
69
- // This lets a validator on one document check against the post-edit state of its siblings. getStoredConfig
70
- // is the counterpart: it always resolves the committed value, so a validator that must compare its own
71
- // document against its previous state (e.g. detecting an edit that should have bumped a version marker)
72
- // uses that instead.
73
- const pending = {};
74
- for ( const edit of edits ) {
75
- pending[ edit.configKey ] = edit.value;
76
- }
77
- const context = {
78
- getConfig: ( key ) => {
79
- if ( Object.prototype.hasOwnProperty.call( pending, key ) ) {
80
- return Promise.resolve( clone( pending[ key ] ) );
81
- }
82
- return this.#store.getCurrent( key ).then( ( current ) => ( current ? current.value : null ) );
83
- },
84
- // Always the committed value, even for a document inside this edit batch. A validator comparing its own
85
- // document against its previous state must use this; getConfig would hand back the pending value it is
86
- // currently validating.
87
- getStoredConfig: ( key ) => this.#store.getCurrent( key ).then( ( current ) => ( current ? current.value : null ) )
88
- };
89
-
90
- return Promise.all( edits.map( ( edit ) => {
91
- return this.#registry.validate( edit.configKey, edit.value, context ).then( ( result ) => ( { configKey: edit.configKey, valid: result.valid, errors: result.errors } ) );
92
- } ) ).then( ( results ) => {
93
- const errorsByKey = {};
94
- for ( const result of results ) {
95
- if ( !result.valid ) {
96
- errorsByKey[ result.configKey ] = result.errors;
97
- }
98
- }
99
- if ( Object.keys( errorsByKey ).length > 0 ) {
100
- return { ok: false, errors: errorsByKey };
101
- }
102
- return this.#store.saveChangeSet( edits, meta ).then( ( saved ) => {
103
- this.#notifier.publish( { changeSetID: saved.changeSetID, configKeys: Object.keys( saved.versions ), adminID: meta.adminID, timestamp: new Date().toISOString() } );
104
- return { ok: true, changeSetID: saved.changeSetID, versions: saved.versions };
105
- } );
106
- } );
107
- }
108
-
109
- /* Public interface — entity level (composite editors) */
110
-
111
- /**
112
- * Registers a composite editor over one or more documents.
113
- *
114
- * @method
115
- * @param {string} editorKey
116
- * @param {Object} definition
117
- * @param {string[]} definition.documents The configKeys this editor spans.
118
- * @param {function(Object): *} definition.compose Maps `{ [configKey]: value }` → a view for the UI.
119
- * @param {function(*, Object): Object<string, Object>} definition.decompose Maps `(editedView, currentDocs)` → the
120
- * full new values for the documents that changed (`{ [configKey]: newValue }`).
121
- * @param {Object} [definition.metadata]
122
- * @returns {ConfigService} this (chainable)
123
- * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS}
124
- * @public
125
- */
126
- registerEditor( editorKey, definition ) {
127
- const { documents, compose, decompose, metadata = {} } = definition || {};
128
- if ( !editorKey || !Array.isArray( documents ) || documents.length === 0 || typeof compose !== "function" || typeof decompose !== "function" ) {
129
- throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-editor-registration", editorKey: editorKey } );
130
- }
131
- this.#editors.set( editorKey, { documents: documents.slice(), compose: compose, decompose: decompose, metadata: metadata || {} } );
132
- return this;
133
- }
134
-
135
- /**
136
- * @method
137
- * @param {string} editorKey
138
- * @returns {boolean}
139
- * @public
140
- */
141
- hasEditor( editorKey ) {
142
- return this.#editors.has( editorKey );
143
- }
144
-
145
- /**
146
- * @method
147
- * @returns {string[]}
148
- * @public
149
- */
150
- listEditors() {
151
- return Array.from( this.#editors.keys() );
152
- }
153
-
154
- /**
155
- * Loads the editor's documents and composes them into a view. Returns the view plus the current per-document
156
- * versions, which the client must echo back on save for optimistic locking.
157
- *
158
- * @method
159
- * @param {string} editorKey
160
- * @returns {Promise<{rows: *, versions: Object<string, number>}>}
161
- * @public
162
- */
163
- composeView( editorKey ) {
164
- const editor = this.#editors.get( editorKey );
165
- if ( !editor ) {
166
- return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-editor", editorKey: editorKey } ) );
167
- }
168
- return this.#loadDocuments( editor.documents ).then( ( { docs, versions } ) => ( { rows: editor.compose( clone( docs ) ), versions: versions } ) );
169
- }
170
-
171
- /**
172
- * Applies an edit made against a composite editor: decompose the edited view into per-document new values, then
173
- * route through {@link ConfigService#applyEdits} (validate-all → atomic change-set). `expectedVersions` should be
174
- * the versions returned by {@link ConfigService#composeView} when the edit started.
175
- *
176
- * @method
177
- * @param {string} editorKey
178
- * @param {*} editedView
179
- * @param {Object} meta
180
- * @param {Object<string, number>} [expectedVersions]
181
- * @returns {Promise<Object>} The {@link ConfigService#applyEdits} result (or `{ ok:true, changeSetID:null }` if nothing changed).
182
- * @public
183
- */
184
- saveEditorEdit( editorKey, editedView, meta, expectedVersions = {} ) {
185
- const editor = this.#editors.get( editorKey );
186
- if ( !editor ) {
187
- return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-editor", editorKey: editorKey } ) );
188
- }
189
- return this.#loadDocuments( editor.documents ).then( ( { docs, versions } ) => {
190
- const newValues = editor.decompose( editedView, clone( docs ) ) || {};
191
- const edits = Object.keys( newValues ).map( ( key ) => ( {
192
- configKey: key,
193
- value: newValues[ key ],
194
- expectedVersion: ( expectedVersions && expectedVersions[ key ] != null ) ? expectedVersions[ key ] : versions[ key ]
195
- } ) );
196
- if ( edits.length === 0 ) {
197
- return { ok: true, changeSetID: null, versions: {} };
198
- }
199
- return this.applyEdits( edits, meta );
200
- } );
201
- }
202
-
203
- /* Public interface — audit, history, and restore */
204
-
205
- /**
206
- * Restores a prior change-set through the validated path: rebuild edits from the change-set's historic snapshots
207
- * and route them through {@link ConfigService#applyEdits} — so the restore is **re-validated against the current
208
- * schemas/validators** (a snapshot valid when written may be invalid now) and emits `config:changed`. Returns the
209
- * `applyEdits` result (`{ ok:false, errors }` if a snapshot no longer validates; nothing is written then).
210
- *
211
- * @method
212
- * @param {string} changeSetID
213
- * @param {Object} meta
214
- * @param {string} meta.adminID
215
- * @param {string} [meta.note]
216
- * @returns {Promise<Object>}
217
- * @public
218
- */
219
- restoreChangeSet( changeSetID, meta ) {
220
- if ( !meta || !meta.adminID ) {
221
- return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-restore-input" } ) );
222
- }
223
- return this.#store.getChangeSet( changeSetID ).then( ( record ) => {
224
- if ( !record ) {
225
- throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-changeset", changeSetID: changeSetID } );
226
- }
227
- return Promise.all( record.documents.map( ( doc ) => {
228
- return Promise.all( [ this.#store.getVersion( doc.configKey, doc.version ), this.#store.getCurrent( doc.configKey ) ] ).then( ( [ historic, current ] ) => ( {
229
- configKey: doc.configKey,
230
- value: historic ? historic.snapshot : null,
231
- expectedVersion: current ? current.version : 0
232
- } ) );
233
- } ) ).then( ( edits ) => this.applyEdits( edits, { adminID: meta.adminID, note: meta.note || ( "restored from change-set " + changeSetID ) } ) );
234
- } );
235
- }
236
-
237
- /**
238
- * @method
239
- * @param {string} configKey
240
- * @returns {Promise<Object|null>} The current envelope for a configuration document.
241
- * @public
242
- */
243
- getCurrent( configKey ) {
244
- return this.#store.getCurrent( configKey );
245
- }
246
-
247
- /**
248
- * @method
249
- * @param {string} configKey
250
- * @returns {Promise<Array<Object>>} The document's version history (ascending), each a full snapshot entry.
251
- * @public
252
- */
253
- getHistory( configKey ) {
254
- return this.#store.listHistory( configKey );
255
- }
256
-
257
- /**
258
- * @method
259
- * @param {string} changeSetID
260
- * @returns {Promise<Object|null>} A single change-set record.
261
- * @public
262
- */
263
- getChange( changeSetID ) {
264
- return this.#store.getChangeSet( changeSetID );
265
- }
266
-
267
- /**
268
- * @method
269
- * @returns {Promise<Array<Object>>} The cross-document audit feed (change-sets, most-recent first).
270
- * @public
271
- */
272
- listChanges() {
273
- return this.#store.listChangeSets();
274
- }
275
-
276
- /**
277
- * Builds a downloadable snapshot of the current live configuration — for every registered document, its repo file
278
- * `path` (from registration metadata, if provided), current `version`, and `value`. This is the one-way export
279
- * *out* of the store; an admin downloads it and commits the files to git. The store remains the live truth.
280
- *
281
- * @method
282
- * @param {Object} [meta]
283
- * @param {string} [meta.adminID]
284
- * @returns {Promise<{exportedAt: string, exportedBy: (string|null), documents: Array<{configKey: string, path: (string|null), version: number, value: Object}>}>}
285
- * @public
286
- */
287
- exportBundle( meta = {} ) {
288
- const keys = this.#registry.list();
289
- return Promise.all( keys.map( ( configKey ) => {
290
- return this.#store.getCurrent( configKey ).then( ( current ) => {
291
- const metadata = this.#registry.metadataFor( configKey ) || {};
292
- return {
293
- configKey: configKey,
294
- path: metadata.path || null,
295
- version: current ? current.version : 0,
296
- value: current ? current.value : null
297
- };
298
- } );
299
- } ) ).then( ( documents ) => ( {
300
- exportedAt: new Date().toISOString(),
301
- exportedBy: meta.adminID || null,
302
- documents: documents
303
- } ) );
304
- }
305
-
306
- /**
307
- * Seeds a document's default value into the store only if it has never been written (idempotent bootstrap).
308
- * Used by an application to bring its file defaults into the store at startup before serving live config.
309
- *
310
- * @method
311
- * @param {string} configKey
312
- * @param {Object} defaultValue
313
- * @returns {Promise<Object>} The current envelope.
314
- * @public
315
- */
316
- seedDefault( configKey, defaultValue ) {
317
- return this.#store.seedIfEmpty( configKey, defaultValue );
318
- }
319
-
320
- /**
321
- * Subscribes a listener to `config:changed` events (delegates to the change notifier). Returns an unsubscribe fn.
322
- *
323
- * @method
324
- * @param {function(Object): void} listener
325
- * @returns {function(): void}
326
- * @public
327
- */
328
- onConfigChanged( listener ) {
329
- return this.#notifier.subscribe( listener );
330
- }
331
-
332
- /* Private interface */
333
-
334
- /**
335
- * @method
336
- * @param {string[]} keys
337
- * @returns {Promise<{docs: Object<string, Object>, versions: Object<string, number>}>}
338
- * @private
339
- */
340
- #loadDocuments( keys ) {
341
- return Promise.all( keys.map( ( key ) => this.#store.getCurrent( key ) ) ).then( ( currents ) => {
342
- const docs = {};
343
- const versions = {};
344
- keys.forEach( ( key, index ) => {
345
- docs[ key ] = currents[ index ] ? currents[ index ].value : null;
346
- versions[ key ] = currents[ index ] ? currents[ index ].version : 0;
347
- } );
348
- return { docs: docs, versions: versions };
349
- } );
350
- }
351
-
352
- }
353
-
354
- function clone( value ) {
355
- return value === undefined || value === null ? value : JSON.parse( JSON.stringify( value ) );
356
- }
357
-
358
- const instance = new ConfigService();
359
- module.exports = ConfigService;
360
- module.exports.instance = instance;
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 {@link ValidatorContext} whose `getConfig` sees the *pending* values of the same edit —
17
+ * letting a validator check a sibling document's post-edit state — while `getStoredConfig` always returns the
18
+ * committed value, even for the document currently under validation) and, only if all pass, commit them as one
19
+ * change-set. Validation failures return `{ ok:false, errors }` and write nothing; a version conflict from the
20
+ * store surfaces as a rejection.
21
+ * - **Entity level** — composite editors registered with `compose(docs)→view` / `decompose(edited, docs)→{key:value}`,
22
+ * so the UI edits a domain entity (e.g. a "competency") that is projected from, and scattered back into, several
23
+ * documents. {@link ConfigService#saveEditorEdit} decomposes the edit and routes it through `applyEdits`.
24
+ *
25
+ * @class ConfigService
26
+ * @public
27
+ */
28
+ class ConfigService {
29
+
30
+ #store;
31
+ #registry;
32
+ #notifier;
33
+ #editors = new Map();
34
+
35
+ /**
36
+ * @constructor
37
+ * @param {Object} [options]
38
+ * @param {ConfigStore} [options.store] Defaults to the ConfigStore singleton.
39
+ * @param {ConfigRegistry} [options.registry] Defaults to the ConfigRegistry singleton.
40
+ * @param {ConfigChangeNotifier} [options.notifier] Defaults to the ConfigChangeNotifier singleton.
41
+ */
42
+ constructor( options = {} ) {
43
+ this.#store = options.store || require( "#config-store" ).instance;
44
+ this.#registry = options.registry || require( "#config-registry" ).instance;
45
+ this.#notifier = options.notifier || require( "#config-change-notifier" ).instance;
46
+ }
47
+
48
+ /* Public interface — document level */
49
+
50
+ /**
51
+ * Validates and commits a set of document edits atomically. Each edit: `{ configKey, value, expectedVersion }`.
52
+ *
53
+ * @method
54
+ * @param {Array<{configKey: string, value: Object, expectedVersion: number}>} edits
55
+ * @param {Object} meta
56
+ * @param {string} meta.adminID
57
+ * @param {string} [meta.note]
58
+ * @returns {Promise<{ok: true, changeSetID: string, versions: Object<string, number>} | {ok: false, errors: Object<string, Array>}>}
59
+ * @public
60
+ */
61
+ applyEdits( edits, meta ) {
62
+ if ( !Array.isArray( edits ) || edits.length === 0 || !meta || !meta.adminID ) {
63
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-apply-input" } ) );
64
+ }
65
+
66
+ // Cross-document validation context: a document being edited is seen at its *pending* value via getConfig —
67
+ // even when that document is the one currently under validation, so calling getConfig on "yourself" just
68
+ // hands back the same incoming value already passed as the validator's first argument, not its prior state.
69
+ // This lets a validator on one document check against the post-edit state of its siblings. getStoredConfig
70
+ // is the counterpart: it always resolves the committed value, so a validator that must compare its own
71
+ // document against its previous state (e.g. detecting an edit that should have bumped a version marker)
72
+ // uses that instead.
73
+ const pending = {};
74
+ for ( const edit of edits ) {
75
+ pending[ edit.configKey ] = edit.value;
76
+ }
77
+ const context = {
78
+ getConfig: ( key ) => {
79
+ if ( Object.prototype.hasOwnProperty.call( pending, key ) ) {
80
+ return Promise.resolve( clone( pending[ key ] ) );
81
+ }
82
+ return this.#store.getCurrent( key ).then( ( current ) => ( current ? current.value : null ) );
83
+ },
84
+ // Always the committed value, even for a document inside this edit batch. A validator comparing its own
85
+ // document against its previous state must use this; getConfig would hand back the pending value it is
86
+ // currently validating.
87
+ getStoredConfig: ( key ) => this.#store.getCurrent( key ).then( ( current ) => ( current ? current.value : null ) )
88
+ };
89
+
90
+ return Promise.all( edits.map( ( edit ) => {
91
+ return this.#registry.validate( edit.configKey, edit.value, context ).then( ( result ) => ( { configKey: edit.configKey, valid: result.valid, errors: result.errors } ) );
92
+ } ) ).then( ( results ) => {
93
+ const errorsByKey = {};
94
+ for ( const result of results ) {
95
+ if ( !result.valid ) {
96
+ errorsByKey[ result.configKey ] = result.errors;
97
+ }
98
+ }
99
+ if ( Object.keys( errorsByKey ).length > 0 ) {
100
+ return { ok: false, errors: errorsByKey };
101
+ }
102
+ return this.#store.saveChangeSet( edits, meta ).then( ( saved ) => {
103
+ this.#notifier.publish( { changeSetID: saved.changeSetID, configKeys: Object.keys( saved.versions ), adminID: meta.adminID, timestamp: new Date().toISOString() } );
104
+ return { ok: true, changeSetID: saved.changeSetID, versions: saved.versions };
105
+ } );
106
+ } );
107
+ }
108
+
109
+ /* Public interface — entity level (composite editors) */
110
+
111
+ /**
112
+ * Registers a composite editor over one or more documents.
113
+ *
114
+ * @method
115
+ * @param {string} editorKey
116
+ * @param {Object} definition
117
+ * @param {string[]} definition.documents The configKeys this editor spans.
118
+ * @param {function(Object): *} definition.compose Maps `{ [configKey]: value }` → a view for the UI.
119
+ * @param {function(*, Object): Object<string, Object>} definition.decompose Maps `(editedView, currentDocs)` → the
120
+ * full new values for the documents that changed (`{ [configKey]: newValue }`).
121
+ * @param {Object} [definition.metadata]
122
+ * @returns {ConfigService} this (chainable)
123
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS}
124
+ * @public
125
+ */
126
+ registerEditor( editorKey, definition ) {
127
+ const { documents, compose, decompose, metadata = {} } = definition || {};
128
+ if ( !editorKey || !Array.isArray( documents ) || documents.length === 0 || typeof compose !== "function" || typeof decompose !== "function" ) {
129
+ throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-editor-registration", editorKey: editorKey } );
130
+ }
131
+ this.#editors.set( editorKey, { documents: documents.slice(), compose: compose, decompose: decompose, metadata: metadata || {} } );
132
+ return this;
133
+ }
134
+
135
+ /**
136
+ * @method
137
+ * @param {string} editorKey
138
+ * @returns {boolean}
139
+ * @public
140
+ */
141
+ hasEditor( editorKey ) {
142
+ return this.#editors.has( editorKey );
143
+ }
144
+
145
+ /**
146
+ * @method
147
+ * @returns {string[]}
148
+ * @public
149
+ */
150
+ listEditors() {
151
+ return Array.from( this.#editors.keys() );
152
+ }
153
+
154
+ /**
155
+ * Loads the editor's documents and composes them into a view. Returns the view plus the current per-document
156
+ * versions, which the client must echo back on save for optimistic locking.
157
+ *
158
+ * @method
159
+ * @param {string} editorKey
160
+ * @returns {Promise<{rows: *, versions: Object<string, number>}>}
161
+ * @public
162
+ */
163
+ composeView( editorKey ) {
164
+ const editor = this.#editors.get( editorKey );
165
+ if ( !editor ) {
166
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-editor", editorKey: editorKey } ) );
167
+ }
168
+ return this.#loadDocuments( editor.documents ).then( ( { docs, versions } ) => ( { rows: editor.compose( clone( docs ) ), versions: versions } ) );
169
+ }
170
+
171
+ /**
172
+ * Applies an edit made against a composite editor: decompose the edited view into per-document new values, then
173
+ * route through {@link ConfigService#applyEdits} (validate-all → atomic change-set). `expectedVersions` should be
174
+ * the versions returned by {@link ConfigService#composeView} when the edit started.
175
+ *
176
+ * @method
177
+ * @param {string} editorKey
178
+ * @param {*} editedView
179
+ * @param {Object} meta
180
+ * @param {Object<string, number>} [expectedVersions]
181
+ * @returns {Promise<Object>} The {@link ConfigService#applyEdits} result (or `{ ok:true, changeSetID:null }` if nothing changed).
182
+ * @public
183
+ */
184
+ saveEditorEdit( editorKey, editedView, meta, expectedVersions = {} ) {
185
+ const editor = this.#editors.get( editorKey );
186
+ if ( !editor ) {
187
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-editor", editorKey: editorKey } ) );
188
+ }
189
+ return this.#loadDocuments( editor.documents ).then( ( { docs, versions } ) => {
190
+ const newValues = editor.decompose( editedView, clone( docs ) ) || {};
191
+ const edits = Object.keys( newValues ).map( ( key ) => ( {
192
+ configKey: key,
193
+ value: newValues[ key ],
194
+ expectedVersion: ( expectedVersions && expectedVersions[ key ] != null ) ? expectedVersions[ key ] : versions[ key ]
195
+ } ) );
196
+ if ( edits.length === 0 ) {
197
+ return { ok: true, changeSetID: null, versions: {} };
198
+ }
199
+ return this.applyEdits( edits, meta );
200
+ } );
201
+ }
202
+
203
+ /* Public interface — audit, history, and restore */
204
+
205
+ /**
206
+ * Restores a prior change-set through the validated path: rebuild edits from the change-set's historic snapshots
207
+ * and route them through {@link ConfigService#applyEdits} — so the restore is **re-validated against the current
208
+ * schemas/validators** (a snapshot valid when written may be invalid now) and emits `config:changed`. Returns the
209
+ * `applyEdits` result (`{ ok:false, errors }` if a snapshot no longer validates; nothing is written then).
210
+ *
211
+ * @method
212
+ * @param {string} changeSetID
213
+ * @param {Object} meta
214
+ * @param {string} meta.adminID
215
+ * @param {string} [meta.note]
216
+ * @returns {Promise<Object>}
217
+ * @public
218
+ */
219
+ restoreChangeSet( changeSetID, meta ) {
220
+ if ( !meta || !meta.adminID ) {
221
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-restore-input" } ) );
222
+ }
223
+ return this.#store.getChangeSet( changeSetID ).then( ( record ) => {
224
+ if ( !record ) {
225
+ throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-changeset", changeSetID: changeSetID } );
226
+ }
227
+ return Promise.all( record.documents.map( ( doc ) => {
228
+ return Promise.all( [ this.#store.getVersion( doc.configKey, doc.version ), this.#store.getCurrent( doc.configKey ) ] ).then( ( [ historic, current ] ) => ( {
229
+ configKey: doc.configKey,
230
+ value: historic ? historic.snapshot : null,
231
+ expectedVersion: current ? current.version : 0
232
+ } ) );
233
+ } ) ).then( ( edits ) => this.applyEdits( edits, { adminID: meta.adminID, note: meta.note || ( "restored from change-set " + changeSetID ) } ) );
234
+ } );
235
+ }
236
+
237
+ /**
238
+ * @method
239
+ * @param {string} configKey
240
+ * @returns {Promise<Object|null>} The current envelope for a configuration document.
241
+ * @public
242
+ */
243
+ getCurrent( configKey ) {
244
+ return this.#store.getCurrent( configKey );
245
+ }
246
+
247
+ /**
248
+ * @method
249
+ * @param {string} configKey
250
+ * @returns {Promise<Array<Object>>} The document's version history (ascending), each a full snapshot entry.
251
+ * @public
252
+ */
253
+ getHistory( configKey ) {
254
+ return this.#store.listHistory( configKey );
255
+ }
256
+
257
+ /**
258
+ * @method
259
+ * @param {string} changeSetID
260
+ * @returns {Promise<Object|null>} A single change-set record.
261
+ * @public
262
+ */
263
+ getChange( changeSetID ) {
264
+ return this.#store.getChangeSet( changeSetID );
265
+ }
266
+
267
+ /**
268
+ * @method
269
+ * @returns {Promise<Array<Object>>} The cross-document audit feed (change-sets, most-recent first).
270
+ * @public
271
+ */
272
+ listChanges() {
273
+ return this.#store.listChangeSets();
274
+ }
275
+
276
+ /**
277
+ * Builds a downloadable snapshot of the current live configuration — for every registered document, its repo file
278
+ * `path` (from registration metadata, if provided), current `version`, and `value`. This is the one-way export
279
+ * *out* of the store; an admin downloads it and commits the files to git. The store remains the live truth.
280
+ *
281
+ * @method
282
+ * @param {Object} [meta]
283
+ * @param {string} [meta.adminID]
284
+ * @returns {Promise<{exportedAt: string, exportedBy: (string|null), documents: Array<{configKey: string, path: (string|null), version: number, value: Object}>}>}
285
+ * @public
286
+ */
287
+ exportBundle( meta = {} ) {
288
+ const keys = this.#registry.list();
289
+ return Promise.all( keys.map( ( configKey ) => {
290
+ return this.#store.getCurrent( configKey ).then( ( current ) => {
291
+ const metadata = this.#registry.metadataFor( configKey ) || {};
292
+ return {
293
+ configKey: configKey,
294
+ path: metadata.path || null,
295
+ version: current ? current.version : 0,
296
+ value: current ? current.value : null
297
+ };
298
+ } );
299
+ } ) ).then( ( documents ) => ( {
300
+ exportedAt: new Date().toISOString(),
301
+ exportedBy: meta.adminID || null,
302
+ documents: documents
303
+ } ) );
304
+ }
305
+
306
+ /**
307
+ * Seeds a document's default value into the store only if it has never been written (idempotent bootstrap).
308
+ * Used by an application to bring its file defaults into the store at startup before serving live config.
309
+ *
310
+ * @method
311
+ * @param {string} configKey
312
+ * @param {Object} defaultValue
313
+ * @returns {Promise<Object>} The current envelope.
314
+ * @public
315
+ */
316
+ seedDefault( configKey, defaultValue ) {
317
+ return this.#store.seedIfEmpty( configKey, defaultValue );
318
+ }
319
+
320
+ /**
321
+ * Subscribes a listener to `config:changed` events (delegates to the change notifier). Returns an unsubscribe fn.
322
+ *
323
+ * @method
324
+ * @param {function(Object): void} listener
325
+ * @returns {function(): void}
326
+ * @public
327
+ */
328
+ onConfigChanged( listener ) {
329
+ return this.#notifier.subscribe( listener );
330
+ }
331
+
332
+ /* Private interface */
333
+
334
+ /**
335
+ * @method
336
+ * @param {string[]} keys
337
+ * @returns {Promise<{docs: Object<string, Object>, versions: Object<string, number>}>}
338
+ * @private
339
+ */
340
+ #loadDocuments( keys ) {
341
+ return Promise.all( keys.map( ( key ) => this.#store.getCurrent( key ) ) ).then( ( currents ) => {
342
+ const docs = {};
343
+ const versions = {};
344
+ keys.forEach( ( key, index ) => {
345
+ docs[ key ] = currents[ index ] ? currents[ index ].value : null;
346
+ versions[ key ] = currents[ index ] ? currents[ index ].version : 0;
347
+ } );
348
+ return { docs: docs, versions: versions };
349
+ } );
350
+ }
351
+
352
+ }
353
+
354
+ function clone( value ) {
355
+ return value === undefined || value === null ? value : JSON.parse( JSON.stringify( value ) );
356
+ }
357
+
358
+ const instance = new ConfigService();
359
+ module.exports = ConfigService;
360
+ module.exports.instance = instance;