@ti-engine/web-framework 1.19.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +4 -4
- package/CHANGELOG.md +384 -353
- package/README.md +73 -73
- package/bin/build/post-install.js +18 -18
- package/bin/localization/web-server-labels.json +27 -27
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
- package/bin/static/fragments/components/component-notification-bar.html +21 -21
- package/bin/static/fragments/components/component-sidebar.html +33 -33
- package/bin/static/fragments/components/component-tooltip.html +10 -10
- package/bin/static/fragments/components/component-topbar.html +5 -5
- package/bin/static/fragments/frame-administration.html +2 -2
- package/bin/static/fragments/frame-application.html +18 -18
- package/bin/static/fragments/frame-dashboard.html +2 -2
- package/bin/static/fragments/frame-login.html +119 -119
- package/bin/static/fragments/frame-not-found.html +2 -2
- package/bin/static/fragments/frame-profile.html +2 -2
- package/bin/static/index.html +22 -22
- package/bin/static/scripts/ti-charts.js +1591 -1591
- package/bin/static/scripts/ti-framework.css +3194 -3194
- package/bin/static/scripts/ti-framework.js +1427 -1427
- package/bin/static/scripts/ti-theme-black-glass.css +216 -216
- package/bin/static/scripts/ti-theme-daylight.css +87 -87
- package/bin/web-app-manager.js +660 -663
- package/bin/web-server.js +936 -937
- package/bin/web-server.json +48 -48
- package/components/admin-config-handlers.js +95 -92
- package/components/auth-manager.js +438 -442
- package/components/authorization.js +135 -135
- package/components/config-change-notifier.js +98 -98
- package/components/config-registry.js +257 -260
- package/components/config-service.js +363 -360
- package/components/config-store.js +244 -246
- package/components/definitions.types.js +28 -26
- package/components/session-store.js +113 -110
- package/components/user.js +134 -132
- package/components/web-config-env.js +85 -85
- package/components/web-handlers.js +803 -800
- package/package.json +139 -67
- package/types/bin/web-app-manager.d.ts +194 -0
- package/types/bin/web-server.d.ts +373 -0
- package/types/components/admin-config-handlers.d.ts +11 -0
- package/types/components/auth-manager.d.ts +125 -0
- package/types/components/authorization.d.ts +54 -0
- package/types/components/config-change-notifier.d.ts +73 -0
- package/types/components/config-registry.d.ts +149 -0
- package/types/components/config-service.d.ts +218 -0
- package/types/components/config-store.d.ts +128 -0
- package/types/components/definitions.types.d.ts +31 -0
- package/types/components/session-store.d.ts +56 -0
- package/types/components/user.d.ts +83 -0
- package/types/components/web-config-env.d.ts +17 -0
- package/types/components/web-handlers.d.ts +23 -0
|
@@ -1,246 +1,244 @@
|
|
|
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 cache = require( "@ti-engine/core/cache" );
|
|
10
|
-
const tools = require( "@ti-engine/core/tools" );
|
|
11
|
-
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
12
|
-
|
|
13
|
-
const KEY_CURRENT = "ti:config:cur:"; // + configKey → current document envelope
|
|
14
|
-
const KEY_HISTORY = "ti:config:hist:"; // + configKey + ":" + version → history snapshot entry
|
|
15
|
-
const KEY_CHANGESET = "ti:config:cs:"; // + changeSetID → change-set record
|
|
16
|
-
const SEED_ACTOR = "system:seed";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* A versioned, change-set-aware configuration store backed by the common memory cache (RedisJSON).
|
|
20
|
-
* <br/>
|
|
21
|
-
* Each editable configuration is a *document* identified by a `configKey`. Every committed edit:
|
|
22
|
-
* - bumps the document's monotonic `version`,
|
|
23
|
-
* - writes a full **snapshot** to history (enabling restore),
|
|
24
|
-
* - and is correlated with the other documents written in the same logical edit via a shared **change-set** id,
|
|
25
|
-
* so a multi-document edit (and its restore) is treated as one unit even though storage is per-document.
|
|
26
|
-
* <br/>
|
|
27
|
-
* Optimistic locking: callers pass the `expectedVersion` they edited from; the save is rejected if any document
|
|
28
|
-
* moved on in the meantime. This component is storage-only — schema/semantic validation is a separate pipeline
|
|
29
|
-
* that must run *before* {@link ConfigStore#saveChangeSet}.
|
|
30
|
-
* <br/>
|
|
31
|
-
* NOTE: true cross-document atomicity is not provided (the cache exposes per-key commands only). All locks are
|
|
32
|
-
* checked *before* any write, so the common conflict case is safe; a mid-write process failure can leave a
|
|
33
|
-
* partially-applied change-set, detectable via the change-set record. Hardening (a Lua/MULTI write) is deferred.
|
|
34
|
-
*
|
|
35
|
-
* @class ConfigStore
|
|
36
|
-
* @public
|
|
37
|
-
*/
|
|
38
|
-
class ConfigStore {
|
|
39
|
-
|
|
40
|
-
/* Public interface */
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Returns the current envelope `{ value, version, updatedAt, updatedBy, changeSetID }` for a configuration
|
|
44
|
-
* document, or `null` if it has never been written.
|
|
45
|
-
*
|
|
46
|
-
* @method
|
|
47
|
-
* @param {string} configKey
|
|
48
|
-
* @returns {Promise<Object|null>}
|
|
49
|
-
* @public
|
|
50
|
-
*/
|
|
51
|
-
getCurrent( configKey ) {
|
|
52
|
-
return this.#readJSON( KEY_CURRENT + configKey );
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Writes the default value as version 1 only if the document does not yet exist (idempotent bootstrap).
|
|
57
|
-
* Resolves with the current envelope either way.
|
|
58
|
-
*
|
|
59
|
-
* @method
|
|
60
|
-
* @param {string} configKey
|
|
61
|
-
* @param {Object} defaultValue
|
|
62
|
-
* @returns {Promise<Object>}
|
|
63
|
-
* @public
|
|
64
|
-
*/
|
|
65
|
-
seedIfEmpty( configKey, defaultValue ) {
|
|
66
|
-
return this.getCurrent( configKey ).then( ( current ) => {
|
|
67
|
-
if ( current ) return current;
|
|
68
|
-
const timestamp = new Date().toISOString();
|
|
69
|
-
const envelope = { value: defaultValue, version: 1, updatedAt: timestamp, updatedBy: SEED_ACTOR, changeSetID: null };
|
|
70
|
-
const historyEntry = { version: 1, timestamp: timestamp, adminID: SEED_ACTOR, note: "seed from defaults", changeSetID: null, snapshot: defaultValue };
|
|
71
|
-
return Promise.all( [
|
|
72
|
-
this.#writeJSON( KEY_CURRENT + configKey, envelope ),
|
|
73
|
-
this.#writeJSON( KEY_HISTORY + configKey + ":1", historyEntry )
|
|
74
|
-
] ).then( () => envelope );
|
|
75
|
-
} );
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Commits an edit spanning one or more documents as a single change-set. All optimistic-lock checks run before
|
|
80
|
-
* any write. Each edit: `{ configKey, value, expectedVersion }`.
|
|
81
|
-
*
|
|
82
|
-
* @method
|
|
83
|
-
* @param {Array<{configKey: string, value: Object, expectedVersion: number}>} edits
|
|
84
|
-
* @param {Object} meta
|
|
85
|
-
* @param {string} meta.adminID
|
|
86
|
-
* @param {string} [meta.note]
|
|
87
|
-
* @returns {Promise<{changeSetID: string, versions: Object<string, number>}>}
|
|
88
|
-
* @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} On bad input or a version conflict (see `details`).
|
|
89
|
-
* @public
|
|
90
|
-
*/
|
|
91
|
-
saveChangeSet( edits, meta ) {
|
|
92
|
-
if ( !Array.isArray( edits ) || edits.length === 0 || !meta || !meta.adminID ) {
|
|
93
|
-
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-changeset-input" } ) );
|
|
94
|
-
}
|
|
95
|
-
const keys = edits.map( ( e ) => e.configKey );
|
|
96
|
-
if ( new Set( keys ).size !== keys.length ) {
|
|
97
|
-
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "duplicate-configKey-in-changeset" } ) );
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return Promise.all( keys.map( ( key ) => this.getCurrent( key ) ) ).then( ( currents ) => {
|
|
101
|
-
// Lock check across the whole set first — no writes until every document is confirmed unchanged.
|
|
102
|
-
const conflicts = [];
|
|
103
|
-
edits.forEach( ( edit, i ) => {
|
|
104
|
-
const actual = currents[ i ] ? currents[ i ].version : 0;
|
|
105
|
-
if ( edit.expectedVersion !== actual ) conflicts.push( { configKey: edit.configKey, expectedVersion: edit.expectedVersion, actualVersion: actual } );
|
|
106
|
-
} );
|
|
107
|
-
if ( conflicts.length ) {
|
|
108
|
-
throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "version-conflict", conflicts: conflicts } );
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const changeSetID = tools.getUUID();
|
|
112
|
-
const timestamp = new Date().toISOString();
|
|
113
|
-
const note = meta.note || "";
|
|
114
|
-
const versions = {};
|
|
115
|
-
const writes = [];
|
|
116
|
-
edits.forEach( ( edit, i ) => {
|
|
117
|
-
const newVersion = ( currents[ i ] ? currents[ i ].version : 0 ) + 1;
|
|
118
|
-
versions[ edit.configKey ] = newVersion;
|
|
119
|
-
const envelope = { value: edit.value, version: newVersion, updatedAt: timestamp, updatedBy: meta.adminID, changeSetID: changeSetID };
|
|
120
|
-
const historyEntry = { version: newVersion, timestamp: timestamp, adminID: meta.adminID, note: note, changeSetID: changeSetID, snapshot: edit.value };
|
|
121
|
-
writes.push( this.#writeJSON( KEY_CURRENT + edit.configKey, envelope ) );
|
|
122
|
-
writes.push( this.#writeJSON( KEY_HISTORY + edit.configKey + ":" + newVersion, historyEntry ) );
|
|
123
|
-
} );
|
|
124
|
-
const changeSetRecord = {
|
|
125
|
-
changeSetID: changeSetID,
|
|
126
|
-
timestamp: timestamp,
|
|
127
|
-
adminID: meta.adminID,
|
|
128
|
-
note: note,
|
|
129
|
-
documents: edits.map( ( edit ) => ( { configKey: edit.configKey, version: versions[ edit.configKey ] } ) )
|
|
130
|
-
};
|
|
131
|
-
writes.push( this.#writeJSON( KEY_CHANGESET + changeSetID, changeSetRecord ) );
|
|
132
|
-
|
|
133
|
-
return Promise.all( writes ).then( () => ( { changeSetID: changeSetID, versions: versions } ) );
|
|
134
|
-
} );
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Returns all history entries for a document, ascending by version.
|
|
139
|
-
*
|
|
140
|
-
* @method
|
|
141
|
-
* @param {string} configKey
|
|
142
|
-
* @returns {Promise<Array<Object>>}
|
|
143
|
-
* @public
|
|
144
|
-
*/
|
|
145
|
-
listHistory( configKey ) {
|
|
146
|
-
return cache.instance.matchKeys( KEY_HISTORY + configKey + ":*" ).then( ( keys ) => {
|
|
147
|
-
return Promise.all( ( keys || [] ).map( ( k ) => this.#readJSON( k ) ) );
|
|
148
|
-
} ).then( ( entries ) => entries.filter( Boolean ).sort( ( a, b ) => a.version - b.version ) );
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Returns a single history snapshot entry for a document version, or `null`.
|
|
153
|
-
*
|
|
154
|
-
* @method
|
|
155
|
-
* @param {string} configKey
|
|
156
|
-
* @param {number} version
|
|
157
|
-
* @returns {Promise<Object|null>}
|
|
158
|
-
* @public
|
|
159
|
-
*/
|
|
160
|
-
getVersion( configKey, version ) {
|
|
161
|
-
return this.#readJSON( KEY_HISTORY + configKey + ":" + version );
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Returns a change-set record by id, or `null`.
|
|
166
|
-
*
|
|
167
|
-
* @method
|
|
168
|
-
* @param {string} changeSetID
|
|
169
|
-
* @returns {Promise<Object|null>}
|
|
170
|
-
* @public
|
|
171
|
-
*/
|
|
172
|
-
getChangeSet( changeSetID ) {
|
|
173
|
-
return this.#readJSON( KEY_CHANGESET + changeSetID );
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Returns every change-set record, most-recent first (the cross-document audit feed).
|
|
178
|
-
*
|
|
179
|
-
* @method
|
|
180
|
-
* @returns {Promise<Array<Object>>}
|
|
181
|
-
* @public
|
|
182
|
-
*/
|
|
183
|
-
listChangeSets() {
|
|
184
|
-
return cache.instance.matchKeys( KEY_CHANGESET + "*" ).then( ( keys ) => {
|
|
185
|
-
return Promise.all( ( keys || [] ).map( ( key ) => this.#readJSON( key ) ) );
|
|
186
|
-
} ).then( ( records ) => records.filter( Boolean ).sort( ( a, b ) => ( a.timestamp < b.timestamp ? 1 : ( a.timestamp > b.timestamp ? -1 : 0 ) ) ) );
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Restores every document in a prior change-set to that change-set's snapshot, committing it as a *new*
|
|
191
|
-
* change-set (restore is never destructive — it moves forward to a past state).
|
|
192
|
-
*
|
|
193
|
-
* @method
|
|
194
|
-
* @param {string} changeSetID
|
|
195
|
-
* @param {Object} meta
|
|
196
|
-
* @param {string} meta.adminID
|
|
197
|
-
* @param {string} [meta.note]
|
|
198
|
-
* @returns {Promise<{changeSetID: string, versions: Object<string, number>}>}
|
|
199
|
-
* @public
|
|
200
|
-
*/
|
|
201
|
-
restoreChangeSet( changeSetID, meta ) {
|
|
202
|
-
if ( !meta || !meta.adminID ) {
|
|
203
|
-
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-restore-input" } ) );
|
|
204
|
-
}
|
|
205
|
-
return this.getChangeSet( changeSetID ).then( ( record ) => {
|
|
206
|
-
if ( !record ) throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-changeset", changeSetID: changeSetID } );
|
|
207
|
-
return Promise.all( record.documents.map( ( doc ) => {
|
|
208
|
-
return Promise.all( [ this.getVersion( doc.configKey, doc.version ), this.getCurrent( doc.configKey ) ] ).then( ( [ historic, current ] ) => {
|
|
209
|
-
return { configKey: doc.configKey, value: historic ? historic.snapshot : null, expectedVersion: current ? current.version : 0 };
|
|
210
|
-
} );
|
|
211
|
-
} ) ).then( ( edits ) => {
|
|
212
|
-
return this.saveChangeSet( edits, { adminID: meta.adminID, note: meta.note || ( "restored from change-set " + changeSetID ) } );
|
|
213
|
-
} );
|
|
214
|
-
} );
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/* Private interface */
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Reads a whole JSON document at the `$` root and unwraps RedisJSON's array result.
|
|
221
|
-
*
|
|
222
|
-
* @method
|
|
223
|
-
* @param {string} key
|
|
224
|
-
* @returns {Promise<Object|null>}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
* @
|
|
233
|
-
* @param {
|
|
234
|
-
* @
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
module.exports = ConfigStore;
|
|
246
|
-
module.exports.instance = Object.freeze( 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 cache = require( "@ti-engine/core/cache" );
|
|
10
|
+
const tools = require( "@ti-engine/core/tools" );
|
|
11
|
+
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
12
|
+
|
|
13
|
+
const KEY_CURRENT = "ti:config:cur:"; // + configKey → current document envelope
|
|
14
|
+
const KEY_HISTORY = "ti:config:hist:"; // + configKey + ":" + version → history snapshot entry
|
|
15
|
+
const KEY_CHANGESET = "ti:config:cs:"; // + changeSetID → change-set record
|
|
16
|
+
const SEED_ACTOR = "system:seed";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A versioned, change-set-aware configuration store backed by the common memory cache (RedisJSON).
|
|
20
|
+
* <br/>
|
|
21
|
+
* Each editable configuration is a *document* identified by a `configKey`. Every committed edit:
|
|
22
|
+
* - bumps the document's monotonic `version`,
|
|
23
|
+
* - writes a full **snapshot** to history (enabling restore),
|
|
24
|
+
* - and is correlated with the other documents written in the same logical edit via a shared **change-set** id,
|
|
25
|
+
* so a multi-document edit (and its restore) is treated as one unit even though storage is per-document.
|
|
26
|
+
* <br/>
|
|
27
|
+
* Optimistic locking: callers pass the `expectedVersion` they edited from; the save is rejected if any document
|
|
28
|
+
* moved on in the meantime. This component is storage-only — schema/semantic validation is a separate pipeline
|
|
29
|
+
* that must run *before* {@link ConfigStore#saveChangeSet}.
|
|
30
|
+
* <br/>
|
|
31
|
+
* NOTE: true cross-document atomicity is not provided (the cache exposes per-key commands only). All locks are
|
|
32
|
+
* checked *before* any write, so the common conflict case is safe; a mid-write process failure can leave a
|
|
33
|
+
* partially-applied change-set, detectable via the change-set record. Hardening (a Lua/MULTI write) is deferred.
|
|
34
|
+
*
|
|
35
|
+
* @class ConfigStore
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
class ConfigStore {
|
|
39
|
+
|
|
40
|
+
/* Public interface */
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns the current envelope `{ value, version, updatedAt, updatedBy, changeSetID }` for a configuration
|
|
44
|
+
* document, or `null` if it has never been written.
|
|
45
|
+
*
|
|
46
|
+
* @method
|
|
47
|
+
* @param {string} configKey
|
|
48
|
+
* @returns {Promise<Object|null>}
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
getCurrent( configKey ) {
|
|
52
|
+
return this.#readJSON( KEY_CURRENT + configKey );
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Writes the default value as version 1 only if the document does not yet exist (idempotent bootstrap).
|
|
57
|
+
* Resolves with the current envelope either way.
|
|
58
|
+
*
|
|
59
|
+
* @method
|
|
60
|
+
* @param {string} configKey
|
|
61
|
+
* @param {Object} defaultValue
|
|
62
|
+
* @returns {Promise<Object>}
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
seedIfEmpty( configKey, defaultValue ) {
|
|
66
|
+
return this.getCurrent( configKey ).then( ( current ) => {
|
|
67
|
+
if ( current ) return current;
|
|
68
|
+
const timestamp = new Date().toISOString();
|
|
69
|
+
const envelope = { value: defaultValue, version: 1, updatedAt: timestamp, updatedBy: SEED_ACTOR, changeSetID: null };
|
|
70
|
+
const historyEntry = { version: 1, timestamp: timestamp, adminID: SEED_ACTOR, note: "seed from defaults", changeSetID: null, snapshot: defaultValue };
|
|
71
|
+
return Promise.all( [
|
|
72
|
+
this.#writeJSON( KEY_CURRENT + configKey, envelope ),
|
|
73
|
+
this.#writeJSON( KEY_HISTORY + configKey + ":1", historyEntry )
|
|
74
|
+
] ).then( () => envelope );
|
|
75
|
+
} );
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Commits an edit spanning one or more documents as a single change-set. All optimistic-lock checks run before
|
|
80
|
+
* any write. Each edit: `{ configKey, value, expectedVersion }`.
|
|
81
|
+
*
|
|
82
|
+
* @method
|
|
83
|
+
* @param {Array<{configKey: string, value: Object, expectedVersion: number}>} edits
|
|
84
|
+
* @param {Object} meta
|
|
85
|
+
* @param {string} meta.adminID
|
|
86
|
+
* @param {string} [meta.note]
|
|
87
|
+
* @returns {Promise<{changeSetID: string, versions: Object<string, number>}>}
|
|
88
|
+
* @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} On bad input or a version conflict (see `details`).
|
|
89
|
+
* @public
|
|
90
|
+
*/
|
|
91
|
+
saveChangeSet( edits, meta ) {
|
|
92
|
+
if ( !Array.isArray( edits ) || edits.length === 0 || !meta || !meta.adminID ) {
|
|
93
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-changeset-input" } ) );
|
|
94
|
+
}
|
|
95
|
+
const keys = edits.map( ( e ) => e.configKey );
|
|
96
|
+
if ( new Set( keys ).size !== keys.length ) {
|
|
97
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "duplicate-configKey-in-changeset" } ) );
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return Promise.all( keys.map( ( key ) => this.getCurrent( key ) ) ).then( ( currents ) => {
|
|
101
|
+
// Lock check across the whole set first — no writes until every document is confirmed unchanged.
|
|
102
|
+
const conflicts = [];
|
|
103
|
+
edits.forEach( ( edit, i ) => {
|
|
104
|
+
const actual = currents[ i ] ? currents[ i ].version : 0;
|
|
105
|
+
if ( edit.expectedVersion !== actual ) conflicts.push( { configKey: edit.configKey, expectedVersion: edit.expectedVersion, actualVersion: actual } );
|
|
106
|
+
} );
|
|
107
|
+
if ( conflicts.length ) {
|
|
108
|
+
throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "version-conflict", conflicts: conflicts } );
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const changeSetID = tools.getUUID();
|
|
112
|
+
const timestamp = new Date().toISOString();
|
|
113
|
+
const note = meta.note || "";
|
|
114
|
+
const versions = {};
|
|
115
|
+
const writes = [];
|
|
116
|
+
edits.forEach( ( edit, i ) => {
|
|
117
|
+
const newVersion = ( currents[ i ] ? currents[ i ].version : 0 ) + 1;
|
|
118
|
+
versions[ edit.configKey ] = newVersion;
|
|
119
|
+
const envelope = { value: edit.value, version: newVersion, updatedAt: timestamp, updatedBy: meta.adminID, changeSetID: changeSetID };
|
|
120
|
+
const historyEntry = { version: newVersion, timestamp: timestamp, adminID: meta.adminID, note: note, changeSetID: changeSetID, snapshot: edit.value };
|
|
121
|
+
writes.push( this.#writeJSON( KEY_CURRENT + edit.configKey, envelope ) );
|
|
122
|
+
writes.push( this.#writeJSON( KEY_HISTORY + edit.configKey + ":" + newVersion, historyEntry ) );
|
|
123
|
+
} );
|
|
124
|
+
const changeSetRecord = {
|
|
125
|
+
changeSetID: changeSetID,
|
|
126
|
+
timestamp: timestamp,
|
|
127
|
+
adminID: meta.adminID,
|
|
128
|
+
note: note,
|
|
129
|
+
documents: edits.map( ( edit ) => ( { configKey: edit.configKey, version: versions[ edit.configKey ] } ) )
|
|
130
|
+
};
|
|
131
|
+
writes.push( this.#writeJSON( KEY_CHANGESET + changeSetID, changeSetRecord ) );
|
|
132
|
+
|
|
133
|
+
return Promise.all( writes ).then( () => ( { changeSetID: changeSetID, versions: versions } ) );
|
|
134
|
+
} );
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns all history entries for a document, ascending by version.
|
|
139
|
+
*
|
|
140
|
+
* @method
|
|
141
|
+
* @param {string} configKey
|
|
142
|
+
* @returns {Promise<Array<Object>>}
|
|
143
|
+
* @public
|
|
144
|
+
*/
|
|
145
|
+
listHistory( configKey ) {
|
|
146
|
+
return cache.instance.matchKeys( KEY_HISTORY + configKey + ":*" ).then( ( keys ) => {
|
|
147
|
+
return Promise.all( ( keys || [] ).map( ( k ) => this.#readJSON( k ) ) );
|
|
148
|
+
} ).then( ( entries ) => entries.filter( Boolean ).sort( ( a, b ) => a.version - b.version ) );
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns a single history snapshot entry for a document version, or `null`.
|
|
153
|
+
*
|
|
154
|
+
* @method
|
|
155
|
+
* @param {string} configKey
|
|
156
|
+
* @param {number} version
|
|
157
|
+
* @returns {Promise<Object|null>}
|
|
158
|
+
* @public
|
|
159
|
+
*/
|
|
160
|
+
getVersion( configKey, version ) {
|
|
161
|
+
return this.#readJSON( KEY_HISTORY + configKey + ":" + version );
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns a change-set record by id, or `null`.
|
|
166
|
+
*
|
|
167
|
+
* @method
|
|
168
|
+
* @param {string} changeSetID
|
|
169
|
+
* @returns {Promise<Object|null>}
|
|
170
|
+
* @public
|
|
171
|
+
*/
|
|
172
|
+
getChangeSet( changeSetID ) {
|
|
173
|
+
return this.#readJSON( KEY_CHANGESET + changeSetID );
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Returns every change-set record, most-recent first (the cross-document audit feed).
|
|
178
|
+
*
|
|
179
|
+
* @method
|
|
180
|
+
* @returns {Promise<Array<Object>>}
|
|
181
|
+
* @public
|
|
182
|
+
*/
|
|
183
|
+
listChangeSets() {
|
|
184
|
+
return cache.instance.matchKeys( KEY_CHANGESET + "*" ).then( ( keys ) => {
|
|
185
|
+
return Promise.all( ( keys || [] ).map( ( key ) => this.#readJSON( key ) ) );
|
|
186
|
+
} ).then( ( records ) => records.filter( Boolean ).sort( ( a, b ) => ( a.timestamp < b.timestamp ? 1 : ( a.timestamp > b.timestamp ? -1 : 0 ) ) ) );
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Restores every document in a prior change-set to that change-set's snapshot, committing it as a *new*
|
|
191
|
+
* change-set (restore is never destructive — it moves forward to a past state).
|
|
192
|
+
*
|
|
193
|
+
* @method
|
|
194
|
+
* @param {string} changeSetID
|
|
195
|
+
* @param {Object} meta
|
|
196
|
+
* @param {string} meta.adminID
|
|
197
|
+
* @param {string} [meta.note]
|
|
198
|
+
* @returns {Promise<{changeSetID: string, versions: Object<string, number>}>}
|
|
199
|
+
* @public
|
|
200
|
+
*/
|
|
201
|
+
restoreChangeSet( changeSetID, meta ) {
|
|
202
|
+
if ( !meta || !meta.adminID ) {
|
|
203
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-restore-input" } ) );
|
|
204
|
+
}
|
|
205
|
+
return this.getChangeSet( changeSetID ).then( ( record ) => {
|
|
206
|
+
if ( !record ) throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-changeset", changeSetID: changeSetID } );
|
|
207
|
+
return Promise.all( record.documents.map( ( doc ) => {
|
|
208
|
+
return Promise.all( [ this.getVersion( doc.configKey, doc.version ), this.getCurrent( doc.configKey ) ] ).then( ( [ historic, current ] ) => {
|
|
209
|
+
return { configKey: doc.configKey, value: historic ? historic.snapshot : null, expectedVersion: current ? current.version : 0 };
|
|
210
|
+
} );
|
|
211
|
+
} ) ).then( ( edits ) => {
|
|
212
|
+
return this.saveChangeSet( edits, { adminID: meta.adminID, note: meta.note || ( "restored from change-set " + changeSetID ) } );
|
|
213
|
+
} );
|
|
214
|
+
} );
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* Private interface */
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Reads a whole JSON document at the `$` root and unwraps RedisJSON's array result.
|
|
221
|
+
*
|
|
222
|
+
* @method
|
|
223
|
+
* @param {string} key
|
|
224
|
+
* @returns {Promise<Object|null>}
|
|
225
|
+
*/
|
|
226
|
+
#readJSON( key ) {
|
|
227
|
+
return cache.instance.getJSON( key ).then( ( result ) => ( Array.isArray( result ) ? ( result[ 0 ] ?? null ) : ( result ?? null ) ) );
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @method
|
|
232
|
+
* @param {string} key
|
|
233
|
+
* @param {Object} value
|
|
234
|
+
* @returns {Promise}
|
|
235
|
+
*/
|
|
236
|
+
#writeJSON( key, value ) {
|
|
237
|
+
return cache.instance.setJSON( key, value );
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const instance = new ConfigStore();
|
|
243
|
+
module.exports = ConfigStore;
|
|
244
|
+
ConfigStore.instance = Object.freeze( instance );
|
|
@@ -1,26 +1,28 @@
|
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* @
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* @
|
|
19
|
-
* @property {
|
|
20
|
-
* @property {Object} [
|
|
21
|
-
* @property {
|
|
22
|
-
* @property {
|
|
23
|
-
* @property {
|
|
24
|
-
* @property {
|
|
25
|
-
* @property {
|
|
26
|
-
|
|
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
|
+
/** @import { TiLocalizationLanguage } from "@ti-engine/core/localization" */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @callback TiSessionCallback
|
|
13
|
+
* @param {Error|null} [error]
|
|
14
|
+
* @returns {void}
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} TiSession
|
|
19
|
+
* @property {string} id
|
|
20
|
+
* @property {Object} [user]
|
|
21
|
+
* @property {TiLocalizationLanguage} [language]
|
|
22
|
+
* @property {Object} [cookie]
|
|
23
|
+
* @property {Object} [oidc]
|
|
24
|
+
* @property {string} [csrfToken]
|
|
25
|
+
* @property {(callback: TiSessionCallback) => TiSession} regenerate
|
|
26
|
+
* @property {(callback: TiSessionCallback) => TiSession} destroy
|
|
27
|
+
* @property {(callback?: TiSessionCallback) => TiSession} save
|
|
28
|
+
*/
|