@objectstack/metadata 1.0.0 → 1.0.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.
@@ -9,6 +9,8 @@ import type {
9
9
  MetadataLoadResult,
10
10
  MetadataStats,
11
11
  MetadataLoaderContract,
12
+ MetadataSaveOptions,
13
+ MetadataSaveResult,
12
14
  } from '@objectstack/spec/system';
13
15
 
14
16
  /**
@@ -67,4 +69,19 @@ export interface MetadataLoader {
67
69
  * @returns Array of item names
68
70
  */
69
71
  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>;
70
86
  }
87
+
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Memory Metadata Loader
3
+ *
4
+ * Stores metadata in memory only. Changes are lost when process restarts.
5
+ * Useful for testing, temporary overrides, or "dirty" edits.
6
+ */
7
+
8
+ import type {
9
+ MetadataLoadOptions,
10
+ MetadataLoadResult,
11
+ MetadataStats,
12
+ MetadataLoaderContract,
13
+ MetadataSaveOptions,
14
+ MetadataSaveResult,
15
+ } from '@objectstack/spec/system';
16
+ import type { MetadataLoader } from './loader-interface.js';
17
+
18
+ export class MemoryLoader implements MetadataLoader {
19
+ readonly contract: MetadataLoaderContract = {
20
+ name: 'memory',
21
+ protocol: 'memory',
22
+ capabilities: {
23
+ read: true,
24
+ write: true,
25
+ watch: false,
26
+ list: true,
27
+ },
28
+ };
29
+
30
+ // Storage: Type -> Name -> Data
31
+ private storage = new Map<string, Map<string, any>>();
32
+
33
+ async load(
34
+ type: string,
35
+ name: string,
36
+ _options?: MetadataLoadOptions
37
+ ): Promise<MetadataLoadResult> {
38
+ const typeStore = this.storage.get(type);
39
+ const data = typeStore?.get(name);
40
+
41
+ if (data) {
42
+ return {
43
+ data,
44
+ source: 'memory',
45
+ format: 'json',
46
+ loadTime: 0,
47
+ };
48
+ }
49
+
50
+ return { data: null };
51
+ }
52
+
53
+ async loadMany<T = any>(
54
+ type: string,
55
+ _options?: MetadataLoadOptions
56
+ ): Promise<T[]> {
57
+ const typeStore = this.storage.get(type);
58
+ if (!typeStore) return [];
59
+ return Array.from(typeStore.values()) as T[];
60
+ }
61
+
62
+ async exists(type: string, name: string): Promise<boolean> {
63
+ return this.storage.get(type)?.has(name) ?? false;
64
+ }
65
+
66
+ async stat(type: string, name: string): Promise<MetadataStats | null> {
67
+ if (await this.exists(type, name)) {
68
+ return {
69
+ size: 0, // In-memory
70
+ mtime: new Date(),
71
+ format: 'json',
72
+ };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ async list(type: string): Promise<string[]> {
78
+ const typeStore = this.storage.get(type);
79
+ if (!typeStore) return [];
80
+ return Array.from(typeStore.keys());
81
+ }
82
+
83
+ async save(
84
+ type: string,
85
+ name: string,
86
+ data: any,
87
+ _options?: MetadataSaveOptions
88
+ ): Promise<MetadataSaveResult> {
89
+ if (!this.storage.has(type)) {
90
+ this.storage.set(type, new Map());
91
+ }
92
+
93
+ this.storage.get(type)!.set(name, data);
94
+
95
+ return {
96
+ success: true,
97
+ path: `memory://${type}/${name}`,
98
+ saveTime: 0,
99
+ };
100
+ }
101
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Remote Metadata Loader
3
+ *
4
+ * Loads metadata from an HTTP API.
5
+ * This loader is stateless and delegates storage to the remote server.
6
+ */
7
+
8
+ import type {
9
+ MetadataLoadOptions,
10
+ MetadataLoadResult,
11
+ MetadataStats,
12
+ MetadataLoaderContract,
13
+ MetadataSaveOptions,
14
+ MetadataSaveResult,
15
+ } from '@objectstack/spec/system';
16
+ import type { MetadataLoader } from './loader-interface.js';
17
+
18
+ export class RemoteLoader implements MetadataLoader {
19
+ readonly contract: MetadataLoaderContract = {
20
+ name: 'remote',
21
+ protocol: 'http',
22
+ capabilities: {
23
+ read: true,
24
+ write: true,
25
+ watch: false, // Could implement SSE/WebSocket in future
26
+ list: true,
27
+ },
28
+ };
29
+
30
+ constructor(private baseUrl: string, private authToken?: string) {}
31
+
32
+ private get headers() {
33
+ return {
34
+ 'Content-Type': 'application/json',
35
+ ...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
36
+ };
37
+ }
38
+
39
+ async load(
40
+ type: string,
41
+ name: string,
42
+ _options?: MetadataLoadOptions
43
+ ): Promise<MetadataLoadResult> {
44
+ try {
45
+ const response = await fetch(`${this.baseUrl}/${type}/${name}`, {
46
+ method: 'GET',
47
+ headers: this.headers,
48
+ });
49
+
50
+ if (response.status === 404) {
51
+ return { data: null };
52
+ }
53
+
54
+ if (!response.ok) {
55
+ throw new Error(`Remote load failed: ${response.statusText}`);
56
+ }
57
+
58
+ const data = await response.json();
59
+ return {
60
+ data,
61
+ source: this.baseUrl,
62
+ format: 'json',
63
+ loadTime: 0,
64
+ };
65
+ } catch (error) {
66
+ console.error(`RemoteLoader error loading ${type}/${name}`, error);
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ async loadMany<T = any>(
72
+ type: string,
73
+ _options?: MetadataLoadOptions
74
+ ): Promise<T[]> {
75
+ const response = await fetch(`${this.baseUrl}/${type}`, {
76
+ method: 'GET',
77
+ headers: this.headers,
78
+ });
79
+
80
+ if (!response.ok) {
81
+ return [];
82
+ }
83
+
84
+ return (await response.json()) as T[];
85
+ }
86
+
87
+ async exists(type: string, name: string): Promise<boolean> {
88
+ const response = await fetch(`${this.baseUrl}/${type}/${name}`, {
89
+ method: 'HEAD',
90
+ headers: this.headers,
91
+ });
92
+ return response.ok;
93
+ }
94
+
95
+ async stat(type: string, name: string): Promise<MetadataStats | null> {
96
+ // Basic implementation using HEAD
97
+ const response = await fetch(`${this.baseUrl}/${type}/${name}`, {
98
+ method: 'HEAD',
99
+ headers: this.headers,
100
+ });
101
+
102
+ if (!response.ok) return null;
103
+
104
+ return {
105
+ size: Number(response.headers.get('content-length') || 0),
106
+ mtime: new Date(response.headers.get('last-modified') || Date.now()),
107
+ format: 'json',
108
+ };
109
+ }
110
+
111
+ async list(type: string): Promise<string[]> {
112
+ const items = await this.loadMany<{ name: string }>(type);
113
+ return items.map(i => i.name);
114
+ }
115
+
116
+ async save(
117
+ type: string,
118
+ name: string,
119
+ data: any,
120
+ _options?: MetadataSaveOptions
121
+ ): Promise<MetadataSaveResult> {
122
+ const response = await fetch(`${this.baseUrl}/${type}/${name}`, {
123
+ method: 'PUT',
124
+ headers: this.headers,
125
+ body: JSON.stringify(data),
126
+ });
127
+
128
+ if (!response.ok) {
129
+ throw new Error(`Remote save failed: ${response.statusText}`);
130
+ }
131
+
132
+ return {
133
+ success: true,
134
+ path: `${this.baseUrl}/${type}/${name}`,
135
+ saveTime: 0,
136
+ };
137
+ }
138
+ }
@@ -1,13 +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
7
 
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
8
  import type {
12
9
  MetadataManagerConfig,
13
10
  MetadataLoadOptions,
@@ -17,7 +14,6 @@ import type {
17
14
  MetadataFormat,
18
15
  } from '@objectstack/spec/system';
19
16
  import { createLogger, type Logger } from '@objectstack/core';
20
- import { FilesystemLoader } from './loaders/filesystem-loader.js';
21
17
  import { JSONSerializer } from './serializers/json-serializer.js';
22
18
  import { YAMLSerializer } from './serializers/yaml-serializer.js';
23
19
  import { TypeScriptSerializer } from './serializers/typescript-serializer.js';
@@ -29,17 +25,23 @@ import type { MetadataLoader } from './loaders/loader-interface.js';
29
25
  */
30
26
  export type WatchCallback = (event: MetadataWatchEvent) => void | Promise<void>;
31
27
 
28
+ export interface MetadataManagerOptions extends MetadataManagerConfig {
29
+ loaders?: MetadataLoader[];
30
+ }
31
+
32
32
  /**
33
33
  * Main metadata manager class
34
34
  */
35
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) {
36
+ private loaders: Map<string, MetadataLoader> = new Map();
37
+ // Protected so subclasses can access serializers if needed
38
+ protected serializers: Map<MetadataFormat, MetadataSerializer>;
39
+ protected logger: Logger;
40
+ protected watchCallbacks = new Map<string, Set<WatchCallback>>();
41
+ protected config: MetadataManagerOptions;
42
+
43
+ constructor(config: MetadataManagerOptions) {
44
+ this.config = config;
43
45
  this.logger = createLogger({ level: 'info', format: 'pretty' });
44
46
 
45
47
  // Initialize serializers
@@ -59,149 +61,160 @@ export class MetadataManager {
59
61
  this.serializers.set('javascript', new TypeScriptSerializer('javascript'));
60
62
  }
61
63
 
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();
64
+ // Initialize Loaders
65
+ if (config.loaders && config.loaders.length > 0) {
66
+ config.loaders.forEach(loader => this.registerLoader(loader));
69
67
  }
68
+ // Note: No default loader in base class. Subclasses (NodeMetadataManager) or caller must provide one.
69
+ }
70
+
71
+ /**
72
+ * Register a new metadata loader (data source)
73
+ */
74
+ registerLoader(loader: MetadataLoader) {
75
+ this.loaders.set(loader.contract.name, loader);
76
+ this.logger.info(`Registered metadata loader: ${loader.contract.name} (${loader.contract.protocol})`);
70
77
  }
71
78
 
72
79
  /**
73
80
  * Load a single metadata item
81
+ * Iterates through registered loaders until found
74
82
  */
75
83
  async load<T = any>(
76
84
  type: string,
77
85
  name: string,
78
86
  options?: MetadataLoadOptions
79
87
  ): Promise<T | null> {
80
- const result = await this.loader.load(type, name, options);
81
- return result.data;
88
+ // Priority: Database > Filesystem (Implementation-dependent)
89
+ // For now, we just iterate.
90
+ for (const loader of this.loaders.values()) {
91
+ try {
92
+ const result = await loader.load(type, name, options);
93
+ if (result.data) {
94
+ return result.data;
95
+ }
96
+ } catch (e) {
97
+ this.logger.warn(`Loader ${loader.contract.name} failed to load ${type}:${name}`, { error: e });
98
+ }
99
+ }
100
+ return null;
82
101
  }
83
102
 
84
103
  /**
85
104
  * Load multiple metadata items
105
+ * Aggregates results from all loaders
86
106
  */
87
107
  async loadMany<T = any>(
88
108
  type: string,
89
109
  options?: MetadataLoadOptions
90
110
  ): Promise<T[]> {
91
- return this.loader.loadMany<T>(type, options);
111
+ const results: T[] = [];
112
+
113
+ for (const loader of this.loaders.values()) {
114
+ try {
115
+ const items = await loader.loadMany<T>(type, options);
116
+ for (const item of items) {
117
+ // TODO: Deduplicate based on 'name' if property exists
118
+ results.push(item);
119
+ }
120
+ } catch (e) {
121
+ this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
122
+ }
123
+ }
124
+ return results;
92
125
  }
93
126
 
94
127
  /**
95
128
  * Save metadata to disk
96
129
  */
130
+ /**
131
+ * Save metadata item
132
+ */
97
133
  async save<T = any>(
98
134
  type: string,
99
135
  name: string,
100
136
  data: T,
101
137
  options?: MetadataSaveOptions
102
138
  ): 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}`);
139
+ const targetLoader = (options as any)?.loader;
140
+
141
+ // Find suitable loader
142
+ let loader: MetadataLoader | undefined;
143
+
144
+ if (targetLoader) {
145
+ loader = this.loaders.get(targetLoader);
146
+ if (!loader) {
147
+ throw new Error(`Loader not found: ${targetLoader}`);
120
148
  }
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;
149
+ } else {
150
+ // 1. Try to find existing writable loader containing this item (Update existing)
151
+ for (const l of this.loaders.values()) {
152
+ // Skip if loader is strictly read-only
153
+ if (!l.save) continue;
154
+
155
+ try {
156
+ if (await l.exists(type, name)) {
157
+ loader = l;
158
+ this.logger.info(`Updating existing metadata in loader: ${l.contract.name}`);
159
+ break;
160
+ }
161
+ } catch (e) {
162
+ // Ignore existence check errors (e.g. network down)
136
163
  }
137
- }
138
164
  }
139
165
 
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
166
+ // 2. Default to 'filesystem' if available (Create new)
167
+ if (!loader) {
168
+ const fsLoader = this.loaders.get('filesystem');
169
+ if (fsLoader && fsLoader.save) {
170
+ loader = fsLoader;
152
171
  }
153
172
  }
154
173
 
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');
174
+ // 3. Fallback to any writable loader
175
+ if (!loader) {
176
+ for (const l of this.loaders.values()) {
177
+ if (l.save) {
178
+ loader = l;
179
+ break;
180
+ }
181
+ }
169
182
  }
183
+ }
170
184
 
171
- // Get stats
172
- const stats = await fs.stat(filePath);
173
- const etag = this.generateETag(content);
185
+ if (!loader) {
186
+ throw new Error(`No loader available for saving type: ${type}`);
187
+ }
174
188
 
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;
189
+ if (!loader.save) {
190
+ throw new Error(`Loader '${loader.contract?.name}' does not support saving`);
190
191
  }
192
+
193
+ return loader.save(type, name, data, options);
191
194
  }
192
195
 
193
196
  /**
194
197
  * Check if metadata item exists
195
198
  */
196
199
  async exists(type: string, name: string): Promise<boolean> {
197
- return this.loader.exists(type, name);
200
+ for (const loader of this.loaders.values()) {
201
+ if (await loader.exists(type, name)) {
202
+ return true;
203
+ }
204
+ }
205
+ return false;
198
206
  }
199
207
 
200
208
  /**
201
209
  * List all items of a type
202
210
  */
203
211
  async list(type: string): Promise<string[]> {
204
- return this.loader.list(type);
212
+ const items = new Set<string>();
213
+ for (const loader of this.loaders.values()) {
214
+ const result = await loader.list(type);
215
+ result.forEach(item => items.add(item));
216
+ }
217
+ return Array.from(items);
205
218
  }
206
219
 
207
220
  /**
@@ -231,108 +244,23 @@ export class MetadataManager {
231
244
  * Stop all watching
232
245
  */
233
246
  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 });
247
+ // Override in subclass
268
248
  }
269
249
 
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
-
250
+ protected notifyWatchers(type: string, event: MetadataWatchEvent) {
289
251
  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
-
252
+ if (!callbacks) return;
253
+
316
254
  for (const callback of callbacks) {
317
255
  try {
318
- await callback(event);
256
+ void callback(event);
319
257
  } catch (error) {
320
258
  this.logger.error('Watch callback error', undefined, {
321
259
  type,
322
- name,
323
260
  error: error instanceof Error ? error.message : String(error),
324
261
  });
325
262
  }
326
263
  }
327
264
  }
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
265
  }
266
+