@ti-engine/web-framework 1.25.0 → 1.26.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,38 @@
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.26.0
6
+
7
+ * feat(config-management): report stored documents that no longer satisfy their registered schema.
8
+ `ConfigService.listSchemaViolations()` returns one entry per registered document whose **stored** value fails its
9
+ schema, and `ConfigRegistry.validateSchema( configKey, value )` exposes the schema half of validation on its own.
10
+ Nothing validated a stored value on the way out before this: the store hands back whatever it holds and a consumer
11
+ freezes it into its config exports, so a release that tightens a schema — adding a required top-level key, say —
12
+ leaves any deployment seeded before the change serving a document that can no longer be saved. The failure used to
13
+ surface much later, as an admin edit rejected for a key the admin never touched, in a screen unrelated to the
14
+ change. Schema-only by design: a semantic validator may need a `ValidatorContext` the caller has no reason to
15
+ build, and several are about an *edit* rather than the document standing alone. Read-only by design too —
16
+ reconciling a stale document is the drift panel's job, under audit; an automatic repair here would write config
17
+ outside the change-set machinery. A document that has never been stored is not a violation.
18
+ * refactor(config-management): `ConfigRegistry.validate()` now delegates its schema step to `validateSchema()`, so
19
+ the two can never disagree about what is structurally admissible.
20
+
21
+ ## Version 1.25.1
22
+
23
+ * fix(ti-framework): resolve a label key whose own name contains a literal dot. `getLabel` split the key on every
24
+ dot and descended one object level per segment, so a group storing flat dotted keys — because the key IS a dotted
25
+ path in the consuming application's domain, e.g. audit-log field labels keyed by employee field path
26
+ (`"personal.workSite"`, `"career.roleFamily"`) — could never be reached. The miss was silent: the caller's
27
+ fallback rendered instead, which is why competence's Employee Management audit tab had shown every changed field
28
+ as its raw field path for as long as those labels have existed. Resolution now tries the longest literal key that
29
+ matches at each level, shortens a segment at a time, and backtracks when a matched branch turns out not to hold
30
+ the rest of the key — so a purely nested catalogue resolves exactly as before, a literal dotted key wins over the
31
+ nested path of the same name, and a genuinely absent key still returns the fallback
32
+ * test(ti-framework): cover `tiApplication.getLabel` against the store the browser actually gets. `ti-framework.js`
33
+ is a plain browser script with no module exports, so `test/helpers/ti-framework-sandbox.js` loads it in a Node
34
+ sandbox, fires `alpine:init` and hands back the registered stores — exercising the shipped resolver rather than
35
+ asserting against its source text, which is what let this defect sit unnoticed
36
+
5
37
  ## Version 1.25.0
6
38
 
7
39
  * feat(config-management): surface a `driftTracked` flag on the `getDrift` / `listDrift` payloads, defaulting to
@@ -800,27 +800,69 @@ const configureApplication = () => {
800
800
  const tiToolbox = Alpine.store( "tiToolbox" );
801
801
 
802
802
  /**
803
- * Used to extract a label from a nested labels object.
803
+ * Marker returned by {@link resolveLabel} when a key is not present in the labels object. A dedicated sentinel
804
+ * rather than `undefined` so that backtracking can never be confused by a stored value.
805
+ *
806
+ * @constant
807
+ * @private
808
+ */
809
+ const LABEL_NOT_FOUND = Object.freeze( {} );
810
+
811
+ /**
812
+ * Resolves a label key against a labels object, trying the longest literal key that matches at each level and
813
+ * shortening on a miss.
814
+ * <br/>
815
+ * A labels catalogue is not always purely nested: a group may store flat keys that themselves contain a dot,
816
+ * because the key IS a dotted path in the application's own domain — competence keys its audit-log field labels
817
+ * by employee field path ("personal.workSite", "career.roleFamily"). Descending exactly one object level per dot
818
+ * can never reach those, and the miss is silent: the caller's fallback renders instead. So each level tries the
819
+ * whole remaining key first, shortens a segment at a time, and backtracks whenever a matched branch turns out
820
+ * not to hold the rest of the key.
804
821
  *
805
822
  * @method
806
823
  * @param {Object} labels
807
824
  * @param {String[]} keys
808
- * @param {String} fallback
809
- * @returns {String}
825
+ * @returns {String|Object} The resolved value, or {@link LABEL_NOT_FOUND}.
810
826
  * @private
811
827
  */
812
- const extractLabel = ( labels, keys, fallback ) => {
813
- let key = keys.shift();
814
- if ( labels && typeof labels === "object" && key && Object.prototype.hasOwnProperty.call( labels, key ) ) {
828
+ const resolveLabel = ( labels, keys ) => {
829
+ if ( !labels || typeof labels !== "object" || keys.length === 0 ) {
830
+ return LABEL_NOT_FOUND;
831
+ }
832
+ for ( let length = keys.length; length > 0; length-- ) {
833
+ const key = keys.slice( 0, length ).join( "." );
834
+ if ( !Object.prototype.hasOwnProperty.call( labels, key ) ) {
835
+ continue;
836
+ }
815
837
  const value = labels[ key ];
838
+ const remainder = keys.slice( length );
816
839
  if ( typeof value === "string" ) {
817
- return keys.length === 0 ? value : fallback;
818
- }
819
- if ( value && typeof value === "object" ) {
820
- return extractLabel( value, keys, fallback );
840
+ if ( remainder.length === 0 ) {
841
+ return value;
842
+ }
843
+ } else if ( value && typeof value === "object" ) {
844
+ const resolved = resolveLabel( value, remainder );
845
+ if ( resolved !== LABEL_NOT_FOUND ) {
846
+ return resolved;
847
+ }
821
848
  }
822
849
  }
823
- return fallback;
850
+ return LABEL_NOT_FOUND;
851
+ };
852
+
853
+ /**
854
+ * Used to extract a label from a nested labels object.
855
+ *
856
+ * @method
857
+ * @param {Object} labels
858
+ * @param {String[]} keys
859
+ * @param {String} fallback
860
+ * @returns {String}
861
+ * @private
862
+ */
863
+ const extractLabel = ( labels, keys, fallback ) => {
864
+ const resolved = resolveLabel( labels, keys );
865
+ return ( typeof resolved === "string" ) ? resolved : fallback;
824
866
  };
825
867
 
826
868
  /**
@@ -197,13 +197,7 @@ class ConfigRegistry {
197
197
  return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKey: configKey } ) );
198
198
  }
199
199
 
200
- const errors = [];
201
- const validateSchema = this.#schemaValidator( registration );
202
- if ( !validateSchema( value ) ) {
203
- for ( const error of ( validateSchema.errors || [] ) ) {
204
- errors.push( { path: error.dataPath || instancePathToDataPath( error.instancePath ), message: error.message || "schema violation", code: "schema", params: error.params } );
205
- }
206
- }
200
+ const errors = this.validateSchema( configKey, value ).errors.slice();
207
201
 
208
202
  return Promise.all( registration.validators.map( ( validator ) => {
209
203
  return Promise.resolve( validator( value, context ) ).then( ( issues ) => ( Array.isArray( issues ) ? issues : [] ) );
@@ -217,6 +211,38 @@ class ConfigRegistry {
217
211
  } );
218
212
  }
219
213
 
214
+ /**
215
+ * Validates a value against a document's registered schema **only**, skipping the semantic validators.
216
+ * <br/>
217
+ * Separate from {@link ConfigRegistry#validate} because the two answer different questions. A semantic validator
218
+ * may need a {@link ValidatorContext} the caller has no reason to build, and several of them are about an edit
219
+ * (did the version move? does this removal orphan a reference?) rather than about the document standing alone.
220
+ * A caller that just wants to know whether a value is structurally admissible — a boot-time check on what the
221
+ * store already holds, say — needs this half and not the other.
222
+ *
223
+ * @method
224
+ * @param {string} configKey
225
+ * @param {Object} value
226
+ * @returns {{valid: boolean, errors: ConfigValidationIssue[]}}
227
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
228
+ * @public
229
+ */
230
+ validateSchema( configKey, value ) {
231
+ const registration = this.#registrations.get( configKey );
232
+ if ( !registration ) {
233
+ throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKey: configKey } );
234
+ }
235
+
236
+ const errors = [];
237
+ const validateSchema = this.#schemaValidator( registration );
238
+ if ( !validateSchema( value ) ) {
239
+ for ( const error of ( validateSchema.errors || [] ) ) {
240
+ errors.push( { path: error.dataPath || instancePathToDataPath( error.instancePath ), message: error.message || "schema violation", code: "schema", params: error.params } );
241
+ }
242
+ }
243
+ return { valid: errors.length === 0, errors: errors };
244
+ }
245
+
220
246
  /* Private interface */
221
247
 
222
248
  /**
@@ -21,6 +21,7 @@ const configDrift = require( "#config-drift" );
21
21
  /** @import ConfigChangeNotifier from "#config-change-notifier" */
22
22
  /** @import ConfigRegistry from "#config-registry" */
23
23
  /** @import ConfigStore from "#config-store" */
24
+ /** @import { ConfigValidationIssue } from "#config-registry" */
24
25
 
25
26
  /**
26
27
  * Orchestrates validated, versioned configuration edits on top of {@link ConfigStore} and {@link ConfigRegistry}.
@@ -374,6 +375,37 @@ class ConfigService {
374
375
  } ) );
375
376
  }
376
377
 
378
+ /**
379
+ * Reports every registered document whose **stored** value no longer satisfies its registered schema.
380
+ * <br/>
381
+ * Nothing validates a stored value on the way out: the store hands back whatever it holds, and a consumer
382
+ * freezes it into its config exports. That is fine until a release tightens a schema — adds a required
383
+ * top-level key, say — at which point a deployment seeded before the change keeps serving a document that
384
+ * cannot be saved any more. The failure surfaces much later, as an admin edit rejected for a key the admin
385
+ * never touched, in a screen that has nothing to do with the change.
386
+ * <br/>
387
+ * This answers that question at a moment when it is cheap to act on. It is deliberately schema-only (see
388
+ * {@link ConfigRegistry#validateSchema}) and deliberately read-only: reconciling a stale document is what the
389
+ * drift panel is for, and an automatic repair here would write config outside the audited change-set machinery.
390
+ * A document that has never been stored is not a violation and is omitted.
391
+ *
392
+ * @method
393
+ * @returns {Promise<Array<{configKey: string, errors: ConfigValidationIssue[]}>>} Empty when every stored
394
+ * document validates.
395
+ * @public
396
+ */
397
+ listSchemaViolations() {
398
+ return Promise.all( this.#registry.list().map( ( configKey ) => {
399
+ return this.#store.getCurrent( configKey ).then( ( current ) => {
400
+ if ( !current ) {
401
+ return null;
402
+ }
403
+ const result = this.#registry.validateSchema( configKey, current.value );
404
+ return result.valid ? null : { configKey: configKey, errors: result.errors };
405
+ } );
406
+ } ) ).then( ( results ) => results.filter( Boolean ) );
407
+ }
408
+
377
409
  /**
378
410
  * Applies the registered file defaults for the given documents, as a single validated change-set.
379
411
  * <br/>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ti-engine/web-framework",
3
- "version": "1.25.0",
3
+ "version": "1.26.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",
@@ -142,6 +142,26 @@ declare class ConfigRegistry {
142
142
  valid: boolean;
143
143
  errors: ConfigValidationIssue[];
144
144
  }>;
145
+ /**
146
+ * Validates a value against a document's registered schema **only**, skipping the semantic validators.
147
+ * <br/>
148
+ * Separate from {@link ConfigRegistry#validate} because the two answer different questions. A semantic validator
149
+ * may need a {@link ValidatorContext} the caller has no reason to build, and several of them are about an edit
150
+ * (did the version move? does this removal orphan a reference?) rather than about the document standing alone.
151
+ * A caller that just wants to know whether a value is structurally admissible — a boot-time check on what the
152
+ * store already holds, say — needs this half and not the other.
153
+ *
154
+ * @method
155
+ * @param {string} configKey
156
+ * @param {Object} value
157
+ * @returns {{valid: boolean, errors: ConfigValidationIssue[]}}
158
+ * @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
159
+ * @public
160
+ */
161
+ validateSchema(configKey: string, value: Object): {
162
+ valid: boolean;
163
+ errors: ConfigValidationIssue[];
164
+ };
145
165
  }
146
166
  declare namespace ConfigRegistry {
147
167
  export { instance };
@@ -2,9 +2,11 @@ export = ConfigService;
2
2
  import type ConfigChangeNotifier from "#config-change-notifier";
3
3
  import type ConfigRegistry from "#config-registry";
4
4
  import type ConfigStore from "#config-store";
5
+ import type { ConfigValidationIssue } from "#config-registry";
5
6
  /** @import ConfigChangeNotifier from "#config-change-notifier" */
6
7
  /** @import ConfigRegistry from "#config-registry" */
7
8
  /** @import ConfigStore from "#config-store" */
9
+ /** @import { ConfigValidationIssue } from "#config-registry" */
8
10
  /**
9
11
  * Orchestrates validated, versioned configuration edits on top of {@link ConfigStore} and {@link ConfigRegistry}.
10
12
  *
@@ -223,6 +225,29 @@ declare class ConfigService {
223
225
  * @public
224
226
  */
225
227
  listDrift(): Promise<Array<Object>>;
228
+ /**
229
+ * Reports every registered document whose **stored** value no longer satisfies its registered schema.
230
+ * <br/>
231
+ * Nothing validates a stored value on the way out: the store hands back whatever it holds, and a consumer
232
+ * freezes it into its config exports. That is fine until a release tightens a schema — adds a required
233
+ * top-level key, say — at which point a deployment seeded before the change keeps serving a document that
234
+ * cannot be saved any more. The failure surfaces much later, as an admin edit rejected for a key the admin
235
+ * never touched, in a screen that has nothing to do with the change.
236
+ * <br/>
237
+ * This answers that question at a moment when it is cheap to act on. It is deliberately schema-only (see
238
+ * {@link ConfigRegistry#validateSchema}) and deliberately read-only: reconciling a stale document is what the
239
+ * drift panel is for, and an automatic repair here would write config outside the audited change-set machinery.
240
+ * A document that has never been stored is not a violation and is omitted.
241
+ *
242
+ * @method
243
+ * @returns {Promise<Array<{configKey: string, errors: ConfigValidationIssue[]}>>} Empty when every stored
244
+ * document validates.
245
+ * @public
246
+ */
247
+ listSchemaViolations(): Promise<Array<{
248
+ configKey: string;
249
+ errors: ConfigValidationIssue[];
250
+ }>>;
226
251
  /**
227
252
  * Applies the registered file defaults for the given documents, as a single validated change-set.
228
253
  * <br/>