@openfairygui/backend 0.2.0-alpha.6 → 0.2.0-alpha.8
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 +40 -2
- package/dist/index.cjs +146 -1
- package/dist/index.d.cts +33 -2
- package/dist/index.d.mts +33 -2
- package/dist/index.mjs +146 -2
- package/dist/node.cjs +1 -1
- package/dist/node.d.cts +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.mjs +1 -1
- package/dist/{runtime-CqARWlcn.mjs → runtime-DFatY9W0.mjs} +223 -12
- package/dist/{runtime--ytGfQCF.cjs → runtime-GKzsXJdO.cjs} +222 -11
- package/dist/{runtime-BPuZITUC.d.mts → runtime-GyNVxAQ0.d.mts} +66 -9
- package/dist/{runtime-DlM27Izm.d.cts → runtime-Jec6FcF5.d.cts} +66 -9
- package/package.json +3 -3
- package/src/index.ts +12 -0
- package/src/runtime.ts +98 -8
- package/src/services/authoring-service.ts +318 -16
- package/src/services/context.ts +1 -0
- package/src/services/runtime-service.ts +12 -4
- package/src/storage.ts +192 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ProjectWriter, type FileSystem } from '@openfairygui/core/project-io';
|
|
2
|
-
import { materializeUamProject } from '@openfairygui/core/uam';
|
|
2
|
+
import { materializeUamProject, validateUamProject } from '@openfairygui/core/uam';
|
|
3
3
|
import { applyUamTransactionApp, type ApplyUamTransactionAppError } from '@openfairygui/functions/uam';
|
|
4
4
|
import type { CacheService } from './cache-service.js';
|
|
5
5
|
import { failure, success, type BackendContext } from './context.js';
|
|
@@ -10,12 +10,18 @@ import type {
|
|
|
10
10
|
BackendFileSystem,
|
|
11
11
|
BackendResult,
|
|
12
12
|
BackendSessionSnapshot,
|
|
13
|
+
InProcessLockConflictError,
|
|
14
|
+
MaterializeSessionInput,
|
|
15
|
+
MaterializeSessionSnapshot,
|
|
16
|
+
MaterializeValidationFailedError,
|
|
17
|
+
MaterializeWriteFailedError,
|
|
18
|
+
SaveSessionInput,
|
|
13
19
|
SavePartialFailureError,
|
|
14
20
|
SessionNotFoundError,
|
|
15
21
|
SessionStaleWriteError,
|
|
16
22
|
} from '../runtime.js';
|
|
17
23
|
import type { BackendDiagnostic } from '../contracts.js';
|
|
18
|
-
import { validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
|
|
24
|
+
import { normalizeComparablePath, validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
|
|
19
25
|
import { createSessionNotFoundError, createStaleWriteError, toSessionSnapshot } from './session-utils.js';
|
|
20
26
|
|
|
21
27
|
function createWriterFileSystem(
|
|
@@ -91,6 +97,63 @@ function toBackendDiagnostics(error: ApplyUamTransactionAppError): BackendDiagno
|
|
|
91
97
|
];
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
function createCapabilityUnavailableError(message: string): BackendCapabilityUnavailableError {
|
|
101
|
+
return {
|
|
102
|
+
code: 'capability_unavailable',
|
|
103
|
+
message,
|
|
104
|
+
capability: 'fileSystem',
|
|
105
|
+
requiredAdapter: 'BackendFileSystem',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validationDiagnostics(sessionProject: Parameters<typeof validateUamProject>[0]): BackendDiagnostic[] {
|
|
110
|
+
const issues = validateUamProject(sessionProject);
|
|
111
|
+
return issues.map((issue) => ({
|
|
112
|
+
code: 'materialize_validation_failed',
|
|
113
|
+
message: issue.message,
|
|
114
|
+
severity: 'error',
|
|
115
|
+
path: issue.path,
|
|
116
|
+
operationKind: 'materializeSession',
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toMaterializeSnapshot(
|
|
121
|
+
session: Parameters<typeof toSessionSnapshot>[0],
|
|
122
|
+
capabilities: Parameters<typeof toSessionSnapshot>[1],
|
|
123
|
+
input: {
|
|
124
|
+
reason?: string;
|
|
125
|
+
writtenPaths: string[];
|
|
126
|
+
skippedPaths: string[];
|
|
127
|
+
diagnostics: BackendDiagnostic[];
|
|
128
|
+
},
|
|
129
|
+
): MaterializeSessionSnapshot {
|
|
130
|
+
return {
|
|
131
|
+
...toSessionSnapshot(session, capabilities),
|
|
132
|
+
mode: 'fullProject',
|
|
133
|
+
reason: input.reason,
|
|
134
|
+
materializeRevision: session.revision,
|
|
135
|
+
saveRevision: session.lastSavedRevision,
|
|
136
|
+
writtenPaths: [...input.writtenPaths],
|
|
137
|
+
skippedPaths: [...input.skippedPaths],
|
|
138
|
+
diagnostics: input.diagnostics.map((diagnostic) => ({ ...diagnostic })),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function storageCanonicalTarget(input: NonNullable<MaterializeSessionInput['storage']>): {
|
|
143
|
+
fileSystem: BackendFileSystem;
|
|
144
|
+
fairyPath: string;
|
|
145
|
+
canonicalProjectPath: string;
|
|
146
|
+
canonicalPathKey: string;
|
|
147
|
+
} {
|
|
148
|
+
const canonicalProjectPath = input.canonicalProjectPath ?? (input.fileSystem.dirname(input.fairyPath) || '.');
|
|
149
|
+
return {
|
|
150
|
+
fileSystem: input.fileSystem,
|
|
151
|
+
fairyPath: input.fairyPath,
|
|
152
|
+
canonicalProjectPath,
|
|
153
|
+
canonicalPathKey: input.canonicalPathKey ?? normalizeComparablePath(canonicalProjectPath),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
94
157
|
export class AuthoringService {
|
|
95
158
|
public constructor(
|
|
96
159
|
private readonly context: BackendContext,
|
|
@@ -180,35 +243,42 @@ export class AuthoringService {
|
|
|
180
243
|
});
|
|
181
244
|
}
|
|
182
245
|
|
|
183
|
-
public async saveSession(input:
|
|
184
|
-
sessionId: string;
|
|
185
|
-
expectedRevision?: number;
|
|
186
|
-
targetPath?: string;
|
|
187
|
-
}): Promise<
|
|
246
|
+
public async saveSession(input: SaveSessionInput): Promise<
|
|
188
247
|
BackendResult<
|
|
189
|
-
BackendSessionSnapshot,
|
|
248
|
+
BackendSessionSnapshot | MaterializeSessionSnapshot,
|
|
190
249
|
| SessionNotFoundError
|
|
191
250
|
| SessionStaleWriteError
|
|
192
251
|
| SavePartialFailureError
|
|
252
|
+
| MaterializeValidationFailedError
|
|
253
|
+
| MaterializeWriteFailedError
|
|
193
254
|
| PathPolicyViolationError
|
|
255
|
+
| InProcessLockConflictError
|
|
194
256
|
| BackendCapabilityUnavailableError
|
|
195
257
|
>
|
|
196
258
|
> {
|
|
259
|
+
if (input.force === true || input.mode === 'materializeCleanSession') {
|
|
260
|
+
return this.materializeSession({
|
|
261
|
+
sessionId: input.sessionId,
|
|
262
|
+
expectedRevision: input.expectedRevision,
|
|
263
|
+
targetPath: input.targetPath,
|
|
264
|
+
fileSystem: input.fileSystem,
|
|
265
|
+
mode: 'fullProject',
|
|
266
|
+
reason: 'force_save',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
197
269
|
const startedAt = Date.now();
|
|
198
270
|
const session = this.context.sessions.get(input.sessionId);
|
|
199
271
|
if (!session || session.closed) {
|
|
200
272
|
return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
|
|
201
273
|
}
|
|
202
|
-
|
|
274
|
+
const fileSystem = input.fileSystem ?? session.fileSystem ?? this.context.fileSystem;
|
|
275
|
+
if (!fileSystem) {
|
|
203
276
|
return failure(
|
|
204
277
|
'authoring',
|
|
205
278
|
startedAt,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
capability: 'fileSystem',
|
|
210
|
-
requiredAdapter: 'BackendFileSystem',
|
|
211
|
-
},
|
|
279
|
+
createCapabilityUnavailableError(
|
|
280
|
+
'saveSession requires an injected BackendFileSystem adapter.',
|
|
281
|
+
),
|
|
212
282
|
toSessionSnapshot(session, this.context.capabilities),
|
|
213
283
|
{
|
|
214
284
|
sessionId: session.sessionId,
|
|
@@ -228,7 +298,6 @@ export class AuthoringService {
|
|
|
228
298
|
},
|
|
229
299
|
);
|
|
230
300
|
}
|
|
231
|
-
const fileSystem = this.context.fileSystem;
|
|
232
301
|
const targetViolation = await validateSaveTarget(fileSystem, session.fairyPath, input.targetPath);
|
|
233
302
|
if (targetViolation) {
|
|
234
303
|
return failure(
|
|
@@ -310,4 +379,237 @@ export class AuthoringService {
|
|
|
310
379
|
);
|
|
311
380
|
}
|
|
312
381
|
}
|
|
382
|
+
|
|
383
|
+
public async materializeSession(input: MaterializeSessionInput): Promise<
|
|
384
|
+
BackendResult<
|
|
385
|
+
MaterializeSessionSnapshot,
|
|
386
|
+
| SessionNotFoundError
|
|
387
|
+
| SessionStaleWriteError
|
|
388
|
+
| MaterializeValidationFailedError
|
|
389
|
+
| MaterializeWriteFailedError
|
|
390
|
+
| PathPolicyViolationError
|
|
391
|
+
| InProcessLockConflictError
|
|
392
|
+
| BackendCapabilityUnavailableError
|
|
393
|
+
>
|
|
394
|
+
> {
|
|
395
|
+
const startedAt = Date.now();
|
|
396
|
+
const session = this.context.sessions.get(input.sessionId);
|
|
397
|
+
if (!session || session.closed) {
|
|
398
|
+
return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
|
|
399
|
+
}
|
|
400
|
+
if (input.expectedRevision !== undefined && input.expectedRevision !== session.revision) {
|
|
401
|
+
return failure(
|
|
402
|
+
'authoring',
|
|
403
|
+
startedAt,
|
|
404
|
+
createStaleWriteError(session, input.expectedRevision),
|
|
405
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
406
|
+
{
|
|
407
|
+
sessionId: session.sessionId,
|
|
408
|
+
revision: session.revision,
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const storageTarget = input.storage ? storageCanonicalTarget(input.storage) : null;
|
|
414
|
+
const fileSystem = storageTarget?.fileSystem ?? input.fileSystem ?? session.fileSystem ?? this.context.fileSystem;
|
|
415
|
+
if (!fileSystem) {
|
|
416
|
+
return failure(
|
|
417
|
+
'authoring',
|
|
418
|
+
startedAt,
|
|
419
|
+
createCapabilityUnavailableError(
|
|
420
|
+
'materializeSession requires an injected BackendFileSystem adapter.',
|
|
421
|
+
),
|
|
422
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
423
|
+
{
|
|
424
|
+
sessionId: session.sessionId,
|
|
425
|
+
revision: session.revision,
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const fairyPath = storageTarget?.fairyPath ?? session.fairyPath;
|
|
431
|
+
const targetViolation = await validateSaveTarget(fileSystem, fairyPath, input.targetPath);
|
|
432
|
+
if (targetViolation) {
|
|
433
|
+
return failure(
|
|
434
|
+
'authoring',
|
|
435
|
+
startedAt,
|
|
436
|
+
targetViolation,
|
|
437
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
438
|
+
{
|
|
439
|
+
sessionId: session.sessionId,
|
|
440
|
+
revision: session.revision,
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (storageTarget) {
|
|
446
|
+
const holderSessionId = this.context.sessionsByPath.get(storageTarget.canonicalPathKey);
|
|
447
|
+
if (holderSessionId && holderSessionId !== session.sessionId) {
|
|
448
|
+
return failure(
|
|
449
|
+
'authoring',
|
|
450
|
+
startedAt,
|
|
451
|
+
{
|
|
452
|
+
code: 'lock_conflict',
|
|
453
|
+
kind: 'in_process_session_exists',
|
|
454
|
+
message: `Project is already open in this backend runtime: ${storageTarget.canonicalProjectPath}`,
|
|
455
|
+
canonicalPathKey: storageTarget.canonicalPathKey,
|
|
456
|
+
holderSessionId,
|
|
457
|
+
},
|
|
458
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
459
|
+
{
|
|
460
|
+
sessionId: session.sessionId,
|
|
461
|
+
revision: session.revision,
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const diagnostics = validationDiagnostics(session.project);
|
|
468
|
+
if (diagnostics.length > 0) {
|
|
469
|
+
const error: MaterializeValidationFailedError = {
|
|
470
|
+
code: 'materialize_validation_failed',
|
|
471
|
+
message: `UAM materialize validation failed with ${diagnostics.length} issue(s).`,
|
|
472
|
+
sessionId: session.sessionId,
|
|
473
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
474
|
+
issueCount: diagnostics.length,
|
|
475
|
+
diagnostics,
|
|
476
|
+
};
|
|
477
|
+
return failure(
|
|
478
|
+
'authoring',
|
|
479
|
+
startedAt,
|
|
480
|
+
error,
|
|
481
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
482
|
+
{
|
|
483
|
+
sessionId: session.sessionId,
|
|
484
|
+
revision: session.revision,
|
|
485
|
+
diagnostics,
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let document: ReturnType<typeof materializeUamProject>;
|
|
491
|
+
try {
|
|
492
|
+
document = materializeUamProject(session.project);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
const diagnosticsFromError: BackendDiagnostic[] = [
|
|
495
|
+
{
|
|
496
|
+
code: 'materialize_validation_failed',
|
|
497
|
+
message: error instanceof Error ? error.message : String(error),
|
|
498
|
+
severity: 'error',
|
|
499
|
+
operationKind: 'materializeSession',
|
|
500
|
+
},
|
|
501
|
+
];
|
|
502
|
+
return failure(
|
|
503
|
+
'authoring',
|
|
504
|
+
startedAt,
|
|
505
|
+
{
|
|
506
|
+
code: 'materialize_validation_failed',
|
|
507
|
+
message: error instanceof Error ? error.message : String(error),
|
|
508
|
+
sessionId: session.sessionId,
|
|
509
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
510
|
+
issueCount: diagnosticsFromError.length,
|
|
511
|
+
diagnostics: diagnosticsFromError,
|
|
512
|
+
},
|
|
513
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
514
|
+
{
|
|
515
|
+
sessionId: session.sessionId,
|
|
516
|
+
revision: session.revision,
|
|
517
|
+
diagnostics: diagnosticsFromError,
|
|
518
|
+
},
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const writtenPaths: string[] = [];
|
|
523
|
+
const failedPaths: string[] = [];
|
|
524
|
+
const skippedPaths: string[] = [];
|
|
525
|
+
this.eventService.emit({
|
|
526
|
+
kind: 'save.started',
|
|
527
|
+
sessionId: session.sessionId,
|
|
528
|
+
canonicalPathKey: storageTarget?.canonicalPathKey ?? session.canonicalPathKey,
|
|
529
|
+
revision: session.revision,
|
|
530
|
+
});
|
|
531
|
+
try {
|
|
532
|
+
const writer = new ProjectWriter(createWriterFileSystem(fileSystem, writtenPaths, failedPaths));
|
|
533
|
+
await writer.write(document, fairyPath);
|
|
534
|
+
if (storageTarget) {
|
|
535
|
+
this.context.sessionsByPath.delete(session.canonicalPathKey);
|
|
536
|
+
session.fileSystem = storageTarget.fileSystem;
|
|
537
|
+
session.fairyPath = storageTarget.fairyPath;
|
|
538
|
+
session.canonicalProjectPath = storageTarget.canonicalProjectPath;
|
|
539
|
+
session.canonicalPathKey = storageTarget.canonicalPathKey;
|
|
540
|
+
this.context.sessionsByPath.set(session.canonicalPathKey, session.sessionId);
|
|
541
|
+
}
|
|
542
|
+
session.lastSavedRevision = session.revision;
|
|
543
|
+
session.dirty = false;
|
|
544
|
+
const cacheEntry = this.cacheService.refreshSession(session);
|
|
545
|
+
this.eventService.emit({
|
|
546
|
+
kind: 'save.completed',
|
|
547
|
+
sessionId: session.sessionId,
|
|
548
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
549
|
+
revision: session.revision,
|
|
550
|
+
});
|
|
551
|
+
this.eventService.emit({
|
|
552
|
+
kind: 'cache.updated',
|
|
553
|
+
sessionId: session.sessionId,
|
|
554
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
555
|
+
revision: session.revision,
|
|
556
|
+
cacheRevision: cacheEntry.revision,
|
|
557
|
+
});
|
|
558
|
+
return success(
|
|
559
|
+
'authoring',
|
|
560
|
+
startedAt,
|
|
561
|
+
toMaterializeSnapshot(session, this.context.capabilities, {
|
|
562
|
+
reason: input.reason,
|
|
563
|
+
writtenPaths,
|
|
564
|
+
skippedPaths,
|
|
565
|
+
diagnostics: [],
|
|
566
|
+
}),
|
|
567
|
+
{
|
|
568
|
+
sessionId: session.sessionId,
|
|
569
|
+
revision: session.revision,
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
const diagnosticsFromError: BackendDiagnostic[] = [
|
|
574
|
+
{
|
|
575
|
+
code: 'write_failed',
|
|
576
|
+
message: error instanceof Error ? error.message : String(error),
|
|
577
|
+
severity: 'error',
|
|
578
|
+
path: failedPaths[0],
|
|
579
|
+
operationKind: 'materializeSession',
|
|
580
|
+
},
|
|
581
|
+
];
|
|
582
|
+
this.cacheService.invalidateSession(session);
|
|
583
|
+
this.eventService.emit({
|
|
584
|
+
kind: 'save.failed',
|
|
585
|
+
sessionId: session.sessionId,
|
|
586
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
587
|
+
revision: session.revision,
|
|
588
|
+
diagnostics: diagnosticsFromError,
|
|
589
|
+
});
|
|
590
|
+
return failure(
|
|
591
|
+
'authoring',
|
|
592
|
+
startedAt,
|
|
593
|
+
{
|
|
594
|
+
code: 'write_failed',
|
|
595
|
+
message: error instanceof Error ? error.message : String(error),
|
|
596
|
+
sessionId: session.sessionId,
|
|
597
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
598
|
+
attemptedRevision: session.revision,
|
|
599
|
+
lastSavedRevision: session.lastSavedRevision,
|
|
600
|
+
writtenPaths,
|
|
601
|
+
failedPaths,
|
|
602
|
+
skippedPaths,
|
|
603
|
+
diagnostics: diagnosticsFromError,
|
|
604
|
+
diskMayBePartiallyUpdated: true,
|
|
605
|
+
},
|
|
606
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
607
|
+
{
|
|
608
|
+
sessionId: session.sessionId,
|
|
609
|
+
revision: session.revision,
|
|
610
|
+
diagnostics: diagnosticsFromError,
|
|
611
|
+
},
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
313
615
|
}
|
package/src/services/context.ts
CHANGED
|
@@ -25,6 +25,7 @@ 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;
|
|
29
30
|
revision: number;
|
|
30
31
|
lastSavedRevision: number;
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
OpenProjectSessionInput,
|
|
15
15
|
SessionNotFoundError,
|
|
16
16
|
} from '../runtime.js';
|
|
17
|
-
import { resolveCanonicalProjectRoot } from '../path-policy.js';
|
|
17
|
+
import { normalizeComparablePath, resolveCanonicalProjectRoot } from '../path-policy.js';
|
|
18
18
|
import { createSessionNotFoundError, toSessionSnapshot } from './session-utils.js';
|
|
19
19
|
|
|
20
20
|
function randomId(): string {
|
|
@@ -137,6 +137,7 @@ export class RuntimeService {
|
|
|
137
137
|
canonicalProjectPath,
|
|
138
138
|
canonicalPathKey,
|
|
139
139
|
lockFilePath,
|
|
140
|
+
fileSystem,
|
|
140
141
|
project,
|
|
141
142
|
revision: 0,
|
|
142
143
|
lastSavedRevision: 0,
|
|
@@ -174,8 +175,14 @@ export class RuntimeService {
|
|
|
174
175
|
public openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot> {
|
|
175
176
|
const startedAt = Date.now();
|
|
176
177
|
const sessionId = input.sessionId ?? randomId();
|
|
177
|
-
const
|
|
178
|
-
const
|
|
178
|
+
const storage = input.storage;
|
|
179
|
+
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());
|
|
179
186
|
const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
|
|
180
187
|
if (existingSessionId) {
|
|
181
188
|
return failure('runtime', startedAt, {
|
|
@@ -189,10 +196,11 @@ export class RuntimeService {
|
|
|
189
196
|
|
|
190
197
|
const session: BackendSessionState = {
|
|
191
198
|
sessionId,
|
|
192
|
-
fairyPath: canonicalProjectPath,
|
|
199
|
+
fairyPath: storage?.fairyPath ?? canonicalProjectPath,
|
|
193
200
|
canonicalProjectPath,
|
|
194
201
|
canonicalPathKey,
|
|
195
202
|
lockFilePath: '',
|
|
203
|
+
fileSystem: storage?.fileSystem,
|
|
196
204
|
project: normalizeUamProject(input.project),
|
|
197
205
|
revision: 0,
|
|
198
206
|
lastSavedRevision: 0,
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { FileSystem as CoreProjectFileSystem } from '@openfairygui/core/project-io';
|
|
2
|
+
import type { BackendFileHandle, BackendFileStat, BackendFileSystem } from './runtime.js';
|
|
3
|
+
|
|
4
|
+
type StorageStatKind = 'file' | 'directory';
|
|
5
|
+
|
|
6
|
+
export interface BackendStorageStatLike {
|
|
7
|
+
kind?: StorageStatKind;
|
|
8
|
+
type?: StorageStatKind;
|
|
9
|
+
isFile?(): boolean;
|
|
10
|
+
isDirectory?(): boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BackendAsyncStorageAdapter {
|
|
14
|
+
readFile(filePath: string): Promise<string>;
|
|
15
|
+
readFileRaw(filePath: string): Promise<Uint8Array>;
|
|
16
|
+
writeFile(filePath: string, content: string): Promise<void>;
|
|
17
|
+
writeFileRaw(filePath: string, data: Uint8Array): Promise<void>;
|
|
18
|
+
mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void>;
|
|
19
|
+
readdir(dirPath: string): Promise<string[]>;
|
|
20
|
+
exists?(filePath: string): Promise<boolean>;
|
|
21
|
+
stat?(filePath: string): Promise<BackendStorageStatLike>;
|
|
22
|
+
resolvePath?(filePath: string): Promise<string>;
|
|
23
|
+
openExclusive?(filePath: string): Promise<BackendFileHandle>;
|
|
24
|
+
unlink?(filePath: string): Promise<void>;
|
|
25
|
+
join?(...paths: string[]): string;
|
|
26
|
+
dirname?(filePath: string): string;
|
|
27
|
+
resolve?(...paths: string[]): string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class StorageFileStat implements BackendFileStat {
|
|
31
|
+
public constructor(private readonly kind: StorageStatKind) {}
|
|
32
|
+
|
|
33
|
+
public isFile(): boolean {
|
|
34
|
+
return this.kind === 'file';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public isDirectory(): boolean {
|
|
38
|
+
return this.kind === 'directory';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createPathError(code: string, message: string): Error & { code: string } {
|
|
43
|
+
const error = new Error(message) as Error & { code: string };
|
|
44
|
+
error.code = code;
|
|
45
|
+
return error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeStoragePath(value: string): string {
|
|
49
|
+
const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
50
|
+
const absolute = normalized.startsWith('/');
|
|
51
|
+
const rawSegments = normalized.split('/').filter((segment) => segment.length > 0);
|
|
52
|
+
const segments: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const segment of rawSegments) {
|
|
55
|
+
if (segment === '.') continue;
|
|
56
|
+
if (segment === '..') {
|
|
57
|
+
if (segments.length > 0) segments.pop();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
segments.push(segment);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const joined = segments.join('/');
|
|
64
|
+
if (absolute) return joined ? `/${joined}` : '/';
|
|
65
|
+
return joined || '.';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function joinStoragePath(...paths: string[]): string {
|
|
69
|
+
return normalizeStoragePath(paths.filter((part) => part.length > 0).join('/'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dirnameStoragePath(filePath: string): string {
|
|
73
|
+
const normalized = normalizeStoragePath(filePath);
|
|
74
|
+
if (normalized === '/' || normalized === '.') return '.';
|
|
75
|
+
const absolute = normalized.startsWith('/');
|
|
76
|
+
const parts = normalized.split('/').filter((part) => part.length > 0);
|
|
77
|
+
parts.pop();
|
|
78
|
+
if (parts.length === 0) return absolute ? '/' : '.';
|
|
79
|
+
return `${absolute ? '/' : ''}${parts.join('/')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statFromLike(stat: BackendStorageStatLike): BackendFileStat {
|
|
83
|
+
if (typeof stat.isFile === 'function' && typeof stat.isDirectory === 'function') {
|
|
84
|
+
return stat as BackendFileStat;
|
|
85
|
+
}
|
|
86
|
+
const kind = stat.kind ?? stat.type;
|
|
87
|
+
if (kind === 'file' || kind === 'directory') return new StorageFileStat(kind);
|
|
88
|
+
throw createPathError('EINVAL', 'Storage stat must provide kind/type or isFile()/isDirectory().');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function inferStat(storage: BackendAsyncStorageAdapter, filePath: string): Promise<BackendFileStat> {
|
|
92
|
+
if (storage.stat) return statFromLike(await storage.stat(filePath));
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await storage.readdir(filePath);
|
|
96
|
+
return new StorageFileStat('directory');
|
|
97
|
+
} catch {
|
|
98
|
+
// Try file probes below.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await storage.readFileRaw(filePath);
|
|
103
|
+
return new StorageFileStat('file');
|
|
104
|
+
} catch {
|
|
105
|
+
try {
|
|
106
|
+
await storage.readFile(filePath);
|
|
107
|
+
return new StorageFileStat('file');
|
|
108
|
+
} catch {
|
|
109
|
+
throw createPathError('ENOENT', `Storage path not found: ${filePath}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type BackendStorageFileSystem = BackendFileSystem & CoreProjectFileSystem;
|
|
115
|
+
|
|
116
|
+
export function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapter): BackendStorageFileSystem {
|
|
117
|
+
const lockedPaths = new Set<string>();
|
|
118
|
+
|
|
119
|
+
const fileSystem: BackendStorageFileSystem = {
|
|
120
|
+
stat(filePath: string): Promise<BackendFileStat> {
|
|
121
|
+
return inferStat(storage, fileSystem.resolve(filePath));
|
|
122
|
+
},
|
|
123
|
+
readdir(dirPath: string): Promise<string[]> {
|
|
124
|
+
return storage.readdir(fileSystem.resolve(dirPath));
|
|
125
|
+
},
|
|
126
|
+
readFile(filePath: string): Promise<string> {
|
|
127
|
+
return storage.readFile(fileSystem.resolve(filePath));
|
|
128
|
+
},
|
|
129
|
+
readFileRaw(filePath: string): Promise<Uint8Array> {
|
|
130
|
+
return storage.readFileRaw(fileSystem.resolve(filePath));
|
|
131
|
+
},
|
|
132
|
+
writeFile(filePath: string, content: string): Promise<void> {
|
|
133
|
+
return storage.writeFile(fileSystem.resolve(filePath), content);
|
|
134
|
+
},
|
|
135
|
+
writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
|
|
136
|
+
return storage.writeFileRaw(fileSystem.resolve(filePath), data);
|
|
137
|
+
},
|
|
138
|
+
mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void> {
|
|
139
|
+
return storage.mkdir(fileSystem.resolve(dirPath), options);
|
|
140
|
+
},
|
|
141
|
+
async exists(filePath: string): Promise<boolean> {
|
|
142
|
+
if (storage.exists) return storage.exists(fileSystem.resolve(filePath));
|
|
143
|
+
try {
|
|
144
|
+
await fileSystem.stat(filePath);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
resolvePath(filePath: string): Promise<string> {
|
|
151
|
+
const resolved = fileSystem.resolve(filePath);
|
|
152
|
+
return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
|
|
153
|
+
},
|
|
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}`);
|
|
159
|
+
}
|
|
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
|
+
};
|
|
173
|
+
},
|
|
174
|
+
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().');
|
|
179
|
+
},
|
|
180
|
+
join(...paths: string[]): string {
|
|
181
|
+
return storage.join ? storage.join(...paths) : joinStoragePath(...paths);
|
|
182
|
+
},
|
|
183
|
+
dirname(filePath: string): string {
|
|
184
|
+
return storage.dirname ? storage.dirname(filePath) : dirnameStoragePath(filePath);
|
|
185
|
+
},
|
|
186
|
+
resolve(...paths: string[]): string {
|
|
187
|
+
return storage.resolve ? storage.resolve(...paths) : joinStoragePath(...paths);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return fileSystem;
|
|
192
|
+
}
|