@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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem Metadata Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads metadata from the filesystem using glob patterns
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs/promises';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { glob } from 'glob';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
export class FilesystemLoader {
|
|
11
|
+
constructor(rootDir, serializers, logger) {
|
|
12
|
+
this.rootDir = rootDir;
|
|
13
|
+
this.serializers = serializers;
|
|
14
|
+
this.logger = logger;
|
|
15
|
+
this.contract = {
|
|
16
|
+
name: 'filesystem',
|
|
17
|
+
supportedFormats: ['json', 'yaml', 'typescript', 'javascript'],
|
|
18
|
+
supportsWatch: true,
|
|
19
|
+
supportsWrite: true,
|
|
20
|
+
supportsCache: true,
|
|
21
|
+
};
|
|
22
|
+
this.cache = new Map();
|
|
23
|
+
}
|
|
24
|
+
async load(type, name, options) {
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
const { validate: _validate = true, useCache = true, ifNoneMatch } = options || {};
|
|
27
|
+
try {
|
|
28
|
+
// Find the file
|
|
29
|
+
const filePath = await this.findFile(type, name);
|
|
30
|
+
if (!filePath) {
|
|
31
|
+
return {
|
|
32
|
+
data: null,
|
|
33
|
+
fromCache: false,
|
|
34
|
+
notModified: false,
|
|
35
|
+
loadTime: Date.now() - startTime,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Get stats
|
|
39
|
+
const stats = await this.stat(type, name);
|
|
40
|
+
if (!stats) {
|
|
41
|
+
return {
|
|
42
|
+
data: null,
|
|
43
|
+
fromCache: false,
|
|
44
|
+
notModified: false,
|
|
45
|
+
loadTime: Date.now() - startTime,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Check cache
|
|
49
|
+
if (useCache && ifNoneMatch && stats.etag === ifNoneMatch) {
|
|
50
|
+
return {
|
|
51
|
+
data: null,
|
|
52
|
+
fromCache: true,
|
|
53
|
+
notModified: true,
|
|
54
|
+
etag: stats.etag,
|
|
55
|
+
stats,
|
|
56
|
+
loadTime: Date.now() - startTime,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Check memory cache
|
|
60
|
+
const cacheKey = `${type}:${name}`;
|
|
61
|
+
if (useCache && this.cache.has(cacheKey)) {
|
|
62
|
+
const cached = this.cache.get(cacheKey);
|
|
63
|
+
if (cached.etag === stats.etag) {
|
|
64
|
+
return {
|
|
65
|
+
data: cached.data,
|
|
66
|
+
fromCache: true,
|
|
67
|
+
notModified: false,
|
|
68
|
+
etag: stats.etag,
|
|
69
|
+
stats,
|
|
70
|
+
loadTime: Date.now() - startTime,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Load and deserialize
|
|
75
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
76
|
+
const serializer = this.getSerializer(stats.format);
|
|
77
|
+
if (!serializer) {
|
|
78
|
+
throw new Error(`No serializer found for format: ${stats.format}`);
|
|
79
|
+
}
|
|
80
|
+
const data = serializer.deserialize(content);
|
|
81
|
+
// Update cache
|
|
82
|
+
if (useCache) {
|
|
83
|
+
this.cache.set(cacheKey, {
|
|
84
|
+
data,
|
|
85
|
+
etag: stats.etag,
|
|
86
|
+
timestamp: Date.now(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
data,
|
|
91
|
+
fromCache: false,
|
|
92
|
+
notModified: false,
|
|
93
|
+
etag: stats.etag,
|
|
94
|
+
stats,
|
|
95
|
+
loadTime: Date.now() - startTime,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
this.logger?.error('Failed to load metadata', undefined, {
|
|
100
|
+
type,
|
|
101
|
+
name,
|
|
102
|
+
error: error instanceof Error ? error.message : String(error),
|
|
103
|
+
});
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async loadMany(type, options) {
|
|
108
|
+
const { patterns = ['**/*'], recursive: _recursive = true, limit } = options || {};
|
|
109
|
+
const typeDir = path.join(this.rootDir, type);
|
|
110
|
+
const items = [];
|
|
111
|
+
try {
|
|
112
|
+
// Build glob patterns
|
|
113
|
+
const globPatterns = patterns.map(pattern => path.join(typeDir, pattern));
|
|
114
|
+
for (const pattern of globPatterns) {
|
|
115
|
+
const files = await glob(pattern, {
|
|
116
|
+
ignore: ['**/node_modules/**', '**/*.test.*', '**/*.spec.*'],
|
|
117
|
+
nodir: true,
|
|
118
|
+
});
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
if (limit && items.length >= limit) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const content = await fs.readFile(file, 'utf-8');
|
|
125
|
+
const format = this.detectFormat(file);
|
|
126
|
+
const serializer = this.getSerializer(format);
|
|
127
|
+
if (serializer) {
|
|
128
|
+
const data = serializer.deserialize(content);
|
|
129
|
+
items.push(data);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
this.logger?.warn('Failed to load file', {
|
|
134
|
+
file,
|
|
135
|
+
error: error instanceof Error ? error.message : String(error),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (limit && items.length >= limit) {
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
this.logger?.error('Failed to load many', undefined, {
|
|
147
|
+
type,
|
|
148
|
+
patterns,
|
|
149
|
+
error: error instanceof Error ? error.message : String(error),
|
|
150
|
+
});
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async exists(type, name) {
|
|
155
|
+
const filePath = await this.findFile(type, name);
|
|
156
|
+
return filePath !== null;
|
|
157
|
+
}
|
|
158
|
+
async stat(type, name) {
|
|
159
|
+
const filePath = await this.findFile(type, name);
|
|
160
|
+
if (!filePath) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const stats = await fs.stat(filePath);
|
|
165
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
166
|
+
const etag = this.generateETag(content);
|
|
167
|
+
const format = this.detectFormat(filePath);
|
|
168
|
+
return {
|
|
169
|
+
size: stats.size,
|
|
170
|
+
modifiedAt: stats.mtime,
|
|
171
|
+
etag,
|
|
172
|
+
format,
|
|
173
|
+
path: filePath,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
this.logger?.error('Failed to stat file', undefined, {
|
|
178
|
+
type,
|
|
179
|
+
name,
|
|
180
|
+
filePath,
|
|
181
|
+
error: error instanceof Error ? error.message : String(error),
|
|
182
|
+
});
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async list(type) {
|
|
187
|
+
const typeDir = path.join(this.rootDir, type);
|
|
188
|
+
try {
|
|
189
|
+
const files = await glob('**/*', {
|
|
190
|
+
cwd: typeDir,
|
|
191
|
+
ignore: ['**/node_modules/**', '**/*.test.*', '**/*.spec.*'],
|
|
192
|
+
nodir: true,
|
|
193
|
+
});
|
|
194
|
+
return files.map(file => {
|
|
195
|
+
const ext = path.extname(file);
|
|
196
|
+
const basename = path.basename(file, ext);
|
|
197
|
+
return basename;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
this.logger?.error('Failed to list', undefined, {
|
|
202
|
+
type,
|
|
203
|
+
error: error instanceof Error ? error.message : String(error),
|
|
204
|
+
});
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Find file for a given type and name
|
|
210
|
+
*/
|
|
211
|
+
async findFile(type, name) {
|
|
212
|
+
const typeDir = path.join(this.rootDir, type);
|
|
213
|
+
const extensions = ['.json', '.yaml', '.yml', '.ts', '.js'];
|
|
214
|
+
for (const ext of extensions) {
|
|
215
|
+
const filePath = path.join(typeDir, `${name}${ext}`);
|
|
216
|
+
try {
|
|
217
|
+
await fs.access(filePath);
|
|
218
|
+
return filePath;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// File doesn't exist, try next extension
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Detect format from file extension
|
|
228
|
+
*/
|
|
229
|
+
detectFormat(filePath) {
|
|
230
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
231
|
+
switch (ext) {
|
|
232
|
+
case '.json':
|
|
233
|
+
return 'json';
|
|
234
|
+
case '.yaml':
|
|
235
|
+
case '.yml':
|
|
236
|
+
return 'yaml';
|
|
237
|
+
case '.ts':
|
|
238
|
+
return 'typescript';
|
|
239
|
+
case '.js':
|
|
240
|
+
return 'javascript';
|
|
241
|
+
default:
|
|
242
|
+
return 'json'; // Default to JSON
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Get serializer for format
|
|
247
|
+
*/
|
|
248
|
+
getSerializer(format) {
|
|
249
|
+
return this.serializers.get(format);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Generate ETag for content
|
|
253
|
+
* Uses SHA-256 hash truncated to 32 characters for reasonable collision resistance
|
|
254
|
+
* while keeping ETag headers compact (full 64-char hash is overkill for this use case)
|
|
255
|
+
*/
|
|
256
|
+
generateETag(content) {
|
|
257
|
+
const hash = createHash('sha256').update(content).digest('hex').substring(0, 32);
|
|
258
|
+
return `"${hash}"`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Loader Interface
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract for loading metadata from various sources
|
|
5
|
+
*/
|
|
6
|
+
import type { MetadataLoadOptions, MetadataLoadResult, MetadataStats, MetadataLoaderContract } from '@objectstack/spec/system';
|
|
7
|
+
/**
|
|
8
|
+
* Abstract interface for metadata loaders
|
|
9
|
+
* Implementations can load from filesystem, HTTP, S3, databases, etc.
|
|
10
|
+
*/
|
|
11
|
+
export interface MetadataLoader {
|
|
12
|
+
/**
|
|
13
|
+
* Loader contract information
|
|
14
|
+
*/
|
|
15
|
+
readonly contract: MetadataLoaderContract;
|
|
16
|
+
/**
|
|
17
|
+
* Load a single metadata item
|
|
18
|
+
* @param type The metadata type (e.g., 'object', 'view', 'app')
|
|
19
|
+
* @param name The item name/identifier
|
|
20
|
+
* @param options Load options
|
|
21
|
+
* @returns Load result with data or null if not found
|
|
22
|
+
*/
|
|
23
|
+
load(type: string, name: string, options?: MetadataLoadOptions): Promise<MetadataLoadResult>;
|
|
24
|
+
/**
|
|
25
|
+
* Load multiple items matching patterns
|
|
26
|
+
* @param type The metadata type
|
|
27
|
+
* @param options Load options with patterns
|
|
28
|
+
* @returns Array of loaded items
|
|
29
|
+
*/
|
|
30
|
+
loadMany<T = any>(type: string, options?: MetadataLoadOptions): Promise<T[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Check if item exists
|
|
33
|
+
* @param type The metadata type
|
|
34
|
+
* @param name The item name
|
|
35
|
+
* @returns True if exists
|
|
36
|
+
*/
|
|
37
|
+
exists(type: string, name: string): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Get item metadata (without loading full content)
|
|
40
|
+
* @param type The metadata type
|
|
41
|
+
* @param name The item name
|
|
42
|
+
* @returns Metadata statistics
|
|
43
|
+
*/
|
|
44
|
+
stat(type: string, name: string): Promise<MetadataStats | null>;
|
|
45
|
+
/**
|
|
46
|
+
* List all items of a type
|
|
47
|
+
* @param type The metadata type
|
|
48
|
+
* @returns Array of item names
|
|
49
|
+
*/
|
|
50
|
+
list(type: string): Promise<string[]>;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=loader-interface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loader-interface.d.ts","sourceRoot":"","sources":["../../src/loaders/loader-interface.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EACV,mBAAmB,EACnB,kBAAkB,EAClB,aAAa,EACb,sBAAsB,EACvB,MAAM,0BAA0B,CAAC;AAElC;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B;;OAEG;IACH,QAAQ,CAAC,QAAQ,EAAE,sBAAsB,CAAC;IAE1C;;;;;;OAMG;IACH,IAAI,CACF,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAE/B;;;;;OAKG;IACH,QAAQ,CAAC,CAAC,GAAG,GAAG,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;IAEhB;;;;;OAKG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAErD;;;;;OAKG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IAEhE;;;;OAIG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACvC"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Manager
|
|
3
|
+
*
|
|
4
|
+
* Main orchestrator for metadata loading, saving, and persistence
|
|
5
|
+
*/
|
|
6
|
+
import type { MetadataManagerConfig, MetadataLoadOptions, MetadataSaveOptions, MetadataSaveResult, MetadataWatchEvent } from '@objectstack/spec/system';
|
|
7
|
+
/**
|
|
8
|
+
* Watch callback function
|
|
9
|
+
*/
|
|
10
|
+
export type WatchCallback = (event: MetadataWatchEvent) => void | Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Main metadata manager class
|
|
13
|
+
*/
|
|
14
|
+
export declare class MetadataManager {
|
|
15
|
+
private config;
|
|
16
|
+
private loader;
|
|
17
|
+
private serializers;
|
|
18
|
+
private logger;
|
|
19
|
+
private watcher?;
|
|
20
|
+
private watchCallbacks;
|
|
21
|
+
constructor(config: MetadataManagerConfig);
|
|
22
|
+
/**
|
|
23
|
+
* Load a single metadata item
|
|
24
|
+
*/
|
|
25
|
+
load<T = any>(type: string, name: string, options?: MetadataLoadOptions): Promise<T | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Load multiple metadata items
|
|
28
|
+
*/
|
|
29
|
+
loadMany<T = any>(type: string, options?: MetadataLoadOptions): Promise<T[]>;
|
|
30
|
+
/**
|
|
31
|
+
* Save metadata to disk
|
|
32
|
+
*/
|
|
33
|
+
save<T = any>(type: string, name: string, data: T, options?: MetadataSaveOptions): Promise<MetadataSaveResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Check if metadata item exists
|
|
36
|
+
*/
|
|
37
|
+
exists(type: string, name: string): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* List all items of a type
|
|
40
|
+
*/
|
|
41
|
+
list(type: string): Promise<string[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Watch for metadata changes
|
|
44
|
+
*/
|
|
45
|
+
watch(type: string, callback: WatchCallback): void;
|
|
46
|
+
/**
|
|
47
|
+
* Unwatch metadata changes
|
|
48
|
+
*/
|
|
49
|
+
unwatch(type: string, callback: WatchCallback): void;
|
|
50
|
+
/**
|
|
51
|
+
* Stop all watching
|
|
52
|
+
*/
|
|
53
|
+
stopWatching(): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Start watching for file changes
|
|
56
|
+
*/
|
|
57
|
+
private startWatching;
|
|
58
|
+
/**
|
|
59
|
+
* Handle file change events
|
|
60
|
+
*/
|
|
61
|
+
private handleFileEvent;
|
|
62
|
+
/**
|
|
63
|
+
* Generate ETag for content
|
|
64
|
+
* Uses SHA-256 hash truncated to 32 characters for reasonable collision resistance
|
|
65
|
+
* while keeping ETag headers compact (full 64-char hash is overkill for this use case)
|
|
66
|
+
*/
|
|
67
|
+
private generateETag;
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=metadata-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata-manager.d.ts","sourceRoot":"","sources":["../src/metadata-manager.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EACV,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAEnB,MAAM,0BAA0B,CAAC;AASlC;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAEhF;;GAEG;AACH,qBAAa,eAAe;IAOd,OAAO,CAAC,MAAM;IAN1B,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,WAAW,CAA0C;IAC7D,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,OAAO,CAAC,CAAY;IAC5B,OAAO,CAAC,cAAc,CAAyC;gBAE3C,MAAM,EAAE,qBAAqB;IA8BjD;;OAEG;IACG,IAAI,CAAC,CAAC,GAAG,GAAG,EAChB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAKpB;;OAEG;IACG,QAAQ,CAAC,CAAC,GAAG,GAAG,EACpB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,CAAC,EAAE,CAAC;IAIf;;OAEG;IACG,IAAI,CAAC,CAAC,GAAG,GAAG,EAChB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,kBAAkB,CAAC;IA2F9B;;OAEG;IACG,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI1D;;OAEG;IACG,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAI3C;;OAEG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,IAAI;IAOlD;;OAEG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,IAAI;IAUpD;;OAEG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAQnC;;OAEG;IACH,OAAO,CAAC,aAAa;IA0BrB;;OAEG;YACW,eAAe;IAwD7B;;;;OAIG;IACH,OAAO,CAAC,YAAY;CAIrB"}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Manager
|
|
3
|
+
*
|
|
4
|
+
* Main orchestrator for metadata loading, saving, and persistence
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs/promises';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { watch as chokidarWatch } from 'chokidar';
|
|
10
|
+
import { createLogger } from '@objectstack/core';
|
|
11
|
+
import { FilesystemLoader } from './loaders/filesystem-loader.js';
|
|
12
|
+
import { JSONSerializer } from './serializers/json-serializer.js';
|
|
13
|
+
import { YAMLSerializer } from './serializers/yaml-serializer.js';
|
|
14
|
+
import { TypeScriptSerializer } from './serializers/typescript-serializer.js';
|
|
15
|
+
/**
|
|
16
|
+
* Main metadata manager class
|
|
17
|
+
*/
|
|
18
|
+
export class MetadataManager {
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.watchCallbacks = new Map();
|
|
22
|
+
this.logger = createLogger({ level: 'info', format: 'pretty' });
|
|
23
|
+
// Initialize serializers
|
|
24
|
+
this.serializers = new Map();
|
|
25
|
+
const formats = config.formats || ['typescript', 'json', 'yaml'];
|
|
26
|
+
if (formats.includes('json')) {
|
|
27
|
+
this.serializers.set('json', new JSONSerializer());
|
|
28
|
+
}
|
|
29
|
+
if (formats.includes('yaml')) {
|
|
30
|
+
this.serializers.set('yaml', new YAMLSerializer());
|
|
31
|
+
}
|
|
32
|
+
if (formats.includes('typescript')) {
|
|
33
|
+
this.serializers.set('typescript', new TypeScriptSerializer('typescript'));
|
|
34
|
+
}
|
|
35
|
+
if (formats.includes('javascript')) {
|
|
36
|
+
this.serializers.set('javascript', new TypeScriptSerializer('javascript'));
|
|
37
|
+
}
|
|
38
|
+
// Initialize loader
|
|
39
|
+
const rootDir = config.rootDir || process.cwd();
|
|
40
|
+
this.loader = new FilesystemLoader(rootDir, this.serializers, this.logger);
|
|
41
|
+
// Start watching if enabled
|
|
42
|
+
if (config.watch) {
|
|
43
|
+
this.startWatching();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Load a single metadata item
|
|
48
|
+
*/
|
|
49
|
+
async load(type, name, options) {
|
|
50
|
+
const result = await this.loader.load(type, name, options);
|
|
51
|
+
return result.data;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Load multiple metadata items
|
|
55
|
+
*/
|
|
56
|
+
async loadMany(type, options) {
|
|
57
|
+
return this.loader.loadMany(type, options);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Save metadata to disk
|
|
61
|
+
*/
|
|
62
|
+
async save(type, name, data, options) {
|
|
63
|
+
const startTime = Date.now();
|
|
64
|
+
const { format = 'typescript', prettify = true, indent = 2, sortKeys = false, backup = false, overwrite = true, atomic = true, path: customPath, } = options || {};
|
|
65
|
+
try {
|
|
66
|
+
// Get serializer
|
|
67
|
+
const serializer = this.serializers.get(format);
|
|
68
|
+
if (!serializer) {
|
|
69
|
+
throw new Error(`No serializer found for format: ${format}`);
|
|
70
|
+
}
|
|
71
|
+
// Determine file path
|
|
72
|
+
const typeDir = path.join(this.config.rootDir || process.cwd(), type);
|
|
73
|
+
const fileName = `${name}${serializer.getExtension()}`;
|
|
74
|
+
const filePath = customPath || path.join(typeDir, fileName);
|
|
75
|
+
// Check if file exists
|
|
76
|
+
if (!overwrite) {
|
|
77
|
+
try {
|
|
78
|
+
await fs.access(filePath);
|
|
79
|
+
throw new Error(`File already exists: ${filePath}`);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// File doesn't exist, continue
|
|
83
|
+
if (error.code !== 'ENOENT') {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Create directory if it doesn't exist
|
|
89
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
90
|
+
// Create backup if requested
|
|
91
|
+
let backupPath;
|
|
92
|
+
if (backup) {
|
|
93
|
+
try {
|
|
94
|
+
await fs.access(filePath);
|
|
95
|
+
backupPath = `${filePath}.bak`;
|
|
96
|
+
await fs.copyFile(filePath, backupPath);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// File doesn't exist, no backup needed
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Serialize data
|
|
103
|
+
const content = serializer.serialize(data, {
|
|
104
|
+
prettify,
|
|
105
|
+
indent,
|
|
106
|
+
sortKeys,
|
|
107
|
+
});
|
|
108
|
+
// Write to disk (atomic or direct)
|
|
109
|
+
if (atomic) {
|
|
110
|
+
const tempPath = `${filePath}.tmp`;
|
|
111
|
+
await fs.writeFile(tempPath, content, 'utf-8');
|
|
112
|
+
await fs.rename(tempPath, filePath);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
116
|
+
}
|
|
117
|
+
// Get stats
|
|
118
|
+
const stats = await fs.stat(filePath);
|
|
119
|
+
const etag = this.generateETag(content);
|
|
120
|
+
return {
|
|
121
|
+
success: true,
|
|
122
|
+
path: filePath,
|
|
123
|
+
etag,
|
|
124
|
+
size: stats.size,
|
|
125
|
+
saveTime: Date.now() - startTime,
|
|
126
|
+
backupPath,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
this.logger.error('Failed to save metadata', undefined, {
|
|
131
|
+
type,
|
|
132
|
+
name,
|
|
133
|
+
error: error instanceof Error ? error.message : String(error),
|
|
134
|
+
});
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Check if metadata item exists
|
|
140
|
+
*/
|
|
141
|
+
async exists(type, name) {
|
|
142
|
+
return this.loader.exists(type, name);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* List all items of a type
|
|
146
|
+
*/
|
|
147
|
+
async list(type) {
|
|
148
|
+
return this.loader.list(type);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Watch for metadata changes
|
|
152
|
+
*/
|
|
153
|
+
watch(type, callback) {
|
|
154
|
+
if (!this.watchCallbacks.has(type)) {
|
|
155
|
+
this.watchCallbacks.set(type, new Set());
|
|
156
|
+
}
|
|
157
|
+
this.watchCallbacks.get(type).add(callback);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Unwatch metadata changes
|
|
161
|
+
*/
|
|
162
|
+
unwatch(type, callback) {
|
|
163
|
+
const callbacks = this.watchCallbacks.get(type);
|
|
164
|
+
if (callbacks) {
|
|
165
|
+
callbacks.delete(callback);
|
|
166
|
+
if (callbacks.size === 0) {
|
|
167
|
+
this.watchCallbacks.delete(type);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Stop all watching
|
|
173
|
+
*/
|
|
174
|
+
async stopWatching() {
|
|
175
|
+
if (this.watcher) {
|
|
176
|
+
await this.watcher.close();
|
|
177
|
+
this.watcher = undefined;
|
|
178
|
+
this.watchCallbacks.clear();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Start watching for file changes
|
|
183
|
+
*/
|
|
184
|
+
startWatching() {
|
|
185
|
+
const rootDir = this.config.rootDir || process.cwd();
|
|
186
|
+
const { ignored = ['**/node_modules/**', '**/*.test.*'], persistent = true } = this.config.watchOptions || {};
|
|
187
|
+
this.watcher = chokidarWatch(rootDir, {
|
|
188
|
+
ignored,
|
|
189
|
+
persistent,
|
|
190
|
+
ignoreInitial: true,
|
|
191
|
+
});
|
|
192
|
+
this.watcher.on('add', async (filePath) => {
|
|
193
|
+
await this.handleFileEvent('added', filePath);
|
|
194
|
+
});
|
|
195
|
+
this.watcher.on('change', async (filePath) => {
|
|
196
|
+
await this.handleFileEvent('changed', filePath);
|
|
197
|
+
});
|
|
198
|
+
this.watcher.on('unlink', async (filePath) => {
|
|
199
|
+
await this.handleFileEvent('deleted', filePath);
|
|
200
|
+
});
|
|
201
|
+
this.logger.info('File watcher started', { rootDir });
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Handle file change events
|
|
205
|
+
*/
|
|
206
|
+
async handleFileEvent(eventType, filePath) {
|
|
207
|
+
const rootDir = this.config.rootDir || process.cwd();
|
|
208
|
+
const relativePath = path.relative(rootDir, filePath);
|
|
209
|
+
const parts = relativePath.split(path.sep);
|
|
210
|
+
if (parts.length < 2) {
|
|
211
|
+
return; // Not a metadata file
|
|
212
|
+
}
|
|
213
|
+
const type = parts[0];
|
|
214
|
+
const fileName = parts[parts.length - 1];
|
|
215
|
+
const name = path.basename(fileName, path.extname(fileName));
|
|
216
|
+
const callbacks = this.watchCallbacks.get(type);
|
|
217
|
+
if (!callbacks || callbacks.size === 0) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
let data = undefined;
|
|
221
|
+
if (eventType !== 'deleted') {
|
|
222
|
+
try {
|
|
223
|
+
data = await this.load(type, name, { useCache: false });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
this.logger.error('Failed to load changed file', undefined, {
|
|
227
|
+
filePath,
|
|
228
|
+
error: error instanceof Error ? error.message : String(error),
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const event = {
|
|
234
|
+
type: eventType,
|
|
235
|
+
metadataType: type,
|
|
236
|
+
name,
|
|
237
|
+
path: filePath,
|
|
238
|
+
data,
|
|
239
|
+
timestamp: new Date(),
|
|
240
|
+
};
|
|
241
|
+
for (const callback of callbacks) {
|
|
242
|
+
try {
|
|
243
|
+
await callback(event);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
this.logger.error('Watch callback error', undefined, {
|
|
247
|
+
type,
|
|
248
|
+
name,
|
|
249
|
+
error: error instanceof Error ? error.message : String(error),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Generate ETag for content
|
|
256
|
+
* Uses SHA-256 hash truncated to 32 characters for reasonable collision resistance
|
|
257
|
+
* while keeping ETag headers compact (full 64-char hash is overkill for this use case)
|
|
258
|
+
*/
|
|
259
|
+
generateETag(content) {
|
|
260
|
+
const hash = createHash('sha256').update(content).digest('hex').substring(0, 32);
|
|
261
|
+
return `"${hash}"`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Metadata Serializer
|
|
3
|
+
*
|
|
4
|
+
* Handles JSON format serialization and deserialization
|
|
5
|
+
*/
|
|
6
|
+
import type { z } from 'zod';
|
|
7
|
+
import type { MetadataFormat } from '@objectstack/spec/system';
|
|
8
|
+
import type { MetadataSerializer, SerializeOptions } from './serializer-interface.js';
|
|
9
|
+
export declare class JSONSerializer implements MetadataSerializer {
|
|
10
|
+
serialize<T>(item: T, options?: SerializeOptions): string;
|
|
11
|
+
deserialize<T>(content: string, schema?: z.ZodSchema): T;
|
|
12
|
+
getExtension(): string;
|
|
13
|
+
canHandle(format: MetadataFormat): boolean;
|
|
14
|
+
getFormat(): MetadataFormat;
|
|
15
|
+
/**
|
|
16
|
+
* Recursively sort object keys
|
|
17
|
+
*/
|
|
18
|
+
private sortObjectKeys;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=json-serializer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json-serializer.d.ts","sourceRoot":"","sources":["../../src/serializers/json-serializer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAC7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAEtF,qBAAa,cAAe,YAAW,kBAAkB;IACvD,SAAS,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,MAAM;IAgBzD,WAAW,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,SAAS,GAAG,CAAC;IAUxD,YAAY,IAAI,MAAM;IAItB,SAAS,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO;IAI1C,SAAS,IAAI,cAAc;IAI3B;;OAEG;IACH,OAAO,CAAC,cAAc;CAkBvB"}
|