@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.
Files changed (177) hide show
  1. package/dist/command-handler.js +13 -24
  2. package/dist/command-handler.js.map +1 -1
  3. package/dist/command-manager.d.ts +21 -6
  4. package/dist/command-manager.js +34 -32
  5. package/dist/command-manager.js.map +1 -1
  6. package/dist/commands/calculate.js +101 -46
  7. package/dist/commands/calculate.js.map +1 -1
  8. package/dist/commands/create.js +420 -320
  9. package/dist/commands/create.js.map +1 -1
  10. package/dist/commands/edit.js +117 -68
  11. package/dist/commands/edit.js.map +1 -1
  12. package/dist/commands/export.js +301 -252
  13. package/dist/commands/export.js.map +1 -1
  14. package/dist/commands/fetch.js +205 -156
  15. package/dist/commands/fetch.js.map +1 -1
  16. package/dist/commands/import.js +189 -134
  17. package/dist/commands/import.js.map +1 -1
  18. package/dist/commands/migrate.js +91 -45
  19. package/dist/commands/migrate.js.map +1 -1
  20. package/dist/commands/move.js +347 -267
  21. package/dist/commands/move.js.map +1 -1
  22. package/dist/commands/remove.d.ts +1 -0
  23. package/dist/commands/remove.js +202 -135
  24. package/dist/commands/remove.js.map +1 -1
  25. package/dist/commands/rename.js +233 -187
  26. package/dist/commands/rename.js.map +1 -1
  27. package/dist/commands/show.d.ts +8 -8
  28. package/dist/commands/show.js +477 -372
  29. package/dist/commands/show.js.map +1 -1
  30. package/dist/commands/transition.js +119 -73
  31. package/dist/commands/transition.js.map +1 -1
  32. package/dist/commands/update.js +8 -3
  33. package/dist/commands/update.js.map +1 -1
  34. package/dist/commands/validate.js +1 -1
  35. package/dist/commands/validate.js.map +1 -1
  36. package/dist/containers/project/calculation-engine.js +0 -1
  37. package/dist/containers/project/calculation-engine.js.map +1 -1
  38. package/dist/containers/project/card-cache.js +1 -1
  39. package/dist/containers/project/card-cache.js.map +1 -1
  40. package/dist/containers/project.d.ts +16 -0
  41. package/dist/containers/project.js +59 -1
  42. package/dist/containers/project.js.map +1 -1
  43. package/dist/containers/template.js +1 -1
  44. package/dist/containers/template.js.map +1 -1
  45. package/dist/interfaces/command-options.d.ts +1 -0
  46. package/dist/interfaces/resource-interfaces.d.ts +5 -12
  47. package/dist/interfaces/resource-interfaces.js.map +1 -1
  48. package/dist/macros/base-macro.js +1 -1
  49. package/dist/macros/base-macro.js.map +1 -1
  50. package/dist/macros/graph/index.js +3 -1
  51. package/dist/macros/graph/index.js.map +1 -1
  52. package/dist/macros/include/index.js +16 -1
  53. package/dist/macros/include/index.js.map +1 -1
  54. package/dist/macros/include/types.d.ts +15 -12
  55. package/dist/macros/index.js +4 -1
  56. package/dist/macros/index.js.map +1 -1
  57. package/dist/macros/report/index.js +1 -1
  58. package/dist/macros/report/index.js.map +1 -1
  59. package/dist/module-manager.js +5 -3
  60. package/dist/module-manager.js.map +1 -1
  61. package/dist/project-settings.js +2 -2
  62. package/dist/project-settings.js.map +1 -1
  63. package/dist/resources/card-type-resource.js +1 -1
  64. package/dist/resources/card-type-resource.js.map +1 -1
  65. package/dist/resources/create-defaults.js +0 -1
  66. package/dist/resources/create-defaults.js.map +1 -1
  67. package/dist/resources/field-type-resource.js +2 -5
  68. package/dist/resources/field-type-resource.js.map +1 -1
  69. package/dist/resources/file-resource.js +4 -1
  70. package/dist/resources/file-resource.js.map +1 -1
  71. package/dist/resources/folder-resource.d.ts +1 -1
  72. package/dist/resources/folder-resource.js +4 -1
  73. package/dist/resources/folder-resource.js.map +1 -1
  74. package/dist/resources/graph-model-resource.d.ts +1 -8
  75. package/dist/resources/graph-model-resource.js +0 -14
  76. package/dist/resources/graph-model-resource.js.map +1 -1
  77. package/dist/resources/graph-view-resource.d.ts +1 -8
  78. package/dist/resources/graph-view-resource.js +0 -14
  79. package/dist/resources/graph-view-resource.js.map +1 -1
  80. package/dist/resources/link-type-resource.js +1 -1
  81. package/dist/resources/link-type-resource.js.map +1 -1
  82. package/dist/resources/report-resource.d.ts +1 -8
  83. package/dist/resources/report-resource.js +0 -14
  84. package/dist/resources/report-resource.js.map +1 -1
  85. package/dist/resources/resource-object.d.ts +11 -1
  86. package/dist/resources/resource-object.js +19 -2
  87. package/dist/resources/resource-object.js.map +1 -1
  88. package/dist/resources/template-resource.d.ts +1 -9
  89. package/dist/resources/template-resource.js +0 -15
  90. package/dist/resources/template-resource.js.map +1 -1
  91. package/dist/resources/workflow-resource.d.ts +6 -0
  92. package/dist/resources/workflow-resource.js +29 -13
  93. package/dist/resources/workflow-resource.js.map +1 -1
  94. package/dist/utils/card-utils.js +1 -1
  95. package/dist/utils/card-utils.js.map +1 -1
  96. package/dist/utils/commit-context.d.ts +23 -0
  97. package/dist/utils/commit-context.js +30 -0
  98. package/dist/utils/commit-context.js.map +1 -0
  99. package/dist/utils/csv.d.ts +8 -0
  100. package/dist/utils/csv.js +11 -0
  101. package/dist/utils/csv.js.map +1 -1
  102. package/dist/utils/file-utils.js +3 -1
  103. package/dist/utils/file-utils.js.map +1 -1
  104. package/dist/utils/git-manager.d.ts +29 -0
  105. package/dist/utils/git-manager.js +76 -0
  106. package/dist/utils/git-manager.js.map +1 -0
  107. package/dist/utils/handlebars-helpers.d.ts +22 -0
  108. package/dist/utils/handlebars-helpers.js +78 -0
  109. package/dist/utils/handlebars-helpers.js.map +1 -0
  110. package/dist/utils/json.d.ts +17 -10
  111. package/dist/utils/json.js +27 -14
  112. package/dist/utils/json.js.map +1 -1
  113. package/dist/utils/log-utils.d.ts +7 -2
  114. package/dist/utils/log-utils.js +28 -3
  115. package/dist/utils/log-utils.js.map +1 -1
  116. package/dist/utils/report.d.ts +0 -19
  117. package/dist/utils/report.js +4 -63
  118. package/dist/utils/report.js.map +1 -1
  119. package/dist/utils/rw-lock.d.ts +71 -0
  120. package/dist/utils/rw-lock.js +220 -0
  121. package/dist/utils/rw-lock.js.map +1 -0
  122. package/dist/utils/user-preferences.js +3 -3
  123. package/dist/utils/user-preferences.js.map +1 -1
  124. package/package.json +10 -10
  125. package/src/command-handler.ts +14 -22
  126. package/src/command-manager.ts +43 -37
  127. package/src/commands/calculate.ts +8 -1
  128. package/src/commands/create.ts +39 -6
  129. package/src/commands/edit.ts +3 -0
  130. package/src/commands/export.ts +8 -2
  131. package/src/commands/fetch.ts +3 -0
  132. package/src/commands/import.ts +5 -0
  133. package/src/commands/migrate.ts +2 -0
  134. package/src/commands/move.ts +34 -0
  135. package/src/commands/remove.ts +24 -2
  136. package/src/commands/rename.ts +2 -0
  137. package/src/commands/show.ts +63 -34
  138. package/src/commands/transition.ts +2 -0
  139. package/src/commands/update.ts +9 -3
  140. package/src/commands/validate.ts +1 -1
  141. package/src/containers/project/calculation-engine.ts +0 -1
  142. package/src/containers/project/card-cache.ts +1 -0
  143. package/src/containers/project.ts +75 -1
  144. package/src/containers/template.ts +1 -1
  145. package/src/interfaces/command-options.ts +1 -0
  146. package/src/interfaces/resource-interfaces.ts +5 -12
  147. package/src/macros/base-macro.ts +1 -1
  148. package/src/macros/graph/index.ts +3 -0
  149. package/src/macros/include/index.ts +19 -1
  150. package/src/macros/include/types.ts +15 -12
  151. package/src/macros/index.ts +4 -1
  152. package/src/macros/report/index.ts +1 -0
  153. package/src/module-manager.ts +5 -2
  154. package/src/project-settings.ts +2 -1
  155. package/src/resources/card-type-resource.ts +1 -1
  156. package/src/resources/create-defaults.ts +0 -1
  157. package/src/resources/field-type-resource.ts +2 -4
  158. package/src/resources/file-resource.ts +3 -1
  159. package/src/resources/folder-resource.ts +7 -2
  160. package/src/resources/graph-model-resource.ts +1 -25
  161. package/src/resources/graph-view-resource.ts +1 -25
  162. package/src/resources/link-type-resource.ts +1 -1
  163. package/src/resources/report-resource.ts +1 -25
  164. package/src/resources/resource-object.ts +22 -1
  165. package/src/resources/template-resource.ts +0 -23
  166. package/src/resources/workflow-resource.ts +45 -16
  167. package/src/utils/card-utils.ts +1 -1
  168. package/src/utils/commit-context.ts +45 -0
  169. package/src/utils/csv.ts +12 -0
  170. package/src/utils/file-utils.ts +3 -1
  171. package/src/utils/git-manager.ts +87 -0
  172. package/src/utils/handlebars-helpers.ts +95 -0
  173. package/src/utils/json.ts +29 -15
  174. package/src/utils/log-utils.ts +33 -4
  175. package/src/utils/report.ts +8 -74
  176. package/src/utils/rw-lock.ts +279 -0
  177. 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.
@@ -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
- // silent logger as default
17
+ // Silent logger as default.
16
18
  let _logger: Logger = pino({ level: 'silent' });
19
+ let initialized = false;
17
20
 
18
- export function setLogger(logger: Logger) {
19
- _logger = logger;
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
  */
@@ -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 { resourceName } from './resource-utils.js';
17
-
18
- /**
19
- * Formats a value from a logic program for use to graphviz
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
- calculate,
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
  }