@openfairygui/backend 0.2.0-alpha.3 → 0.2.0-alpha.30

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,15 @@ 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>;
35
+ /** Empty resource directories deferred until a successful replacement project write. */
36
+ pendingStaleResourceFolders: Map<string, import('@openfairygui/core/project-io').ProjectResourceFolder>;
31
37
  dirty: boolean;
32
38
  lockHeld: boolean;
33
39
  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,94 @@ function createProjectReaderFileSystem(fileSystem: NonNullable<BackendContext['f
74
79
  };
75
80
  }
76
81
 
82
+ function createCaptureFileSystem(
83
+ files: Map<string, string | Uint8Array>,
84
+ directories: Set<string>,
85
+ ): FileSystem {
86
+ const normalize = (filePath: string): string => filePath.replace(/\\/g, '/').replace(/\/+/g, '/');
87
+ return {
88
+ async readFile(filePath: string): Promise<string> {
89
+ const value = files.get(normalize(filePath));
90
+ if (typeof value !== 'string') throw new Error(`Captured text file was not found: ${filePath}`);
91
+ return value;
92
+ },
93
+ async readFileRaw(filePath: string): Promise<Uint8Array> {
94
+ const value = files.get(normalize(filePath));
95
+ if (!(value instanceof Uint8Array)) throw new Error(`Captured binary file was not found: ${filePath}`);
96
+ return value.slice();
97
+ },
98
+ async writeFile(filePath: string, content: string): Promise<void> {
99
+ files.set(normalize(filePath), content);
100
+ },
101
+ async writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
102
+ files.set(normalize(filePath), data.slice());
103
+ },
104
+ async mkdir(dirPath: string): Promise<void> {
105
+ directories.add(normalize(dirPath));
106
+ },
107
+ async readdir(): Promise<string[]> {
108
+ return [];
109
+ },
110
+ async exists(filePath: string): Promise<boolean> {
111
+ return files.has(normalize(filePath));
112
+ },
113
+ join(...paths: string[]): string {
114
+ return normalize(paths.filter(Boolean).join('/'));
115
+ },
116
+ dirname(filePath: string): string {
117
+ const normalized = normalize(filePath);
118
+ const separator = normalized.lastIndexOf('/');
119
+ return separator < 0 ? '' : normalized.slice(0, separator);
120
+ },
121
+ async unlink(filePath: string): Promise<void> {
122
+ files.delete(normalize(filePath));
123
+ },
124
+ };
125
+ }
126
+
127
+ function capturedFilesEqual(left: Map<string, string | Uint8Array>, right: Map<string, string | Uint8Array>): boolean {
128
+ if (left.size !== right.size) return false;
129
+ for (const [filePath, leftValue] of left) {
130
+ const rightValue = right.get(filePath);
131
+ if (typeof leftValue === 'string') {
132
+ if (leftValue !== rightValue) return false;
133
+ continue;
134
+ }
135
+ if (!(rightValue instanceof Uint8Array) || leftValue.length !== rightValue.length) return false;
136
+ for (let index = 0; index < leftValue.length; index += 1) {
137
+ if (leftValue[index] !== rightValue[index]) return false;
138
+ }
139
+ }
140
+ return true;
141
+ }
142
+
143
+ function capturedDirectoriesEqual(left: Set<string>, right: Set<string>): boolean {
144
+ return left.size === right.size && [...left].every((directory) => right.has(directory));
145
+ }
146
+
147
+ async function hasFullUamFidelity(
148
+ document: Awaited<ReturnType<ProjectReader['read']>>,
149
+ project: UamProject,
150
+ ): Promise<boolean> {
151
+ const sourceFiles = new Map<string, string | Uint8Array>();
152
+ const materializedFiles = new Map<string, string | Uint8Array>();
153
+ const sourceDirectories = new Set<string>();
154
+ const materializedDirectories = new Set<string>();
155
+ try {
156
+ await Promise.all([
157
+ new ProjectWriter(createCaptureFileSystem(sourceFiles, sourceDirectories)).write(document, 'Project.fairy'),
158
+ new ProjectWriter(createCaptureFileSystem(materializedFiles, materializedDirectories)).write(
159
+ materializeUamProject(project),
160
+ 'Project.fairy',
161
+ ),
162
+ ]);
163
+ } catch {
164
+ return false;
165
+ }
166
+ return capturedFilesEqual(sourceFiles, materializedFiles)
167
+ && capturedDirectoriesEqual(sourceDirectories, materializedDirectories);
168
+ }
169
+
77
170
  export class RuntimeService {
78
171
  public constructor(
79
172
  private readonly context: BackendContext,
@@ -129,7 +222,8 @@ export class RuntimeService {
129
222
  await advisoryLock.close();
130
223
 
131
224
  const reader = new ProjectReader(createProjectReaderFileSystem(fileSystem));
132
- const project = liftDocumentToUamProject(await reader.read(fairyPath));
225
+ const document = await reader.read(fairyPath, { hydrateResourceBytes: true });
226
+ const project = liftDocumentToUamProject(document);
133
227
  const sessionId = randomId();
134
228
  const session: BackendSessionState = {
135
229
  sessionId,
@@ -137,9 +231,13 @@ export class RuntimeService {
137
231
  canonicalProjectPath,
138
232
  canonicalPathKey,
139
233
  lockFilePath,
234
+ fileSystem,
140
235
  project,
236
+ uamFidelity: (await hasFullUamFidelity(document, project)) ? 'full' : 'unsupported',
141
237
  revision: 0,
142
238
  lastSavedRevision: 0,
239
+ pendingStaleSourceFiles: new Map(),
240
+ pendingStaleResourceFolders: new Map(),
143
241
  dirty: false,
144
242
  lockHeld: true,
145
243
  closed: false,
@@ -174,8 +272,16 @@ export class RuntimeService {
174
272
  public openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot> {
175
273
  const startedAt = Date.now();
176
274
  const sessionId = input.sessionId ?? randomId();
177
- const canonicalProjectPath = input.canonicalProjectPath ?? `memory://${sessionId}`;
178
- const canonicalPathKey = input.canonicalPathKey ?? canonicalProjectPath.toLowerCase();
275
+ const storage = input.storage;
276
+ const memoryProjectPath = `memory://${sessionId}`;
277
+ const canonicalProjectPath =
278
+ storage?.canonicalProjectPath ??
279
+ input.canonicalProjectPath ??
280
+ (storage ? storage.fileSystem.dirname(storage.fairyPath) || '.' : memoryProjectPath);
281
+ const canonicalPathKey =
282
+ storage?.canonicalPathKey ??
283
+ input.canonicalPathKey ??
284
+ (storage ? normalizeComparablePath(canonicalProjectPath) : canonicalProjectPath.toLowerCase());
179
285
  const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
180
286
  if (existingSessionId) {
181
287
  return failure('runtime', startedAt, {
@@ -189,13 +295,17 @@ export class RuntimeService {
189
295
 
190
296
  const session: BackendSessionState = {
191
297
  sessionId,
192
- fairyPath: canonicalProjectPath,
298
+ fairyPath: storage?.fairyPath ?? canonicalProjectPath,
193
299
  canonicalProjectPath,
194
300
  canonicalPathKey,
195
301
  lockFilePath: '',
302
+ fileSystem: storage?.fileSystem,
196
303
  project: normalizeUamProject(input.project),
304
+ uamFidelity: 'full',
197
305
  revision: 0,
198
306
  lastSavedRevision: 0,
307
+ pendingStaleSourceFiles: new Map(),
308
+ pendingStaleResourceFolders: new Map(),
199
309
  dirty: false,
200
310
  lockHeld: false,
201
311
  closed: false,
@@ -0,0 +1,67 @@
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
+ rmdir: (path) => trackWrite(path, () => fileSystem.rmdir(path)),
48
+ };
49
+ }
50
+
51
+ export async function writeSessionProject(input: {
52
+ fileSystem: BackendFileSystem;
53
+ document: Document;
54
+ fairyPath: string;
55
+ staleSourceFiles: ProjectSourceFile[];
56
+ staleResourceFolders: import('@openfairygui/core/project-io').ProjectResourceFolder[];
57
+ writtenPaths: string[];
58
+ failedPaths: string[];
59
+ }): Promise<void> {
60
+ const writer = new ProjectWriter(
61
+ createWriterFileSystem(input.fileSystem, input.writtenPaths, input.failedPaths),
62
+ );
63
+ await writer.write(input.document, input.fairyPath, {
64
+ staleSourceFiles: input.staleSourceFiles,
65
+ staleResourceFolders: input.staleResourceFolders,
66
+ });
67
+ }
@@ -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,201 @@
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
+ rmdir(dirPath: string): Promise<void>;
26
+ join?(...paths: string[]): string;
27
+ dirname?(filePath: string): string;
28
+ resolve?(...paths: string[]): string;
29
+ }
30
+
31
+ class StorageFileStat implements BackendFileStat {
32
+ public constructor(private readonly kind: StorageStatKind) {}
33
+
34
+ public isFile(): boolean {
35
+ return this.kind === 'file';
36
+ }
37
+
38
+ public isDirectory(): boolean {
39
+ return this.kind === 'directory';
40
+ }
41
+ }
42
+
43
+ function createPathError(code: string, message: string): Error & { code: string } {
44
+ const error = new Error(message) as Error & { code: string };
45
+ error.code = code;
46
+ return error;
47
+ }
48
+
49
+ function normalizeStoragePath(value: string): string {
50
+ const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/');
51
+ const absolute = normalized.startsWith('/');
52
+ const rawSegments = normalized.split('/').filter((segment) => segment.length > 0);
53
+ const segments: string[] = [];
54
+
55
+ for (const segment of rawSegments) {
56
+ if (segment === '.') continue;
57
+ if (segment === '..') {
58
+ if (segments.length > 0) segments.pop();
59
+ continue;
60
+ }
61
+ segments.push(segment);
62
+ }
63
+
64
+ const joined = segments.join('/');
65
+ if (absolute) return joined ? `/${joined}` : '/';
66
+ return joined || '.';
67
+ }
68
+
69
+ function joinStoragePath(...paths: string[]): string {
70
+ return normalizeStoragePath(paths.filter((part) => part.length > 0).join('/'));
71
+ }
72
+
73
+ function dirnameStoragePath(filePath: string): string {
74
+ const normalized = normalizeStoragePath(filePath);
75
+ if (normalized === '/' || normalized === '.') return '.';
76
+ const absolute = normalized.startsWith('/');
77
+ const parts = normalized.split('/').filter((part) => part.length > 0);
78
+ parts.pop();
79
+ if (parts.length === 0) return absolute ? '/' : '.';
80
+ return `${absolute ? '/' : ''}${parts.join('/')}`;
81
+ }
82
+
83
+ function statFromLike(stat: BackendStorageStatLike): BackendFileStat {
84
+ if (typeof stat.isFile === 'function' && typeof stat.isDirectory === 'function') {
85
+ return stat as BackendFileStat;
86
+ }
87
+ const kind = stat.kind ?? stat.type;
88
+ if (kind === 'file' || kind === 'directory') return new StorageFileStat(kind);
89
+ throw createPathError('EINVAL', 'Storage stat must provide kind/type or isFile()/isDirectory().');
90
+ }
91
+
92
+ async function inferStat(storage: BackendAsyncStorageAdapter, filePath: string): Promise<BackendFileStat> {
93
+ if (storage.stat) return statFromLike(await storage.stat(filePath));
94
+
95
+ try {
96
+ await storage.readdir(filePath);
97
+ return new StorageFileStat('directory');
98
+ } catch {
99
+ // Try file probes below.
100
+ }
101
+
102
+ try {
103
+ await storage.readFileRaw(filePath);
104
+ return new StorageFileStat('file');
105
+ } catch {
106
+ try {
107
+ await storage.readFile(filePath);
108
+ return new StorageFileStat('file');
109
+ } catch {
110
+ throw createPathError('ENOENT', `Storage path not found: ${filePath}`);
111
+ }
112
+ }
113
+ }
114
+
115
+ export type BackendStorageFileSystem = BackendFileSystem & CoreProjectFileSystem;
116
+
117
+ export function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapter): BackendStorageFileSystem {
118
+ if (typeof storage.unlink !== 'function') {
119
+ throw createPathError('ENOTSUP', 'Storage adapter must provide unlink() for project resource lifecycle writes.');
120
+ }
121
+ if (typeof storage.rmdir !== 'function') {
122
+ throw createPathError('ENOTSUP', 'Storage adapter must provide rmdir() for project resource folder lifecycle writes.');
123
+ }
124
+ const lockedPaths = new Set<string>();
125
+
126
+ const fileSystem: BackendStorageFileSystem = {
127
+ stat(filePath: string): Promise<BackendFileStat> {
128
+ return inferStat(storage, fileSystem.resolve(filePath));
129
+ },
130
+ readdir(dirPath: string): Promise<string[]> {
131
+ return storage.readdir(fileSystem.resolve(dirPath));
132
+ },
133
+ readFile(filePath: string): Promise<string> {
134
+ return storage.readFile(fileSystem.resolve(filePath));
135
+ },
136
+ readFileRaw(filePath: string): Promise<Uint8Array> {
137
+ return storage.readFileRaw(fileSystem.resolve(filePath));
138
+ },
139
+ writeFile(filePath: string, content: string): Promise<void> {
140
+ return storage.writeFile(fileSystem.resolve(filePath), content);
141
+ },
142
+ writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
143
+ return storage.writeFileRaw(fileSystem.resolve(filePath), data);
144
+ },
145
+ mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void> {
146
+ return storage.mkdir(fileSystem.resolve(dirPath), options);
147
+ },
148
+ async exists(filePath: string): Promise<boolean> {
149
+ if (storage.exists) return storage.exists(fileSystem.resolve(filePath));
150
+ try {
151
+ await fileSystem.stat(filePath);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ },
157
+ resolvePath(filePath: string): Promise<string> {
158
+ const resolved = fileSystem.resolve(filePath);
159
+ return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
160
+ },
161
+ async openExclusive(filePath: string): Promise<BackendFileHandle> {
162
+ const resolved = fileSystem.resolve(filePath);
163
+ if (storage.openExclusive) return storage.openExclusive(resolved);
164
+ if (lockedPaths.has(resolved) || await fileSystem.exists(resolved)) {
165
+ throw createPathError('EEXIST', `Storage path already exists: ${resolved}`);
166
+ }
167
+ lockedPaths.add(resolved);
168
+ let closed = false;
169
+ return {
170
+ async writeFile(content: string): Promise<void> {
171
+ if (closed) throw createPathError('EBADF', `Storage lock handle is closed: ${resolved}`);
172
+ await fileSystem.mkdir(fileSystem.dirname(resolved), { recursive: true });
173
+ await storage.writeFile(resolved, content);
174
+ },
175
+ async close(): Promise<void> {
176
+ closed = true;
177
+ lockedPaths.delete(resolved);
178
+ },
179
+ };
180
+ },
181
+ unlink(filePath: string): Promise<void> {
182
+ const resolved = fileSystem.resolve(filePath);
183
+ lockedPaths.delete(resolved);
184
+ return storage.unlink(resolved);
185
+ },
186
+ rmdir(dirPath: string): Promise<void> {
187
+ return storage.rmdir(fileSystem.resolve(dirPath));
188
+ },
189
+ join(...paths: string[]): string {
190
+ return storage.join ? storage.join(...paths) : joinStoragePath(...paths);
191
+ },
192
+ dirname(filePath: string): string {
193
+ return storage.dirname ? storage.dirname(filePath) : dirnameStoragePath(filePath);
194
+ },
195
+ resolve(...paths: string[]): string {
196
+ return storage.resolve ? storage.resolve(...paths) : joinStoragePath(...paths);
197
+ },
198
+ };
199
+
200
+ return fileSystem;
201
+ }