@openfairygui/backend 0.2.0-alpha.1 → 0.2.0-alpha.13
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/LICENSE +21 -0
- package/README.md +40 -2
- package/dist/index.cjs +146 -1
- package/dist/index.d.cts +33 -2
- package/dist/index.d.mts +33 -2
- package/dist/index.mjs +146 -2
- package/dist/node.cjs +1 -1
- package/dist/node.d.cts +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.mjs +1 -1
- package/dist/{runtime-DLWLghmH.mjs → runtime-DFatY9W0.mjs} +244 -19
- package/dist/{runtime-BXbt135W.cjs → runtime-GKzsXJdO.cjs} +251 -26
- package/dist/{runtime-C5JY_kRI.d.cts → runtime-GyNVxAQ0.d.mts} +81 -11
- package/dist/{runtime-C5ZsWMnH.d.mts → runtime-Jec6FcF5.d.cts} +81 -11
- package/package.json +65 -65
- package/src/contracts.ts +8 -0
- package/src/index.ts +12 -0
- package/src/runtime.ts +156 -29
- package/src/services/authoring-service.ts +462 -46
- package/src/services/context.ts +2 -1
- package/src/services/runtime-service.ts +62 -25
- package/src/storage.ts +192 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ProjectReader,
|
|
1
|
+
import { ProjectReader, type FileSystem } from '@openfairygui/core/project-io';
|
|
2
|
+
import { liftDocumentToUamProject, normalizeUamProject } from '@openfairygui/core/uam';
|
|
2
3
|
import { failure, success, type BackendContext, type BackendSessionState } from './context.js';
|
|
3
4
|
import type { CacheService } from './cache-service.js';
|
|
4
5
|
import type { EventService } from './event-service.js';
|
|
@@ -13,14 +14,16 @@ import type {
|
|
|
13
14
|
OpenProjectSessionInput,
|
|
14
15
|
SessionNotFoundError,
|
|
15
16
|
} from '../runtime.js';
|
|
16
|
-
import { resolveCanonicalProjectRoot } from '../path-policy.js';
|
|
17
|
+
import { normalizeComparablePath, resolveCanonicalProjectRoot } from '../path-policy.js';
|
|
17
18
|
import { createSessionNotFoundError, toSessionSnapshot } from './session-utils.js';
|
|
18
19
|
|
|
19
20
|
function randomId(): string {
|
|
20
21
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function createCapabilityUnavailableError(
|
|
24
|
+
function createCapabilityUnavailableError(
|
|
25
|
+
capability: BackendCapabilityUnavailableError['capability'],
|
|
26
|
+
): BackendCapabilityUnavailableError {
|
|
24
27
|
const artifactCapability = capability === 'artifact.publish' || capability === 'artifact.restore';
|
|
25
28
|
return {
|
|
26
29
|
code: 'capability_unavailable',
|
|
@@ -34,7 +37,7 @@ function createCapabilityUnavailableError(capability: BackendCapabilityUnavailab
|
|
|
34
37
|
};
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
function createProjectReaderFileSystem(fileSystem: NonNullable<BackendContext['fileSystem']>):
|
|
40
|
+
function createProjectReaderFileSystem(fileSystem: NonNullable<BackendContext['fileSystem']>): FileSystem {
|
|
38
41
|
return {
|
|
39
42
|
readFile(filePath: string): Promise<string> {
|
|
40
43
|
return fileSystem.readFile(filePath);
|
|
@@ -79,7 +82,14 @@ export class RuntimeService {
|
|
|
79
82
|
private readonly jobService: JobService,
|
|
80
83
|
) {}
|
|
81
84
|
|
|
82
|
-
public async openSession(input: {
|
|
85
|
+
public async openSession(input: {
|
|
86
|
+
projectPath: string;
|
|
87
|
+
}): Promise<
|
|
88
|
+
BackendResult<
|
|
89
|
+
BackendSessionSnapshot,
|
|
90
|
+
InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError
|
|
91
|
+
>
|
|
92
|
+
> {
|
|
83
93
|
const startedAt = Date.now();
|
|
84
94
|
if (!this.context.fileSystem) {
|
|
85
95
|
return failure('runtime', startedAt, createCapabilityUnavailableError('fileSystem'));
|
|
@@ -104,14 +114,18 @@ export class RuntimeService {
|
|
|
104
114
|
let advisoryLock: BackendFileHandle | null = null;
|
|
105
115
|
try {
|
|
106
116
|
advisoryLock = await fileSystem.openExclusive(lockFilePath);
|
|
107
|
-
await advisoryLock.writeFile(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
await advisoryLock.writeFile(
|
|
118
|
+
JSON.stringify(
|
|
119
|
+
this.context.host?.lockMetadata?.({
|
|
120
|
+
canonicalPathKey,
|
|
121
|
+
canonicalProjectPath,
|
|
122
|
+
lockFilePath,
|
|
123
|
+
}) ?? {
|
|
124
|
+
createdAt: new Date().toISOString(),
|
|
125
|
+
canonicalPathKey,
|
|
126
|
+
},
|
|
127
|
+
),
|
|
128
|
+
);
|
|
115
129
|
await advisoryLock.close();
|
|
116
130
|
|
|
117
131
|
const reader = new ProjectReader(createProjectReaderFileSystem(fileSystem));
|
|
@@ -123,6 +137,7 @@ export class RuntimeService {
|
|
|
123
137
|
canonicalProjectPath,
|
|
124
138
|
canonicalPathKey,
|
|
125
139
|
lockFilePath,
|
|
140
|
+
fileSystem,
|
|
126
141
|
project,
|
|
127
142
|
revision: 0,
|
|
128
143
|
lastSavedRevision: 0,
|
|
@@ -160,8 +175,14 @@ export class RuntimeService {
|
|
|
160
175
|
public openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot> {
|
|
161
176
|
const startedAt = Date.now();
|
|
162
177
|
const sessionId = input.sessionId ?? randomId();
|
|
163
|
-
const
|
|
164
|
-
const
|
|
178
|
+
const storage = input.storage;
|
|
179
|
+
const memoryProjectPath = `memory://${sessionId}`;
|
|
180
|
+
const canonicalProjectPath = storage?.canonicalProjectPath
|
|
181
|
+
?? input.canonicalProjectPath
|
|
182
|
+
?? (storage ? storage.fileSystem.dirname(storage.fairyPath) || '.' : memoryProjectPath);
|
|
183
|
+
const canonicalPathKey = storage?.canonicalPathKey
|
|
184
|
+
?? input.canonicalPathKey
|
|
185
|
+
?? (storage ? normalizeComparablePath(canonicalProjectPath) : canonicalProjectPath.toLowerCase());
|
|
165
186
|
const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
|
|
166
187
|
if (existingSessionId) {
|
|
167
188
|
return failure('runtime', startedAt, {
|
|
@@ -175,10 +196,11 @@ export class RuntimeService {
|
|
|
175
196
|
|
|
176
197
|
const session: BackendSessionState = {
|
|
177
198
|
sessionId,
|
|
178
|
-
fairyPath: canonicalProjectPath,
|
|
199
|
+
fairyPath: storage?.fairyPath ?? canonicalProjectPath,
|
|
179
200
|
canonicalProjectPath,
|
|
180
201
|
canonicalPathKey,
|
|
181
202
|
lockFilePath: '',
|
|
203
|
+
fileSystem: storage?.fileSystem,
|
|
182
204
|
project: normalizeUamProject(input.project),
|
|
183
205
|
revision: 0,
|
|
184
206
|
lastSavedRevision: 0,
|
|
@@ -197,16 +219,21 @@ export class RuntimeService {
|
|
|
197
219
|
});
|
|
198
220
|
}
|
|
199
221
|
|
|
200
|
-
public async closeSession(
|
|
201
|
-
|
|
202
|
-
): Promise<BackendResult<{ sessionId: string; closed: true }, SessionNotFoundError>> {
|
|
222
|
+
public async closeSession(input: {
|
|
223
|
+
sessionId: string;
|
|
224
|
+
}): Promise<BackendResult<{ sessionId: string; closed: true }, SessionNotFoundError>> {
|
|
203
225
|
const startedAt = Date.now();
|
|
204
226
|
const session = this.context.sessions.get(input.sessionId);
|
|
205
227
|
if (!session || session.closed) {
|
|
206
228
|
return failure('runtime', startedAt, createSessionNotFoundError(input.sessionId));
|
|
207
229
|
}
|
|
208
230
|
|
|
209
|
-
this.eventService.emit({
|
|
231
|
+
this.eventService.emit({
|
|
232
|
+
kind: 'session.closeRequested',
|
|
233
|
+
sessionId: session.sessionId,
|
|
234
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
235
|
+
revision: session.revision,
|
|
236
|
+
});
|
|
210
237
|
if (this.context.fileSystem && session.lockFilePath) {
|
|
211
238
|
await this.context.fileSystem.unlink(session.lockFilePath).catch(() => undefined);
|
|
212
239
|
}
|
|
@@ -216,12 +243,22 @@ export class RuntimeService {
|
|
|
216
243
|
this.context.sessionsByPath.delete(session.canonicalPathKey);
|
|
217
244
|
this.cacheService.removeSession(session.sessionId);
|
|
218
245
|
this.jobService.removeSession(session.sessionId);
|
|
219
|
-
this.eventService.emit({
|
|
246
|
+
this.eventService.emit({
|
|
247
|
+
kind: 'session.closed',
|
|
248
|
+
sessionId: session.sessionId,
|
|
249
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
250
|
+
revision: session.revision,
|
|
251
|
+
});
|
|
220
252
|
this.eventService.removeSession(session.sessionId);
|
|
221
253
|
|
|
222
|
-
return success(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
254
|
+
return success(
|
|
255
|
+
'runtime',
|
|
256
|
+
startedAt,
|
|
257
|
+
{
|
|
258
|
+
sessionId: session.sessionId,
|
|
259
|
+
closed: true,
|
|
260
|
+
},
|
|
261
|
+
{ sessionId: session.sessionId, revision: session.revision },
|
|
262
|
+
);
|
|
226
263
|
}
|
|
227
264
|
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { FileSystem as CoreProjectFileSystem } from '@openfairygui/core/project-io';
|
|
2
|
+
import type { BackendFileHandle, BackendFileStat, BackendFileSystem } from './runtime.js';
|
|
3
|
+
|
|
4
|
+
type StorageStatKind = 'file' | 'directory';
|
|
5
|
+
|
|
6
|
+
export interface BackendStorageStatLike {
|
|
7
|
+
kind?: StorageStatKind;
|
|
8
|
+
type?: StorageStatKind;
|
|
9
|
+
isFile?(): boolean;
|
|
10
|
+
isDirectory?(): boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BackendAsyncStorageAdapter {
|
|
14
|
+
readFile(filePath: string): Promise<string>;
|
|
15
|
+
readFileRaw(filePath: string): Promise<Uint8Array>;
|
|
16
|
+
writeFile(filePath: string, content: string): Promise<void>;
|
|
17
|
+
writeFileRaw(filePath: string, data: Uint8Array): Promise<void>;
|
|
18
|
+
mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void>;
|
|
19
|
+
readdir(dirPath: string): Promise<string[]>;
|
|
20
|
+
exists?(filePath: string): Promise<boolean>;
|
|
21
|
+
stat?(filePath: string): Promise<BackendStorageStatLike>;
|
|
22
|
+
resolvePath?(filePath: string): Promise<string>;
|
|
23
|
+
openExclusive?(filePath: string): Promise<BackendFileHandle>;
|
|
24
|
+
unlink?(filePath: string): Promise<void>;
|
|
25
|
+
join?(...paths: string[]): string;
|
|
26
|
+
dirname?(filePath: string): string;
|
|
27
|
+
resolve?(...paths: string[]): string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class StorageFileStat implements BackendFileStat {
|
|
31
|
+
public constructor(private readonly kind: StorageStatKind) {}
|
|
32
|
+
|
|
33
|
+
public isFile(): boolean {
|
|
34
|
+
return this.kind === 'file';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public isDirectory(): boolean {
|
|
38
|
+
return this.kind === 'directory';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createPathError(code: string, message: string): Error & { code: string } {
|
|
43
|
+
const error = new Error(message) as Error & { code: string };
|
|
44
|
+
error.code = code;
|
|
45
|
+
return error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeStoragePath(value: string): string {
|
|
49
|
+
const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
50
|
+
const absolute = normalized.startsWith('/');
|
|
51
|
+
const rawSegments = normalized.split('/').filter((segment) => segment.length > 0);
|
|
52
|
+
const segments: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const segment of rawSegments) {
|
|
55
|
+
if (segment === '.') continue;
|
|
56
|
+
if (segment === '..') {
|
|
57
|
+
if (segments.length > 0) segments.pop();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
segments.push(segment);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const joined = segments.join('/');
|
|
64
|
+
if (absolute) return joined ? `/${joined}` : '/';
|
|
65
|
+
return joined || '.';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function joinStoragePath(...paths: string[]): string {
|
|
69
|
+
return normalizeStoragePath(paths.filter((part) => part.length > 0).join('/'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dirnameStoragePath(filePath: string): string {
|
|
73
|
+
const normalized = normalizeStoragePath(filePath);
|
|
74
|
+
if (normalized === '/' || normalized === '.') return '.';
|
|
75
|
+
const absolute = normalized.startsWith('/');
|
|
76
|
+
const parts = normalized.split('/').filter((part) => part.length > 0);
|
|
77
|
+
parts.pop();
|
|
78
|
+
if (parts.length === 0) return absolute ? '/' : '.';
|
|
79
|
+
return `${absolute ? '/' : ''}${parts.join('/')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statFromLike(stat: BackendStorageStatLike): BackendFileStat {
|
|
83
|
+
if (typeof stat.isFile === 'function' && typeof stat.isDirectory === 'function') {
|
|
84
|
+
return stat as BackendFileStat;
|
|
85
|
+
}
|
|
86
|
+
const kind = stat.kind ?? stat.type;
|
|
87
|
+
if (kind === 'file' || kind === 'directory') return new StorageFileStat(kind);
|
|
88
|
+
throw createPathError('EINVAL', 'Storage stat must provide kind/type or isFile()/isDirectory().');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function inferStat(storage: BackendAsyncStorageAdapter, filePath: string): Promise<BackendFileStat> {
|
|
92
|
+
if (storage.stat) return statFromLike(await storage.stat(filePath));
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await storage.readdir(filePath);
|
|
96
|
+
return new StorageFileStat('directory');
|
|
97
|
+
} catch {
|
|
98
|
+
// Try file probes below.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await storage.readFileRaw(filePath);
|
|
103
|
+
return new StorageFileStat('file');
|
|
104
|
+
} catch {
|
|
105
|
+
try {
|
|
106
|
+
await storage.readFile(filePath);
|
|
107
|
+
return new StorageFileStat('file');
|
|
108
|
+
} catch {
|
|
109
|
+
throw createPathError('ENOENT', `Storage path not found: ${filePath}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type BackendStorageFileSystem = BackendFileSystem & CoreProjectFileSystem;
|
|
115
|
+
|
|
116
|
+
export function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapter): BackendStorageFileSystem {
|
|
117
|
+
const lockedPaths = new Set<string>();
|
|
118
|
+
|
|
119
|
+
const fileSystem: BackendStorageFileSystem = {
|
|
120
|
+
stat(filePath: string): Promise<BackendFileStat> {
|
|
121
|
+
return inferStat(storage, fileSystem.resolve(filePath));
|
|
122
|
+
},
|
|
123
|
+
readdir(dirPath: string): Promise<string[]> {
|
|
124
|
+
return storage.readdir(fileSystem.resolve(dirPath));
|
|
125
|
+
},
|
|
126
|
+
readFile(filePath: string): Promise<string> {
|
|
127
|
+
return storage.readFile(fileSystem.resolve(filePath));
|
|
128
|
+
},
|
|
129
|
+
readFileRaw(filePath: string): Promise<Uint8Array> {
|
|
130
|
+
return storage.readFileRaw(fileSystem.resolve(filePath));
|
|
131
|
+
},
|
|
132
|
+
writeFile(filePath: string, content: string): Promise<void> {
|
|
133
|
+
return storage.writeFile(fileSystem.resolve(filePath), content);
|
|
134
|
+
},
|
|
135
|
+
writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
|
|
136
|
+
return storage.writeFileRaw(fileSystem.resolve(filePath), data);
|
|
137
|
+
},
|
|
138
|
+
mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void> {
|
|
139
|
+
return storage.mkdir(fileSystem.resolve(dirPath), options);
|
|
140
|
+
},
|
|
141
|
+
async exists(filePath: string): Promise<boolean> {
|
|
142
|
+
if (storage.exists) return storage.exists(fileSystem.resolve(filePath));
|
|
143
|
+
try {
|
|
144
|
+
await fileSystem.stat(filePath);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
resolvePath(filePath: string): Promise<string> {
|
|
151
|
+
const resolved = fileSystem.resolve(filePath);
|
|
152
|
+
return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
|
|
153
|
+
},
|
|
154
|
+
async openExclusive(filePath: string): Promise<BackendFileHandle> {
|
|
155
|
+
const resolved = fileSystem.resolve(filePath);
|
|
156
|
+
if (storage.openExclusive) return storage.openExclusive(resolved);
|
|
157
|
+
if (lockedPaths.has(resolved) || await fileSystem.exists(resolved)) {
|
|
158
|
+
throw createPathError('EEXIST', `Storage path already exists: ${resolved}`);
|
|
159
|
+
}
|
|
160
|
+
lockedPaths.add(resolved);
|
|
161
|
+
let closed = false;
|
|
162
|
+
return {
|
|
163
|
+
async writeFile(content: string): Promise<void> {
|
|
164
|
+
if (closed) throw createPathError('EBADF', `Storage lock handle is closed: ${resolved}`);
|
|
165
|
+
await fileSystem.mkdir(fileSystem.dirname(resolved), { recursive: true });
|
|
166
|
+
await storage.writeFile(resolved, content);
|
|
167
|
+
},
|
|
168
|
+
async close(): Promise<void> {
|
|
169
|
+
closed = true;
|
|
170
|
+
lockedPaths.delete(resolved);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
unlink(filePath: string): Promise<void> {
|
|
175
|
+
const resolved = fileSystem.resolve(filePath);
|
|
176
|
+
lockedPaths.delete(resolved);
|
|
177
|
+
if (storage.unlink) return storage.unlink(resolved);
|
|
178
|
+
throw createPathError('ENOTSUP', 'Storage adapter does not provide unlink().');
|
|
179
|
+
},
|
|
180
|
+
join(...paths: string[]): string {
|
|
181
|
+
return storage.join ? storage.join(...paths) : joinStoragePath(...paths);
|
|
182
|
+
},
|
|
183
|
+
dirname(filePath: string): string {
|
|
184
|
+
return storage.dirname ? storage.dirname(filePath) : dirnameStoragePath(filePath);
|
|
185
|
+
},
|
|
186
|
+
resolve(...paths: string[]): string {
|
|
187
|
+
return storage.resolve ? storage.resolve(...paths) : joinStoragePath(...paths);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return fileSystem;
|
|
192
|
+
}
|