@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.
- package/.env +4 -0
- package/CHANGELOG.md +274 -0
- package/README.md +26 -0
- package/bin/build/post-install.js +18 -0
- package/bin/localization/web-server-labels.json +28 -0
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +6 -0
- package/bin/static/favicon.ico +0 -0
- package/bin/static/fragments/components/component-notification-bar.html +21 -0
- package/bin/static/fragments/components/component-sidebar-flyout.html +37 -0
- package/bin/static/fragments/components/component-sidebar.html +33 -0
- package/bin/static/fragments/components/component-tooltip.html +11 -0
- package/bin/static/fragments/components/component-topbar.html +6 -0
- package/bin/static/fragments/frame-administration.html +3 -0
- package/bin/static/fragments/frame-application.html +19 -0
- package/bin/static/fragments/frame-dashboard.html +3 -0
- package/bin/static/fragments/frame-login.html +105 -0
- package/bin/static/fragments/frame-not-found.html +3 -0
- package/bin/static/fragments/frame-profile.html +3 -0
- package/bin/static/index.html +23 -0
- package/bin/static/scripts/lib/alpinejs-csp.min.js +7 -0
- package/bin/static/scripts/lib/htmx.min.js +1 -0
- package/bin/static/scripts/lib/safe-nonce.min.js +1 -0
- package/bin/static/scripts/ti-charts.js +1591 -0
- package/bin/static/scripts/ti-framework.css +3195 -0
- package/bin/static/scripts/ti-framework.js +1427 -0
- package/bin/static/scripts/ti-theme-black-glass.css +216 -0
- package/bin/static/scripts/ti-theme-daylight.css +87 -0
- package/bin/web-app-manager.js +564 -0
- package/bin/web-server.js +604 -0
- package/bin/web-server.json +49 -0
- package/components/admin-config-handlers.js +92 -0
- package/components/auth-manager.js +344 -0
- package/components/authorization.js +135 -0
- package/components/config-change-notifier.js +98 -0
- package/components/config-registry.js +246 -0
- package/components/config-service.js +349 -0
- package/components/config-store.js +246 -0
- package/components/definitions.types.js +26 -0
- package/components/session-store.js +111 -0
- package/components/user.js +133 -0
- package/components/web-handlers.js +765 -0
- 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;
|