@openfairygui/backend 0.2.0-alpha.0
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/README.md +59 -0
- package/dist/index.cjs +1120 -0
- package/dist/index.d.cts +364 -0
- package/dist/index.d.mts +364 -0
- package/dist/index.mjs +1091 -0
- package/package.json +54 -0
- package/src/contracts.ts +32 -0
- package/src/index.ts +50 -0
- package/src/path-policy.ts +105 -0
- package/src/runtime.ts +600 -0
- package/src/services/artifact-service.ts +9 -0
- package/src/services/authoring-service.ts +186 -0
- package/src/services/cache-service.ts +60 -0
- package/src/services/context.ts +112 -0
- package/src/services/event-service.ts +84 -0
- package/src/services/job-service.ts +239 -0
- package/src/services/read-service.ts +24 -0
- package/src/services/runtime-service.ts +125 -0
- package/src/services/session-utils.ts +42 -0
- package/src/services/snapshot-utils.ts +43 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { failure, success, type BackendContext } from './context.js';
|
|
2
|
+
import type { BackendCapabilities, BackendResult, BackendSessionSnapshot, SessionNotFoundError } from '../runtime.js';
|
|
3
|
+
import { createSessionNotFoundError, toSessionSnapshot } from './session-utils.js';
|
|
4
|
+
import { cloneCapabilitiesSnapshot } from './snapshot-utils.js';
|
|
5
|
+
|
|
6
|
+
export class ReadService {
|
|
7
|
+
public constructor(private readonly context: BackendContext) {}
|
|
8
|
+
|
|
9
|
+
public getCapabilities(): BackendResult<BackendCapabilities> {
|
|
10
|
+
return success('read', Date.now(), cloneCapabilitiesSnapshot(this.context.capabilities));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public getSession(input: { sessionId: string }): BackendResult<BackendSessionSnapshot, SessionNotFoundError> {
|
|
14
|
+
const startedAt = Date.now();
|
|
15
|
+
const session = this.context.sessions.get(input.sessionId);
|
|
16
|
+
if (!session || session.closed) {
|
|
17
|
+
return failure('read', startedAt, createSessionNotFoundError(input.sessionId));
|
|
18
|
+
}
|
|
19
|
+
return success('read', startedAt, toSessionSnapshot(session, this.context.capabilities), {
|
|
20
|
+
sessionId: session.sessionId,
|
|
21
|
+
revision: session.revision,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { NodeIO, readProjectAsUam } from '@openfairygui/core';
|
|
2
|
+
import { failure, success, type BackendContext, type BackendSessionState } from './context.js';
|
|
3
|
+
import type { CacheService } from './cache-service.js';
|
|
4
|
+
import type { EventService } from './event-service.js';
|
|
5
|
+
import type { JobService } from './job-service.js';
|
|
6
|
+
import type {
|
|
7
|
+
AdvisoryLockConflictError,
|
|
8
|
+
BackendFileHandle,
|
|
9
|
+
BackendResult,
|
|
10
|
+
BackendSessionSnapshot,
|
|
11
|
+
InProcessLockConflictError,
|
|
12
|
+
SessionNotFoundError,
|
|
13
|
+
} from '../runtime.js';
|
|
14
|
+
import { resolveCanonicalProjectRoot } from '../path-policy.js';
|
|
15
|
+
import { createSessionNotFoundError, toSessionSnapshot } from './session-utils.js';
|
|
16
|
+
|
|
17
|
+
function randomId(): string {
|
|
18
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RuntimeService {
|
|
22
|
+
public constructor(
|
|
23
|
+
private readonly context: BackendContext,
|
|
24
|
+
private readonly cacheService: CacheService,
|
|
25
|
+
private readonly eventService: EventService,
|
|
26
|
+
private readonly jobService: JobService,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
public async openSession(input: { projectPath: string }): Promise<BackendResult<BackendSessionSnapshot, InProcessLockConflictError | AdvisoryLockConflictError>> {
|
|
30
|
+
const startedAt = Date.now();
|
|
31
|
+
const resolved = await resolveCanonicalProjectRoot(this.context.fileSystem, input.projectPath);
|
|
32
|
+
const { fairyPath, canonicalProjectPath, canonicalPathKey } = resolved;
|
|
33
|
+
const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
|
|
34
|
+
const lockFilePath = this.context.fileSystem.join(canonicalProjectPath, '.openfairygui.backend.lock');
|
|
35
|
+
|
|
36
|
+
if (existingSessionId) {
|
|
37
|
+
return failure('runtime', startedAt, {
|
|
38
|
+
code: 'lock_conflict',
|
|
39
|
+
kind: 'in_process_session_exists',
|
|
40
|
+
message: `Project is already open in this backend runtime: ${canonicalProjectPath}`,
|
|
41
|
+
canonicalPathKey,
|
|
42
|
+
holderSessionId: existingSessionId,
|
|
43
|
+
lockFilePath,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let advisoryLock: BackendFileHandle | null = null;
|
|
48
|
+
try {
|
|
49
|
+
advisoryLock = await this.context.fileSystem.openExclusive(lockFilePath);
|
|
50
|
+
await advisoryLock.writeFile(JSON.stringify({
|
|
51
|
+
pid: process.pid,
|
|
52
|
+
createdAt: new Date().toISOString(),
|
|
53
|
+
canonicalPathKey,
|
|
54
|
+
}));
|
|
55
|
+
await advisoryLock.close();
|
|
56
|
+
|
|
57
|
+
const io = new NodeIO();
|
|
58
|
+
const project = await readProjectAsUam(io, fairyPath);
|
|
59
|
+
const sessionId = randomId();
|
|
60
|
+
const session: BackendSessionState = {
|
|
61
|
+
sessionId,
|
|
62
|
+
fairyPath,
|
|
63
|
+
canonicalProjectPath,
|
|
64
|
+
canonicalPathKey,
|
|
65
|
+
lockFilePath,
|
|
66
|
+
project,
|
|
67
|
+
revision: 0,
|
|
68
|
+
lastSavedRevision: 0,
|
|
69
|
+
dirty: false,
|
|
70
|
+
lockHeld: true,
|
|
71
|
+
closed: false,
|
|
72
|
+
};
|
|
73
|
+
this.context.sessions.set(sessionId, session);
|
|
74
|
+
this.context.sessionsByPath.set(canonicalPathKey, sessionId);
|
|
75
|
+
this.cacheService.refreshSession(session);
|
|
76
|
+
this.eventService.emit({ kind: 'session.opened', sessionId, canonicalPathKey, revision: session.revision });
|
|
77
|
+
|
|
78
|
+
return success('runtime', startedAt, toSessionSnapshot(session, this.context.capabilities), {
|
|
79
|
+
sessionId: session.sessionId,
|
|
80
|
+
revision: session.revision,
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if ((error as NodeJS.ErrnoException)?.code === 'EEXIST') {
|
|
84
|
+
return failure('runtime', startedAt, {
|
|
85
|
+
code: 'lock_conflict',
|
|
86
|
+
kind: 'advisory_lock_conflict',
|
|
87
|
+
message: `Advisory lock already exists for project: ${canonicalProjectPath}`,
|
|
88
|
+
canonicalPathKey,
|
|
89
|
+
lockFilePath,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (advisoryLock) {
|
|
93
|
+
await advisoryLock.close().catch(() => undefined);
|
|
94
|
+
await this.context.fileSystem.unlink(lockFilePath).catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public async closeSession(
|
|
101
|
+
input: { sessionId: string },
|
|
102
|
+
): Promise<BackendResult<{ sessionId: string; closed: true }, SessionNotFoundError>> {
|
|
103
|
+
const startedAt = Date.now();
|
|
104
|
+
const session = this.context.sessions.get(input.sessionId);
|
|
105
|
+
if (!session || session.closed) {
|
|
106
|
+
return failure('runtime', startedAt, createSessionNotFoundError(input.sessionId));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.eventService.emit({ kind: 'session.closeRequested', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
|
|
110
|
+
await this.context.fileSystem.unlink(session.lockFilePath).catch(() => undefined);
|
|
111
|
+
session.lockHeld = false;
|
|
112
|
+
session.closed = true;
|
|
113
|
+
this.context.sessions.delete(session.sessionId);
|
|
114
|
+
this.context.sessionsByPath.delete(session.canonicalPathKey);
|
|
115
|
+
this.cacheService.removeSession(session.sessionId);
|
|
116
|
+
this.jobService.removeSession(session.sessionId);
|
|
117
|
+
this.eventService.emit({ kind: 'session.closed', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
|
|
118
|
+
this.eventService.removeSession(session.sessionId);
|
|
119
|
+
|
|
120
|
+
return success('runtime', startedAt, {
|
|
121
|
+
sessionId: session.sessionId,
|
|
122
|
+
closed: true,
|
|
123
|
+
}, { sessionId: session.sessionId, revision: session.revision });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BackendCapabilities,
|
|
3
|
+
BackendSessionSnapshot,
|
|
4
|
+
SessionNotFoundError,
|
|
5
|
+
SessionStaleWriteError,
|
|
6
|
+
} from '../runtime.js';
|
|
7
|
+
import type { BackendSessionState } from './context.js';
|
|
8
|
+
import { cloneCapabilitiesSnapshot } from './snapshot-utils.js';
|
|
9
|
+
|
|
10
|
+
export function toSessionSnapshot(session: BackendSessionState, capabilities: BackendCapabilities): BackendSessionSnapshot {
|
|
11
|
+
return {
|
|
12
|
+
sessionId: session.sessionId,
|
|
13
|
+
canonicalProjectPath: session.canonicalProjectPath,
|
|
14
|
+
revision: session.revision,
|
|
15
|
+
lastSavedRevision: session.lastSavedRevision,
|
|
16
|
+
dirty: session.dirty,
|
|
17
|
+
lockHeld: session.lockHeld,
|
|
18
|
+
capabilities: cloneCapabilitiesSnapshot(capabilities),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSessionNotFoundError(sessionId: string): SessionNotFoundError {
|
|
23
|
+
return {
|
|
24
|
+
code: 'session_not_found',
|
|
25
|
+
message: `Session was not found: ${sessionId}`,
|
|
26
|
+
sessionId,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createStaleWriteError(
|
|
31
|
+
session: Pick<BackendSessionState, 'sessionId' | 'canonicalPathKey' | 'revision'>,
|
|
32
|
+
expectedRevision: number,
|
|
33
|
+
): SessionStaleWriteError {
|
|
34
|
+
return {
|
|
35
|
+
code: 'stale_write',
|
|
36
|
+
message: `Expected revision ${expectedRevision} does not match current revision ${session.revision}.`,
|
|
37
|
+
sessionId: session.sessionId,
|
|
38
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
39
|
+
expectedRevision,
|
|
40
|
+
actualRevision: session.revision,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BackendCacheEntry,
|
|
3
|
+
BackendCapabilities,
|
|
4
|
+
BackendEvent,
|
|
5
|
+
BackendJobSnapshot,
|
|
6
|
+
} from '../runtime.js';
|
|
7
|
+
|
|
8
|
+
function cloneJsonValue<T>(value: T): T {
|
|
9
|
+
if (value === undefined || value === null) return value;
|
|
10
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function cloneEventSnapshot(event: BackendEvent): BackendEvent {
|
|
14
|
+
return {
|
|
15
|
+
...event,
|
|
16
|
+
diagnostics: event.diagnostics.map((diagnostic) => ({ ...diagnostic })),
|
|
17
|
+
payload: cloneJsonValue(event.payload),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function cloneJobSnapshot(job: BackendJobSnapshot): BackendJobSnapshot {
|
|
22
|
+
return {
|
|
23
|
+
...job,
|
|
24
|
+
diagnostics: job.diagnostics.map((diagnostic) => ({ ...diagnostic })),
|
|
25
|
+
progress: job.progress ? { ...job.progress } : undefined,
|
|
26
|
+
result: cloneJsonValue(job.result),
|
|
27
|
+
error: cloneJsonValue(job.error),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function cloneCacheEntrySnapshot(entry: BackendCacheEntry): BackendCacheEntry {
|
|
32
|
+
return {
|
|
33
|
+
...entry,
|
|
34
|
+
summary: {
|
|
35
|
+
...entry.summary,
|
|
36
|
+
diagnostics: entry.summary.diagnostics.map((diagnostic) => ({ ...diagnostic })),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function cloneCapabilitiesSnapshot(capabilities: BackendCapabilities): BackendCapabilities {
|
|
42
|
+
return cloneJsonValue(capabilities);
|
|
43
|
+
}
|