project-roadmap-tracking 0.1.0 → 0.2.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/README.md +291 -24
- package/dist/commands/add.d.ts +2 -0
- package/dist/commands/add.js +39 -31
- package/dist/commands/complete.d.ts +2 -0
- package/dist/commands/complete.js +35 -12
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +63 -46
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +65 -62
- package/dist/commands/pass-test.d.ts +4 -1
- package/dist/commands/pass-test.js +36 -13
- package/dist/commands/show.d.ts +4 -1
- package/dist/commands/show.js +38 -59
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +54 -31
- package/dist/commands/validate.d.ts +4 -1
- package/dist/commands/validate.js +74 -32
- package/dist/errors/base.error.d.ts +21 -0
- package/dist/errors/base.error.js +35 -0
- package/dist/errors/circular-dependency.error.d.ts +8 -0
- package/dist/errors/circular-dependency.error.js +13 -0
- package/dist/errors/config-not-found.error.d.ts +7 -0
- package/dist/errors/config-not-found.error.js +12 -0
- package/dist/errors/index.d.ts +16 -0
- package/dist/errors/index.js +26 -0
- package/dist/errors/invalid-task.error.d.ts +7 -0
- package/dist/errors/invalid-task.error.js +12 -0
- package/dist/errors/roadmap-not-found.error.d.ts +7 -0
- package/dist/errors/roadmap-not-found.error.js +12 -0
- package/dist/errors/task-not-found.error.d.ts +7 -0
- package/dist/errors/task-not-found.error.js +12 -0
- package/dist/errors/validation.error.d.ts +16 -0
- package/dist/errors/validation.error.js +16 -0
- package/dist/repositories/config.repository.d.ts +76 -0
- package/dist/repositories/config.repository.js +282 -0
- package/dist/repositories/index.d.ts +2 -0
- package/dist/repositories/index.js +2 -0
- package/dist/repositories/roadmap.repository.d.ts +82 -0
- package/dist/repositories/roadmap.repository.js +201 -0
- package/dist/services/display.service.d.ts +182 -0
- package/dist/services/display.service.js +320 -0
- package/dist/services/error-handler.service.d.ts +114 -0
- package/dist/services/error-handler.service.js +169 -0
- package/dist/services/roadmap.service.d.ts +142 -0
- package/dist/services/roadmap.service.js +269 -0
- package/dist/services/task-dependency.service.d.ts +210 -0
- package/dist/services/task-dependency.service.js +371 -0
- package/dist/services/task-query.service.d.ts +123 -0
- package/dist/services/task-query.service.js +259 -0
- package/dist/services/task.service.d.ts +132 -0
- package/dist/services/task.service.js +173 -0
- package/dist/util/read-config.js +12 -2
- package/dist/util/read-roadmap.js +12 -2
- package/dist/util/types.d.ts +5 -0
- package/dist/util/update-task.js +2 -1
- package/dist/util/validate-task.js +6 -5
- package/oclif.manifest.json +114 -5
- package/package.json +19 -3
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { Ajv } from 'ajv';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ConfigNotFoundError, ValidationError } from '../errors/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* ConfigRepository provides caching, validation, and inheritance for config files.
|
|
8
|
+
* Features:
|
|
9
|
+
* - In-memory cache with mtime-based invalidation
|
|
10
|
+
* - Multi-level config inheritance (project → user → global)
|
|
11
|
+
* - JSON schema validation using schemas/config/v1.1.json
|
|
12
|
+
* - Shallow merge strategy for inherited configs
|
|
13
|
+
*/
|
|
14
|
+
export class ConfigRepository {
|
|
15
|
+
static configSchema = {
|
|
16
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
properties: {
|
|
19
|
+
$schema: {
|
|
20
|
+
description: 'Reference to the JSON schema for validation',
|
|
21
|
+
type: 'string',
|
|
22
|
+
},
|
|
23
|
+
cache: {
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
description: 'Caching configuration for the roadmap repository',
|
|
26
|
+
properties: {
|
|
27
|
+
enabled: {
|
|
28
|
+
default: true,
|
|
29
|
+
description: 'Enable in-memory caching of roadmap data',
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
},
|
|
32
|
+
maxSize: {
|
|
33
|
+
default: 10,
|
|
34
|
+
description: 'Maximum number of roadmaps to cache (LRU eviction)',
|
|
35
|
+
minimum: 1,
|
|
36
|
+
type: 'number',
|
|
37
|
+
},
|
|
38
|
+
watchFiles: {
|
|
39
|
+
default: true,
|
|
40
|
+
description: 'Watch roadmap files for external changes and auto-invalidate cache',
|
|
41
|
+
type: 'boolean',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
type: 'object',
|
|
45
|
+
},
|
|
46
|
+
metadata: {
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
description: 'Metadata about the project roadmap',
|
|
49
|
+
properties: {
|
|
50
|
+
description: {
|
|
51
|
+
description: 'Description of the project roadmap',
|
|
52
|
+
type: 'string',
|
|
53
|
+
},
|
|
54
|
+
name: {
|
|
55
|
+
description: 'Name of the project roadmap',
|
|
56
|
+
type: 'string',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
type: 'object',
|
|
60
|
+
},
|
|
61
|
+
path: {
|
|
62
|
+
default: './prt.json',
|
|
63
|
+
description: 'Path to the prt.json roadmap file',
|
|
64
|
+
type: 'string',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: ['path'],
|
|
68
|
+
type: 'object',
|
|
69
|
+
};
|
|
70
|
+
cache = null;
|
|
71
|
+
config;
|
|
72
|
+
validateSchema;
|
|
73
|
+
constructor(config) {
|
|
74
|
+
this.config = {
|
|
75
|
+
cacheEnabled: config?.cacheEnabled ?? true,
|
|
76
|
+
searchPaths: config?.searchPaths ?? this.getDefaultSearchPaths(),
|
|
77
|
+
};
|
|
78
|
+
// Initialize JSON schema validator
|
|
79
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
80
|
+
this.validateSchema = ajv.compile(ConfigRepository.configSchema);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get the currently cached config (if any)
|
|
84
|
+
*/
|
|
85
|
+
getCachedConfig() {
|
|
86
|
+
return this.cache?.data ?? null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Clear the in-memory cache
|
|
90
|
+
*/
|
|
91
|
+
invalidateCache() {
|
|
92
|
+
this.cache = null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Load config with caching and inheritance
|
|
96
|
+
* Searches for config files in order: project → user → global
|
|
97
|
+
* Merges configs with shallow merge (project overrides user overrides global)
|
|
98
|
+
*/
|
|
99
|
+
async load() {
|
|
100
|
+
// Check cache if enabled
|
|
101
|
+
if (this.config.cacheEnabled && this.cache) {
|
|
102
|
+
const isValid = await this.isCacheValid();
|
|
103
|
+
if (isValid) {
|
|
104
|
+
return this.cache.data;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Load and merge configs from all search paths
|
|
108
|
+
const configs = await this.loadAllConfigs();
|
|
109
|
+
if (configs.length === 0) {
|
|
110
|
+
throw new ConfigNotFoundError('.prtrc.json');
|
|
111
|
+
}
|
|
112
|
+
// Shallow merge: project overrides user overrides global
|
|
113
|
+
const merged = this.mergeConfigs(configs);
|
|
114
|
+
// Validate merged config
|
|
115
|
+
this.validateConfig(merged);
|
|
116
|
+
// Cache the result
|
|
117
|
+
if (this.config.cacheEnabled) {
|
|
118
|
+
const stats = await stat(configs[0].path); // Use mtime of project-level config
|
|
119
|
+
this.cache = {
|
|
120
|
+
data: merged,
|
|
121
|
+
mtime: stats.mtimeMs,
|
|
122
|
+
path: configs[0].path,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return merged;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Reload config from disk, bypassing cache
|
|
129
|
+
*/
|
|
130
|
+
async reload() {
|
|
131
|
+
this.invalidateCache();
|
|
132
|
+
return this.load();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get default config file search paths
|
|
136
|
+
* Order: project → user → global
|
|
137
|
+
*/
|
|
138
|
+
getDefaultSearchPaths() {
|
|
139
|
+
const paths = [
|
|
140
|
+
'.prtrc.json', // Project level (current directory)
|
|
141
|
+
join(homedir(), '.prtrc.json'), // User level
|
|
142
|
+
];
|
|
143
|
+
// Global level (Unix-like systems only)
|
|
144
|
+
if (process.platform !== 'win32') {
|
|
145
|
+
paths.push('/etc/prt/.prtrc.json');
|
|
146
|
+
}
|
|
147
|
+
return paths;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check if cached config is still valid by comparing mtime
|
|
151
|
+
*/
|
|
152
|
+
async isCacheValid() {
|
|
153
|
+
if (!this.cache) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const stats = await stat(this.cache.path);
|
|
158
|
+
return stats.mtimeMs === this.cache.mtime;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// If stat fails, cache is invalid
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Load all available configs from search paths
|
|
167
|
+
* Returns array in order of precedence (project first, global last)
|
|
168
|
+
*/
|
|
169
|
+
async loadAllConfigs() {
|
|
170
|
+
const paths = this.config.searchPaths ?? [];
|
|
171
|
+
const configPromises = paths.map(async (path) => {
|
|
172
|
+
const config = await this.loadConfigFromPath(path);
|
|
173
|
+
return config ? { config, path } : null;
|
|
174
|
+
});
|
|
175
|
+
const results = await Promise.all(configPromises);
|
|
176
|
+
return results.filter((result) => result !== null);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Load config from a specific path
|
|
180
|
+
*/
|
|
181
|
+
async loadConfigFromPath(path) {
|
|
182
|
+
try {
|
|
183
|
+
const data = await readFile(path, 'utf8');
|
|
184
|
+
const config = JSON.parse(data);
|
|
185
|
+
return config;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
// Re-throw SyntaxError for JSON parsing issues
|
|
189
|
+
if (error instanceof SyntaxError) {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
// Return null for file not found (not an error during inheritance search)
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Merge configs using shallow merge strategy
|
|
198
|
+
* Project-level config takes precedence over user-level over global-level
|
|
199
|
+
*/
|
|
200
|
+
mergeConfigs(configs) {
|
|
201
|
+
if (configs.length === 0) {
|
|
202
|
+
throw new ConfigNotFoundError('.prtrc.json');
|
|
203
|
+
}
|
|
204
|
+
// Start with the lowest precedence config (last in array)
|
|
205
|
+
const lastConfig = configs.at(-1);
|
|
206
|
+
if (!lastConfig) {
|
|
207
|
+
throw new ConfigNotFoundError('.prtrc.json');
|
|
208
|
+
}
|
|
209
|
+
let merged = { ...lastConfig.config };
|
|
210
|
+
// Apply each higher-precedence config (working backwards)
|
|
211
|
+
for (let i = configs.length - 2; i >= 0; i--) {
|
|
212
|
+
merged = {
|
|
213
|
+
...merged,
|
|
214
|
+
...configs[i].config,
|
|
215
|
+
// Deep merge for nested objects
|
|
216
|
+
cache: configs[i].config.cache ? { ...merged.cache, ...configs[i].config.cache } : merged.cache,
|
|
217
|
+
metadata: configs[i].config.metadata ? { ...merged.metadata, ...configs[i].config.metadata } : merged.metadata,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return merged;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Validate config against JSON schema
|
|
224
|
+
*/
|
|
225
|
+
validateConfig(config) {
|
|
226
|
+
try {
|
|
227
|
+
const valid = this.validateSchema(config);
|
|
228
|
+
if (!valid) {
|
|
229
|
+
// Handle case where validateSchema.errors might be null/undefined
|
|
230
|
+
if (!this.validateSchema.errors || !Array.isArray(this.validateSchema.errors)) {
|
|
231
|
+
throw new ValidationError([
|
|
232
|
+
{
|
|
233
|
+
field: 'config',
|
|
234
|
+
message: 'Schema validation failed',
|
|
235
|
+
type: 'structure',
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
}
|
|
239
|
+
const errorDetails = this.validateSchema.errors.map((err) => ({
|
|
240
|
+
field: err.instancePath || 'config',
|
|
241
|
+
message: err.message || 'Unknown error',
|
|
242
|
+
type: 'structure',
|
|
243
|
+
}));
|
|
244
|
+
throw new ValidationError(errorDetails);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
// If it's already a ValidationError, re-throw it
|
|
249
|
+
if (error instanceof ValidationError) {
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
// Wrap any other error (like TypeError) in ValidationError
|
|
253
|
+
throw new ValidationError([
|
|
254
|
+
{
|
|
255
|
+
field: 'config',
|
|
256
|
+
message: error instanceof Error ? error.message : String(error),
|
|
257
|
+
type: 'structure',
|
|
258
|
+
},
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Singleton instance with default configuration
|
|
264
|
+
let defaultInstance = null;
|
|
265
|
+
/**
|
|
266
|
+
* Get the default config repository instance
|
|
267
|
+
*/
|
|
268
|
+
export function getDefaultConfigRepository() {
|
|
269
|
+
if (!defaultInstance) {
|
|
270
|
+
defaultInstance = new ConfigRepository();
|
|
271
|
+
}
|
|
272
|
+
return defaultInstance;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Reset the default config repository instance (useful for testing)
|
|
276
|
+
*/
|
|
277
|
+
export function resetDefaultConfigRepository() {
|
|
278
|
+
if (defaultInstance) {
|
|
279
|
+
defaultInstance.invalidateCache();
|
|
280
|
+
defaultInstance = null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Config, Roadmap } from '../util/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration for RoadmapRepository
|
|
4
|
+
*/
|
|
5
|
+
export interface RepositoryConfig {
|
|
6
|
+
cacheEnabled?: boolean;
|
|
7
|
+
maxCacheSize?: number;
|
|
8
|
+
watchFiles?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* RoadmapRepository provides caching and file watching for roadmap data.
|
|
12
|
+
* Features:
|
|
13
|
+
* - In-memory LRU cache with configurable size
|
|
14
|
+
* - Automatic cache invalidation on writes
|
|
15
|
+
* - File system watching for external changes (using chokidar)
|
|
16
|
+
* - Configuration via .prtrc.json cache settings
|
|
17
|
+
*/
|
|
18
|
+
export declare class RoadmapRepository {
|
|
19
|
+
private cache;
|
|
20
|
+
private config;
|
|
21
|
+
private watchers;
|
|
22
|
+
constructor(config?: RepositoryConfig);
|
|
23
|
+
/**
|
|
24
|
+
* Create a repository instance from a Config object
|
|
25
|
+
*/
|
|
26
|
+
static fromConfig(config: Config): RoadmapRepository;
|
|
27
|
+
/**
|
|
28
|
+
* Stop all file watchers and clear cache
|
|
29
|
+
*/
|
|
30
|
+
dispose(): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Get current cache size
|
|
33
|
+
*/
|
|
34
|
+
getCacheSize(): number;
|
|
35
|
+
/**
|
|
36
|
+
* Invalidate cache for a specific path
|
|
37
|
+
* @param path - Path to invalidate
|
|
38
|
+
*/
|
|
39
|
+
invalidate(path: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Invalidate all cached roadmaps
|
|
42
|
+
*/
|
|
43
|
+
invalidateAll(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Check if a path is cached
|
|
46
|
+
*/
|
|
47
|
+
isCached(path: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Load a roadmap from the file system with caching
|
|
50
|
+
* @param path - Path to the roadmap file
|
|
51
|
+
* @returns The loaded roadmap
|
|
52
|
+
*/
|
|
53
|
+
load(path: string): Promise<Roadmap>;
|
|
54
|
+
/**
|
|
55
|
+
* Save a roadmap to the file system and update cache
|
|
56
|
+
* @param path - Path to the roadmap file
|
|
57
|
+
* @param roadmap - The roadmap to save
|
|
58
|
+
*/
|
|
59
|
+
save(path: string, roadmap: Roadmap): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Add entry to cache with LRU eviction
|
|
62
|
+
*/
|
|
63
|
+
private addToCache;
|
|
64
|
+
/**
|
|
65
|
+
* Load roadmap from disk without caching
|
|
66
|
+
*/
|
|
67
|
+
private loadFromDisk;
|
|
68
|
+
/**
|
|
69
|
+
* Set up file watcher for a path
|
|
70
|
+
*/
|
|
71
|
+
private setupWatcher;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get the default repository instance
|
|
75
|
+
*/
|
|
76
|
+
export declare function getDefaultRepository(): RoadmapRepository;
|
|
77
|
+
/**
|
|
78
|
+
* Reset the default repository instance (useful for testing)
|
|
79
|
+
*/
|
|
80
|
+
export declare function resetDefaultRepository(): void;
|
|
81
|
+
declare const _default: RoadmapRepository;
|
|
82
|
+
export default _default;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { watch } from 'chokidar';
|
|
2
|
+
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
/**
|
|
4
|
+
* RoadmapRepository provides caching and file watching for roadmap data.
|
|
5
|
+
* Features:
|
|
6
|
+
* - In-memory LRU cache with configurable size
|
|
7
|
+
* - Automatic cache invalidation on writes
|
|
8
|
+
* - File system watching for external changes (using chokidar)
|
|
9
|
+
* - Configuration via .prtrc.json cache settings
|
|
10
|
+
*/
|
|
11
|
+
export class RoadmapRepository {
|
|
12
|
+
cache = new Map();
|
|
13
|
+
config;
|
|
14
|
+
watchers = new Map();
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = {
|
|
17
|
+
cacheEnabled: config?.cacheEnabled ?? true,
|
|
18
|
+
maxCacheSize: config?.maxCacheSize ?? 10,
|
|
19
|
+
watchFiles: config?.watchFiles ?? true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a repository instance from a Config object
|
|
24
|
+
*/
|
|
25
|
+
static fromConfig(config) {
|
|
26
|
+
return new RoadmapRepository({
|
|
27
|
+
cacheEnabled: config.cache?.enabled ?? true,
|
|
28
|
+
maxCacheSize: config.cache?.maxSize ?? 10,
|
|
29
|
+
watchFiles: config.cache?.watchFiles ?? true,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Stop all file watchers and clear cache
|
|
34
|
+
*/
|
|
35
|
+
async dispose() {
|
|
36
|
+
// Close all watchers
|
|
37
|
+
await Promise.all([...this.watchers.values()].map((watcher) => watcher.close()));
|
|
38
|
+
this.watchers.clear();
|
|
39
|
+
this.cache.clear();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get current cache size
|
|
43
|
+
*/
|
|
44
|
+
getCacheSize() {
|
|
45
|
+
return this.cache.size;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Invalidate cache for a specific path
|
|
49
|
+
* @param path - Path to invalidate
|
|
50
|
+
*/
|
|
51
|
+
invalidate(path) {
|
|
52
|
+
this.cache.delete(path);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Invalidate all cached roadmaps
|
|
56
|
+
*/
|
|
57
|
+
invalidateAll() {
|
|
58
|
+
this.cache.clear();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Check if a path is cached
|
|
62
|
+
*/
|
|
63
|
+
isCached(path) {
|
|
64
|
+
return this.cache.has(path);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Load a roadmap from the file system with caching
|
|
68
|
+
* @param path - Path to the roadmap file
|
|
69
|
+
* @returns The loaded roadmap
|
|
70
|
+
*/
|
|
71
|
+
async load(path) {
|
|
72
|
+
// Check if caching is disabled
|
|
73
|
+
if (!this.config.cacheEnabled) {
|
|
74
|
+
return this.loadFromDisk(path);
|
|
75
|
+
}
|
|
76
|
+
// Check if we have a cached version
|
|
77
|
+
const cached = this.cache.get(path);
|
|
78
|
+
if (cached) {
|
|
79
|
+
// Verify cache is still valid by checking mtime
|
|
80
|
+
try {
|
|
81
|
+
const stats = await stat(path);
|
|
82
|
+
const currentMtime = stats.mtimeMs;
|
|
83
|
+
if (currentMtime === cached.mtime) {
|
|
84
|
+
// Cache hit - move to end for LRU
|
|
85
|
+
this.cache.delete(path);
|
|
86
|
+
this.cache.set(path, cached);
|
|
87
|
+
return cached.data;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// If stat fails, invalidate cache and reload
|
|
92
|
+
this.invalidate(path);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Cache miss or stale - load from disk
|
|
96
|
+
const roadmap = await this.loadFromDisk(path);
|
|
97
|
+
const stats = await stat(path);
|
|
98
|
+
// Add to cache with LRU eviction
|
|
99
|
+
this.addToCache(path, roadmap, stats.mtimeMs);
|
|
100
|
+
// Set up file watcher if enabled
|
|
101
|
+
if (this.config.watchFiles && !this.watchers.has(path)) {
|
|
102
|
+
this.setupWatcher(path);
|
|
103
|
+
}
|
|
104
|
+
return roadmap;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Save a roadmap to the file system and update cache
|
|
108
|
+
* @param path - Path to the roadmap file
|
|
109
|
+
* @param roadmap - The roadmap to save
|
|
110
|
+
*/
|
|
111
|
+
async save(path, roadmap) {
|
|
112
|
+
await writeFile(path, JSON.stringify(roadmap, null, 2), 'utf8');
|
|
113
|
+
// Update cache if caching is enabled
|
|
114
|
+
if (this.config.cacheEnabled) {
|
|
115
|
+
const stats = await stat(path);
|
|
116
|
+
this.addToCache(path, roadmap, stats.mtimeMs);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Add entry to cache with LRU eviction
|
|
121
|
+
*/
|
|
122
|
+
addToCache(path, data, mtime) {
|
|
123
|
+
// Remove old entry if exists (for LRU reordering)
|
|
124
|
+
if (this.cache.has(path)) {
|
|
125
|
+
this.cache.delete(path);
|
|
126
|
+
}
|
|
127
|
+
// Evict oldest entry if cache is full
|
|
128
|
+
if (this.cache.size >= (this.config.maxCacheSize ?? 10)) {
|
|
129
|
+
const firstKey = this.cache.keys().next().value;
|
|
130
|
+
if (firstKey) {
|
|
131
|
+
this.cache.delete(firstKey);
|
|
132
|
+
// Also stop watching evicted file
|
|
133
|
+
const watcher = this.watchers.get(firstKey);
|
|
134
|
+
if (watcher) {
|
|
135
|
+
watcher.close().catch(() => {
|
|
136
|
+
/* ignore close errors */
|
|
137
|
+
});
|
|
138
|
+
this.watchers.delete(firstKey);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Add new entry
|
|
143
|
+
this.cache.set(path, { data, mtime, path });
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Load roadmap from disk without caching
|
|
147
|
+
*/
|
|
148
|
+
async loadFromDisk(path) {
|
|
149
|
+
const data = await readFile(path, 'utf8');
|
|
150
|
+
return JSON.parse(data);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Set up file watcher for a path
|
|
154
|
+
*/
|
|
155
|
+
setupWatcher(path) {
|
|
156
|
+
const watcher = watch(path, {
|
|
157
|
+
awaitWriteFinish: {
|
|
158
|
+
pollInterval: 100,
|
|
159
|
+
stabilityThreshold: 250,
|
|
160
|
+
},
|
|
161
|
+
persistent: false,
|
|
162
|
+
});
|
|
163
|
+
watcher.on('change', () => {
|
|
164
|
+
this.invalidate(path);
|
|
165
|
+
});
|
|
166
|
+
watcher.on('unlink', () => {
|
|
167
|
+
this.invalidate(path);
|
|
168
|
+
this.watchers
|
|
169
|
+
.get(path)
|
|
170
|
+
?.close()
|
|
171
|
+
.catch(() => {
|
|
172
|
+
/* ignore close errors */
|
|
173
|
+
});
|
|
174
|
+
this.watchers.delete(path);
|
|
175
|
+
});
|
|
176
|
+
this.watchers.set(path, watcher);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Singleton instance with default configuration
|
|
180
|
+
let defaultInstance = null;
|
|
181
|
+
/**
|
|
182
|
+
* Get the default repository instance
|
|
183
|
+
*/
|
|
184
|
+
export function getDefaultRepository() {
|
|
185
|
+
if (!defaultInstance) {
|
|
186
|
+
defaultInstance = new RoadmapRepository();
|
|
187
|
+
}
|
|
188
|
+
return defaultInstance;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Reset the default repository instance (useful for testing)
|
|
192
|
+
*/
|
|
193
|
+
export function resetDefaultRepository() {
|
|
194
|
+
if (defaultInstance) {
|
|
195
|
+
defaultInstance.dispose().catch(() => {
|
|
196
|
+
/* ignore disposal errors */
|
|
197
|
+
});
|
|
198
|
+
defaultInstance = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export default getDefaultRepository();
|