@openfairygui/backend 0.2.0-alpha.3 → 0.2.0-alpha.31
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 +48 -2
- package/dist/index.cjs +150 -1
- package/dist/index.d.cts +34 -2
- package/dist/index.d.mts +34 -2
- package/dist/index.mjs +150 -2
- package/dist/node.cjs +4 -1
- package/dist/node.d.cts +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.mjs +4 -1
- package/dist/{runtime-BG5qOVkJ.mjs → runtime-BDkZmTFN.mjs} +480 -126
- package/dist/{runtime-IrXqqmko.d.cts → runtime-BQOEw22M.d.mts} +85 -10
- package/dist/{runtime-jKuQmcqM.cjs → runtime-DtcaaVfy.cjs} +479 -125
- package/dist/{runtime-u1Bq_ysz.d.mts → runtime-Wu6vGZ-j.d.cts} +86 -11
- package/package.json +5 -4
- package/src/contracts.ts +8 -0
- package/src/index.ts +25 -12
- package/src/node.ts +3 -0
- package/src/runtime/capabilities.ts +126 -0
- package/src/runtime/contracts.ts +513 -0
- package/src/runtime.ts +67 -551
- package/src/services/authoring-service.ts +504 -81
- package/src/services/cache-service.ts +1 -2
- package/src/services/context.ts +14 -8
- package/src/services/event-service.ts +1 -2
- package/src/services/job-service.ts +4 -5
- package/src/services/read-service.ts +1 -2
- package/src/services/runtime-service.ts +122 -12
- package/src/services/session-project-writer.ts +67 -0
- package/src/services/session-utils.ts +6 -3
- package/src/storage.ts +201 -0
- package/src/services/snapshot-utils.ts +0 -43
|
@@ -7,7 +7,6 @@ import type {
|
|
|
7
7
|
GetCacheSnapshotInput,
|
|
8
8
|
SessionNotFoundError,
|
|
9
9
|
} from '../runtime.js';
|
|
10
|
-
import { cloneCacheEntrySnapshot } from './snapshot-utils.js';
|
|
11
10
|
|
|
12
11
|
function createCacheEntry(session: BackendSessionState, valid: boolean): BackendCacheEntry {
|
|
13
12
|
return {
|
|
@@ -38,7 +37,7 @@ export class CacheService {
|
|
|
38
37
|
const entry = this.context.cacheBySession.get(input.sessionId);
|
|
39
38
|
return success('read', startedAt, {
|
|
40
39
|
cacheRevision: entry?.revision ?? session.revision,
|
|
41
|
-
entries: entry ? [
|
|
40
|
+
entries: entry ? [structuredClone(entry)] : [],
|
|
42
41
|
}, { sessionId: session.sessionId, revision: session.revision });
|
|
43
42
|
}
|
|
44
43
|
|
package/src/services/context.ts
CHANGED
|
@@ -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;
|
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
GetEventsSnapshot,
|
|
9
9
|
SessionNotFoundError,
|
|
10
10
|
} from '../runtime.js';
|
|
11
|
-
import { cloneEventSnapshot } from './snapshot-utils.js';
|
|
12
11
|
|
|
13
12
|
const DEFAULT_EVENT_RETENTION_LIMIT = 1000;
|
|
14
13
|
|
|
@@ -71,7 +70,7 @@ export class EventService {
|
|
|
71
70
|
const filtered = events.filter((event) => event.sequence > after);
|
|
72
71
|
const limit = input.limit === undefined ? filtered.length : Math.max(0, input.limit);
|
|
73
72
|
return success('runtime', startedAt, {
|
|
74
|
-
events: filtered.slice(0, limit).map(
|
|
73
|
+
events: filtered.slice(0, limit).map((event) => structuredClone(event)),
|
|
75
74
|
oldestSequence,
|
|
76
75
|
currentSequence,
|
|
77
76
|
cursorExpired: false,
|
|
@@ -2,7 +2,6 @@ import { failure, success, type BackendContext } from './context.js';
|
|
|
2
2
|
import type { CacheService } from './cache-service.js';
|
|
3
3
|
import type { EventService } from './event-service.js';
|
|
4
4
|
import { createSessionNotFoundError } from './session-utils.js';
|
|
5
|
-
import { cloneJobSnapshot } from './snapshot-utils.js';
|
|
6
5
|
import type {
|
|
7
6
|
BackendJobListSnapshot,
|
|
8
7
|
BackendJobNotCancellableError,
|
|
@@ -76,7 +75,7 @@ export class JobService {
|
|
|
76
75
|
this.eventService.emit({ kind: 'job.created', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, jobId });
|
|
77
76
|
this.scheduleRefreshJob(session.sessionId, jobId);
|
|
78
77
|
|
|
79
|
-
return success('runtime', startedAt,
|
|
78
|
+
return success('runtime', startedAt, structuredClone(job), { sessionId: session.sessionId, revision: session.revision });
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
public getJob(input: GetJobInput): BackendResult<BackendJobSnapshot, SessionNotFoundError | BackendJobNotFoundError> {
|
|
@@ -92,7 +91,7 @@ export class JobService {
|
|
|
92
91
|
jobId: input.jobId,
|
|
93
92
|
}, undefined, { sessionId: session.sessionId, revision: session.revision });
|
|
94
93
|
}
|
|
95
|
-
return success('runtime', startedAt,
|
|
94
|
+
return success('runtime', startedAt, structuredClone(job), { sessionId: session.sessionId, revision: session.revision });
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
public listJobs(input: ListJobsInput): BackendResult<BackendJobListSnapshot, SessionNotFoundError> {
|
|
@@ -107,7 +106,7 @@ export class JobService {
|
|
|
107
106
|
else jobs = jobs.filter((job) => job.status === input.status);
|
|
108
107
|
}
|
|
109
108
|
if (input.limit !== undefined) jobs = jobs.slice(0, Math.max(0, input.limit));
|
|
110
|
-
return success('runtime', startedAt, { jobs: jobs.map(
|
|
109
|
+
return success('runtime', startedAt, { jobs: jobs.map((job) => structuredClone(job)) }, { sessionId: session.sessionId, revision: session.revision });
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
public cancelJob(input: CancelJobInput): BackendResult<BackendJobSnapshot, SessionNotFoundError | BackendJobNotFoundError | BackendJobNotCancellableError> {
|
|
@@ -135,7 +134,7 @@ export class JobService {
|
|
|
135
134
|
this.cancellationRequests.add(job.jobId);
|
|
136
135
|
this.eventService.emit({ kind: 'job.cancelRequested', sessionId: input.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, jobId: job.jobId });
|
|
137
136
|
const cancelled = this.cancelRefreshJob(session.sessionId, job);
|
|
138
|
-
return success('runtime', startedAt,
|
|
137
|
+
return success('runtime', startedAt, structuredClone(cancelled), { sessionId: session.sessionId, revision: session.revision });
|
|
139
138
|
}
|
|
140
139
|
|
|
141
140
|
public removeSession(sessionId: string): void {
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { failure, success, type BackendContext } from './context.js';
|
|
2
2
|
import type { BackendCapabilities, BackendResult, BackendSessionSnapshot, SessionNotFoundError } from '../runtime.js';
|
|
3
3
|
import { createSessionNotFoundError, toSessionSnapshot } from './session-utils.js';
|
|
4
|
-
import { cloneCapabilitiesSnapshot } from './snapshot-utils.js';
|
|
5
4
|
|
|
6
5
|
export class ReadService {
|
|
7
6
|
public constructor(private readonly context: BackendContext) {}
|
|
8
7
|
|
|
9
8
|
public getCapabilities(): BackendResult<BackendCapabilities> {
|
|
10
|
-
return success('read', Date.now(),
|
|
9
|
+
return success('read', Date.now(), structuredClone(this.context.capabilities));
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
public getSession(input: { sessionId: string }): BackendResult<BackendSessionSnapshot, SessionNotFoundError> {
|
|
@@ -1,20 +1,25 @@
|
|
|
1
|
-
import { ProjectReader,
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 {
|
|
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
|
|
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
|
|
178
|
-
const
|
|
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
|
+
}
|
|
@@ -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(
|
|
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:
|
|
21
|
+
capabilities: structuredClone(capabilities),
|
|
19
22
|
};
|
|
20
23
|
}
|
|
21
24
|
|
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
|
+
}
|
|
@@ -1,43 +0,0 @@
|
|
|
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
|
-
}
|