@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.
- package/CHANGELOG.md +28 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/loaders/filesystem-loader.d.ts +2 -1
- package/dist/loaders/filesystem-loader.d.ts.map +1 -1
- package/dist/loaders/filesystem-loader.js +75 -0
- package/dist/loaders/loader-interface.d.ts +9 -1
- package/dist/loaders/loader-interface.d.ts.map +1 -1
- package/dist/loaders/memory-loader.d.ts +19 -0
- package/dist/loaders/memory-loader.d.ts.map +1 -0
- package/dist/loaders/memory-loader.js +71 -0
- package/dist/loaders/remote-loader.d.ts +22 -0
- package/dist/loaders/remote-loader.d.ts.map +1 -0
- package/dist/loaders/remote-loader.js +103 -0
- package/dist/metadata-manager.d.ts +25 -23
- package/dist/metadata-manager.d.ts.map +1 -1
- package/dist/metadata-manager.js +104 -156
- package/dist/node-metadata-manager.d.ts +26 -0
- package/dist/node-metadata-manager.d.ts.map +1 -0
- package/dist/node-metadata-manager.js +98 -0
- package/dist/node.d.ts +8 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +7 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +2 -2
- package/package.json +16 -4
- package/src/index.ts +3 -2
- package/src/loaders/filesystem-loader.ts +97 -0
- package/src/loaders/loader-interface.ts +17 -0
- package/src/loaders/memory-loader.ts +101 -0
- package/src/loaders/remote-loader.ts +138 -0
- package/src/metadata-manager.ts +121 -193
- package/src/node-metadata-manager.ts +124 -0
- package/src/node.ts +8 -0
- package/src/plugin.ts +3 -3
|
@@ -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
|
+
}
|
package/src/metadata-manager.ts
CHANGED
|
@@ -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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
//
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
185
|
+
if (!loader) {
|
|
186
|
+
throw new Error(`No loader available for saving type: ${type}`);
|
|
187
|
+
}
|
|
174
188
|
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
291
|
-
|
|
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
|
-
|
|
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
|
+
|