@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,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,6 @@
1
+ /**
2
+ * Metadata Loader Interface
3
+ *
4
+ * Defines the contract for loading metadata from various sources
5
+ */
6
+ export {};
@@ -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"}