@openfairygui/backend 0.2.0-alpha.0 → 0.2.0-alpha.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/LICENSE +21 -0
- package/README.md +24 -1
- package/dist/index.cjs +5 -1119
- package/dist/index.d.cts +2 -364
- package/dist/index.d.mts +2 -364
- package/dist/index.mjs +2 -1091
- package/dist/node.cjs +107 -0
- package/dist/node.d.cts +8 -0
- package/dist/node.d.mts +8 -0
- package/dist/node.mjs +78 -0
- package/dist/runtime-BL9Pkfc7.cjs +1217 -0
- package/dist/runtime-BPWzx-Tv.mjs +1193 -0
- package/dist/runtime-OO1sHuCl.d.mts +424 -0
- package/dist/runtime-qcSlD_9-.d.cts +424 -0
- package/package.json +65 -53
- package/src/index.ts +6 -1
- package/src/node.ts +96 -0
- package/src/runtime.ts +155 -80
- package/src/services/artifact-service.ts +11 -1
- package/src/services/authoring-service.ts +151 -37
- package/src/services/context.ts +14 -3
- package/src/services/event-service.ts +1 -1
- package/src/services/runtime-service.ts +155 -24
package/src/services/context.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
BackendEvent,
|
|
6
6
|
BackendFailure,
|
|
7
7
|
BackendFileSystem,
|
|
8
|
+
BackendHostAdapter,
|
|
8
9
|
BackendJobSnapshot,
|
|
9
10
|
BackendSessionSnapshot,
|
|
10
11
|
BackendSuccess,
|
|
@@ -24,7 +25,7 @@ export interface BackendSessionState {
|
|
|
24
25
|
canonicalProjectPath: string;
|
|
25
26
|
canonicalPathKey: string;
|
|
26
27
|
lockFilePath: string;
|
|
27
|
-
project: import('@openfairygui/core').UamProject;
|
|
28
|
+
project: import('@openfairygui/core/uam').UamProject;
|
|
28
29
|
revision: number;
|
|
29
30
|
lastSavedRevision: number;
|
|
30
31
|
dirty: boolean;
|
|
@@ -33,7 +34,8 @@ export interface BackendSessionState {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export interface BackendContext {
|
|
36
|
-
fileSystem
|
|
37
|
+
fileSystem?: BackendFileSystem;
|
|
38
|
+
host?: BackendHostAdapter;
|
|
37
39
|
capabilities: BackendCapabilities;
|
|
38
40
|
sessions: Map<string, BackendSessionState>;
|
|
39
41
|
sessionsByPath: Map<string, string>;
|
|
@@ -43,6 +45,14 @@ export interface BackendContext {
|
|
|
43
45
|
nextEventSequence: () => number;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function diagnosticFromError(error: BackendError): BackendDiagnostic {
|
|
49
|
+
return {
|
|
50
|
+
code: error.code,
|
|
51
|
+
message: error.message,
|
|
52
|
+
severity: 'error',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
function randomId(): string {
|
|
47
57
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
48
58
|
}
|
|
@@ -103,9 +113,10 @@ export function failure<E extends BackendError>(
|
|
|
103
113
|
diagnostics?: BackendDiagnostic[];
|
|
104
114
|
},
|
|
105
115
|
): BackendFailure<E> {
|
|
116
|
+
const diagnostics = options?.diagnostics ?? [diagnosticFromError(error)];
|
|
106
117
|
return {
|
|
107
118
|
ok: false,
|
|
108
|
-
meta: createMeta(stage, startedAt, options),
|
|
119
|
+
meta: createMeta(stage, startedAt, { ...options, diagnostics }),
|
|
109
120
|
error,
|
|
110
121
|
session,
|
|
111
122
|
};
|
|
@@ -51,7 +51,7 @@ export class EventService {
|
|
|
51
51
|
after: String(input.after),
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
|
-
if (events.length > 0 && after < oldestSequence - 1) {
|
|
54
|
+
if (events.length > 0 && after !== 0 && after < oldestSequence - 1) {
|
|
55
55
|
return failure('runtime', startedAt, {
|
|
56
56
|
code: 'event_cursor_invalid',
|
|
57
57
|
message: `Event cursor has expired: ${after}`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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';
|
|
@@ -8,7 +9,9 @@ import type {
|
|
|
8
9
|
BackendFileHandle,
|
|
9
10
|
BackendResult,
|
|
10
11
|
BackendSessionSnapshot,
|
|
12
|
+
BackendCapabilityUnavailableError,
|
|
11
13
|
InProcessLockConflictError,
|
|
14
|
+
OpenProjectSessionInput,
|
|
12
15
|
SessionNotFoundError,
|
|
13
16
|
} from '../runtime.js';
|
|
14
17
|
import { resolveCanonicalProjectRoot } from '../path-policy.js';
|
|
@@ -18,6 +21,59 @@ function randomId(): string {
|
|
|
18
21
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
function createCapabilityUnavailableError(
|
|
25
|
+
capability: BackendCapabilityUnavailableError['capability'],
|
|
26
|
+
): BackendCapabilityUnavailableError {
|
|
27
|
+
const artifactCapability = capability === 'artifact.publish' || capability === 'artifact.restore';
|
|
28
|
+
return {
|
|
29
|
+
code: 'capability_unavailable',
|
|
30
|
+
message: artifactCapability
|
|
31
|
+
? `${capability} requires the Node bridge boundary exposed by @openfairygui/backend/node.`
|
|
32
|
+
: `${capability} requires an injected BackendFileSystem adapter.`,
|
|
33
|
+
capability,
|
|
34
|
+
requiredAdapter: capability === 'fileSystem' ? 'BackendFileSystem' : undefined,
|
|
35
|
+
requiredHost: artifactCapability ? 'node' : undefined,
|
|
36
|
+
bridgeBoundary: artifactCapability ? 'external-bridge' : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createProjectReaderFileSystem(fileSystem: NonNullable<BackendContext['fileSystem']>): FileSystem {
|
|
41
|
+
return {
|
|
42
|
+
readFile(filePath: string): Promise<string> {
|
|
43
|
+
return fileSystem.readFile(filePath);
|
|
44
|
+
},
|
|
45
|
+
readFileRaw(filePath: string): Promise<Uint8Array> {
|
|
46
|
+
return fileSystem.readFileRaw(filePath);
|
|
47
|
+
},
|
|
48
|
+
writeFile(filePath: string, content: string): Promise<void> {
|
|
49
|
+
return fileSystem.writeFile(filePath, content);
|
|
50
|
+
},
|
|
51
|
+
writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
|
|
52
|
+
return fileSystem.writeFileRaw(filePath, data);
|
|
53
|
+
},
|
|
54
|
+
async mkdir(dirPath: string): Promise<void> {
|
|
55
|
+
await fileSystem.mkdir(dirPath, { recursive: true });
|
|
56
|
+
},
|
|
57
|
+
readdir(dirPath: string): Promise<string[]> {
|
|
58
|
+
return fileSystem.readdir(dirPath);
|
|
59
|
+
},
|
|
60
|
+
async exists(filePath: string): Promise<boolean> {
|
|
61
|
+
try {
|
|
62
|
+
await fileSystem.stat(filePath);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
join(...paths: string[]): string {
|
|
69
|
+
return fileSystem.join(...paths);
|
|
70
|
+
},
|
|
71
|
+
dirname(filePath: string): string {
|
|
72
|
+
return fileSystem.dirname(filePath);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
21
77
|
export class RuntimeService {
|
|
22
78
|
public constructor(
|
|
23
79
|
private readonly context: BackendContext,
|
|
@@ -26,12 +82,23 @@ export class RuntimeService {
|
|
|
26
82
|
private readonly jobService: JobService,
|
|
27
83
|
) {}
|
|
28
84
|
|
|
29
|
-
public async openSession(input: {
|
|
85
|
+
public async openSession(input: {
|
|
86
|
+
projectPath: string;
|
|
87
|
+
}): Promise<
|
|
88
|
+
BackendResult<
|
|
89
|
+
BackendSessionSnapshot,
|
|
90
|
+
InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError
|
|
91
|
+
>
|
|
92
|
+
> {
|
|
30
93
|
const startedAt = Date.now();
|
|
31
|
-
|
|
94
|
+
if (!this.context.fileSystem) {
|
|
95
|
+
return failure('runtime', startedAt, createCapabilityUnavailableError('fileSystem'));
|
|
96
|
+
}
|
|
97
|
+
const fileSystem = this.context.fileSystem;
|
|
98
|
+
const resolved = await resolveCanonicalProjectRoot(fileSystem, input.projectPath);
|
|
32
99
|
const { fairyPath, canonicalProjectPath, canonicalPathKey } = resolved;
|
|
33
100
|
const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
|
|
34
|
-
const lockFilePath =
|
|
101
|
+
const lockFilePath = fileSystem.join(canonicalProjectPath, '.openfairygui.backend.lock');
|
|
35
102
|
|
|
36
103
|
if (existingSessionId) {
|
|
37
104
|
return failure('runtime', startedAt, {
|
|
@@ -46,16 +113,23 @@ export class RuntimeService {
|
|
|
46
113
|
|
|
47
114
|
let advisoryLock: BackendFileHandle | null = null;
|
|
48
115
|
try {
|
|
49
|
-
advisoryLock = await
|
|
50
|
-
await advisoryLock.writeFile(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
116
|
+
advisoryLock = await fileSystem.openExclusive(lockFilePath);
|
|
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
|
+
);
|
|
55
129
|
await advisoryLock.close();
|
|
56
130
|
|
|
57
|
-
const
|
|
58
|
-
const project = await
|
|
131
|
+
const reader = new ProjectReader(createProjectReaderFileSystem(fileSystem));
|
|
132
|
+
const project = liftDocumentToUamProject(await reader.read(fairyPath));
|
|
59
133
|
const sessionId = randomId();
|
|
60
134
|
const session: BackendSessionState = {
|
|
61
135
|
sessionId,
|
|
@@ -80,7 +154,7 @@ export class RuntimeService {
|
|
|
80
154
|
revision: session.revision,
|
|
81
155
|
});
|
|
82
156
|
} catch (error) {
|
|
83
|
-
if (
|
|
157
|
+
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
|
|
84
158
|
return failure('runtime', startedAt, {
|
|
85
159
|
code: 'lock_conflict',
|
|
86
160
|
kind: 'advisory_lock_conflict',
|
|
@@ -91,35 +165,92 @@ export class RuntimeService {
|
|
|
91
165
|
}
|
|
92
166
|
if (advisoryLock) {
|
|
93
167
|
await advisoryLock.close().catch(() => undefined);
|
|
94
|
-
await
|
|
168
|
+
await fileSystem.unlink(lockFilePath).catch(() => undefined);
|
|
95
169
|
}
|
|
96
170
|
throw error;
|
|
97
171
|
}
|
|
98
172
|
}
|
|
99
173
|
|
|
100
|
-
public
|
|
101
|
-
|
|
102
|
-
|
|
174
|
+
public openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot> {
|
|
175
|
+
const startedAt = Date.now();
|
|
176
|
+
const sessionId = input.sessionId ?? randomId();
|
|
177
|
+
const canonicalProjectPath = input.canonicalProjectPath ?? `memory://${sessionId}`;
|
|
178
|
+
const canonicalPathKey = input.canonicalPathKey ?? canonicalProjectPath.toLowerCase();
|
|
179
|
+
const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
|
|
180
|
+
if (existingSessionId) {
|
|
181
|
+
return failure('runtime', startedAt, {
|
|
182
|
+
code: 'lock_conflict',
|
|
183
|
+
kind: 'in_process_session_exists',
|
|
184
|
+
message: `Project is already open in this backend runtime: ${canonicalProjectPath}`,
|
|
185
|
+
canonicalPathKey,
|
|
186
|
+
holderSessionId: existingSessionId,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const session: BackendSessionState = {
|
|
191
|
+
sessionId,
|
|
192
|
+
fairyPath: canonicalProjectPath,
|
|
193
|
+
canonicalProjectPath,
|
|
194
|
+
canonicalPathKey,
|
|
195
|
+
lockFilePath: '',
|
|
196
|
+
project: normalizeUamProject(input.project),
|
|
197
|
+
revision: 0,
|
|
198
|
+
lastSavedRevision: 0,
|
|
199
|
+
dirty: false,
|
|
200
|
+
lockHeld: false,
|
|
201
|
+
closed: false,
|
|
202
|
+
};
|
|
203
|
+
this.context.sessions.set(sessionId, session);
|
|
204
|
+
this.context.sessionsByPath.set(canonicalPathKey, sessionId);
|
|
205
|
+
this.cacheService.refreshSession(session);
|
|
206
|
+
this.eventService.emit({ kind: 'session.opened', sessionId, canonicalPathKey, revision: session.revision });
|
|
207
|
+
|
|
208
|
+
return success('runtime', startedAt, toSessionSnapshot(session, this.context.capabilities), {
|
|
209
|
+
sessionId: session.sessionId,
|
|
210
|
+
revision: session.revision,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public async closeSession(input: {
|
|
215
|
+
sessionId: string;
|
|
216
|
+
}): Promise<BackendResult<{ sessionId: string; closed: true }, SessionNotFoundError>> {
|
|
103
217
|
const startedAt = Date.now();
|
|
104
218
|
const session = this.context.sessions.get(input.sessionId);
|
|
105
219
|
if (!session || session.closed) {
|
|
106
220
|
return failure('runtime', startedAt, createSessionNotFoundError(input.sessionId));
|
|
107
221
|
}
|
|
108
222
|
|
|
109
|
-
this.eventService.emit({
|
|
110
|
-
|
|
223
|
+
this.eventService.emit({
|
|
224
|
+
kind: 'session.closeRequested',
|
|
225
|
+
sessionId: session.sessionId,
|
|
226
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
227
|
+
revision: session.revision,
|
|
228
|
+
});
|
|
229
|
+
if (this.context.fileSystem && session.lockFilePath) {
|
|
230
|
+
await this.context.fileSystem.unlink(session.lockFilePath).catch(() => undefined);
|
|
231
|
+
}
|
|
111
232
|
session.lockHeld = false;
|
|
112
233
|
session.closed = true;
|
|
113
234
|
this.context.sessions.delete(session.sessionId);
|
|
114
235
|
this.context.sessionsByPath.delete(session.canonicalPathKey);
|
|
115
236
|
this.cacheService.removeSession(session.sessionId);
|
|
116
237
|
this.jobService.removeSession(session.sessionId);
|
|
117
|
-
this.eventService.emit({
|
|
238
|
+
this.eventService.emit({
|
|
239
|
+
kind: 'session.closed',
|
|
240
|
+
sessionId: session.sessionId,
|
|
241
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
242
|
+
revision: session.revision,
|
|
243
|
+
});
|
|
118
244
|
this.eventService.removeSession(session.sessionId);
|
|
119
245
|
|
|
120
|
-
return success(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
246
|
+
return success(
|
|
247
|
+
'runtime',
|
|
248
|
+
startedAt,
|
|
249
|
+
{
|
|
250
|
+
sessionId: session.sessionId,
|
|
251
|
+
closed: true,
|
|
252
|
+
},
|
|
253
|
+
{ sessionId: session.sessionId, revision: session.revision },
|
|
254
|
+
);
|
|
124
255
|
}
|
|
125
256
|
}
|