@cyberismo/data-handler 0.0.20 → 0.0.22
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/dist/command-handler.js +13 -24
- package/dist/command-handler.js.map +1 -1
- package/dist/command-manager.d.ts +21 -6
- package/dist/command-manager.js +34 -32
- package/dist/command-manager.js.map +1 -1
- package/dist/commands/calculate.js +101 -46
- package/dist/commands/calculate.js.map +1 -1
- package/dist/commands/create.js +420 -320
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/edit.js +117 -68
- package/dist/commands/edit.js.map +1 -1
- package/dist/commands/export.js +301 -252
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/fetch.js +205 -156
- package/dist/commands/fetch.js.map +1 -1
- package/dist/commands/import.js +189 -134
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/migrate.js +91 -45
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/move.js +347 -267
- package/dist/commands/move.js.map +1 -1
- package/dist/commands/remove.d.ts +1 -0
- package/dist/commands/remove.js +202 -135
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/rename.js +233 -187
- package/dist/commands/rename.js.map +1 -1
- package/dist/commands/show.d.ts +8 -8
- package/dist/commands/show.js +477 -372
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/transition.js +119 -73
- package/dist/commands/transition.js.map +1 -1
- package/dist/commands/update.js +8 -3
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/validate.js +1 -1
- package/dist/commands/validate.js.map +1 -1
- package/dist/containers/project/calculation-engine.js +0 -1
- package/dist/containers/project/calculation-engine.js.map +1 -1
- package/dist/containers/project/card-cache.js +1 -1
- package/dist/containers/project/card-cache.js.map +1 -1
- package/dist/containers/project.d.ts +16 -0
- package/dist/containers/project.js +59 -1
- package/dist/containers/project.js.map +1 -1
- package/dist/containers/template.js +1 -1
- package/dist/containers/template.js.map +1 -1
- package/dist/interfaces/command-options.d.ts +1 -0
- package/dist/interfaces/resource-interfaces.d.ts +5 -12
- package/dist/interfaces/resource-interfaces.js.map +1 -1
- package/dist/macros/base-macro.js +1 -1
- package/dist/macros/base-macro.js.map +1 -1
- package/dist/macros/graph/index.js +3 -1
- package/dist/macros/graph/index.js.map +1 -1
- package/dist/macros/include/index.js +16 -1
- package/dist/macros/include/index.js.map +1 -1
- package/dist/macros/include/types.d.ts +15 -12
- package/dist/macros/index.js +4 -1
- package/dist/macros/index.js.map +1 -1
- package/dist/macros/report/index.js +1 -1
- package/dist/macros/report/index.js.map +1 -1
- package/dist/module-manager.js +5 -3
- package/dist/module-manager.js.map +1 -1
- package/dist/project-settings.js +2 -2
- package/dist/project-settings.js.map +1 -1
- package/dist/resources/card-type-resource.js +1 -1
- package/dist/resources/card-type-resource.js.map +1 -1
- package/dist/resources/create-defaults.js +0 -1
- package/dist/resources/create-defaults.js.map +1 -1
- package/dist/resources/field-type-resource.js +2 -5
- package/dist/resources/field-type-resource.js.map +1 -1
- package/dist/resources/file-resource.js +4 -1
- package/dist/resources/file-resource.js.map +1 -1
- package/dist/resources/folder-resource.d.ts +1 -1
- package/dist/resources/folder-resource.js +4 -1
- package/dist/resources/folder-resource.js.map +1 -1
- package/dist/resources/graph-model-resource.d.ts +1 -8
- package/dist/resources/graph-model-resource.js +0 -14
- package/dist/resources/graph-model-resource.js.map +1 -1
- package/dist/resources/graph-view-resource.d.ts +1 -8
- package/dist/resources/graph-view-resource.js +0 -14
- package/dist/resources/graph-view-resource.js.map +1 -1
- package/dist/resources/link-type-resource.js +1 -1
- package/dist/resources/link-type-resource.js.map +1 -1
- package/dist/resources/report-resource.d.ts +1 -8
- package/dist/resources/report-resource.js +0 -14
- package/dist/resources/report-resource.js.map +1 -1
- package/dist/resources/resource-object.d.ts +11 -1
- package/dist/resources/resource-object.js +19 -2
- package/dist/resources/resource-object.js.map +1 -1
- package/dist/resources/template-resource.d.ts +1 -9
- package/dist/resources/template-resource.js +0 -15
- package/dist/resources/template-resource.js.map +1 -1
- package/dist/resources/workflow-resource.d.ts +6 -0
- package/dist/resources/workflow-resource.js +29 -13
- package/dist/resources/workflow-resource.js.map +1 -1
- package/dist/utils/card-utils.js +1 -1
- package/dist/utils/card-utils.js.map +1 -1
- package/dist/utils/commit-context.d.ts +23 -0
- package/dist/utils/commit-context.js +30 -0
- package/dist/utils/commit-context.js.map +1 -0
- package/dist/utils/csv.d.ts +8 -0
- package/dist/utils/csv.js +11 -0
- package/dist/utils/csv.js.map +1 -1
- package/dist/utils/file-utils.js +3 -1
- package/dist/utils/file-utils.js.map +1 -1
- package/dist/utils/git-manager.d.ts +29 -0
- package/dist/utils/git-manager.js +76 -0
- package/dist/utils/git-manager.js.map +1 -0
- package/dist/utils/handlebars-helpers.d.ts +22 -0
- package/dist/utils/handlebars-helpers.js +78 -0
- package/dist/utils/handlebars-helpers.js.map +1 -0
- package/dist/utils/json.d.ts +17 -10
- package/dist/utils/json.js +27 -14
- package/dist/utils/json.js.map +1 -1
- package/dist/utils/log-utils.d.ts +7 -2
- package/dist/utils/log-utils.js +28 -3
- package/dist/utils/log-utils.js.map +1 -1
- package/dist/utils/report.d.ts +0 -19
- package/dist/utils/report.js +4 -63
- package/dist/utils/report.js.map +1 -1
- package/dist/utils/rw-lock.d.ts +71 -0
- package/dist/utils/rw-lock.js +220 -0
- package/dist/utils/rw-lock.js.map +1 -0
- package/dist/utils/user-preferences.js +3 -3
- package/dist/utils/user-preferences.js.map +1 -1
- package/package.json +10 -10
- package/src/command-handler.ts +14 -22
- package/src/command-manager.ts +43 -37
- package/src/commands/calculate.ts +8 -1
- package/src/commands/create.ts +39 -6
- package/src/commands/edit.ts +3 -0
- package/src/commands/export.ts +8 -2
- package/src/commands/fetch.ts +3 -0
- package/src/commands/import.ts +5 -0
- package/src/commands/migrate.ts +2 -0
- package/src/commands/move.ts +34 -0
- package/src/commands/remove.ts +24 -2
- package/src/commands/rename.ts +2 -0
- package/src/commands/show.ts +63 -34
- package/src/commands/transition.ts +2 -0
- package/src/commands/update.ts +9 -3
- package/src/commands/validate.ts +1 -1
- package/src/containers/project/calculation-engine.ts +0 -1
- package/src/containers/project/card-cache.ts +1 -0
- package/src/containers/project.ts +75 -1
- package/src/containers/template.ts +1 -1
- package/src/interfaces/command-options.ts +1 -0
- package/src/interfaces/resource-interfaces.ts +5 -12
- package/src/macros/base-macro.ts +1 -1
- package/src/macros/graph/index.ts +3 -0
- package/src/macros/include/index.ts +19 -1
- package/src/macros/include/types.ts +15 -12
- package/src/macros/index.ts +4 -1
- package/src/macros/report/index.ts +1 -0
- package/src/module-manager.ts +5 -2
- package/src/project-settings.ts +2 -1
- package/src/resources/card-type-resource.ts +1 -1
- package/src/resources/create-defaults.ts +0 -1
- package/src/resources/field-type-resource.ts +2 -4
- package/src/resources/file-resource.ts +3 -1
- package/src/resources/folder-resource.ts +7 -2
- package/src/resources/graph-model-resource.ts +1 -25
- package/src/resources/graph-view-resource.ts +1 -25
- package/src/resources/link-type-resource.ts +1 -1
- package/src/resources/report-resource.ts +1 -25
- package/src/resources/resource-object.ts +22 -1
- package/src/resources/template-resource.ts +0 -23
- package/src/resources/workflow-resource.ts +45 -16
- package/src/utils/card-utils.ts +1 -1
- package/src/utils/commit-context.ts +45 -0
- package/src/utils/csv.ts +12 -0
- package/src/utils/file-utils.ts +3 -1
- package/src/utils/git-manager.ts +87 -0
- package/src/utils/handlebars-helpers.ts +95 -0
- package/src/utils/json.ts +29 -15
- package/src/utils/log-utils.ts +33 -4
- package/src/utils/report.ts +8 -74
- package/src/utils/rw-lock.ts +279 -0
- package/src/utils/user-preferences.ts +3 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Cyberismo
|
|
3
|
+
Copyright © Cyberismo Ltd and contributors 2024
|
|
4
|
+
|
|
5
|
+
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License version 3 as published by the Free Software Foundation.
|
|
6
|
+
|
|
7
|
+
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 Affero General Public License for more details.
|
|
8
|
+
|
|
9
|
+
You should have received a copy of the GNU Affero General Public
|
|
10
|
+
License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
11
|
+
*/
|
|
12
|
+
import type Handlebars from 'handlebars';
|
|
13
|
+
import { escapeJsonString } from './json.js';
|
|
14
|
+
import { escapeCsvField } from './csv.js';
|
|
15
|
+
import { resourceName } from './resource-utils.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Formats a value from a logic program for use in graphviz
|
|
19
|
+
* @param value - The value to format
|
|
20
|
+
* @returns The formatted value
|
|
21
|
+
*/
|
|
22
|
+
function formatAttributeValue(value?: string) {
|
|
23
|
+
if (!value) {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
// value is an html-like string
|
|
27
|
+
if (value.length > 1 && value.startsWith('<') && value.endsWith('>')) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
// value is a normal string and needs to be wrapped in quotes
|
|
31
|
+
return `"${value}"`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Checks if a field is a custom field
|
|
36
|
+
* @param field - The field to check
|
|
37
|
+
* @returns True if the field is a custom field, false otherwise
|
|
38
|
+
*/
|
|
39
|
+
function isCustomField(field: string) {
|
|
40
|
+
try {
|
|
41
|
+
const { type } = resourceName(field, true);
|
|
42
|
+
return type === 'fieldTypes';
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Formats a value for display in a report
|
|
50
|
+
* @param value - The value to format
|
|
51
|
+
* @returns The formatted value
|
|
52
|
+
*/
|
|
53
|
+
function formatValue(value: unknown): string {
|
|
54
|
+
if (typeof value === 'object') {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.map((v) => formatValue(v)).join(', ');
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
value != null &&
|
|
60
|
+
'displayValue' in value &&
|
|
61
|
+
typeof value.displayValue === 'string'
|
|
62
|
+
) {
|
|
63
|
+
return value.displayValue;
|
|
64
|
+
}
|
|
65
|
+
if (value != null && 'value' in value && typeof value.value === 'string') {
|
|
66
|
+
return formatValue(value.value);
|
|
67
|
+
}
|
|
68
|
+
return JSON.stringify(value, null, 2);
|
|
69
|
+
}
|
|
70
|
+
if (typeof value === 'boolean') {
|
|
71
|
+
return value ? 'Yes' : 'No';
|
|
72
|
+
}
|
|
73
|
+
return value?.toString() ?? '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Registers comparison helpers (eq and ne) with a Handlebars instance
|
|
78
|
+
* @param instance handlebars instance
|
|
79
|
+
*/
|
|
80
|
+
export function registerComparisonHelpers(instance: typeof Handlebars) {
|
|
81
|
+
instance.registerHelper('eq', (a: unknown, b: unknown) => a === b);
|
|
82
|
+
instance.registerHelper('ne', (a: unknown, b: unknown) => a !== b);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Registers report-specific helpers with a Handlebars instance
|
|
87
|
+
* @param instance handlebars instance
|
|
88
|
+
*/
|
|
89
|
+
export function registerReportHelpers(instance: typeof Handlebars) {
|
|
90
|
+
instance.registerHelper('formatAttributeValue', formatAttributeValue);
|
|
91
|
+
instance.registerHelper('isCustomField', isCustomField);
|
|
92
|
+
instance.registerHelper('formatValue', formatValue);
|
|
93
|
+
instance.registerHelper('jsonEscape', escapeJsonString);
|
|
94
|
+
instance.registerHelper('csvEscape', escapeCsvField);
|
|
95
|
+
}
|
package/src/utils/json.ts
CHANGED
|
@@ -15,6 +15,29 @@
|
|
|
15
15
|
import { readFileSync } from 'node:fs';
|
|
16
16
|
import { type FileHandle, readFile, writeFile } from 'node:fs/promises';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Escapes a string for use as a JSON string value.
|
|
20
|
+
* Escapes double quotes, backslashes, and control characters.
|
|
21
|
+
* @param content The string to escape
|
|
22
|
+
* @returns The escaped string suitable for use in JSON
|
|
23
|
+
*/
|
|
24
|
+
export function escapeJsonString(content: string): string {
|
|
25
|
+
return JSON.stringify(content).slice(1, -1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format an object with JSON.stringify
|
|
30
|
+
*
|
|
31
|
+
* The purpose of this function is to format the JSON output in a centralised function
|
|
32
|
+
* so that the format can be controlled in a single location.
|
|
33
|
+
*
|
|
34
|
+
* @param json JSON object to format.
|
|
35
|
+
* @returns Formatted JSON string
|
|
36
|
+
*/
|
|
37
|
+
export function formatJson(json: object) {
|
|
38
|
+
return JSON.stringify(json, trimReplacer, 4);
|
|
39
|
+
}
|
|
40
|
+
|
|
18
41
|
/**
|
|
19
42
|
* Handles reading of a JSON file.
|
|
20
43
|
* @param file file name (and path) to read.
|
|
@@ -28,7 +51,9 @@ export function readJsonFileSync(file: string) {
|
|
|
28
51
|
return returnValue;
|
|
29
52
|
} catch (error) {
|
|
30
53
|
if (error instanceof Error) {
|
|
31
|
-
throw new Error(`Invalid JSON in file '${file}': ${error.message}
|
|
54
|
+
throw new Error(`Invalid JSON in file '${file}': ${error.message}`, {
|
|
55
|
+
cause: error,
|
|
56
|
+
});
|
|
32
57
|
}
|
|
33
58
|
}
|
|
34
59
|
}
|
|
@@ -45,7 +70,9 @@ export async function readJsonFile(file: string) {
|
|
|
45
70
|
return JSON.parse(raw);
|
|
46
71
|
} catch (error) {
|
|
47
72
|
if (error instanceof Error) {
|
|
48
|
-
throw new Error(`Invalid JSON in file '${file}': ${error.message}
|
|
73
|
+
throw new Error(`Invalid JSON in file '${file}': ${error.message}`, {
|
|
74
|
+
cause: error,
|
|
75
|
+
});
|
|
49
76
|
}
|
|
50
77
|
}
|
|
51
78
|
}
|
|
@@ -79,19 +106,6 @@ export function trimReplacer(_: string, value: unknown) {
|
|
|
79
106
|
return value;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
|
-
/**
|
|
83
|
-
* Format an object with JSON.stringify
|
|
84
|
-
*
|
|
85
|
-
* The purpose of this function is to format the JSON output in a centralised function
|
|
86
|
-
* so that the format can be controlled in a single location.
|
|
87
|
-
*
|
|
88
|
-
* @param json JSON object to format.
|
|
89
|
-
* @returns Formatted JSON string
|
|
90
|
-
*/
|
|
91
|
-
export function formatJson(json: object) {
|
|
92
|
-
return JSON.stringify(json, trimReplacer, 4);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
109
|
/**
|
|
96
110
|
* Writes and formats a JSON file.
|
|
97
111
|
* @param filename file name (and path) to write.
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -9,15 +9,44 @@
|
|
|
9
9
|
You should have received a copy of the GNU Affero General Public
|
|
10
10
|
License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
11
11
|
*/
|
|
12
|
-
import pino, { type ChildLoggerOptions, type Logger } from 'pino';
|
|
12
|
+
import pino, { type Level, type ChildLoggerOptions, type Logger } from 'pino';
|
|
13
|
+
import { mkdirSync } from 'node:fs';
|
|
14
|
+
import { dirname } from 'node:path';
|
|
13
15
|
|
|
14
16
|
// This could be also a more generic interface, but since we use pino and this is an internal package, let's keep it simple
|
|
15
|
-
//
|
|
17
|
+
// Silent logger as default.
|
|
16
18
|
let _logger: Logger = pino({ level: 'silent' });
|
|
19
|
+
let initialized = false;
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the logger
|
|
23
|
+
* @param level Log level for stdout output.
|
|
24
|
+
* @param logPath Optional file path for full trace logging.
|
|
25
|
+
*/
|
|
26
|
+
export function initLogger(level: Level, logPath?: string): void {
|
|
27
|
+
if (initialized) return;
|
|
28
|
+
initialized = true;
|
|
29
|
+
if (logPath) {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Failed to create log directory '${dirname(logPath)}': ${error instanceof Error ? error.message : String(error)}`,
|
|
35
|
+
{ cause: error },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
_logger = pino(
|
|
39
|
+
{ level: 'trace' },
|
|
40
|
+
pino.multistream([
|
|
41
|
+
{ stream: pino.destination(logPath), level: 'trace' },
|
|
42
|
+
{ stream: pino.destination(1), level },
|
|
43
|
+
]),
|
|
44
|
+
);
|
|
45
|
+
} else {
|
|
46
|
+
_logger = pino({ level }, pino.destination(1));
|
|
47
|
+
}
|
|
20
48
|
}
|
|
49
|
+
|
|
21
50
|
/**
|
|
22
51
|
* Returns the logger instance.
|
|
23
52
|
*/
|
package/src/utils/report.ts
CHANGED
|
@@ -13,65 +13,10 @@ import Handlebars from 'handlebars';
|
|
|
13
13
|
import type { CalculationEngine } from '../containers/project/calculation-engine.js';
|
|
14
14
|
import { registerEmptyMacros } from '../macros/index.js';
|
|
15
15
|
import type { Context } from '../interfaces/project-interfaces.js';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
* @param value - The value to format
|
|
21
|
-
* @returns The formatted value
|
|
22
|
-
*/
|
|
23
|
-
export function formatAttributeValue(value?: string) {
|
|
24
|
-
if (!value) {
|
|
25
|
-
return '';
|
|
26
|
-
}
|
|
27
|
-
// value is an html-like string
|
|
28
|
-
if (value.length > 1 && value.startsWith('<') && value.endsWith('>')) {
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
|
-
// value is a normal string and needs to be wrapped in quotes
|
|
32
|
-
return `"${value}"`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Checks if a field is a custom field
|
|
37
|
-
* @param field - The field to check
|
|
38
|
-
* @returns True if the field is a custom field, false otherwise
|
|
39
|
-
*/
|
|
40
|
-
export function isCustomField(field: string) {
|
|
41
|
-
try {
|
|
42
|
-
const { type } = resourceName(field, true);
|
|
43
|
-
return type === 'fieldTypes';
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Formats a value for display in a report
|
|
50
|
-
* @param value - The value to format
|
|
51
|
-
* @returns The formatted value
|
|
52
|
-
*/
|
|
53
|
-
export function formatValue(value: unknown): string {
|
|
54
|
-
if (typeof value === 'object') {
|
|
55
|
-
if (Array.isArray(value)) {
|
|
56
|
-
return value.map((v) => formatValue(v)).join(', ');
|
|
57
|
-
}
|
|
58
|
-
if (
|
|
59
|
-
value != null &&
|
|
60
|
-
'displayValue' in value &&
|
|
61
|
-
typeof value.displayValue === 'string'
|
|
62
|
-
) {
|
|
63
|
-
return value.displayValue;
|
|
64
|
-
}
|
|
65
|
-
if (value != null && 'value' in value && typeof value.value === 'string') {
|
|
66
|
-
return formatValue(value.value);
|
|
67
|
-
}
|
|
68
|
-
return JSON.stringify(value, null, 2);
|
|
69
|
-
}
|
|
70
|
-
if (typeof value === 'boolean') {
|
|
71
|
-
return value ? 'Yes' : 'No';
|
|
72
|
-
}
|
|
73
|
-
return value?.toString() ?? '';
|
|
74
|
-
}
|
|
16
|
+
import {
|
|
17
|
+
registerComparisonHelpers,
|
|
18
|
+
registerReportHelpers,
|
|
19
|
+
} from './handlebars-helpers.js';
|
|
75
20
|
|
|
76
21
|
/**
|
|
77
22
|
* Parameters for the core generation function
|
|
@@ -81,7 +26,6 @@ interface GenerateReportContentParams {
|
|
|
81
26
|
contentTemplate: string;
|
|
82
27
|
queryTemplate: string;
|
|
83
28
|
options: Record<string, string | undefined | boolean>;
|
|
84
|
-
graph?: boolean;
|
|
85
29
|
context: Context;
|
|
86
30
|
}
|
|
87
31
|
|
|
@@ -96,16 +40,11 @@ interface GenerateReportContentParams {
|
|
|
96
40
|
export async function generateReportContent(
|
|
97
41
|
params: GenerateReportContentParams,
|
|
98
42
|
): Promise<string> {
|
|
99
|
-
const {
|
|
100
|
-
|
|
101
|
-
contentTemplate,
|
|
102
|
-
queryTemplate,
|
|
103
|
-
graph,
|
|
104
|
-
options, // Destructure options
|
|
105
|
-
context,
|
|
106
|
-
} = params;
|
|
43
|
+
const { calculate, contentTemplate, queryTemplate, options, context } =
|
|
44
|
+
params;
|
|
107
45
|
|
|
108
46
|
const handlebars = Handlebars.create();
|
|
47
|
+
registerComparisonHelpers(handlebars);
|
|
109
48
|
|
|
110
49
|
// Compile and execute the query template
|
|
111
50
|
const template = handlebars.compile(queryTemplate, {
|
|
@@ -119,12 +58,7 @@ export async function generateReportContent(
|
|
|
119
58
|
}
|
|
120
59
|
// register empty macros so that other macros aren't touched yet
|
|
121
60
|
registerEmptyMacros(handlebars);
|
|
122
|
-
|
|
123
|
-
if (graph) {
|
|
124
|
-
handlebars.registerHelper('formatAttributeValue', formatAttributeValue);
|
|
125
|
-
}
|
|
126
|
-
handlebars.registerHelper('isCustomField', isCustomField);
|
|
127
|
-
handlebars.registerHelper('formatValue', formatValue);
|
|
61
|
+
registerReportHelpers(handlebars);
|
|
128
62
|
|
|
129
63
|
return handlebars.compile(contentTemplate)({
|
|
130
64
|
...options,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Cyberismo
|
|
3
|
+
Copyright © Cyberismo Ltd and contributors 2026
|
|
4
|
+
This program is free software: you can redistribute it and/or modify it under
|
|
5
|
+
the terms of the GNU Affero General Public License version 3 as published by
|
|
6
|
+
the Free Software Foundation. This program is distributed in the hope that it
|
|
7
|
+
will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
|
|
8
|
+
of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
|
9
|
+
See the GNU Affero General Public License for more details.
|
|
10
|
+
You should have received a copy of the GNU Affero General Public
|
|
11
|
+
License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
15
|
+
import { runWithDefaultCommitMessage } from './commit-context.js';
|
|
16
|
+
import { getChildLogger } from './log-utils.js';
|
|
17
|
+
|
|
18
|
+
interface LockContext {
|
|
19
|
+
mode: 'read' | 'write';
|
|
20
|
+
active: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Promise-based read-write lock with writer priority and reentrancy.
|
|
25
|
+
*
|
|
26
|
+
* - Multiple concurrent readers are allowed.
|
|
27
|
+
* - Writers get exclusive access.
|
|
28
|
+
* - Writer-priority: new readers block when a writer is waiting.
|
|
29
|
+
* - Reentrancy via AsyncLocalStorage: nested lock calls within the same
|
|
30
|
+
* async context are no-ops.
|
|
31
|
+
* - After-write hooks fire after the outermost write completes successfully.
|
|
32
|
+
*/
|
|
33
|
+
export class RWLock {
|
|
34
|
+
private readers = 0;
|
|
35
|
+
private writer = false;
|
|
36
|
+
private readerQueue: (() => void)[] = [];
|
|
37
|
+
private writerQueue: (() => void)[] = [];
|
|
38
|
+
private context = new AsyncLocalStorage<LockContext>();
|
|
39
|
+
private afterWriteHooks: (() => Promise<void>)[] = [];
|
|
40
|
+
private writeErrorHooks: ((error: unknown) => Promise<void>)[] = [];
|
|
41
|
+
private readonly logger;
|
|
42
|
+
|
|
43
|
+
constructor(private readonly name: string = 'RWLock') {
|
|
44
|
+
this.logger = getChildLogger({ module: 'RWLock', name });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register a callback that fires after the outermost write completes
|
|
49
|
+
* successfully. Hooks run while still holding the write lock.
|
|
50
|
+
*/
|
|
51
|
+
onAfterWrite(hook: () => Promise<void>): void {
|
|
52
|
+
this.afterWriteHooks.push(hook);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Register a callback that fires when the outermost write fails.
|
|
57
|
+
* Hooks run while still holding the write lock.
|
|
58
|
+
*/
|
|
59
|
+
onWriteError(hook: (error: unknown) => Promise<void>): void {
|
|
60
|
+
this.writeErrorHooks.push(hook);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Execute `fn` under a read lock. Concurrent readers are allowed.
|
|
65
|
+
* If already inside a read or write context, just runs fn directly.
|
|
66
|
+
*/
|
|
67
|
+
async read<T>(fn: () => Promise<T>): Promise<T> {
|
|
68
|
+
const current = this.context.getStore();
|
|
69
|
+
if (current?.active) {
|
|
70
|
+
return fn();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await this.acquireRead();
|
|
74
|
+
const ctx: LockContext = { mode: 'read', active: true };
|
|
75
|
+
try {
|
|
76
|
+
return await this.context.run(ctx, fn);
|
|
77
|
+
} finally {
|
|
78
|
+
ctx.active = false;
|
|
79
|
+
this.releaseRead();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Execute `fn` under an exclusive write lock.
|
|
85
|
+
* If already inside a write context, just runs fn directly (no hooks).
|
|
86
|
+
*/
|
|
87
|
+
async write<T>(fn: () => Promise<T>): Promise<T> {
|
|
88
|
+
const current = this.context.getStore();
|
|
89
|
+
if (current?.active && current.mode === 'write') {
|
|
90
|
+
return fn();
|
|
91
|
+
}
|
|
92
|
+
if (current?.active && current.mode === 'read') {
|
|
93
|
+
throw new Error('Cannot acquire write lock while holding read lock');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await this.acquireWrite();
|
|
97
|
+
const ctx: LockContext = { mode: 'write', active: true };
|
|
98
|
+
try {
|
|
99
|
+
const result = await this.context.run(ctx, fn);
|
|
100
|
+
// Fire after-write hooks while still holding the lock
|
|
101
|
+
for (const hook of this.afterWriteHooks) {
|
|
102
|
+
await this.context.run(ctx, hook);
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Run rollback hooks on error (outermost write only)
|
|
107
|
+
for (const hook of this.writeErrorHooks) {
|
|
108
|
+
await this.context.run(ctx, () => hook(error));
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
} finally {
|
|
112
|
+
ctx.active = false;
|
|
113
|
+
this.releaseWrite();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private acquireRead(): Promise<void> {
|
|
118
|
+
if (!this.writer && this.writerQueue.length === 0) {
|
|
119
|
+
this.readers++;
|
|
120
|
+
this.logger.trace({ readers: this.readers }, 'read lock acquired');
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
}
|
|
123
|
+
this.logger.debug(
|
|
124
|
+
{
|
|
125
|
+
readers: this.readers,
|
|
126
|
+
writer: this.writer,
|
|
127
|
+
writerQueueDepth: this.writerQueue.length,
|
|
128
|
+
readerQueueDepth: this.readerQueue.length,
|
|
129
|
+
},
|
|
130
|
+
'read lock queued (writer active or pending)',
|
|
131
|
+
);
|
|
132
|
+
return new Promise<void>((resolve) => {
|
|
133
|
+
this.readerQueue.push(() => {
|
|
134
|
+
this.readers++;
|
|
135
|
+
this.logger.debug(
|
|
136
|
+
{
|
|
137
|
+
readers: this.readers,
|
|
138
|
+
writerQueueDepth: this.writerQueue.length,
|
|
139
|
+
readerQueueDepth: this.readerQueue.length,
|
|
140
|
+
},
|
|
141
|
+
'read lock acquired after wait',
|
|
142
|
+
);
|
|
143
|
+
resolve();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private releaseRead(): void {
|
|
149
|
+
this.readers--;
|
|
150
|
+
this.logger.trace(
|
|
151
|
+
{
|
|
152
|
+
readers: this.readers,
|
|
153
|
+
writerQueueDepth: this.writerQueue.length,
|
|
154
|
+
readerQueueDepth: this.readerQueue.length,
|
|
155
|
+
},
|
|
156
|
+
'read lock released',
|
|
157
|
+
);
|
|
158
|
+
if (this.readers === 0 && this.writerQueue.length > 0) {
|
|
159
|
+
const next = this.writerQueue.shift()!;
|
|
160
|
+
next();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private acquireWrite(): Promise<void> {
|
|
165
|
+
if (!this.writer && this.readers === 0) {
|
|
166
|
+
this.writer = true;
|
|
167
|
+
this.logger.trace(
|
|
168
|
+
{
|
|
169
|
+
readers: this.readers,
|
|
170
|
+
writerQueueDepth: this.writerQueue.length,
|
|
171
|
+
readerQueueDepth: this.readerQueue.length,
|
|
172
|
+
},
|
|
173
|
+
'write lock acquired',
|
|
174
|
+
);
|
|
175
|
+
return Promise.resolve();
|
|
176
|
+
}
|
|
177
|
+
this.logger.debug(
|
|
178
|
+
{
|
|
179
|
+
readers: this.readers,
|
|
180
|
+
writer: this.writer,
|
|
181
|
+
writerQueueDepth: this.writerQueue.length,
|
|
182
|
+
readerQueueDepth: this.readerQueue.length,
|
|
183
|
+
},
|
|
184
|
+
`write lock queued (readers: ${this.readers}, writer active: ${this.writer})`,
|
|
185
|
+
);
|
|
186
|
+
return new Promise<void>((resolve) => {
|
|
187
|
+
this.writerQueue.push(() => {
|
|
188
|
+
this.writer = true;
|
|
189
|
+
this.logger.debug(
|
|
190
|
+
{
|
|
191
|
+
readers: this.readers,
|
|
192
|
+
writerQueueDepth: this.writerQueue.length,
|
|
193
|
+
readerQueueDepth: this.readerQueue.length,
|
|
194
|
+
},
|
|
195
|
+
'write lock acquired after wait',
|
|
196
|
+
);
|
|
197
|
+
resolve();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private releaseWrite(): void {
|
|
203
|
+
this.writer = false;
|
|
204
|
+
this.logger.trace(
|
|
205
|
+
{
|
|
206
|
+
readers: this.readers,
|
|
207
|
+
writerQueueDepth: this.writerQueue.length,
|
|
208
|
+
readerQueueDepth: this.readerQueue.length,
|
|
209
|
+
},
|
|
210
|
+
'write lock released',
|
|
211
|
+
);
|
|
212
|
+
if (this.writerQueue.length > 0) {
|
|
213
|
+
const next = this.writerQueue.shift()!;
|
|
214
|
+
next();
|
|
215
|
+
} else {
|
|
216
|
+
// Wake ALL waiting readers
|
|
217
|
+
const readers = this.readerQueue.splice(0);
|
|
218
|
+
for (const wake of readers) {
|
|
219
|
+
wake();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Helper to access the lock from a command instance via its `project` property.
|
|
226
|
+
function getLock(instance: object): RWLock {
|
|
227
|
+
const lock = (instance as { project?: { lock?: RWLock } }).project?.lock;
|
|
228
|
+
if (!lock) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
'@read/@write decorator: instance.project.lock is not defined. ' +
|
|
231
|
+
'Ensure the class has a `project` property with `lock: RWLock`.',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return lock;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A Helper decorator built for commands that automatically handles using a read lock
|
|
239
|
+
*/
|
|
240
|
+
export function read<This extends object, Args extends unknown[], Return>(
|
|
241
|
+
target: (this: This, ...args: Args) => Promise<Return>,
|
|
242
|
+
): (this: This, ...args: Args) => Promise<Return> {
|
|
243
|
+
return function (this: This, ...args: Args): Promise<Return> {
|
|
244
|
+
return getLock(this).read(() => target.call(this, ...args));
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A Helper decorator built for commands that automatically handles using a write lock.
|
|
250
|
+
*
|
|
251
|
+
* Two forms:
|
|
252
|
+
* - `@write()`: just acquires the write lock, no commit message (for wrapper methods)
|
|
253
|
+
* - `@write((param) => \`Do ${param}\`)`: acquires the write lock and sets a default commit message
|
|
254
|
+
*/
|
|
255
|
+
export function write<This extends object, Args extends unknown[], Return>(): (
|
|
256
|
+
target: (this: This, ...args: Args) => Promise<Return>,
|
|
257
|
+
) => (this: This, ...args: Args) => Promise<Return>;
|
|
258
|
+
|
|
259
|
+
export function write<This extends object, Args extends unknown[], Return>(
|
|
260
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
261
|
+
message: (...args: any[]) => string,
|
|
262
|
+
): (
|
|
263
|
+
target: (this: This, ...args: Args) => Promise<Return>,
|
|
264
|
+
) => (this: This, ...args: Args) => Promise<Return>;
|
|
265
|
+
|
|
266
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
267
|
+
export function write(message?: (...args: any[]) => string): unknown {
|
|
268
|
+
return function (target: (...args: unknown[]) => Promise<unknown>) {
|
|
269
|
+
return function (this: object, ...args: unknown[]) {
|
|
270
|
+
if (!message) {
|
|
271
|
+
return getLock(this).write(() => target.call(this, ...args));
|
|
272
|
+
}
|
|
273
|
+
const label = message(...args);
|
|
274
|
+
return runWithDefaultCommitMessage(label, () =>
|
|
275
|
+
getLock(this).write(() => target.call(this, ...args)),
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -117,6 +117,7 @@ export class UserPreferences {
|
|
|
117
117
|
if (error.code !== 'EEXIST') {
|
|
118
118
|
throw new Error(
|
|
119
119
|
`Error creating preferences file '${this.prefsFilePath}': ${error}`,
|
|
120
|
+
{ cause: error },
|
|
120
121
|
);
|
|
121
122
|
} else {
|
|
122
123
|
this.logger.warn('Preferences file already exists');
|
|
@@ -124,6 +125,7 @@ export class UserPreferences {
|
|
|
124
125
|
} else {
|
|
125
126
|
throw new Error(
|
|
126
127
|
`Error creating preferences file '${this.prefsFilePath}': ${error}`,
|
|
128
|
+
{ cause: error },
|
|
127
129
|
);
|
|
128
130
|
}
|
|
129
131
|
}
|
|
@@ -142,6 +144,7 @@ export class UserPreferences {
|
|
|
142
144
|
} catch (error) {
|
|
143
145
|
throw new Error(
|
|
144
146
|
`Error reading preferences file '${this.prefsFilePath}': ${error}`,
|
|
147
|
+
{ cause: error },
|
|
145
148
|
);
|
|
146
149
|
}
|
|
147
150
|
}
|