@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +202 -0
  3. package/README.md +201 -0
  4. package/dist/index.d.ts +14 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11 -0
  7. package/dist/loaders/filesystem-loader.d.ts +41 -0
  8. package/dist/loaders/filesystem-loader.d.ts.map +1 -0
  9. package/dist/loaders/filesystem-loader.js +260 -0
  10. package/dist/loaders/loader-interface.d.ts +52 -0
  11. package/dist/loaders/loader-interface.d.ts.map +1 -0
  12. package/dist/loaders/loader-interface.js +6 -0
  13. package/dist/metadata-manager.d.ts +69 -0
  14. package/dist/metadata-manager.d.ts.map +1 -0
  15. package/dist/metadata-manager.js +263 -0
  16. package/dist/serializers/json-serializer.d.ts +20 -0
  17. package/dist/serializers/json-serializer.d.ts.map +1 -0
  18. package/dist/serializers/json-serializer.js +53 -0
  19. package/dist/serializers/serializer-interface.d.ts +57 -0
  20. package/dist/serializers/serializer-interface.d.ts.map +1 -0
  21. package/dist/serializers/serializer-interface.js +6 -0
  22. package/dist/serializers/serializers.test.d.ts +2 -0
  23. package/dist/serializers/serializers.test.d.ts.map +1 -0
  24. package/dist/serializers/serializers.test.js +62 -0
  25. package/dist/serializers/typescript-serializer.d.ts +18 -0
  26. package/dist/serializers/typescript-serializer.d.ts.map +1 -0
  27. package/dist/serializers/typescript-serializer.js +103 -0
  28. package/dist/serializers/yaml-serializer.d.ts +16 -0
  29. package/dist/serializers/yaml-serializer.d.ts.map +1 -0
  30. package/dist/serializers/yaml-serializer.js +35 -0
  31. package/package.json +37 -0
  32. package/src/index.ts +34 -0
  33. package/src/loaders/filesystem-loader.ts +314 -0
  34. package/src/loaders/loader-interface.ts +70 -0
  35. package/src/metadata-manager.ts +338 -0
  36. package/src/serializers/json-serializer.ts +71 -0
  37. package/src/serializers/serializer-interface.ts +63 -0
  38. package/src/serializers/serializers.test.ts +74 -0
  39. package/src/serializers/typescript-serializer.ts +125 -0
  40. package/src/serializers/yaml-serializer.ts +47 -0
  41. 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
+ }