@objectstack/metadata 0.7.1
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 +10 -0
- package/LICENSE +202 -0
- package/README.md +201 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/loaders/filesystem-loader.d.ts +41 -0
- package/dist/loaders/filesystem-loader.d.ts.map +1 -0
- package/dist/loaders/filesystem-loader.js +260 -0
- package/dist/loaders/loader-interface.d.ts +52 -0
- package/dist/loaders/loader-interface.d.ts.map +1 -0
- package/dist/loaders/loader-interface.js +6 -0
- package/dist/metadata-manager.d.ts +69 -0
- package/dist/metadata-manager.d.ts.map +1 -0
- package/dist/metadata-manager.js +263 -0
- package/dist/serializers/json-serializer.d.ts +20 -0
- package/dist/serializers/json-serializer.d.ts.map +1 -0
- package/dist/serializers/json-serializer.js +53 -0
- package/dist/serializers/serializer-interface.d.ts +57 -0
- package/dist/serializers/serializer-interface.d.ts.map +1 -0
- package/dist/serializers/serializer-interface.js +6 -0
- package/dist/serializers/serializers.test.d.ts +2 -0
- package/dist/serializers/serializers.test.d.ts.map +1 -0
- package/dist/serializers/serializers.test.js +62 -0
- package/dist/serializers/typescript-serializer.d.ts +18 -0
- package/dist/serializers/typescript-serializer.d.ts.map +1 -0
- package/dist/serializers/typescript-serializer.js +103 -0
- package/dist/serializers/yaml-serializer.d.ts +16 -0
- package/dist/serializers/yaml-serializer.d.ts.map +1 -0
- package/dist/serializers/yaml-serializer.js +35 -0
- package/package.json +37 -0
- package/src/index.ts +34 -0
- package/src/loaders/filesystem-loader.ts +314 -0
- package/src/loaders/loader-interface.ts +70 -0
- package/src/metadata-manager.ts +338 -0
- package/src/serializers/json-serializer.ts +71 -0
- package/src/serializers/serializer-interface.ts +63 -0
- package/src/serializers/serializers.test.ts +74 -0
- package/src/serializers/typescript-serializer.ts +125 -0
- package/src/serializers/yaml-serializer.ts +47 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem Metadata Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads metadata from the filesystem using glob patterns
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs/promises';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { glob } from 'glob';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import type {
|
|
12
|
+
MetadataLoadOptions,
|
|
13
|
+
MetadataLoadResult,
|
|
14
|
+
MetadataStats,
|
|
15
|
+
MetadataLoaderContract,
|
|
16
|
+
MetadataFormat,
|
|
17
|
+
} from '@objectstack/spec/system';
|
|
18
|
+
import type { Logger } from '@objectstack/core';
|
|
19
|
+
import type { MetadataLoader } from './loader-interface.js';
|
|
20
|
+
import type { MetadataSerializer } from '../serializers/serializer-interface.js';
|
|
21
|
+
|
|
22
|
+
export class FilesystemLoader implements MetadataLoader {
|
|
23
|
+
readonly contract: MetadataLoaderContract = {
|
|
24
|
+
name: 'filesystem',
|
|
25
|
+
supportedFormats: ['json', 'yaml', 'typescript', 'javascript'],
|
|
26
|
+
supportsWatch: true,
|
|
27
|
+
supportsWrite: true,
|
|
28
|
+
supportsCache: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
private cache = new Map<string, { data: any; etag: string; timestamp: number }>();
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private rootDir: string,
|
|
35
|
+
private serializers: Map<MetadataFormat, MetadataSerializer>,
|
|
36
|
+
private logger?: Logger
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
async load(
|
|
40
|
+
type: string,
|
|
41
|
+
name: string,
|
|
42
|
+
options?: MetadataLoadOptions
|
|
43
|
+
): Promise<MetadataLoadResult> {
|
|
44
|
+
const startTime = Date.now();
|
|
45
|
+
const { validate: _validate = true, useCache = true, ifNoneMatch } = options || {};
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Find the file
|
|
49
|
+
const filePath = await this.findFile(type, name);
|
|
50
|
+
|
|
51
|
+
if (!filePath) {
|
|
52
|
+
return {
|
|
53
|
+
data: null,
|
|
54
|
+
fromCache: false,
|
|
55
|
+
notModified: false,
|
|
56
|
+
loadTime: Date.now() - startTime,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Get stats
|
|
61
|
+
const stats = await this.stat(type, name);
|
|
62
|
+
|
|
63
|
+
if (!stats) {
|
|
64
|
+
return {
|
|
65
|
+
data: null,
|
|
66
|
+
fromCache: false,
|
|
67
|
+
notModified: false,
|
|
68
|
+
loadTime: Date.now() - startTime,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check cache
|
|
73
|
+
if (useCache && ifNoneMatch && stats.etag === ifNoneMatch) {
|
|
74
|
+
return {
|
|
75
|
+
data: null,
|
|
76
|
+
fromCache: true,
|
|
77
|
+
notModified: true,
|
|
78
|
+
etag: stats.etag,
|
|
79
|
+
stats,
|
|
80
|
+
loadTime: Date.now() - startTime,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check memory cache
|
|
85
|
+
const cacheKey = `${type}:${name}`;
|
|
86
|
+
if (useCache && this.cache.has(cacheKey)) {
|
|
87
|
+
const cached = this.cache.get(cacheKey)!;
|
|
88
|
+
if (cached.etag === stats.etag) {
|
|
89
|
+
return {
|
|
90
|
+
data: cached.data,
|
|
91
|
+
fromCache: true,
|
|
92
|
+
notModified: false,
|
|
93
|
+
etag: stats.etag,
|
|
94
|
+
stats,
|
|
95
|
+
loadTime: Date.now() - startTime,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Load and deserialize
|
|
101
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
102
|
+
const serializer = this.getSerializer(stats.format);
|
|
103
|
+
|
|
104
|
+
if (!serializer) {
|
|
105
|
+
throw new Error(`No serializer found for format: ${stats.format}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = serializer.deserialize(content);
|
|
109
|
+
|
|
110
|
+
// Update cache
|
|
111
|
+
if (useCache) {
|
|
112
|
+
this.cache.set(cacheKey, {
|
|
113
|
+
data,
|
|
114
|
+
etag: stats.etag,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
data,
|
|
121
|
+
fromCache: false,
|
|
122
|
+
notModified: false,
|
|
123
|
+
etag: stats.etag,
|
|
124
|
+
stats,
|
|
125
|
+
loadTime: Date.now() - startTime,
|
|
126
|
+
};
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.logger?.error('Failed to load metadata', undefined, {
|
|
129
|
+
type,
|
|
130
|
+
name,
|
|
131
|
+
error: error instanceof Error ? error.message : String(error),
|
|
132
|
+
});
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async loadMany<T = any>(
|
|
138
|
+
type: string,
|
|
139
|
+
options?: MetadataLoadOptions
|
|
140
|
+
): Promise<T[]> {
|
|
141
|
+
const { patterns = ['**/*'], recursive: _recursive = true, limit } = options || {};
|
|
142
|
+
|
|
143
|
+
const typeDir = path.join(this.rootDir, type);
|
|
144
|
+
const items: T[] = [];
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// Build glob patterns
|
|
148
|
+
const globPatterns = patterns.map(pattern =>
|
|
149
|
+
path.join(typeDir, pattern)
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
for (const pattern of globPatterns) {
|
|
153
|
+
const files = await glob(pattern, {
|
|
154
|
+
ignore: ['**/node_modules/**', '**/*.test.*', '**/*.spec.*'],
|
|
155
|
+
nodir: true,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (limit && items.length >= limit) {
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const content = await fs.readFile(file, 'utf-8');
|
|
165
|
+
const format = this.detectFormat(file);
|
|
166
|
+
const serializer = this.getSerializer(format);
|
|
167
|
+
|
|
168
|
+
if (serializer) {
|
|
169
|
+
const data = serializer.deserialize<T>(content);
|
|
170
|
+
items.push(data);
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
this.logger?.warn('Failed to load file', {
|
|
174
|
+
file,
|
|
175
|
+
error: error instanceof Error ? error.message : String(error),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (limit && items.length >= limit) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return items;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this.logger?.error('Failed to load many', undefined, {
|
|
188
|
+
type,
|
|
189
|
+
patterns,
|
|
190
|
+
error: error instanceof Error ? error.message : String(error),
|
|
191
|
+
});
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async exists(type: string, name: string): Promise<boolean> {
|
|
197
|
+
const filePath = await this.findFile(type, name);
|
|
198
|
+
return filePath !== null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async stat(type: string, name: string): Promise<MetadataStats | null> {
|
|
202
|
+
const filePath = await this.findFile(type, name);
|
|
203
|
+
|
|
204
|
+
if (!filePath) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const stats = await fs.stat(filePath);
|
|
210
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
211
|
+
const etag = this.generateETag(content);
|
|
212
|
+
const format = this.detectFormat(filePath);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
size: stats.size,
|
|
216
|
+
modifiedAt: stats.mtime,
|
|
217
|
+
etag,
|
|
218
|
+
format,
|
|
219
|
+
path: filePath,
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.logger?.error('Failed to stat file', undefined, {
|
|
223
|
+
type,
|
|
224
|
+
name,
|
|
225
|
+
filePath,
|
|
226
|
+
error: error instanceof Error ? error.message : String(error),
|
|
227
|
+
});
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async list(type: string): Promise<string[]> {
|
|
233
|
+
const typeDir = path.join(this.rootDir, type);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const files = await glob('**/*', {
|
|
237
|
+
cwd: typeDir,
|
|
238
|
+
ignore: ['**/node_modules/**', '**/*.test.*', '**/*.spec.*'],
|
|
239
|
+
nodir: true,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return files.map(file => {
|
|
243
|
+
const ext = path.extname(file);
|
|
244
|
+
const basename = path.basename(file, ext);
|
|
245
|
+
return basename;
|
|
246
|
+
});
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.logger?.error('Failed to list', undefined, {
|
|
249
|
+
type,
|
|
250
|
+
error: error instanceof Error ? error.message : String(error),
|
|
251
|
+
});
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Find file for a given type and name
|
|
258
|
+
*/
|
|
259
|
+
private async findFile(type: string, name: string): Promise<string | null> {
|
|
260
|
+
const typeDir = path.join(this.rootDir, type);
|
|
261
|
+
const extensions = ['.json', '.yaml', '.yml', '.ts', '.js'];
|
|
262
|
+
|
|
263
|
+
for (const ext of extensions) {
|
|
264
|
+
const filePath = path.join(typeDir, `${name}${ext}`);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await fs.access(filePath);
|
|
268
|
+
return filePath;
|
|
269
|
+
} catch {
|
|
270
|
+
// File doesn't exist, try next extension
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Detect format from file extension
|
|
279
|
+
*/
|
|
280
|
+
private detectFormat(filePath: string): MetadataFormat {
|
|
281
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
282
|
+
|
|
283
|
+
switch (ext) {
|
|
284
|
+
case '.json':
|
|
285
|
+
return 'json';
|
|
286
|
+
case '.yaml':
|
|
287
|
+
case '.yml':
|
|
288
|
+
return 'yaml';
|
|
289
|
+
case '.ts':
|
|
290
|
+
return 'typescript';
|
|
291
|
+
case '.js':
|
|
292
|
+
return 'javascript';
|
|
293
|
+
default:
|
|
294
|
+
return 'json'; // Default to JSON
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get serializer for format
|
|
300
|
+
*/
|
|
301
|
+
private getSerializer(format: MetadataFormat): MetadataSerializer | undefined {
|
|
302
|
+
return this.serializers.get(format);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Generate ETag for content
|
|
307
|
+
* Uses SHA-256 hash truncated to 32 characters for reasonable collision resistance
|
|
308
|
+
* while keeping ETag headers compact (full 64-char hash is overkill for this use case)
|
|
309
|
+
*/
|
|
310
|
+
private generateETag(content: string): string {
|
|
311
|
+
const hash = createHash('sha256').update(content).digest('hex').substring(0, 32);
|
|
312
|
+
return `"${hash}"`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Loader Interface
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract for loading metadata from various sources
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
MetadataLoadOptions,
|
|
9
|
+
MetadataLoadResult,
|
|
10
|
+
MetadataStats,
|
|
11
|
+
MetadataLoaderContract,
|
|
12
|
+
} from '@objectstack/spec/system';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Abstract interface for metadata loaders
|
|
16
|
+
* Implementations can load from filesystem, HTTP, S3, databases, etc.
|
|
17
|
+
*/
|
|
18
|
+
export interface MetadataLoader {
|
|
19
|
+
/**
|
|
20
|
+
* Loader contract information
|
|
21
|
+
*/
|
|
22
|
+
readonly contract: MetadataLoaderContract;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load a single metadata item
|
|
26
|
+
* @param type The metadata type (e.g., 'object', 'view', 'app')
|
|
27
|
+
* @param name The item name/identifier
|
|
28
|
+
* @param options Load options
|
|
29
|
+
* @returns Load result with data or null if not found
|
|
30
|
+
*/
|
|
31
|
+
load(
|
|
32
|
+
type: string,
|
|
33
|
+
name: string,
|
|
34
|
+
options?: MetadataLoadOptions
|
|
35
|
+
): Promise<MetadataLoadResult>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load multiple items matching patterns
|
|
39
|
+
* @param type The metadata type
|
|
40
|
+
* @param options Load options with patterns
|
|
41
|
+
* @returns Array of loaded items
|
|
42
|
+
*/
|
|
43
|
+
loadMany<T = any>(
|
|
44
|
+
type: string,
|
|
45
|
+
options?: MetadataLoadOptions
|
|
46
|
+
): Promise<T[]>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if item exists
|
|
50
|
+
* @param type The metadata type
|
|
51
|
+
* @param name The item name
|
|
52
|
+
* @returns True if exists
|
|
53
|
+
*/
|
|
54
|
+
exists(type: string, name: string): Promise<boolean>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get item metadata (without loading full content)
|
|
58
|
+
* @param type The metadata type
|
|
59
|
+
* @param name The item name
|
|
60
|
+
* @returns Metadata statistics
|
|
61
|
+
*/
|
|
62
|
+
stat(type: string, name: string): Promise<MetadataStats | null>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* List all items of a type
|
|
66
|
+
* @param type The metadata type
|
|
67
|
+
* @returns Array of item names
|
|
68
|
+
*/
|
|
69
|
+
list(type: string): Promise<string[]>;
|
|
70
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Manager
|
|
3
|
+
*
|
|
4
|
+
* Main orchestrator for metadata loading, saving, and persistence
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs/promises';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { watch as chokidarWatch, type FSWatcher } from 'chokidar';
|
|
11
|
+
import type {
|
|
12
|
+
MetadataManagerConfig,
|
|
13
|
+
MetadataLoadOptions,
|
|
14
|
+
MetadataSaveOptions,
|
|
15
|
+
MetadataSaveResult,
|
|
16
|
+
MetadataWatchEvent,
|
|
17
|
+
MetadataFormat,
|
|
18
|
+
} from '@objectstack/spec/system';
|
|
19
|
+
import { createLogger, type Logger } from '@objectstack/core';
|
|
20
|
+
import { FilesystemLoader } from './loaders/filesystem-loader.js';
|
|
21
|
+
import { JSONSerializer } from './serializers/json-serializer.js';
|
|
22
|
+
import { YAMLSerializer } from './serializers/yaml-serializer.js';
|
|
23
|
+
import { TypeScriptSerializer } from './serializers/typescript-serializer.js';
|
|
24
|
+
import type { MetadataSerializer } from './serializers/serializer-interface.js';
|
|
25
|
+
import type { MetadataLoader } from './loaders/loader-interface.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Watch callback function
|
|
29
|
+
*/
|
|
30
|
+
export type WatchCallback = (event: MetadataWatchEvent) => void | Promise<void>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Main metadata manager class
|
|
34
|
+
*/
|
|
35
|
+
export class MetadataManager {
|
|
36
|
+
private loader: MetadataLoader;
|
|
37
|
+
private serializers: Map<MetadataFormat, MetadataSerializer>;
|
|
38
|
+
private logger: Logger;
|
|
39
|
+
private watcher?: FSWatcher;
|
|
40
|
+
private watchCallbacks = new Map<string, Set<WatchCallback>>();
|
|
41
|
+
|
|
42
|
+
constructor(private config: MetadataManagerConfig) {
|
|
43
|
+
this.logger = createLogger({ level: 'info', format: 'pretty' });
|
|
44
|
+
|
|
45
|
+
// Initialize serializers
|
|
46
|
+
this.serializers = new Map();
|
|
47
|
+
const formats = config.formats || ['typescript', 'json', 'yaml'];
|
|
48
|
+
|
|
49
|
+
if (formats.includes('json')) {
|
|
50
|
+
this.serializers.set('json', new JSONSerializer());
|
|
51
|
+
}
|
|
52
|
+
if (formats.includes('yaml')) {
|
|
53
|
+
this.serializers.set('yaml', new YAMLSerializer());
|
|
54
|
+
}
|
|
55
|
+
if (formats.includes('typescript')) {
|
|
56
|
+
this.serializers.set('typescript', new TypeScriptSerializer('typescript'));
|
|
57
|
+
}
|
|
58
|
+
if (formats.includes('javascript')) {
|
|
59
|
+
this.serializers.set('javascript', new TypeScriptSerializer('javascript'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Initialize loader
|
|
63
|
+
const rootDir = config.rootDir || process.cwd();
|
|
64
|
+
this.loader = new FilesystemLoader(rootDir, this.serializers, this.logger);
|
|
65
|
+
|
|
66
|
+
// Start watching if enabled
|
|
67
|
+
if (config.watch) {
|
|
68
|
+
this.startWatching();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load a single metadata item
|
|
74
|
+
*/
|
|
75
|
+
async load<T = any>(
|
|
76
|
+
type: string,
|
|
77
|
+
name: string,
|
|
78
|
+
options?: MetadataLoadOptions
|
|
79
|
+
): Promise<T | null> {
|
|
80
|
+
const result = await this.loader.load(type, name, options);
|
|
81
|
+
return result.data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load multiple metadata items
|
|
86
|
+
*/
|
|
87
|
+
async loadMany<T = any>(
|
|
88
|
+
type: string,
|
|
89
|
+
options?: MetadataLoadOptions
|
|
90
|
+
): Promise<T[]> {
|
|
91
|
+
return this.loader.loadMany<T>(type, options);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Save metadata to disk
|
|
96
|
+
*/
|
|
97
|
+
async save<T = any>(
|
|
98
|
+
type: string,
|
|
99
|
+
name: string,
|
|
100
|
+
data: T,
|
|
101
|
+
options?: MetadataSaveOptions
|
|
102
|
+
): Promise<MetadataSaveResult> {
|
|
103
|
+
const startTime = Date.now();
|
|
104
|
+
const {
|
|
105
|
+
format = 'typescript',
|
|
106
|
+
prettify = true,
|
|
107
|
+
indent = 2,
|
|
108
|
+
sortKeys = false,
|
|
109
|
+
backup = false,
|
|
110
|
+
overwrite = true,
|
|
111
|
+
atomic = true,
|
|
112
|
+
path: customPath,
|
|
113
|
+
} = options || {};
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Get serializer
|
|
117
|
+
const serializer = this.serializers.get(format);
|
|
118
|
+
if (!serializer) {
|
|
119
|
+
throw new Error(`No serializer found for format: ${format}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Determine file path
|
|
123
|
+
const typeDir = path.join(this.config.rootDir || process.cwd(), type);
|
|
124
|
+
const fileName = `${name}${serializer.getExtension()}`;
|
|
125
|
+
const filePath = customPath || path.join(typeDir, fileName);
|
|
126
|
+
|
|
127
|
+
// Check if file exists
|
|
128
|
+
if (!overwrite) {
|
|
129
|
+
try {
|
|
130
|
+
await fs.access(filePath);
|
|
131
|
+
throw new Error(`File already exists: ${filePath}`);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// File doesn't exist, continue
|
|
134
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Create directory if it doesn't exist
|
|
141
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
142
|
+
|
|
143
|
+
// Create backup if requested
|
|
144
|
+
let backupPath: string | undefined;
|
|
145
|
+
if (backup) {
|
|
146
|
+
try {
|
|
147
|
+
await fs.access(filePath);
|
|
148
|
+
backupPath = `${filePath}.bak`;
|
|
149
|
+
await fs.copyFile(filePath, backupPath);
|
|
150
|
+
} catch {
|
|
151
|
+
// File doesn't exist, no backup needed
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Serialize data
|
|
156
|
+
const content = serializer.serialize(data, {
|
|
157
|
+
prettify,
|
|
158
|
+
indent,
|
|
159
|
+
sortKeys,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Write to disk (atomic or direct)
|
|
163
|
+
if (atomic) {
|
|
164
|
+
const tempPath = `${filePath}.tmp`;
|
|
165
|
+
await fs.writeFile(tempPath, content, 'utf-8');
|
|
166
|
+
await fs.rename(tempPath, filePath);
|
|
167
|
+
} else {
|
|
168
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Get stats
|
|
172
|
+
const stats = await fs.stat(filePath);
|
|
173
|
+
const etag = this.generateETag(content);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
path: filePath,
|
|
178
|
+
etag,
|
|
179
|
+
size: stats.size,
|
|
180
|
+
saveTime: Date.now() - startTime,
|
|
181
|
+
backupPath,
|
|
182
|
+
};
|
|
183
|
+
} catch (error) {
|
|
184
|
+
this.logger.error('Failed to save metadata', undefined, {
|
|
185
|
+
type,
|
|
186
|
+
name,
|
|
187
|
+
error: error instanceof Error ? error.message : String(error),
|
|
188
|
+
});
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if metadata item exists
|
|
195
|
+
*/
|
|
196
|
+
async exists(type: string, name: string): Promise<boolean> {
|
|
197
|
+
return this.loader.exists(type, name);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* List all items of a type
|
|
202
|
+
*/
|
|
203
|
+
async list(type: string): Promise<string[]> {
|
|
204
|
+
return this.loader.list(type);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Watch for metadata changes
|
|
209
|
+
*/
|
|
210
|
+
watch(type: string, callback: WatchCallback): void {
|
|
211
|
+
if (!this.watchCallbacks.has(type)) {
|
|
212
|
+
this.watchCallbacks.set(type, new Set());
|
|
213
|
+
}
|
|
214
|
+
this.watchCallbacks.get(type)!.add(callback);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Unwatch metadata changes
|
|
219
|
+
*/
|
|
220
|
+
unwatch(type: string, callback: WatchCallback): void {
|
|
221
|
+
const callbacks = this.watchCallbacks.get(type);
|
|
222
|
+
if (callbacks) {
|
|
223
|
+
callbacks.delete(callback);
|
|
224
|
+
if (callbacks.size === 0) {
|
|
225
|
+
this.watchCallbacks.delete(type);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Stop all watching
|
|
232
|
+
*/
|
|
233
|
+
async stopWatching(): Promise<void> {
|
|
234
|
+
if (this.watcher) {
|
|
235
|
+
await this.watcher.close();
|
|
236
|
+
this.watcher = undefined;
|
|
237
|
+
this.watchCallbacks.clear();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Start watching for file changes
|
|
243
|
+
*/
|
|
244
|
+
private startWatching(): void {
|
|
245
|
+
const rootDir = this.config.rootDir || process.cwd();
|
|
246
|
+
const { ignored = ['**/node_modules/**', '**/*.test.*'], persistent = true } =
|
|
247
|
+
this.config.watchOptions || {};
|
|
248
|
+
|
|
249
|
+
this.watcher = chokidarWatch(rootDir, {
|
|
250
|
+
ignored,
|
|
251
|
+
persistent,
|
|
252
|
+
ignoreInitial: true,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
this.watcher.on('add', async (filePath) => {
|
|
256
|
+
await this.handleFileEvent('added', filePath);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
this.watcher.on('change', async (filePath) => {
|
|
260
|
+
await this.handleFileEvent('changed', filePath);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
this.watcher.on('unlink', async (filePath) => {
|
|
264
|
+
await this.handleFileEvent('deleted', filePath);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
this.logger.info('File watcher started', { rootDir });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Handle file change events
|
|
272
|
+
*/
|
|
273
|
+
private async handleFileEvent(
|
|
274
|
+
eventType: 'added' | 'changed' | 'deleted',
|
|
275
|
+
filePath: string
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
const rootDir = this.config.rootDir || process.cwd();
|
|
278
|
+
const relativePath = path.relative(rootDir, filePath);
|
|
279
|
+
const parts = relativePath.split(path.sep);
|
|
280
|
+
|
|
281
|
+
if (parts.length < 2) {
|
|
282
|
+
return; // Not a metadata file
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const type = parts[0];
|
|
286
|
+
const fileName = parts[parts.length - 1];
|
|
287
|
+
const name = path.basename(fileName, path.extname(fileName));
|
|
288
|
+
|
|
289
|
+
const callbacks = this.watchCallbacks.get(type);
|
|
290
|
+
if (!callbacks || callbacks.size === 0) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let data: any = undefined;
|
|
295
|
+
if (eventType !== 'deleted') {
|
|
296
|
+
try {
|
|
297
|
+
data = await this.load(type, name, { useCache: false });
|
|
298
|
+
} catch (error) {
|
|
299
|
+
this.logger.error('Failed to load changed file', undefined, {
|
|
300
|
+
filePath,
|
|
301
|
+
error: error instanceof Error ? error.message : String(error),
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const event: MetadataWatchEvent = {
|
|
308
|
+
type: eventType,
|
|
309
|
+
metadataType: type,
|
|
310
|
+
name,
|
|
311
|
+
path: filePath,
|
|
312
|
+
data,
|
|
313
|
+
timestamp: new Date(),
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
for (const callback of callbacks) {
|
|
317
|
+
try {
|
|
318
|
+
await callback(event);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this.logger.error('Watch callback error', undefined, {
|
|
321
|
+
type,
|
|
322
|
+
name,
|
|
323
|
+
error: error instanceof Error ? error.message : String(error),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Generate ETag for content
|
|
331
|
+
* Uses SHA-256 hash truncated to 32 characters for reasonable collision resistance
|
|
332
|
+
* while keeping ETag headers compact (full 64-char hash is overkill for this use case)
|
|
333
|
+
*/
|
|
334
|
+
private generateETag(content: string): string {
|
|
335
|
+
const hash = createHash('sha256').update(content).digest('hex').substring(0, 32);
|
|
336
|
+
return `"${hash}"`;
|
|
337
|
+
}
|
|
338
|
+
}
|