@openfairygui/backend 0.2.0-alpha.2 → 0.2.0-alpha.21

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.
@@ -1,3 +1,11 @@
1
+ import {
2
+ BACKEND_CAPABILITY_SCHEMA_VERSION,
3
+ BACKEND_CONTRACT_VERSION,
4
+ type BackendDiagnostic,
5
+ type BackendMessage,
6
+ type BackendResponseMeta,
7
+ type BackendStage,
8
+ } from '../contracts.js';
1
9
  import type {
2
10
  BackendCacheEntry,
3
11
  BackendCapabilities,
@@ -10,14 +18,6 @@ import type {
10
18
  BackendSessionSnapshot,
11
19
  BackendSuccess,
12
20
  } from '../runtime.js';
13
- import {
14
- BACKEND_CAPABILITY_SCHEMA_VERSION,
15
- BACKEND_CONTRACT_VERSION,
16
- type BackendDiagnostic,
17
- type BackendMessage,
18
- type BackendResponseMeta,
19
- type BackendStage,
20
- } from '../contracts.js';
21
21
 
22
22
  export interface BackendSessionState {
23
23
  sessionId: string;
@@ -25,9 +25,13 @@ export interface BackendSessionState {
25
25
  canonicalProjectPath: string;
26
26
  canonicalPathKey: string;
27
27
  lockFilePath: string;
28
+ fileSystem?: BackendFileSystem;
28
29
  project: import('@openfairygui/core/uam').UamProject;
30
+ uamFidelity: 'full' | 'unsupported';
29
31
  revision: number;
30
32
  lastSavedRevision: number;
33
+ /** Package-controlled files deferred until a successful replacement project write. */
34
+ pendingStaleSourceFiles: Map<string, import('@openfairygui/core/project-io').ProjectSourceFile>;
31
35
  dirty: boolean;
32
36
  lockHeld: boolean;
33
37
  closed: boolean;
@@ -1,20 +1,25 @@
1
- import { ProjectReader, type FileSystem } from '@openfairygui/core/project-io';
2
- import { liftDocumentToUamProject, normalizeUamProject } from '@openfairygui/core/uam';
3
- import { failure, success, type BackendContext, type BackendSessionState } from './context.js';
4
- import type { CacheService } from './cache-service.js';
5
- import type { EventService } from './event-service.js';
6
- import type { JobService } from './job-service.js';
1
+ import { type FileSystem, ProjectReader, ProjectWriter } from '@openfairygui/core/project-io';
2
+ import {
3
+ liftDocumentToUamProject,
4
+ materializeUamProject,
5
+ normalizeUamProject,
6
+ type UamProject,
7
+ } from '@openfairygui/core/uam';
8
+ import { normalizeComparablePath, resolveCanonicalProjectRoot } from '../path-policy.js';
7
9
  import type {
8
10
  AdvisoryLockConflictError,
11
+ BackendCapabilityUnavailableError,
9
12
  BackendFileHandle,
10
13
  BackendResult,
11
14
  BackendSessionSnapshot,
12
- BackendCapabilityUnavailableError,
13
15
  InProcessLockConflictError,
14
16
  OpenProjectSessionInput,
15
17
  SessionNotFoundError,
16
18
  } from '../runtime.js';
17
- import { resolveCanonicalProjectRoot } from '../path-policy.js';
19
+ import type { CacheService } from './cache-service.js';
20
+ import { type BackendContext, type BackendSessionState, failure, success } from './context.js';
21
+ import type { EventService } from './event-service.js';
22
+ import type { JobService } from './job-service.js';
18
23
  import { createSessionNotFoundError, toSessionSnapshot } from './session-utils.js';
19
24
 
20
25
  function randomId(): string {
@@ -74,6 +79,82 @@ function createProjectReaderFileSystem(fileSystem: NonNullable<BackendContext['f
74
79
  };
75
80
  }
76
81
 
82
+ function createCaptureFileSystem(files: Map<string, string | Uint8Array>): FileSystem {
83
+ const normalize = (filePath: string): string => filePath.replace(/\\/g, '/').replace(/\/+/g, '/');
84
+ return {
85
+ async readFile(filePath: string): Promise<string> {
86
+ const value = files.get(normalize(filePath));
87
+ if (typeof value !== 'string') throw new Error(`Captured text file was not found: ${filePath}`);
88
+ return value;
89
+ },
90
+ async readFileRaw(filePath: string): Promise<Uint8Array> {
91
+ const value = files.get(normalize(filePath));
92
+ if (!(value instanceof Uint8Array)) throw new Error(`Captured binary file was not found: ${filePath}`);
93
+ return value.slice();
94
+ },
95
+ async writeFile(filePath: string, content: string): Promise<void> {
96
+ files.set(normalize(filePath), content);
97
+ },
98
+ async writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
99
+ files.set(normalize(filePath), data.slice());
100
+ },
101
+ async mkdir(): Promise<void> {},
102
+ async readdir(): Promise<string[]> {
103
+ return [];
104
+ },
105
+ async exists(filePath: string): Promise<boolean> {
106
+ return files.has(normalize(filePath));
107
+ },
108
+ join(...paths: string[]): string {
109
+ return normalize(paths.filter(Boolean).join('/'));
110
+ },
111
+ dirname(filePath: string): string {
112
+ const normalized = normalize(filePath);
113
+ const separator = normalized.lastIndexOf('/');
114
+ return separator < 0 ? '' : normalized.slice(0, separator);
115
+ },
116
+ async unlink(filePath: string): Promise<void> {
117
+ files.delete(normalize(filePath));
118
+ },
119
+ };
120
+ }
121
+
122
+ function capturedFilesEqual(left: Map<string, string | Uint8Array>, right: Map<string, string | Uint8Array>): boolean {
123
+ if (left.size !== right.size) return false;
124
+ for (const [filePath, leftValue] of left) {
125
+ const rightValue = right.get(filePath);
126
+ if (typeof leftValue === 'string') {
127
+ if (leftValue !== rightValue) return false;
128
+ continue;
129
+ }
130
+ if (!(rightValue instanceof Uint8Array) || leftValue.length !== rightValue.length) return false;
131
+ for (let index = 0; index < leftValue.length; index += 1) {
132
+ if (leftValue[index] !== rightValue[index]) return false;
133
+ }
134
+ }
135
+ return true;
136
+ }
137
+
138
+ async function hasFullUamFidelity(
139
+ document: Awaited<ReturnType<ProjectReader['read']>>,
140
+ project: UamProject,
141
+ ): Promise<boolean> {
142
+ const sourceFiles = new Map<string, string | Uint8Array>();
143
+ const materializedFiles = new Map<string, string | Uint8Array>();
144
+ try {
145
+ await Promise.all([
146
+ new ProjectWriter(createCaptureFileSystem(sourceFiles)).write(document, 'Project.fairy'),
147
+ new ProjectWriter(createCaptureFileSystem(materializedFiles)).write(
148
+ materializeUamProject(project),
149
+ 'Project.fairy',
150
+ ),
151
+ ]);
152
+ } catch {
153
+ return false;
154
+ }
155
+ return capturedFilesEqual(sourceFiles, materializedFiles);
156
+ }
157
+
77
158
  export class RuntimeService {
78
159
  public constructor(
79
160
  private readonly context: BackendContext,
@@ -129,7 +210,8 @@ export class RuntimeService {
129
210
  await advisoryLock.close();
130
211
 
131
212
  const reader = new ProjectReader(createProjectReaderFileSystem(fileSystem));
132
- const project = liftDocumentToUamProject(await reader.read(fairyPath));
213
+ const document = await reader.read(fairyPath, { hydrateResourceBytes: true });
214
+ const project = liftDocumentToUamProject(document);
133
215
  const sessionId = randomId();
134
216
  const session: BackendSessionState = {
135
217
  sessionId,
@@ -137,9 +219,12 @@ export class RuntimeService {
137
219
  canonicalProjectPath,
138
220
  canonicalPathKey,
139
221
  lockFilePath,
222
+ fileSystem,
140
223
  project,
224
+ uamFidelity: (await hasFullUamFidelity(document, project)) ? 'full' : 'unsupported',
141
225
  revision: 0,
142
226
  lastSavedRevision: 0,
227
+ pendingStaleSourceFiles: new Map(),
143
228
  dirty: false,
144
229
  lockHeld: true,
145
230
  closed: false,
@@ -174,8 +259,16 @@ export class RuntimeService {
174
259
  public openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot> {
175
260
  const startedAt = Date.now();
176
261
  const sessionId = input.sessionId ?? randomId();
177
- const canonicalProjectPath = input.canonicalProjectPath ?? `memory://${sessionId}`;
178
- const canonicalPathKey = input.canonicalPathKey ?? canonicalProjectPath.toLowerCase();
262
+ const storage = input.storage;
263
+ const memoryProjectPath = `memory://${sessionId}`;
264
+ const canonicalProjectPath =
265
+ storage?.canonicalProjectPath ??
266
+ input.canonicalProjectPath ??
267
+ (storage ? storage.fileSystem.dirname(storage.fairyPath) || '.' : memoryProjectPath);
268
+ const canonicalPathKey =
269
+ storage?.canonicalPathKey ??
270
+ input.canonicalPathKey ??
271
+ (storage ? normalizeComparablePath(canonicalProjectPath) : canonicalProjectPath.toLowerCase());
179
272
  const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
180
273
  if (existingSessionId) {
181
274
  return failure('runtime', startedAt, {
@@ -189,13 +282,16 @@ export class RuntimeService {
189
282
 
190
283
  const session: BackendSessionState = {
191
284
  sessionId,
192
- fairyPath: canonicalProjectPath,
285
+ fairyPath: storage?.fairyPath ?? canonicalProjectPath,
193
286
  canonicalProjectPath,
194
287
  canonicalPathKey,
195
288
  lockFilePath: '',
289
+ fileSystem: storage?.fileSystem,
196
290
  project: normalizeUamProject(input.project),
291
+ uamFidelity: 'full',
197
292
  revision: 0,
198
293
  lastSavedRevision: 0,
294
+ pendingStaleSourceFiles: new Map(),
199
295
  dirty: false,
200
296
  lockHeld: false,
201
297
  closed: false,
@@ -0,0 +1,64 @@
1
+ import type { Document } from '@openfairygui/core';
2
+ import { type FileSystem, type ProjectSourceFile, ProjectWriter } from '@openfairygui/core/project-io';
3
+ import type { BackendFileSystem } from '../runtime.js';
4
+
5
+ function createWriterFileSystem(
6
+ fileSystem: BackendFileSystem,
7
+ writtenPaths: string[],
8
+ failedPaths: string[],
9
+ ): FileSystem {
10
+ async function trackWrite<T>(targetPath: string, write: () => Promise<T>): Promise<T> {
11
+ try {
12
+ const result = await write();
13
+ writtenPaths.push(targetPath);
14
+ return result;
15
+ } catch (error) {
16
+ failedPaths.push(targetPath);
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ return {
22
+ readFile: (path) => fileSystem.readFile(path),
23
+ readFileRaw: (path) => fileSystem.readFileRaw(path),
24
+ writeFile: (path, content) =>
25
+ trackWrite(path, async () => {
26
+ await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
27
+ await fileSystem.writeFile(path, content);
28
+ }),
29
+ writeFileRaw: (path, data) =>
30
+ trackWrite(path, async () => {
31
+ await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
32
+ await fileSystem.writeFileRaw(path, data);
33
+ }),
34
+ mkdir: (path) => fileSystem.mkdir(path, { recursive: true }),
35
+ readdir: (path) => fileSystem.readdir(path),
36
+ async exists(path): Promise<boolean> {
37
+ try {
38
+ await fileSystem.stat(path);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ },
44
+ join: (...paths) => fileSystem.join(...paths),
45
+ dirname: (path) => fileSystem.dirname(path),
46
+ unlink: (path) => trackWrite(path, () => fileSystem.unlink(path)),
47
+ };
48
+ }
49
+
50
+ export async function writeSessionProject(input: {
51
+ fileSystem: BackendFileSystem;
52
+ document: Document;
53
+ fairyPath: string;
54
+ staleSourceFiles: ProjectSourceFile[];
55
+ writtenPaths: string[];
56
+ failedPaths: string[];
57
+ }): Promise<void> {
58
+ const writer = new ProjectWriter(
59
+ createWriterFileSystem(input.fileSystem, input.writtenPaths, input.failedPaths),
60
+ );
61
+ await writer.write(input.document, input.fairyPath, {
62
+ staleSourceFiles: input.staleSourceFiles,
63
+ });
64
+ }
@@ -7,13 +7,17 @@ import type {
7
7
  import type { BackendSessionState } from './context.js';
8
8
  import { cloneCapabilitiesSnapshot } from './snapshot-utils.js';
9
9
 
10
- export function toSessionSnapshot(session: BackendSessionState, capabilities: BackendCapabilities): BackendSessionSnapshot {
10
+ export function toSessionSnapshot(
11
+ session: BackendSessionState,
12
+ capabilities: BackendCapabilities,
13
+ ): BackendSessionSnapshot {
11
14
  return {
12
15
  sessionId: session.sessionId,
13
16
  canonicalProjectPath: session.canonicalProjectPath,
14
17
  revision: session.revision,
15
18
  lastSavedRevision: session.lastSavedRevision,
16
19
  dirty: session.dirty,
20
+ uamFidelity: session.uamFidelity,
17
21
  lockHeld: session.lockHeld,
18
22
  capabilities: cloneCapabilitiesSnapshot(capabilities),
19
23
  };
package/src/storage.ts ADDED
@@ -0,0 +1,194 @@
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
+ if (typeof storage.unlink !== 'function') {
118
+ throw createPathError('ENOTSUP', 'Storage adapter must provide unlink() for project resource lifecycle writes.');
119
+ }
120
+ const lockedPaths = new Set<string>();
121
+
122
+ const fileSystem: BackendStorageFileSystem = {
123
+ stat(filePath: string): Promise<BackendFileStat> {
124
+ return inferStat(storage, fileSystem.resolve(filePath));
125
+ },
126
+ readdir(dirPath: string): Promise<string[]> {
127
+ return storage.readdir(fileSystem.resolve(dirPath));
128
+ },
129
+ readFile(filePath: string): Promise<string> {
130
+ return storage.readFile(fileSystem.resolve(filePath));
131
+ },
132
+ readFileRaw(filePath: string): Promise<Uint8Array> {
133
+ return storage.readFileRaw(fileSystem.resolve(filePath));
134
+ },
135
+ writeFile(filePath: string, content: string): Promise<void> {
136
+ return storage.writeFile(fileSystem.resolve(filePath), content);
137
+ },
138
+ writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
139
+ return storage.writeFileRaw(fileSystem.resolve(filePath), data);
140
+ },
141
+ mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void> {
142
+ return storage.mkdir(fileSystem.resolve(dirPath), options);
143
+ },
144
+ async exists(filePath: string): Promise<boolean> {
145
+ if (storage.exists) return storage.exists(fileSystem.resolve(filePath));
146
+ try {
147
+ await fileSystem.stat(filePath);
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
152
+ },
153
+ resolvePath(filePath: string): Promise<string> {
154
+ const resolved = fileSystem.resolve(filePath);
155
+ return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
156
+ },
157
+ async openExclusive(filePath: string): Promise<BackendFileHandle> {
158
+ const resolved = fileSystem.resolve(filePath);
159
+ if (storage.openExclusive) return storage.openExclusive(resolved);
160
+ if (lockedPaths.has(resolved) || await fileSystem.exists(resolved)) {
161
+ throw createPathError('EEXIST', `Storage path already exists: ${resolved}`);
162
+ }
163
+ lockedPaths.add(resolved);
164
+ let closed = false;
165
+ return {
166
+ async writeFile(content: string): Promise<void> {
167
+ if (closed) throw createPathError('EBADF', `Storage lock handle is closed: ${resolved}`);
168
+ await fileSystem.mkdir(fileSystem.dirname(resolved), { recursive: true });
169
+ await storage.writeFile(resolved, content);
170
+ },
171
+ async close(): Promise<void> {
172
+ closed = true;
173
+ lockedPaths.delete(resolved);
174
+ },
175
+ };
176
+ },
177
+ unlink(filePath: string): Promise<void> {
178
+ const resolved = fileSystem.resolve(filePath);
179
+ lockedPaths.delete(resolved);
180
+ return storage.unlink(resolved);
181
+ },
182
+ join(...paths: string[]): string {
183
+ return storage.join ? storage.join(...paths) : joinStoragePath(...paths);
184
+ },
185
+ dirname(filePath: string): string {
186
+ return storage.dirname ? storage.dirname(filePath) : dirnameStoragePath(filePath);
187
+ },
188
+ resolve(...paths: string[]): string {
189
+ return storage.resolve ? storage.resolve(...paths) : joinStoragePath(...paths);
190
+ },
191
+ };
192
+
193
+ return fileSystem;
194
+ }