@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.
- 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-IrXqqmko.d.cts → runtime-BQOEw22M.d.mts} +85 -10
- package/dist/{runtime-jKuQmcqM.cjs → runtime-CcBLI-J7.cjs} +523 -86
- package/dist/{runtime-BG5qOVkJ.mjs → runtime-NiBgr7PF.mjs} +524 -87
- 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 +559 -80
- package/src/services/context.ts +14 -8
- package/src/services/runtime-service.ts +122 -12
- package/src/services/session-project-writer.ts +67 -0
- package/src/services/session-utils.ts +5 -1
- package/src/storage.ts +201 -0
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;
|
|
@@ -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
|
+
}
|
|
@@ -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(
|
|
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
|
+
}
|