@openfairygui/backend 0.2.0-alpha.8 → 0.2.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.
@@ -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,
9
- BackendFileHandle,
11
+ BackendCapabilityUnavailableError,
10
12
  BackendResult,
13
+ BackendSessionLock,
11
14
  BackendSessionSnapshot,
12
- BackendCapabilityUnavailableError,
13
15
  InProcessLockConflictError,
14
16
  OpenProjectSessionInput,
15
17
  SessionNotFoundError,
16
18
  } from '../runtime.js';
17
- import { normalizeComparablePath, 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,
@@ -111,10 +204,10 @@ export class RuntimeService {
111
204
  });
112
205
  }
113
206
 
114
- let advisoryLock: BackendFileHandle | null = null;
207
+ let sessionLock: BackendSessionLock | null = null;
115
208
  try {
116
- advisoryLock = await fileSystem.openExclusive(lockFilePath);
117
- await advisoryLock.writeFile(
209
+ sessionLock = await fileSystem.acquireSessionLock(lockFilePath);
210
+ await sessionLock.writeMetadata(
118
211
  JSON.stringify(
119
212
  this.context.host?.lockMetadata?.({
120
213
  canonicalPathKey,
@@ -126,10 +219,9 @@ export class RuntimeService {
126
219
  },
127
220
  ),
128
221
  );
129
- await advisoryLock.close();
130
-
131
222
  const reader = new ProjectReader(createProjectReaderFileSystem(fileSystem));
132
- const project = liftDocumentToUamProject(await reader.read(fairyPath));
223
+ const document = await reader.read(fairyPath, { hydrateResourceBytes: true });
224
+ const project = liftDocumentToUamProject(document);
133
225
  const sessionId = randomId();
134
226
  const session: BackendSessionState = {
135
227
  sessionId,
@@ -137,10 +229,15 @@ export class RuntimeService {
137
229
  canonicalProjectPath,
138
230
  canonicalPathKey,
139
231
  lockFilePath,
232
+ sessionLock,
140
233
  fileSystem,
141
234
  project,
235
+ uamFidelity: (await hasFullUamFidelity(document, project)) ? 'full' : 'unsupported',
142
236
  revision: 0,
143
237
  lastSavedRevision: 0,
238
+ pendingStaleSourceFiles: new Map(),
239
+ pendingStaleResourceFolders: new Map(),
240
+ pendingStaleBranchDirectories: new Map(),
144
241
  dirty: false,
145
242
  lockHeld: true,
146
243
  closed: false,
@@ -155,6 +252,7 @@ export class RuntimeService {
155
252
  revision: session.revision,
156
253
  });
157
254
  } catch (error) {
255
+ if (sessionLock) await sessionLock.release().catch(() => undefined);
158
256
  if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
159
257
  return failure('runtime', startedAt, {
160
258
  code: 'lock_conflict',
@@ -164,9 +262,11 @@ export class RuntimeService {
164
262
  lockFilePath,
165
263
  });
166
264
  }
167
- if (advisoryLock) {
168
- await advisoryLock.close().catch(() => undefined);
169
- await fileSystem.unlink(lockFilePath).catch(() => undefined);
265
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOTSUP') {
266
+ return failure('runtime', startedAt, {
267
+ ...createCapabilityUnavailableError('fileSystem'),
268
+ message: error instanceof Error ? error.message : String(error),
269
+ });
170
270
  }
171
271
  throw error;
172
272
  }
@@ -177,12 +277,14 @@ export class RuntimeService {
177
277
  const sessionId = input.sessionId ?? randomId();
178
278
  const storage = input.storage;
179
279
  const memoryProjectPath = `memory://${sessionId}`;
180
- const canonicalProjectPath = storage?.canonicalProjectPath
181
- ?? input.canonicalProjectPath
182
- ?? (storage ? storage.fileSystem.dirname(storage.fairyPath) || '.' : memoryProjectPath);
183
- const canonicalPathKey = storage?.canonicalPathKey
184
- ?? input.canonicalPathKey
185
- ?? (storage ? normalizeComparablePath(canonicalProjectPath) : canonicalProjectPath.toLowerCase());
280
+ const canonicalProjectPath =
281
+ storage?.canonicalProjectPath ??
282
+ input.canonicalProjectPath ??
283
+ (storage ? storage.fileSystem.dirname(storage.fairyPath) || '.' : memoryProjectPath);
284
+ const canonicalPathKey =
285
+ storage?.canonicalPathKey ??
286
+ input.canonicalPathKey ??
287
+ (storage ? normalizeComparablePath(canonicalProjectPath) : canonicalProjectPath.toLowerCase());
186
288
  const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
187
289
  if (existingSessionId) {
188
290
  return failure('runtime', startedAt, {
@@ -200,10 +302,15 @@ export class RuntimeService {
200
302
  canonicalProjectPath,
201
303
  canonicalPathKey,
202
304
  lockFilePath: '',
305
+ sessionLock: null,
203
306
  fileSystem: storage?.fileSystem,
204
307
  project: normalizeUamProject(input.project),
308
+ uamFidelity: 'full',
205
309
  revision: 0,
206
310
  lastSavedRevision: 0,
311
+ pendingStaleSourceFiles: new Map(),
312
+ pendingStaleResourceFolders: new Map(),
313
+ pendingStaleBranchDirectories: new Map(),
207
314
  dirty: false,
208
315
  lockHeld: false,
209
316
  closed: false,
@@ -234,9 +341,8 @@ export class RuntimeService {
234
341
  canonicalPathKey: session.canonicalPathKey,
235
342
  revision: session.revision,
236
343
  });
237
- if (this.context.fileSystem && session.lockFilePath) {
238
- await this.context.fileSystem.unlink(session.lockFilePath).catch(() => undefined);
239
- }
344
+ await session.sessionLock?.release().catch(() => undefined);
345
+ session.sessionLock = null;
240
346
  session.lockHeld = false;
241
347
  session.closed = true;
242
348
  this.context.sessions.delete(session.sessionId);
@@ -0,0 +1,69 @@
1
+ import type { Document } from '@openfairygui/core';
2
+ import { type FileSystem, type ProjectBranchDirectory, 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
+ staleBranchDirectories: ProjectBranchDirectory[];
58
+ writtenPaths: string[];
59
+ failedPaths: string[];
60
+ }): Promise<void> {
61
+ const writer = new ProjectWriter(
62
+ createWriterFileSystem(input.fileSystem, input.writtenPaths, input.failedPaths),
63
+ );
64
+ await writer.write(input.document, input.fairyPath, {
65
+ staleSourceFiles: input.staleSourceFiles,
66
+ staleResourceFolders: input.staleResourceFolders,
67
+ staleBranchDirectories: input.staleBranchDirectories,
68
+ });
69
+ }
@@ -5,17 +5,20 @@ import type {
5
5
  SessionStaleWriteError,
6
6
  } from '../runtime.js';
7
7
  import type { BackendSessionState } from './context.js';
8
- import { cloneCapabilitiesSnapshot } from './snapshot-utils.js';
9
8
 
10
- export function toSessionSnapshot(session: BackendSessionState, capabilities: BackendCapabilities): BackendSessionSnapshot {
9
+ export function toSessionSnapshot(
10
+ session: BackendSessionState,
11
+ capabilities: BackendCapabilities,
12
+ ): BackendSessionSnapshot {
11
13
  return {
12
14
  sessionId: session.sessionId,
13
15
  canonicalProjectPath: session.canonicalProjectPath,
14
16
  revision: session.revision,
15
17
  lastSavedRevision: session.lastSavedRevision,
16
18
  dirty: session.dirty,
19
+ uamFidelity: session.uamFidelity,
17
20
  lockHeld: session.lockHeld,
18
- capabilities: cloneCapabilitiesSnapshot(capabilities),
21
+ capabilities: structuredClone(capabilities),
19
22
  };
20
23
  }
21
24
 
package/src/storage.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FileSystem as CoreProjectFileSystem } from '@openfairygui/core/project-io';
2
- import type { BackendFileHandle, BackendFileStat, BackendFileSystem } from './runtime.js';
2
+ import type { BackendFileStat, BackendFileSystem, BackendSessionLock } from './runtime.js';
3
3
 
4
4
  type StorageStatKind = 'file' | 'directory';
5
5
 
@@ -19,9 +19,15 @@ export interface BackendAsyncStorageAdapter {
19
19
  readdir(dirPath: string): Promise<string[]>;
20
20
  exists?(filePath: string): Promise<boolean>;
21
21
  stat?(filePath: string): Promise<BackendStorageStatLike>;
22
+ /** Stable cross-context path identity; also scopes the default Web Lock name. */
22
23
  resolvePath?(filePath: string): Promise<string>;
23
- openExclusive?(filePath: string): Promise<BackendFileHandle>;
24
- unlink?(filePath: string): Promise<void>;
24
+ /**
25
+ * Optional replacement for Web Locks. Acquisition must be atomic across browser contexts, remain held
26
+ * until release(), and recover automatically when the owning document terminates.
27
+ */
28
+ acquireSessionLock?(lockPath: string): Promise<BackendSessionLock>;
29
+ unlink(filePath: string): Promise<void>;
30
+ rmdir(dirPath: string): Promise<void>;
25
31
  join?(...paths: string[]): string;
26
32
  dirname?(filePath: string): string;
27
33
  resolve?(...paths: string[]): string;
@@ -45,6 +51,44 @@ function createPathError(code: string, message: string): Error & { code: string
45
51
  return error;
46
52
  }
47
53
 
54
+ function getWebLockManager(): LockManager | null {
55
+ if (typeof navigator === 'undefined') return null;
56
+ const lockManager = (navigator as Navigator & { locks?: LockManager }).locks;
57
+ return lockManager && typeof lockManager.request === 'function' ? lockManager : null;
58
+ }
59
+
60
+ function acquireWebSessionLock(lockManager: LockManager, lockName: string): Promise<BackendSessionLock> {
61
+ return new Promise((resolve, reject) => {
62
+ let releasePlatformLock = (): void => undefined;
63
+ const held = new Promise<void>((release) => {
64
+ releasePlatformLock = release;
65
+ });
66
+ void lockManager
67
+ .request(lockName, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
68
+ if (!lock) {
69
+ reject(createPathError('EEXIST', `Browser session lock is already held: ${lockName}`));
70
+ return;
71
+ }
72
+ let released = false;
73
+ resolve({
74
+ // Web Locks are the authority; a persisted marker would survive abrupt document termination.
75
+ writeMetadata(): Promise<void> {
76
+ return Promise.resolve();
77
+ },
78
+ release(): Promise<void> {
79
+ if (!released) {
80
+ released = true;
81
+ releasePlatformLock();
82
+ }
83
+ return Promise.resolve();
84
+ },
85
+ });
86
+ await held;
87
+ })
88
+ .catch(reject);
89
+ });
90
+ }
91
+
48
92
  function normalizeStoragePath(value: string): string {
49
93
  const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/');
50
94
  const absolute = normalized.startsWith('/');
@@ -114,8 +158,12 @@ async function inferStat(storage: BackendAsyncStorageAdapter, filePath: string):
114
158
  export type BackendStorageFileSystem = BackendFileSystem & CoreProjectFileSystem;
115
159
 
116
160
  export function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapter): BackendStorageFileSystem {
117
- const lockedPaths = new Set<string>();
118
-
161
+ if (typeof storage.unlink !== 'function') {
162
+ throw createPathError('ENOTSUP', 'Storage adapter must provide unlink() for project resource lifecycle writes.');
163
+ }
164
+ if (typeof storage.rmdir !== 'function') {
165
+ throw createPathError('ENOTSUP', 'Storage adapter must provide rmdir() for project resource folder lifecycle writes.');
166
+ }
119
167
  const fileSystem: BackendStorageFileSystem = {
120
168
  stat(filePath: string): Promise<BackendFileStat> {
121
169
  return inferStat(storage, fileSystem.resolve(filePath));
@@ -151,31 +199,23 @@ export function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapt
151
199
  const resolved = fileSystem.resolve(filePath);
152
200
  return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
153
201
  },
154
- async openExclusive(filePath: string): Promise<BackendFileHandle> {
155
- const resolved = fileSystem.resolve(filePath);
156
- if (storage.openExclusive) return storage.openExclusive(resolved);
157
- if (lockedPaths.has(resolved) || await fileSystem.exists(resolved)) {
158
- throw createPathError('EEXIST', `Storage path already exists: ${resolved}`);
202
+ async acquireSessionLock(lockPath: string): Promise<BackendSessionLock> {
203
+ const resolved = fileSystem.resolve(lockPath);
204
+ if (storage.acquireSessionLock) return storage.acquireSessionLock(resolved);
205
+ const lockManager = getWebLockManager();
206
+ if (!lockManager) {
207
+ throw createPathError(
208
+ 'ENOTSUP',
209
+ 'Browser openSession requires Web Locks or BackendAsyncStorageAdapter.acquireSessionLock().',
210
+ );
159
211
  }
160
- lockedPaths.add(resolved);
161
- let closed = false;
162
- return {
163
- async writeFile(content: string): Promise<void> {
164
- if (closed) throw createPathError('EBADF', `Storage lock handle is closed: ${resolved}`);
165
- await fileSystem.mkdir(fileSystem.dirname(resolved), { recursive: true });
166
- await storage.writeFile(resolved, content);
167
- },
168
- async close(): Promise<void> {
169
- closed = true;
170
- lockedPaths.delete(resolved);
171
- },
172
- };
212
+ return acquireWebSessionLock(lockManager, `@openfairygui/backend:${resolved}`);
173
213
  },
174
214
  unlink(filePath: string): Promise<void> {
175
- const resolved = fileSystem.resolve(filePath);
176
- lockedPaths.delete(resolved);
177
- if (storage.unlink) return storage.unlink(resolved);
178
- throw createPathError('ENOTSUP', 'Storage adapter does not provide unlink().');
215
+ return storage.unlink(fileSystem.resolve(filePath));
216
+ },
217
+ rmdir(dirPath: string): Promise<void> {
218
+ return storage.rmdir(fileSystem.resolve(dirPath));
179
219
  },
180
220
  join(...paths: string[]): string {
181
221
  return storage.join ? storage.join(...paths) : joinStoragePath(...paths);