@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.
@@ -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: BackendFileSystem;
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 { NodeIO, readProjectAsUam } from '@openfairygui/core';
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: { projectPath: string }): Promise<BackendResult<BackendSessionSnapshot, InProcessLockConflictError | AdvisoryLockConflictError>> {
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
- const resolved = await resolveCanonicalProjectRoot(this.context.fileSystem, input.projectPath);
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 = this.context.fileSystem.join(canonicalProjectPath, '.openfairygui.backend.lock');
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 this.context.fileSystem.openExclusive(lockFilePath);
50
- await advisoryLock.writeFile(JSON.stringify({
51
- pid: process.pid,
52
- createdAt: new Date().toISOString(),
53
- canonicalPathKey,
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 io = new NodeIO();
58
- const project = await readProjectAsUam(io, fairyPath);
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 ((error as NodeJS.ErrnoException)?.code === 'EEXIST') {
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 this.context.fileSystem.unlink(lockFilePath).catch(() => undefined);
168
+ await fileSystem.unlink(lockFilePath).catch(() => undefined);
95
169
  }
96
170
  throw error;
97
171
  }
98
172
  }
99
173
 
100
- public async closeSession(
101
- input: { sessionId: string },
102
- ): Promise<BackendResult<{ sessionId: string; closed: true }, SessionNotFoundError>> {
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({ kind: 'session.closeRequested', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
110
- await this.context.fileSystem.unlink(session.lockFilePath).catch(() => undefined);
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({ kind: 'session.closed', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
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('runtime', startedAt, {
121
- sessionId: session.sessionId,
122
- closed: true,
123
- }, { sessionId: session.sessionId, revision: session.revision });
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
  }