@ti-engine/web-framework 1.21.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.
@@ -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.