@ti-engine/web-framework 1.23.0 → 1.24.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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  This document will contain the list of changes made to the framework. The format is based on the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
4
4
 
5
+ ## Version 1.24.0
6
+
7
+ A configuration file change shipped in a release could never reach a deployment that had already been seeded. The
8
+ store writes a file default only when the document has never been written, and the consuming application then lets
9
+ the stored value overwrite the file value on every boot — so the file default is consulted exactly once in a
10
+ deployment's lifetime. Restore could not help either, since it replays a previous version and the oldest version
11
+ *is* the stale one. The framework now detects that difference and lets an admin apply it deliberately.
12
+
13
+ * feat(config-drift): new `#config-drift` module — a pure structural diff between a document's registered file
14
+ default and its stored value. Recurses into objects to report leaf paths, **set-diffs arrays of primitives** so a
15
+ code-list change reads as `+27 codes` rather than an opaque "changed", and compares arrays of objects atomically.
16
+ Paths use the same dot/bracket dialect as schema validation issues
17
+ * feat(config-service): `getDrift`, `listDrift` and `applyDefaults`. Applying routes through `applyEdits`, so a
18
+ file default lands validated, versioned, correlated into one change-set, in the audit feed, and restorable —
19
+ never as a side-channel write
20
+ * feat(config-service): interdependent documents apply as a **single** change-set, which is required rather than
21
+ merely convenient: a semantic validator resolves its siblings at their *pending* value, so a document whose
22
+ constraint spans another can only pass when both are applied together
23
+ * feat(admin-config-handlers): `GET /admin/config/drift`, `GET /admin/config/drift/:configKey` and
24
+ `POST /admin/config/drift/apply`, all admin-gated
25
+ * build(release): bump package version from `1.23.0` to `1.24.0`
26
+
27
+ **Note on statuses:** `absent` (never seeded) is deliberately distinct from `drifted`. A document that is registered
28
+ but never seeded is not a problem to act on, and folding the two together would flag it on every boot of a clean
29
+ install — training operators to ignore exactly the signal this feature exists to raise.
30
+
5
31
  ## Version 1.23.0
6
32
 
7
33
  Local (username/password) authentication is real. It had never been implemented: the constructor overwrote whatever
package/bin/web-server.js CHANGED
@@ -585,6 +585,9 @@ class TiWebServer extends ServiceConsumer {
585
585
  this.#webServer.get( "/admin/config/changes/:changeSetID", requireAdmin, adminConfigHandlers.getChange( service ) );
586
586
  this.#webServer.post( "/admin/config/changes/:changeSetID/restore", requireAdmin, adminConfigHandlers.restoreChangeSet( service ) );
587
587
  this.#webServer.get( "/admin/config/export", requireAdmin, adminConfigHandlers.exportBundle( service ) );
588
+ this.#webServer.get( "/admin/config/drift", requireAdmin, adminConfigHandlers.listDrift( service ) );
589
+ this.#webServer.get( "/admin/config/drift/:configKey", requireAdmin, adminConfigHandlers.getDrift( service ) );
590
+ this.#webServer.post( "/admin/config/drift/apply", requireAdmin, adminConfigHandlers.applyDefaults( service ) );
588
591
  }
589
592
 
590
593
  /**
@@ -93,3 +93,34 @@ module.exports.exportBundle = ( service ) => ( request, response, next ) => {
93
93
  response.status( exceptions.httpCode.C_200 ).send( JSON.stringify( bundle, null, 2 ) );
94
94
  } ).catch( ( error ) => forward( next, error ) );
95
95
  };
96
+
97
+ /**
98
+ * `GET /admin/config/drift` — drift summaries for every registered document.
99
+ *
100
+ * @param {ConfigService} service
101
+ * @returns {ExpressHandler} The Express handler.
102
+ */
103
+ module.exports.listDrift = ( service ) => ( request, response, next ) => {
104
+ service.listDrift().then( ( drift ) => sendData( response, drift ) ).catch( ( error ) => forward( next, error ) );
105
+ };
106
+
107
+ /**
108
+ * `GET /admin/config/drift/:configKey` — one document's drift, including the full entry list.
109
+ *
110
+ * @param {ConfigService} service
111
+ * @returns {ExpressHandler} The Express handler.
112
+ */
113
+ module.exports.getDrift = ( service ) => ( request, response, next ) => {
114
+ service.getDrift( request.params.configKey ).then( ( drift ) => sendData( response, drift ) ).catch( ( error ) => forward( next, error ) );
115
+ };
116
+
117
+ /**
118
+ * `POST /admin/config/drift/apply` — applies the file defaults for the named documents as one change-set.
119
+ *
120
+ * @param {ConfigService} service
121
+ * @returns {ExpressHandler} The Express handler.
122
+ */
123
+ module.exports.applyDefaults = ( service ) => ( request, response, next ) => {
124
+ const body = request.body || {};
125
+ service.applyDefaults( body.configKeys, { adminID: adminID( request ), note: body.note } ).then( ( result ) => sendData( response, result ) ).catch( ( error ) => forward( next, error ) );
126
+ };
@@ -0,0 +1,141 @@
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
+ * Pure structural diff between a configuration document's file default and the value currently held in the store.
11
+ * <br/>
12
+ * The store seeds from a file default exactly once ({@link ConfigStore#seedIfEmpty}), so a release that changes a
13
+ * config file changes nothing an already-seeded deployment serves. This module is the detection half of the remedy:
14
+ * it answers "how does the shipped default differ from what this deployment is running", in terms legible enough for
15
+ * an admin to judge whether applying it is safe.
16
+ * <br/>
17
+ * No I/O — the caller supplies both values.
18
+ *
19
+ * @module config-drift
20
+ */
21
+
22
+ const { isDeepStrictEqual } = require( "node:util" );
23
+
24
+ const STATUS_IN_SYNC = "in-sync";
25
+ const STATUS_DRIFTED = "drifted";
26
+ const STATUS_ABSENT = "absent";
27
+ const STATUS_NO_DEFAULT = "no-default";
28
+
29
+ const KIND_ADDED = "added";
30
+ const KIND_REMOVED = "removed";
31
+ const KIND_CHANGED = "changed";
32
+
33
+ /**
34
+ * @typedef {Object} ConfigDriftEntry
35
+ * @property {string} path Dot/bracket data path, matching the dialect used for schema validation issues.
36
+ * @property {string} kind One of "added", "removed", "changed".
37
+ * @property {number} [addedMembers] For a primitive array: how many members the file default adds.
38
+ * @property {number} [removedMembers] For a primitive array: how many members the file default drops.
39
+ */
40
+
41
+ /**
42
+ * @param {*} value
43
+ * @returns {boolean} True for a non-null, non-array object.
44
+ */
45
+ function isPlainObject( value ) {
46
+ return value !== null && typeof value === "object" && !Array.isArray( value );
47
+ }
48
+
49
+ /**
50
+ * @param {*} value
51
+ * @returns {boolean} True for an array holding no objects (a code list, a set of flags, …).
52
+ */
53
+ function isPrimitiveArray( value ) {
54
+ return Array.isArray( value ) && value.every( ( item ) => item === null || typeof item !== "object" );
55
+ }
56
+
57
+ /**
58
+ * @param {string} base
59
+ * @param {string} key
60
+ * @returns {string} The child path, numeric keys in bracket notation.
61
+ */
62
+ function joinPath( base, key ) {
63
+ return ( /^\d+$/.test( key ) ) ? `${ base }[${ key }]` : `${ base }.${ key }`;
64
+ }
65
+
66
+ /**
67
+ * Recursive worker. Appends to `entries` in place.
68
+ *
69
+ * @param {*} fileValue
70
+ * @param {*} storedValue
71
+ * @param {string} path
72
+ * @param {ConfigDriftEntry[]} entries
73
+ */
74
+ function diffValue( fileValue, storedValue, path, entries ) {
75
+ if ( isPlainObject( fileValue ) && isPlainObject( storedValue ) ) {
76
+ const keys = new Set( [ ...Object.keys( fileValue ), ...Object.keys( storedValue ) ] );
77
+ for ( const key of keys ) {
78
+ const childPath = joinPath( path, key );
79
+ const inFile = Object.prototype.hasOwnProperty.call( fileValue, key );
80
+ const inStored = Object.prototype.hasOwnProperty.call( storedValue, key );
81
+ if ( inFile && !inStored ) {
82
+ entries.push( { path: childPath, kind: KIND_ADDED } );
83
+ } else if ( !inFile && inStored ) {
84
+ entries.push( { path: childPath, kind: KIND_REMOVED } );
85
+ } else {
86
+ diffValue( fileValue[ key ], storedValue[ key ], childPath, entries );
87
+ }
88
+ }
89
+ return;
90
+ }
91
+
92
+ // A list of codes is a set, not a sequence: report which members moved, and treat a pure reorder as no change.
93
+ // This is what turns "role-family-competencies changed" into the far more useful "QE +27 codes".
94
+ // <br/>
95
+ // Set semantics also mean MULTIPLICITY is ignored — `[ "A", "A" ]` and `[ "A" ]` compare as in-sync even though
96
+ // applying would replace one with the other. That is deliberate for a list of codes, where a repeated member is
97
+ // a data error rather than a meaningful difference, and it is unreachable for the documents shipped here: the
98
+ // array-valued competence schemas all declare `uniqueItems`, so a duplicate cannot pass validation on save. A
99
+ // consumer whose arrays are genuinely multisets should not model them as primitive arrays for this diff.
100
+ if ( isPrimitiveArray( fileValue ) && isPrimitiveArray( storedValue ) ) {
101
+ const storedMembers = new Set( storedValue );
102
+ const fileMembers = new Set( fileValue );
103
+ const addedMembers = fileValue.filter( ( item ) => !storedMembers.has( item ) ).length;
104
+ const removedMembers = storedValue.filter( ( item ) => !fileMembers.has( item ) ).length;
105
+ if ( addedMembers > 0 || removedMembers > 0 ) {
106
+ entries.push( { path: path, kind: KIND_CHANGED, addedMembers: addedMembers, removedMembers: removedMembers } );
107
+ }
108
+ return;
109
+ }
110
+
111
+ if ( !isDeepStrictEqual( fileValue, storedValue ) ) {
112
+ entries.push( { path: path, kind: KIND_CHANGED } );
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Diffs a document's registered file default against its stored value.
118
+ *
119
+ * @method
120
+ * @param {*} fileDefault The value registered with {@link ConfigRegistry#register}; `undefined` when none was.
121
+ * @param {*} storedValue The value currently in the store; `null`/`undefined` when never written.
122
+ * @returns {{status: string, entries: ConfigDriftEntry[], counts: {added: number, removed: number, changed: number}}}
123
+ * @public
124
+ */
125
+ module.exports.diffDocument = ( fileDefault, storedValue ) => {
126
+ if ( fileDefault === undefined ) {
127
+ return { status: STATUS_NO_DEFAULT, entries: [], counts: { added: 0, removed: 0, changed: 0 } };
128
+ }
129
+ if ( storedValue === undefined || storedValue === null ) {
130
+ return { status: STATUS_ABSENT, entries: [], counts: { added: 0, removed: 0, changed: 0 } };
131
+ }
132
+
133
+ const entries = [];
134
+ diffValue( fileDefault, storedValue, "", entries );
135
+
136
+ const counts = { added: 0, removed: 0, changed: 0 };
137
+ for ( const entry of entries ) {
138
+ counts[ entry.kind ] += 1;
139
+ }
140
+ return { status: ( entries.length > 0 ) ? STATUS_DRIFTED : STATUS_IN_SYNC, entries: entries, counts: counts };
141
+ };
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const exceptions = require( "@ti-engine/core/exceptions" );
10
+ const configDrift = require( "#config-drift" );
10
11
 
11
12
  /** @import ConfigChangeNotifier from "#config-change-notifier" */
12
13
  /** @import ConfigRegistry from "#config-registry" */
@@ -307,6 +308,102 @@ class ConfigService {
307
308
  } ) );
308
309
  }
309
310
 
311
+ /* Public interface — drift against file defaults */
312
+
313
+ /**
314
+ * Compares a document's registered file default against the value currently in the store. This is how a
315
+ * configuration change shipped in a release becomes visible on a deployment that was seeded before it — the
316
+ * store seeds only once, so a later file change is otherwise invisible.
317
+ *
318
+ * @method
319
+ * @param {string} configKey
320
+ * @returns {Promise<{configKey: string, status: string, counts: Object, entries: Array, storedVersion: number, editable: boolean, label: string}>}
321
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
322
+ * @public
323
+ */
324
+ getDrift( configKey ) {
325
+ if ( !this.#registry.has( configKey ) ) {
326
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKey: configKey } ) );
327
+ }
328
+ const metadata = this.#registry.metadataFor( configKey ) || {};
329
+ return this.#store.getCurrent( configKey ).then( ( current ) => {
330
+ const diff = configDrift.diffDocument( this.#registry.getDefault( configKey ), current ? current.value : null );
331
+ return {
332
+ configKey: configKey,
333
+ status: diff.status,
334
+ counts: diff.counts,
335
+ entries: diff.entries,
336
+ storedVersion: current ? current.version : 0,
337
+ editable: metadata.editable !== false,
338
+ label: metadata.label || configKey
339
+ };
340
+ } );
341
+ }
342
+
343
+ /**
344
+ * Drift summaries for every registered document. This still computes each document's full entry list internally
345
+ * (it delegates to {@link ConfigService#getDrift} per document) — the saving is in the response shape, not the
346
+ * computation: `entries` is omitted here to keep the payload small enough for a landing screen and a startup log,
347
+ * where only the counts are shown.
348
+ *
349
+ * @method
350
+ * @returns {Promise<Array<Object>>}
351
+ * @public
352
+ */
353
+ listDrift() {
354
+ return Promise.all( this.#registry.list().map( ( configKey ) => {
355
+ return this.getDrift( configKey ).then( ( drift ) => ( {
356
+ configKey: drift.configKey,
357
+ status: drift.status,
358
+ counts: drift.counts,
359
+ storedVersion: drift.storedVersion,
360
+ editable: drift.editable,
361
+ label: drift.label
362
+ } ) );
363
+ } ) );
364
+ }
365
+
366
+ /**
367
+ * Applies the registered file defaults for the given documents, as a single validated change-set.
368
+ * <br/>
369
+ * Routing through {@link ConfigService#applyEdits} is deliberate: the application is schema- and
370
+ * semantically validated, versioned, correlated into one change-set, added to the audit feed, and restorable —
371
+ * and, because a validator sees its siblings at their *pending* value, interdependent documents applied
372
+ * together validate against each other rather than against the stale stored state.
373
+ *
374
+ * @method
375
+ * @param {string[]} configKeys
376
+ * @param {Object} meta
377
+ * @param {string} meta.adminID
378
+ * @param {string} [meta.note]
379
+ * @returns {Promise<{ok: true, changeSetID: string, versions: Object}|{ok: false, errors: Object}>}
380
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} On bad input, an unknown key, or a key with no default.
381
+ * @public
382
+ */
383
+ applyDefaults( configKeys, meta ) {
384
+ if ( !Array.isArray( configKeys ) || configKeys.length === 0 || !meta || !meta.adminID ) {
385
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-apply-defaults-input" } ) );
386
+ }
387
+ const keys = Array.from( new Set( configKeys ) );
388
+ const unknown = keys.filter( ( key ) => !this.#registry.has( key ) );
389
+ if ( unknown.length > 0 ) {
390
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKeys: unknown } ) );
391
+ }
392
+ const withoutDefault = keys.filter( ( key ) => this.#registry.getDefault( key ) === undefined );
393
+ if ( withoutDefault.length > 0 ) {
394
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "no-default", configKeys: withoutDefault } ) );
395
+ }
396
+
397
+ return Promise.all( keys.map( ( key ) => this.#store.getCurrent( key ) ) ).then( ( currents ) => {
398
+ const edits = keys.map( ( key, index ) => ( {
399
+ configKey: key,
400
+ value: this.#registry.getDefault( key ),
401
+ expectedVersion: currents[ index ] ? currents[ index ].version : 0
402
+ } ) );
403
+ return this.applyEdits( edits, { adminID: meta.adminID, note: meta.note || "applied file defaults" } );
404
+ } );
405
+ }
406
+
310
407
  /**
311
408
  * Seeds a document's default value into the store only if it has never been written (idempotent bootstrap).
312
409
  * Used by an application to bring its file defaults into the store at startup before serving live config.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ti-engine/web-framework",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "A web-framework based on the ti-engine. It provides a customizable ready-to-use web-server microservice and a set of tools for creating web applications. NOTICE: This is still a work in progress and the full architecture, design, and functionality are not available!",
5
5
  "keywords": [
6
6
  "ti-engine",
@@ -15,6 +15,10 @@
15
15
  "author": "Boris Kostadinov <kostadinov.boris@gmail.com>",
16
16
  "license": "GPL-3.0-or-later",
17
17
  "exports": {
18
+ "./config-drift": {
19
+ "types": "./types/components/config-drift.d.ts",
20
+ "default": "./components/config-drift.js"
21
+ },
18
22
  "./config-management": {
19
23
  "types": "./types/components/config-service.d.ts",
20
24
  "default": "./components/config-service.js"
@@ -57,6 +61,10 @@
57
61
  "types": "./types/components/config-change-notifier.d.ts",
58
62
  "default": "./components/config-change-notifier.js"
59
63
  },
64
+ "#config-drift": {
65
+ "types": "./types/components/config-drift.d.ts",
66
+ "default": "./components/config-drift.js"
67
+ },
60
68
  "#config-registry": {
61
69
  "types": "./types/components/config-registry.d.ts",
62
70
  "default": "./components/config-registry.js"
@@ -7,5 +7,8 @@ export declare var listChanges: (service: any) => (request: any, response: any,
7
7
  export declare var getChange: (service: any) => (request: any, response: any, next: any) => void;
8
8
  export declare var restoreChangeSet: (service: any) => (request: any, response: any, next: any) => void;
9
9
  export declare var exportBundle: (service: any) => (request: any, response: any, next: any) => void;
10
+ export declare var listDrift: (service: ConfigService) => ExpressHandler;
11
+ export declare var getDrift: (service: ConfigService) => ExpressHandler;
12
+ export declare var applyDefaults: (service: ConfigService) => ExpressHandler;
10
13
  import type ConfigService from "#config-service";
11
14
  import type { ExpressHandler } from "#web-handlers";
@@ -0,0 +1,27 @@
1
+ export declare var diffDocument: (fileDefault: any, storedValue: any) => {
2
+ status: string;
3
+ entries: ConfigDriftEntry[];
4
+ counts: {
5
+ added: number;
6
+ removed: number;
7
+ changed: number;
8
+ };
9
+ };
10
+ export type ConfigDriftEntry = {
11
+ /**
12
+ * Dot/bracket data path, matching the dialect used for schema validation issues.
13
+ */
14
+ path: string;
15
+ /**
16
+ * One of "added", "removed", "changed".
17
+ */
18
+ kind: string;
19
+ /**
20
+ * For a primitive array: how many members the file default adds.
21
+ */
22
+ addedMembers?: number;
23
+ /**
24
+ * For a primitive array: how many members the file default drops.
25
+ */
26
+ removedMembers?: number;
27
+ };
@@ -191,6 +191,65 @@ declare class ConfigService {
191
191
  value: Object;
192
192
  }>;
193
193
  }>;
194
+ /**
195
+ * Compares a document's registered file default against the value currently in the store. This is how a
196
+ * configuration change shipped in a release becomes visible on a deployment that was seeded before it — the
197
+ * store seeds only once, so a later file change is otherwise invisible.
198
+ *
199
+ * @method
200
+ * @param {string} configKey
201
+ * @returns {Promise<{configKey: string, status: string, counts: Object, entries: Array, storedVersion: number, editable: boolean, label: string}>}
202
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
203
+ * @public
204
+ */
205
+ getDrift(configKey: string): Promise<{
206
+ configKey: string;
207
+ status: string;
208
+ counts: Object;
209
+ entries: any[];
210
+ storedVersion: number;
211
+ editable: boolean;
212
+ label: string;
213
+ }>;
214
+ /**
215
+ * Drift summaries for every registered document. This still computes each document's full entry list internally
216
+ * (it delegates to {@link ConfigService#getDrift} per document) — the saving is in the response shape, not the
217
+ * computation: `entries` is omitted here to keep the payload small enough for a landing screen and a startup log,
218
+ * where only the counts are shown.
219
+ *
220
+ * @method
221
+ * @returns {Promise<Array<Object>>}
222
+ * @public
223
+ */
224
+ listDrift(): Promise<Array<Object>>;
225
+ /**
226
+ * Applies the registered file defaults for the given documents, as a single validated change-set.
227
+ * <br/>
228
+ * Routing through {@link ConfigService#applyEdits} is deliberate: the application is schema- and
229
+ * semantically validated, versioned, correlated into one change-set, added to the audit feed, and restorable —
230
+ * and, because a validator sees its siblings at their *pending* value, interdependent documents applied
231
+ * together validate against each other rather than against the stale stored state.
232
+ *
233
+ * @method
234
+ * @param {string[]} configKeys
235
+ * @param {Object} meta
236
+ * @param {string} meta.adminID
237
+ * @param {string} [meta.note]
238
+ * @returns {Promise<{ok: true, changeSetID: string, versions: Object}|{ok: false, errors: Object}>}
239
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} On bad input, an unknown key, or a key with no default.
240
+ * @public
241
+ */
242
+ applyDefaults(configKeys: string[], meta: {
243
+ adminID: string;
244
+ note?: string;
245
+ }): Promise<{
246
+ ok: true;
247
+ changeSetID: string;
248
+ versions: Object;
249
+ } | {
250
+ ok: false;
251
+ errors: Object;
252
+ }>;
194
253
  /**
195
254
  * Seeds a document's default value into the store only if it has never been written (idempotent bootstrap).
196
255
  * Used by an application to bring its file defaults into the store at startup before serving live config.