@objectstack/metadata 1.0.0 → 1.0.2

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.
@@ -1,14 +1,10 @@
1
1
  /**
2
2
  * Metadata Manager
3
3
  *
4
- * Main orchestrator for metadata loading, saving, and persistence
4
+ * Main orchestrator for metadata loading, saving, and persistence.
5
+ * Browser-compatible (Pure).
5
6
  */
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
7
  import { createLogger } from '@objectstack/core';
11
- import { FilesystemLoader } from './loaders/filesystem-loader.js';
12
8
  import { JSONSerializer } from './serializers/json-serializer.js';
13
9
  import { YAMLSerializer } from './serializers/yaml-serializer.js';
14
10
  import { TypeScriptSerializer } from './serializers/typescript-serializer.js';
@@ -17,8 +13,9 @@ import { TypeScriptSerializer } from './serializers/typescript-serializer.js';
17
13
  */
18
14
  export class MetadataManager {
19
15
  constructor(config) {
20
- this.config = config;
16
+ this.loaders = new Map();
21
17
  this.watchCallbacks = new Map();
18
+ this.config = config;
22
19
  this.logger = createLogger({ level: 'info', format: 'pretty' });
23
20
  // Initialize serializers
24
21
  this.serializers = new Map();
@@ -35,117 +32,138 @@ export class MetadataManager {
35
32
  if (formats.includes('javascript')) {
36
33
  this.serializers.set('javascript', new TypeScriptSerializer('javascript'));
37
34
  }
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();
35
+ // Initialize Loaders
36
+ if (config.loaders && config.loaders.length > 0) {
37
+ config.loaders.forEach(loader => this.registerLoader(loader));
44
38
  }
39
+ // Note: No default loader in base class. Subclasses (NodeMetadataManager) or caller must provide one.
40
+ }
41
+ /**
42
+ * Register a new metadata loader (data source)
43
+ */
44
+ registerLoader(loader) {
45
+ this.loaders.set(loader.contract.name, loader);
46
+ this.logger.info(`Registered metadata loader: ${loader.contract.name} (${loader.contract.protocol})`);
45
47
  }
46
48
  /**
47
49
  * Load a single metadata item
50
+ * Iterates through registered loaders until found
48
51
  */
49
52
  async load(type, name, options) {
50
- const result = await this.loader.load(type, name, options);
51
- return result.data;
53
+ // Priority: Database > Filesystem (Implementation-dependent)
54
+ // For now, we just iterate.
55
+ for (const loader of this.loaders.values()) {
56
+ try {
57
+ const result = await loader.load(type, name, options);
58
+ if (result.data) {
59
+ return result.data;
60
+ }
61
+ }
62
+ catch (e) {
63
+ this.logger.warn(`Loader ${loader.contract.name} failed to load ${type}:${name}`, { error: e });
64
+ }
65
+ }
66
+ return null;
52
67
  }
53
68
  /**
54
69
  * Load multiple metadata items
70
+ * Aggregates results from all loaders
55
71
  */
56
72
  async loadMany(type, options) {
57
- return this.loader.loadMany(type, options);
73
+ const results = [];
74
+ for (const loader of this.loaders.values()) {
75
+ try {
76
+ const items = await loader.loadMany(type, options);
77
+ for (const item of items) {
78
+ // TODO: Deduplicate based on 'name' if property exists
79
+ results.push(item);
80
+ }
81
+ }
82
+ catch (e) {
83
+ this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
84
+ }
85
+ }
86
+ return results;
58
87
  }
59
88
  /**
60
89
  * Save metadata to disk
61
90
  */
91
+ /**
92
+ * Save metadata item
93
+ */
62
94
  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}`);
95
+ const targetLoader = options?.loader;
96
+ // Find suitable loader
97
+ let loader;
98
+ if (targetLoader) {
99
+ loader = this.loaders.get(targetLoader);
100
+ if (!loader) {
101
+ throw new Error(`Loader not found: ${targetLoader}`);
70
102
  }
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) {
103
+ }
104
+ else {
105
+ // 1. Try to find existing writable loader containing this item (Update existing)
106
+ for (const l of this.loaders.values()) {
107
+ // Skip if loader is strictly read-only
108
+ if (!l.save)
109
+ continue;
77
110
  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;
111
+ if (await l.exists(type, name)) {
112
+ loader = l;
113
+ this.logger.info(`Updating existing metadata in loader: ${l.contract.name}`);
114
+ break;
85
115
  }
86
116
  }
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
117
+ catch (e) {
118
+ // Ignore existence check errors (e.g. network down)
100
119
  }
101
120
  }
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);
121
+ // 2. Default to 'filesystem' if available (Create new)
122
+ if (!loader) {
123
+ const fsLoader = this.loaders.get('filesystem');
124
+ if (fsLoader && fsLoader.save) {
125
+ loader = fsLoader;
126
+ }
113
127
  }
114
- else {
115
- await fs.writeFile(filePath, content, 'utf-8');
128
+ // 3. Fallback to any writable loader
129
+ if (!loader) {
130
+ for (const l of this.loaders.values()) {
131
+ if (l.save) {
132
+ loader = l;
133
+ break;
134
+ }
135
+ }
116
136
  }
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
137
  }
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;
138
+ if (!loader) {
139
+ throw new Error(`No loader available for saving type: ${type}`);
140
+ }
141
+ if (!loader.save) {
142
+ throw new Error(`Loader '${loader.contract?.name}' does not support saving`);
136
143
  }
144
+ return loader.save(type, name, data, options);
137
145
  }
138
146
  /**
139
147
  * Check if metadata item exists
140
148
  */
141
149
  async exists(type, name) {
142
- return this.loader.exists(type, name);
150
+ for (const loader of this.loaders.values()) {
151
+ if (await loader.exists(type, name)) {
152
+ return true;
153
+ }
154
+ }
155
+ return false;
143
156
  }
144
157
  /**
145
158
  * List all items of a type
146
159
  */
147
160
  async list(type) {
148
- return this.loader.list(type);
161
+ const items = new Set();
162
+ for (const loader of this.loaders.values()) {
163
+ const result = await loader.list(type);
164
+ result.forEach(item => items.add(item));
165
+ }
166
+ return Array.from(items);
149
167
  }
150
168
  /**
151
169
  * Watch for metadata changes
@@ -172,92 +190,22 @@ export class MetadataManager {
172
190
  * Stop all watching
173
191
  */
174
192
  async stopWatching() {
175
- if (this.watcher) {
176
- await this.watcher.close();
177
- this.watcher = undefined;
178
- this.watchCallbacks.clear();
179
- }
193
+ // Override in subclass
180
194
  }
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));
195
+ notifyWatchers(type, event) {
216
196
  const callbacks = this.watchCallbacks.get(type);
217
- if (!callbacks || callbacks.size === 0) {
197
+ if (!callbacks)
218
198
  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
199
  for (const callback of callbacks) {
242
200
  try {
243
- await callback(event);
201
+ void callback(event);
244
202
  }
245
203
  catch (error) {
246
204
  this.logger.error('Watch callback error', undefined, {
247
205
  type,
248
- name,
249
206
  error: error instanceof Error ? error.message : String(error),
250
207
  });
251
208
  }
252
209
  }
253
210
  }
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
211
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Node Metadata Manager
3
+ *
4
+ * Extends MetadataManager with Filesystem capabilities (Watching, default loader)
5
+ */
6
+ import { MetadataManager, type MetadataManagerOptions } from './metadata-manager.js';
7
+ /**
8
+ * Node metadata manager class
9
+ */
10
+ export declare class NodeMetadataManager extends MetadataManager {
11
+ private watcher?;
12
+ constructor(config: MetadataManagerOptions);
13
+ /**
14
+ * Stop all watching
15
+ */
16
+ stopWatching(): Promise<void>;
17
+ /**
18
+ * Start watching for file changes
19
+ */
20
+ private startWatching;
21
+ /**
22
+ * Handle file change events
23
+ */
24
+ private handleFileEvent;
25
+ }
26
+ //# sourceMappingURL=node-metadata-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node-metadata-manager.d.ts","sourceRoot":"","sources":["../src/node-metadata-manager.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAQH,OAAO,EAAE,eAAe,EAAE,KAAK,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAErF;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,eAAe;IACtD,OAAO,CAAC,OAAO,CAAC,CAAY;gBAEhB,MAAM,EAAE,sBAAsB;IAgB1C;;OAEG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAQnC;;OAEG;IACH,OAAO,CAAC,aAAa;IA0BrB;;OAEG;YACW,eAAe;CA4C9B"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Node Metadata Manager
3
+ *
4
+ * Extends MetadataManager with Filesystem capabilities (Watching, default loader)
5
+ */
6
+ import * as path from 'node:path';
7
+ import { watch as chokidarWatch } from 'chokidar';
8
+ import { FilesystemLoader } from './loaders/filesystem-loader.js';
9
+ import { MetadataManager } from './metadata-manager.js';
10
+ /**
11
+ * Node metadata manager class
12
+ */
13
+ export class NodeMetadataManager extends MetadataManager {
14
+ constructor(config) {
15
+ super(config);
16
+ // Initialize Default Filesystem Loader if no loaders provided
17
+ // This logic replaces the removed logic from base class
18
+ if (!config.loaders || config.loaders.length === 0) {
19
+ const rootDir = config.rootDir || process.cwd();
20
+ this.registerLoader(new FilesystemLoader(rootDir, this.serializers, this.logger));
21
+ }
22
+ // Start watching if enabled
23
+ if (config.watch) {
24
+ this.startWatching();
25
+ }
26
+ }
27
+ /**
28
+ * Stop all watching
29
+ */
30
+ async stopWatching() {
31
+ if (this.watcher) {
32
+ await this.watcher.close();
33
+ this.watcher = undefined;
34
+ }
35
+ // Call base cleanup if any
36
+ }
37
+ /**
38
+ * Start watching for file changes
39
+ */
40
+ startWatching() {
41
+ const rootDir = this.config.rootDir || process.cwd();
42
+ const { ignored = ['**/node_modules/**', '**/*.test.*'], persistent = true } = this.config.watchOptions || {};
43
+ this.watcher = chokidarWatch(rootDir, {
44
+ ignored,
45
+ persistent,
46
+ ignoreInitial: true,
47
+ });
48
+ this.watcher.on('add', async (filePath) => {
49
+ await this.handleFileEvent('added', filePath);
50
+ });
51
+ this.watcher.on('change', async (filePath) => {
52
+ await this.handleFileEvent('changed', filePath);
53
+ });
54
+ this.watcher.on('unlink', async (filePath) => {
55
+ await this.handleFileEvent('deleted', filePath);
56
+ });
57
+ this.logger.info('File watcher started', { rootDir });
58
+ }
59
+ /**
60
+ * Handle file change events
61
+ */
62
+ async handleFileEvent(eventType, filePath) {
63
+ const rootDir = this.config.rootDir || process.cwd();
64
+ const relativePath = path.relative(rootDir, filePath);
65
+ const parts = relativePath.split(path.sep);
66
+ if (parts.length < 2) {
67
+ return; // Not a metadata file
68
+ }
69
+ const type = parts[0];
70
+ const fileName = parts[parts.length - 1];
71
+ const name = path.basename(fileName, path.extname(fileName));
72
+ // We can't access private watchCallbacks from parent.
73
+ // We need a protected method to trigger watch event or access it.
74
+ // OPTION: Add a method `triggerWatchEvent` to MetadataManager
75
+ let data = undefined;
76
+ if (eventType !== 'deleted') {
77
+ try {
78
+ data = await this.load(type, name, { useCache: false });
79
+ }
80
+ catch (error) {
81
+ this.logger.error('Failed to load changed file', undefined, {
82
+ filePath,
83
+ error: error instanceof Error ? error.message : String(error),
84
+ });
85
+ return;
86
+ }
87
+ }
88
+ const event = {
89
+ type: eventType,
90
+ metadataType: type,
91
+ name,
92
+ path: filePath,
93
+ data,
94
+ timestamp: new Date(),
95
+ };
96
+ this.notifyWatchers(type, event);
97
+ }
98
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Node.js specific exports for @objectstack/metadata
3
+ */
4
+ export * from './index.js';
5
+ export { NodeMetadataManager } from './node-metadata-manager.js';
6
+ export { FilesystemLoader } from './loaders/filesystem-loader.js';
7
+ export { MetadataPlugin } from './plugin.js';
8
+ //# sourceMappingURL=node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAClE,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
package/dist/node.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Node.js specific exports for @objectstack/metadata
3
+ */
4
+ export * from './index.js';
5
+ export { NodeMetadataManager } from './node-metadata-manager.js';
6
+ export { FilesystemLoader } from './loaders/filesystem-loader.js';
7
+ export { MetadataPlugin } from './plugin.js';
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAI1D,MAAM,WAAW,qBAAqB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,qBAAa,cAAe,YAAW,MAAM;IACzC,IAAI,SAA8B;IAClC,OAAO,SAAW;IAElB,OAAO,CAAC,OAAO,CAAkB;IACjC,OAAO,CAAC,OAAO,CAAwB;gBAE3B,OAAO,GAAE,qBAA0B;IAe/C,IAAI,GAAU,KAAK,aAAa,mBAM/B;IAED,KAAK,GAAU,KAAK,aAAa,mBAgDhC;CACJ"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAI1D,MAAM,WAAW,qBAAqB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,qBAAa,cAAe,YAAW,MAAM;IACzC,IAAI,SAA8B;IAClC,OAAO,SAAW;IAElB,OAAO,CAAC,OAAO,CAAsB;IACrC,OAAO,CAAC,OAAO,CAAwB;gBAE3B,OAAO,GAAE,qBAA0B;IAe/C,IAAI,GAAU,KAAK,aAAa,mBAM/B;IAED,KAAK,GAAU,KAAK,aAAa,mBAgDhC;CACJ"}
package/dist/plugin.js CHANGED
@@ -1,4 +1,4 @@
1
- import { MetadataManager } from './metadata-manager.js';
1
+ import { NodeMetadataManager } from './node-metadata-manager.js';
2
2
  import { ObjectStackDefinitionSchema } from '@objectstack/spec';
3
3
  export class MetadataPlugin {
4
4
  constructor(options = {}) {
@@ -62,7 +62,7 @@ export class MetadataPlugin {
62
62
  ...options
63
63
  };
64
64
  const rootDir = this.options.rootDir || process.cwd();
65
- this.manager = new MetadataManager({
65
+ this.manager = new NodeMetadataManager({
66
66
  rootDir,
67
67
  watch: this.options.watch ?? true,
68
68
  formats: ['yaml', 'json', 'typescript', 'javascript']
package/package.json CHANGED
@@ -1,10 +1,22 @@
1
1
  {
2
2
  "name": "@objectstack/metadata",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Metadata loading, saving, and persistence for ObjectStack",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ },
14
+ "./node": {
15
+ "types": "./src/node.ts",
16
+ "import": "./src/node.ts",
17
+ "default": "./src/node.ts"
18
+ }
19
+ },
8
20
  "keywords": [
9
21
  "objectstack",
10
22
  "metadata",
@@ -17,9 +29,9 @@
17
29
  "js-yaml": "^4.1.0",
18
30
  "chokidar": "^3.5.3",
19
31
  "zod": "^4.3.6",
20
- "@objectstack/core": "1.0.0",
21
- "@objectstack/spec": "1.0.0",
22
- "@objectstack/types": "1.0.0"
32
+ "@objectstack/core": "1.0.2",
33
+ "@objectstack/spec": "1.0.2",
34
+ "@objectstack/types": "1.0.2"
23
35
  },
24
36
  "devDependencies": {
25
37
  "@types/js-yaml": "^4.0.9",
package/src/index.ts CHANGED
@@ -5,14 +5,15 @@
5
5
  */
6
6
 
7
7
  // Main Manager
8
- export { MetadataManager, type WatchCallback } from './metadata-manager.js';
8
+ export { MetadataManager, type WatchCallback, type MetadataManagerOptions } from './metadata-manager.js';
9
9
 
10
10
  // Plugin
11
11
  export { MetadataPlugin } from './plugin.js';
12
12
 
13
13
  // Loaders
14
14
  export { type MetadataLoader } from './loaders/loader-interface.js';
15
- export { FilesystemLoader } from './loaders/filesystem-loader.js';
15
+ export { MemoryLoader } from './loaders/memory-loader.js';
16
+ export { RemoteLoader } from './loaders/remote-loader.js';
16
17
 
17
18
  // Serializers
18
19
  export { type MetadataSerializer, type SerializeOptions } from './serializers/serializer-interface.js';
@@ -14,6 +14,8 @@ import type {
14
14
  MetadataStats,
15
15
  MetadataLoaderContract,
16
16
  MetadataFormat,
17
+ MetadataSaveOptions,
18
+ MetadataSaveResult,
17
19
  } from '@objectstack/spec/system';
18
20
  import type { Logger } from '@objectstack/core';
19
21
  import type { MetadataLoader } from './loader-interface.js';
@@ -260,6 +262,101 @@ export class FilesystemLoader implements MetadataLoader {
260
262
  }
261
263
  }
262
264
 
265
+ async save(
266
+ type: string,
267
+ name: string,
268
+ data: any,
269
+ options?: MetadataSaveOptions
270
+ ): Promise<MetadataSaveResult> {
271
+ const startTime = Date.now();
272
+ const {
273
+ format = 'typescript',
274
+ prettify = true,
275
+ indent = 2,
276
+ sortKeys = false,
277
+ backup = false,
278
+ overwrite = true,
279
+ atomic = true,
280
+ path: customPath,
281
+ } = options || {};
282
+
283
+ try {
284
+ // Get serializer
285
+ const serializer = this.getSerializer(format);
286
+ if (!serializer) {
287
+ throw new Error(`No serializer found for format: ${format}`);
288
+ }
289
+
290
+ // Determine file path
291
+ const typeDir = path.join(this.rootDir, type);
292
+ const fileName = `${name}${serializer.getExtension()}`;
293
+ const filePath = customPath || path.join(typeDir, fileName);
294
+
295
+ // Check if file exists
296
+ if (!overwrite) {
297
+ try {
298
+ await fs.access(filePath);
299
+ throw new Error(`File already exists: ${filePath}`);
300
+ } catch (error) {
301
+ // File doesn't exist, continue
302
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
303
+ throw error;
304
+ }
305
+ }
306
+ }
307
+
308
+ // Create directory if it doesn't exist
309
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
310
+
311
+ // Create backup if requested
312
+ let backupPath: string | undefined;
313
+ if (backup) {
314
+ try {
315
+ await fs.access(filePath);
316
+ backupPath = `${filePath}.bak`;
317
+ await fs.copyFile(filePath, backupPath);
318
+ } catch {
319
+ // File doesn't exist, no backup needed
320
+ }
321
+ }
322
+
323
+ // Serialize data
324
+ const content = serializer.serialize(data, {
325
+ prettify,
326
+ indent,
327
+ sortKeys,
328
+ });
329
+
330
+ // Write to disk (atomic or direct)
331
+ if (atomic) {
332
+ const tempPath = `${filePath}.tmp`;
333
+ await fs.writeFile(tempPath, content, 'utf-8');
334
+ await fs.rename(tempPath, filePath);
335
+ } else {
336
+ await fs.writeFile(filePath, content, 'utf-8');
337
+ }
338
+
339
+ // Update cache logic if needed (e.g., invalidate or update)
340
+ // For now, we rely on the watcher to pick up changes
341
+
342
+ return {
343
+ success: true,
344
+ path: filePath,
345
+ // format, // Not in schema
346
+ size: Buffer.byteLength(content, 'utf-8'),
347
+ backupPath,
348
+ saveTime: Date.now() - startTime,
349
+ };
350
+ } catch (error) {
351
+ this.logger?.error('Failed to save metadata', undefined, {
352
+ type,
353
+ name,
354
+ error: error instanceof Error ? error.message : String(error),
355
+ });
356
+ throw error;
357
+ }
358
+ }
359
+
263
360
  /**
264
361
  * Find file for a given type and name
265
362
  */