@objectstack/metadata 0.9.2 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +7 -452
- 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 +83 -1
- 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 +2 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +55 -55
- package/package.json +17 -4
- package/src/index.ts +3 -2
- package/src/loaders/filesystem-loader.ts +106 -2
- 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 +5 -5
|
@@ -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
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node Metadata Manager
|
|
3
|
+
*
|
|
4
|
+
* Extends MetadataManager with Filesystem capabilities (Watching, default loader)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { watch as chokidarWatch, type FSWatcher } from 'chokidar';
|
|
9
|
+
import type {
|
|
10
|
+
MetadataWatchEvent,
|
|
11
|
+
} from '@objectstack/spec/system';
|
|
12
|
+
import { FilesystemLoader } from './loaders/filesystem-loader.js';
|
|
13
|
+
import { MetadataManager, type MetadataManagerOptions } from './metadata-manager.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Node metadata manager class
|
|
17
|
+
*/
|
|
18
|
+
export class NodeMetadataManager extends MetadataManager {
|
|
19
|
+
private watcher?: FSWatcher;
|
|
20
|
+
|
|
21
|
+
constructor(config: MetadataManagerOptions) {
|
|
22
|
+
super(config);
|
|
23
|
+
|
|
24
|
+
// Initialize Default Filesystem Loader if no loaders provided
|
|
25
|
+
// This logic replaces the removed logic from base class
|
|
26
|
+
if (!config.loaders || config.loaders.length === 0) {
|
|
27
|
+
const rootDir = config.rootDir || process.cwd();
|
|
28
|
+
this.registerLoader(new FilesystemLoader(rootDir, this.serializers, this.logger));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Start watching if enabled
|
|
32
|
+
if (config.watch) {
|
|
33
|
+
this.startWatching();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stop all watching
|
|
39
|
+
*/
|
|
40
|
+
async stopWatching(): Promise<void> {
|
|
41
|
+
if (this.watcher) {
|
|
42
|
+
await this.watcher.close();
|
|
43
|
+
this.watcher = undefined;
|
|
44
|
+
}
|
|
45
|
+
// Call base cleanup if any
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Start watching for file changes
|
|
50
|
+
*/
|
|
51
|
+
private startWatching(): void {
|
|
52
|
+
const rootDir = this.config.rootDir || process.cwd();
|
|
53
|
+
const { ignored = ['**/node_modules/**', '**/*.test.*'], persistent = true } =
|
|
54
|
+
this.config.watchOptions || {};
|
|
55
|
+
|
|
56
|
+
this.watcher = chokidarWatch(rootDir, {
|
|
57
|
+
ignored,
|
|
58
|
+
persistent,
|
|
59
|
+
ignoreInitial: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.watcher.on('add', async (filePath) => {
|
|
63
|
+
await this.handleFileEvent('added', filePath);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.watcher.on('change', async (filePath) => {
|
|
67
|
+
await this.handleFileEvent('changed', filePath);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this.watcher.on('unlink', async (filePath) => {
|
|
71
|
+
await this.handleFileEvent('deleted', filePath);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
this.logger.info('File watcher started', { rootDir });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handle file change events
|
|
79
|
+
*/
|
|
80
|
+
private async handleFileEvent(
|
|
81
|
+
eventType: 'added' | 'changed' | 'deleted',
|
|
82
|
+
filePath: string
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const rootDir = this.config.rootDir || process.cwd();
|
|
85
|
+
const relativePath = path.relative(rootDir, filePath);
|
|
86
|
+
const parts = relativePath.split(path.sep);
|
|
87
|
+
|
|
88
|
+
if (parts.length < 2) {
|
|
89
|
+
return; // Not a metadata file
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const type = parts[0];
|
|
93
|
+
const fileName = parts[parts.length - 1];
|
|
94
|
+
const name = path.basename(fileName, path.extname(fileName));
|
|
95
|
+
|
|
96
|
+
// We can't access private watchCallbacks from parent.
|
|
97
|
+
// We need a protected method to trigger watch event or access it.
|
|
98
|
+
// OPTION: Add a method `triggerWatchEvent` to MetadataManager
|
|
99
|
+
|
|
100
|
+
let data: any = undefined;
|
|
101
|
+
if (eventType !== 'deleted') {
|
|
102
|
+
try {
|
|
103
|
+
data = await this.load(type, name, { useCache: false });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
this.logger.error('Failed to load changed file', undefined, {
|
|
106
|
+
filePath,
|
|
107
|
+
error: error instanceof Error ? error.message : String(error),
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const event: MetadataWatchEvent = {
|
|
114
|
+
type: eventType,
|
|
115
|
+
metadataType: type,
|
|
116
|
+
name,
|
|
117
|
+
path: filePath,
|
|
118
|
+
data,
|
|
119
|
+
timestamp: new Date(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
this.notifyWatchers(type, event);
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js specific exports for @objectstack/metadata
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export * from './index.js';
|
|
6
|
+
export { NodeMetadataManager } from './node-metadata-manager.js';
|
|
7
|
+
export { FilesystemLoader } from './loaders/filesystem-loader.js';
|
|
8
|
+
export { MetadataPlugin } from './plugin.js';
|