Skip to content

Commit ffec530

Browse files
committed
feat: implement save functionality in FilesystemLoader and update MetadataLoader interface; enhance metadata capabilities and schemas
1 parent d28f9b2 commit ffec530

6 files changed

Lines changed: 249 additions & 89 deletions

File tree

packages/metadata/src/loaders/filesystem-loader.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {
1414
MetadataStats,
1515
MetadataLoaderContract,
1616
MetadataFormat,
17+
MetadataSaveOptions,
18+
MetadataSaveResult,
1719
} from '@objectstack/spec/system';
1820
import type { Logger } from '@objectstack/core';
1921
import type { MetadataLoader } from './loader-interface.js';
@@ -260,6 +262,108 @@ export class FilesystemLoader implements MetadataLoader {
260262
}
261263
}
262264

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,
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+
357+
return {
358+
success: false,
359+
path: '', // TODO: Should this be optional in result?
360+
format,
361+
error: error instanceof Error ? error : new Error(String(error)),
362+
saveTime: Date.now() - startTime,
363+
};
364+
}
365+
}
366+
263367
/**
264368
* Find file for a given type and name
265369
*/

packages/metadata/src/loaders/loader-interface.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type {
99
MetadataLoadResult,
1010
MetadataStats,
1111
MetadataLoaderContract,
12+
MetadataSaveOptions,
13+
MetadataSaveResult,
1214
} from '@objectstack/spec/system';
1315

1416
/**
@@ -67,4 +69,19 @@ export interface MetadataLoader {
6769
* @returns Array of item names
6870
*/
6971
list(type: string): Promise<string[]>;
72+
73+
/**
74+
* Save metadata item
75+
* @param type The metadata type
76+
* @param name The item name
77+
* @param data The data to save
78+
* @param options Save options
79+
*/
80+
save?(
81+
type: string,
82+
name: string,
83+
data: any,
84+
options?: MetadataSaveOptions
85+
): Promise<MetadataSaveResult>;
7086
}
87+

packages/metadata/src/metadata-manager.ts

Lines changed: 82 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export type WatchCallback = (event: MetadataWatchEvent) => void | Promise<void>;
3333
* Main metadata manager class
3434
*/
3535
export class MetadataManager {
36-
private loader: MetadataLoader;
36+
private loaders: Map<string, MetadataLoader> = new Map();
3737
private serializers: Map<MetadataFormat, MetadataSerializer>;
3838
private logger: Logger;
3939
private watcher?: FSWatcher;
@@ -59,149 +59,142 @@ export class MetadataManager {
5959
this.serializers.set('javascript', new TypeScriptSerializer('javascript'));
6060
}
6161

62-
// Initialize loader
62+
// Initialize Default Filesystem Loader
63+
// This is treated as the "Primary" source for now
6364
const rootDir = config.rootDir || process.cwd();
64-
this.loader = new FilesystemLoader(rootDir, this.serializers, this.logger);
65+
this.registerLoader(new FilesystemLoader(rootDir, this.serializers, this.logger));
6566

6667
// Start watching if enabled
6768
if (config.watch) {
6869
this.startWatching();
6970
}
7071
}
7172

73+
/**
74+
* Register a new metadata loader (data source)
75+
*/
76+
registerLoader(loader: MetadataLoader) {
77+
this.loaders.set(loader.contract.name, loader);
78+
this.logger.info(`Registered metadata loader: ${loader.contract.name} (${loader.contract.protocol})`);
79+
}
80+
7281
/**
7382
* Load a single metadata item
83+
* Iterates through registered loaders until found
7484
*/
7585
async load<T = any>(
7686
type: string,
7787
name: string,
7888
options?: MetadataLoadOptions
7989
): Promise<T | null> {
80-
const result = await this.loader.load(type, name, options);
81-
return result.data;
90+
// Priority: Database > Filesystem (Implementation-dependent)
91+
// For now, we just iterate.
92+
for (const loader of this.loaders.values()) {
93+
try {
94+
const result = await loader.load(type, name, options);
95+
if (result.data) {
96+
return result.data;
97+
}
98+
} catch (e) {
99+
this.logger.warn(`Loader ${loader.contract.name} failed to load ${type}:${name}`, { error: e });
100+
}
101+
}
102+
return null;
82103
}
83104

84105
/**
85106
* Load multiple metadata items
107+
* Aggregates results from all loaders
86108
*/
87109
async loadMany<T = any>(
88110
type: string,
89111
options?: MetadataLoadOptions
90112
): Promise<T[]> {
91-
return this.loader.loadMany<T>(type, options);
113+
const results: T[] = [];
114+
const seen = new Set<string>(); // De-duplication key needed? For now, simple aggregation
115+
116+
for (const loader of this.loaders.values()) {
117+
try {
118+
const items = await loader.loadMany<T>(type, options);
119+
for (const item of items) {
120+
// TODO: Deduplicate based on 'name' if property exists
121+
results.push(item);
122+
}
123+
} catch (e) {
124+
this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
125+
}
126+
}
127+
return results;
92128
}
93129

94130
/**
95131
* Save metadata to disk
96132
*/
133+
/**
134+
* Save metadata item
135+
*/
97136
async save<T = any>(
98137
type: string,
99138
name: string,
100139
data: T,
101140
options?: MetadataSaveOptions
102141
): 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}`);
142+
const targetLoader = (options as any)?.loader;
143+
144+
// Find suitable loader
145+
let loader: MetadataLoader | undefined;
146+
147+
if (targetLoader) {
148+
loader = this.loaders.get(targetLoader);
149+
if (!loader) {
150+
throw new Error(`Loader not found: ${targetLoader}`);
120151
}
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;
152+
} else {
153+
// Default to 'filesystem' or first writable
154+
loader = this.loaders.get('filesystem');
155+
if (!loader) {
156+
for (const l of this.loaders.values()) {
157+
if (l.save) {
158+
loader = l;
159+
break;
136160
}
137161
}
138162
}
163+
}
139164

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-
}
165+
if (!loader) {
166+
throw new Error(`No loader available for saving type: ${type}`);
167+
}
170168

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;
169+
if (!loader.save) {
170+
throw new Error(`Loader '${loader.contract?.name}' does not support saving`);
190171
}
172+
173+
return loader.save(type, name, data, options);
191174
}
192175

193176
/**
194177
* Check if metadata item exists
195178
*/
196179
async exists(type: string, name: string): Promise<boolean> {
197-
return this.loader.exists(type, name);
180+
for (const loader of this.loaders.values()) {
181+
if (await loader.exists(type, name)) {
182+
return true;
183+
}
184+
}
185+
return false;
198186
}
199187

200188
/**
201189
* List all items of a type
202190
*/
203191
async list(type: string): Promise<string[]> {
204-
return this.loader.list(type);
192+
const items = new Set<string>();
193+
for (const loader of this.loaders.values()) {
194+
const result = await loader.list(type);
195+
result.forEach(item => items.add(item));
196+
}
197+
return Array.from(items);
205198
}
206199

207200
/**

0 commit comments

Comments
 (0)