@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
|
@@ -1,87 +1,231 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import type { ProjectResourceFolder, ProjectSourceFile } from '@openfairygui/core/project-io';
|
|
2
|
+
import {
|
|
3
|
+
commitUamProjectSourcePaths,
|
|
4
|
+
materializeUamProject,
|
|
5
|
+
type UamProject,
|
|
6
|
+
validateUamProject,
|
|
7
|
+
} from '@openfairygui/core/uam';
|
|
8
|
+
import { type ApplyUamTransactionAppError, applyUamTransactionApp } from '@openfairygui/functions/uam';
|
|
9
|
+
import type { BackendDiagnostic } from '../contracts.js';
|
|
10
|
+
import { normalizeComparablePath, type PathPolicyViolationError, validateSaveTarget } from '../path-policy.js';
|
|
7
11
|
import type {
|
|
8
12
|
ApplySessionTransactionInput,
|
|
9
13
|
BackendCapabilityUnavailableError,
|
|
10
14
|
BackendFileSystem,
|
|
11
15
|
BackendResult,
|
|
12
16
|
BackendSessionSnapshot,
|
|
17
|
+
InProcessLockConflictError,
|
|
18
|
+
MaterializeSessionInput,
|
|
19
|
+
MaterializeSessionSnapshot,
|
|
20
|
+
MaterializeValidationFailedError,
|
|
21
|
+
MaterializeWriteFailedError,
|
|
13
22
|
SavePartialFailureError,
|
|
23
|
+
SaveSessionInput,
|
|
14
24
|
SessionNotFoundError,
|
|
15
25
|
SessionStaleWriteError,
|
|
26
|
+
UamFidelityUnsupportedError,
|
|
16
27
|
} from '../runtime.js';
|
|
17
|
-
import {
|
|
28
|
+
import type { CacheService } from './cache-service.js';
|
|
29
|
+
import { type BackendContext, failure, success } from './context.js';
|
|
30
|
+
import type { EventService } from './event-service.js';
|
|
31
|
+
import { writeSessionProject } from './session-project-writer.js';
|
|
18
32
|
import { createSessionNotFoundError, createStaleWriteError, toSessionSnapshot } from './session-utils.js';
|
|
19
33
|
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
function projectSourceFiles(project: UamProject): Map<string, ProjectSourceFile> {
|
|
35
|
+
const result = new Map<string, ProjectSourceFile>();
|
|
36
|
+
for (const pkg of project.packages) {
|
|
37
|
+
result.set(`${pkg.id}/package.xml`, {
|
|
38
|
+
packageName: pkg.name,
|
|
39
|
+
branch: '',
|
|
40
|
+
path: '',
|
|
41
|
+
fileName: 'package.xml',
|
|
42
|
+
});
|
|
43
|
+
const branches = new Set<string>();
|
|
44
|
+
for (const folder of pkg.folders) {
|
|
45
|
+
if (folder.branch) branches.add(folder.branch);
|
|
46
|
+
}
|
|
47
|
+
for (const resource of pkg.resources) {
|
|
48
|
+
if (resource.branch) branches.add(resource.branch);
|
|
49
|
+
const fileName = resource.kind === 'component'
|
|
50
|
+
? `${resource.name}.xml`
|
|
51
|
+
: resource.fileName ?? (resource.kind === 'image' ? '' : resource.file) ?? '';
|
|
52
|
+
if (!fileName) continue;
|
|
53
|
+
result.set(`${pkg.id}/${resource.id}`, {
|
|
54
|
+
packageName: pkg.name,
|
|
55
|
+
branch: resource.branch,
|
|
56
|
+
path: resource.path,
|
|
57
|
+
fileName,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
for (const branch of branches) {
|
|
61
|
+
result.set(`${pkg.id}/branch/${branch}`, {
|
|
62
|
+
packageName: pkg.name,
|
|
63
|
+
branch,
|
|
64
|
+
path: '',
|
|
65
|
+
fileName: 'package_branch.xml',
|
|
66
|
+
});
|
|
33
67
|
}
|
|
34
68
|
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
35
71
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
},
|
|
49
|
-
async writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
|
|
50
|
-
await trackWrite(filePath, async () => {
|
|
51
|
-
await fileSystem.mkdir(fileSystem.dirname(filePath), { recursive: true });
|
|
52
|
-
await fileSystem.writeFileRaw(filePath, data);
|
|
72
|
+
function sourceFileKey(source: ProjectSourceFile): string {
|
|
73
|
+
return [source.branch, source.packageName, source.path, source.fileName].join('\0');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function projectResourceFolders(project: UamProject): Map<string, ProjectResourceFolder> {
|
|
77
|
+
const result = new Map<string, ProjectResourceFolder>();
|
|
78
|
+
for (const pkg of project.packages) {
|
|
79
|
+
for (const folder of pkg.folders) {
|
|
80
|
+
result.set(`${pkg.id}/${folder.branch}/${folder.path}`, {
|
|
81
|
+
packageName: pkg.name,
|
|
82
|
+
branch: folder.branch,
|
|
83
|
+
path: folder.path,
|
|
53
84
|
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resourceFolderKey(folder: ProjectResourceFolder): string {
|
|
91
|
+
return [folder.branch, folder.packageName, folder.path].join('\0');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function recordStaleProjectFiles(
|
|
95
|
+
session: Parameters<typeof toSessionSnapshot>[0],
|
|
96
|
+
previousProject: UamProject,
|
|
97
|
+
nextProject: UamProject,
|
|
98
|
+
): void {
|
|
99
|
+
if (!session.fileSystem) return;
|
|
100
|
+
const previousSources = projectSourceFiles(previousProject);
|
|
101
|
+
const nextSourceKeys = new Set([...projectSourceFiles(nextProject).values()].map(sourceFileKey));
|
|
102
|
+
for (const source of previousSources.values()) {
|
|
103
|
+
const key = sourceFileKey(source);
|
|
104
|
+
if (!nextSourceKeys.has(key)) session.pendingStaleSourceFiles.set(key, source);
|
|
105
|
+
}
|
|
106
|
+
for (const key of nextSourceKeys) {
|
|
107
|
+
session.pendingStaleSourceFiles.delete(key);
|
|
108
|
+
}
|
|
109
|
+
const previousFolders = projectResourceFolders(previousProject);
|
|
110
|
+
const nextFolderKeys = new Set([...projectResourceFolders(nextProject).values()].map(resourceFolderKey));
|
|
111
|
+
for (const folder of previousFolders.values()) {
|
|
112
|
+
const key = resourceFolderKey(folder);
|
|
113
|
+
if (!nextFolderKeys.has(key)) session.pendingStaleResourceFolders.set(key, folder);
|
|
114
|
+
}
|
|
115
|
+
for (const key of nextFolderKeys) {
|
|
116
|
+
session.pendingStaleResourceFolders.delete(key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toBackendDiagnostics(error: ApplyUamTransactionAppError): BackendDiagnostic[] {
|
|
121
|
+
return error.diagnostics.length > 0
|
|
122
|
+
? error.diagnostics.map((diagnostic) => ({ ...diagnostic }))
|
|
123
|
+
: [
|
|
124
|
+
{
|
|
125
|
+
code: error.code,
|
|
126
|
+
message: error.message,
|
|
127
|
+
severity: 'error',
|
|
128
|
+
operationKind: error.operationKind,
|
|
129
|
+
opIndex: error.opIndex,
|
|
130
|
+
opId: error.opId,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createCapabilityUnavailableError(message: string): BackendCapabilityUnavailableError {
|
|
136
|
+
return {
|
|
137
|
+
code: 'capability_unavailable',
|
|
138
|
+
message,
|
|
139
|
+
capability: 'fileSystem',
|
|
140
|
+
requiredAdapter: 'BackendFileSystem',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createUamFidelityUnsupportedError(
|
|
145
|
+
session: Parameters<typeof toSessionSnapshot>[0],
|
|
146
|
+
): UamFidelityUnsupportedError {
|
|
147
|
+
return {
|
|
148
|
+
code: 'uam_fidelity_unsupported',
|
|
149
|
+
message: 'The source project contains formal properties that the current UAM cannot preserve.',
|
|
150
|
+
sessionId: session.sessionId,
|
|
151
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validationDiagnostics(sessionProject: Parameters<typeof validateUamProject>[0]): BackendDiagnostic[] {
|
|
156
|
+
const issues = validateUamProject(sessionProject);
|
|
157
|
+
return issues.map((issue) => ({
|
|
158
|
+
code: 'materialize_validation_failed',
|
|
159
|
+
message: issue.message,
|
|
160
|
+
severity: 'error',
|
|
161
|
+
path: issue.path,
|
|
162
|
+
operationKind: 'materializeSession',
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function toMaterializeSnapshot(
|
|
167
|
+
session: Parameters<typeof toSessionSnapshot>[0],
|
|
168
|
+
capabilities: Parameters<typeof toSessionSnapshot>[1],
|
|
169
|
+
input: {
|
|
170
|
+
reason?: string;
|
|
171
|
+
writtenPaths: string[];
|
|
172
|
+
skippedPaths: string[];
|
|
173
|
+
diagnostics: BackendDiagnostic[];
|
|
174
|
+
},
|
|
175
|
+
): MaterializeSessionSnapshot {
|
|
176
|
+
return {
|
|
177
|
+
...toSessionSnapshot(session, capabilities),
|
|
178
|
+
mode: 'fullProject',
|
|
179
|
+
reason: input.reason,
|
|
180
|
+
materializeRevision: session.revision,
|
|
181
|
+
saveRevision: session.lastSavedRevision,
|
|
182
|
+
writtenPaths: [...input.writtenPaths],
|
|
183
|
+
skippedPaths: [...input.skippedPaths],
|
|
184
|
+
diagnostics: input.diagnostics.map((diagnostic) => ({ ...diagnostic })),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function storageCanonicalTarget(input: NonNullable<MaterializeSessionInput['storage']>): {
|
|
189
|
+
fileSystem: BackendFileSystem;
|
|
190
|
+
fairyPath: string;
|
|
191
|
+
canonicalProjectPath: string;
|
|
192
|
+
canonicalPathKey: string;
|
|
193
|
+
} {
|
|
194
|
+
const canonicalProjectPath = input.canonicalProjectPath ?? (input.fileSystem.dirname(input.fairyPath) || '.');
|
|
195
|
+
return {
|
|
196
|
+
fileSystem: input.fileSystem,
|
|
197
|
+
fairyPath: input.fairyPath,
|
|
198
|
+
canonicalProjectPath,
|
|
199
|
+
canonicalPathKey: input.canonicalPathKey ?? normalizeComparablePath(canonicalProjectPath),
|
|
75
200
|
};
|
|
76
201
|
}
|
|
77
202
|
|
|
78
203
|
export class AuthoringService {
|
|
204
|
+
private readonly sessionOperations = new Map<string, Promise<void>>();
|
|
205
|
+
|
|
79
206
|
public constructor(
|
|
80
207
|
private readonly context: BackendContext,
|
|
81
208
|
private readonly cacheService: CacheService,
|
|
82
209
|
private readonly eventService: EventService,
|
|
83
210
|
) {}
|
|
84
211
|
|
|
212
|
+
private async runSessionExclusive<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
|
213
|
+
const previous = this.sessionOperations.get(sessionId) ?? Promise.resolve();
|
|
214
|
+
let release = (): void => undefined;
|
|
215
|
+
const current = new Promise<void>((resolve) => {
|
|
216
|
+
release = resolve;
|
|
217
|
+
});
|
|
218
|
+
const tail = previous.then(() => current);
|
|
219
|
+
this.sessionOperations.set(sessionId, tail);
|
|
220
|
+
await previous;
|
|
221
|
+
try {
|
|
222
|
+
return await operation();
|
|
223
|
+
} finally {
|
|
224
|
+
release();
|
|
225
|
+
if (this.sessionOperations.get(sessionId) === tail) this.sessionOperations.delete(sessionId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
85
229
|
public async applyTransaction(
|
|
86
230
|
input: ApplySessionTransactionInput,
|
|
87
231
|
): Promise<
|
|
@@ -89,6 +233,17 @@ export class AuthoringService {
|
|
|
89
233
|
BackendSessionSnapshot,
|
|
90
234
|
SessionNotFoundError | SessionStaleWriteError | ApplyUamTransactionAppError
|
|
91
235
|
>
|
|
236
|
+
> {
|
|
237
|
+
return this.runSessionExclusive(input.sessionId, () => this.applyTransactionExclusive(input));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async applyTransactionExclusive(
|
|
241
|
+
input: ApplySessionTransactionInput,
|
|
242
|
+
): Promise<
|
|
243
|
+
BackendResult<
|
|
244
|
+
BackendSessionSnapshot,
|
|
245
|
+
SessionNotFoundError | SessionStaleWriteError | ApplyUamTransactionAppError
|
|
246
|
+
>
|
|
92
247
|
> {
|
|
93
248
|
const startedAt = Date.now();
|
|
94
249
|
const session = this.context.sessions.get(input.sessionId);
|
|
@@ -119,17 +274,13 @@ export class AuthoringService {
|
|
|
119
274
|
operations: input.operations,
|
|
120
275
|
});
|
|
121
276
|
if (result.ok === false) {
|
|
277
|
+
const diagnostics = toBackendDiagnostics(result.error);
|
|
122
278
|
this.eventService.emit({
|
|
123
279
|
kind: 'transaction.rejected',
|
|
124
280
|
sessionId: session.sessionId,
|
|
125
281
|
canonicalPathKey: session.canonicalPathKey,
|
|
126
282
|
revision: session.revision,
|
|
127
|
-
diagnostics
|
|
128
|
-
result.error.issues?.map((issue) => ({
|
|
129
|
-
code: result.error.code,
|
|
130
|
-
message: issue.message,
|
|
131
|
-
severity: 'error' as const,
|
|
132
|
-
})) ?? [],
|
|
283
|
+
diagnostics,
|
|
133
284
|
});
|
|
134
285
|
return failure(
|
|
135
286
|
'authoring',
|
|
@@ -139,10 +290,12 @@ export class AuthoringService {
|
|
|
139
290
|
{
|
|
140
291
|
sessionId: session.sessionId,
|
|
141
292
|
revision: session.revision,
|
|
293
|
+
diagnostics,
|
|
142
294
|
},
|
|
143
295
|
);
|
|
144
296
|
}
|
|
145
297
|
|
|
298
|
+
recordStaleProjectFiles(session, session.project, result.project);
|
|
146
299
|
session.project = result.project;
|
|
147
300
|
session.revision += 1;
|
|
148
301
|
session.dirty = true;
|
|
@@ -167,17 +320,48 @@ export class AuthoringService {
|
|
|
167
320
|
});
|
|
168
321
|
}
|
|
169
322
|
|
|
170
|
-
public async saveSession(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
targetPath?: string;
|
|
174
|
-
}): Promise<
|
|
323
|
+
public async saveSession(
|
|
324
|
+
input: SaveSessionInput,
|
|
325
|
+
): Promise<
|
|
175
326
|
BackendResult<
|
|
176
|
-
BackendSessionSnapshot,
|
|
327
|
+
BackendSessionSnapshot | MaterializeSessionSnapshot,
|
|
177
328
|
| SessionNotFoundError
|
|
178
329
|
| SessionStaleWriteError
|
|
179
330
|
| SavePartialFailureError
|
|
331
|
+
| UamFidelityUnsupportedError
|
|
332
|
+
| MaterializeValidationFailedError
|
|
333
|
+
| MaterializeWriteFailedError
|
|
180
334
|
| PathPolicyViolationError
|
|
335
|
+
| InProcessLockConflictError
|
|
336
|
+
| BackendCapabilityUnavailableError
|
|
337
|
+
>
|
|
338
|
+
> {
|
|
339
|
+
if (input.force === true || input.mode === 'materializeCleanSession') {
|
|
340
|
+
return this.materializeSession({
|
|
341
|
+
sessionId: input.sessionId,
|
|
342
|
+
expectedRevision: input.expectedRevision,
|
|
343
|
+
targetPath: input.targetPath,
|
|
344
|
+
fileSystem: input.fileSystem,
|
|
345
|
+
mode: 'fullProject',
|
|
346
|
+
reason: 'force_save',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return this.runSessionExclusive(input.sessionId, () => this.saveSessionExclusive(input));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async saveSessionExclusive(
|
|
353
|
+
input: SaveSessionInput,
|
|
354
|
+
): Promise<
|
|
355
|
+
BackendResult<
|
|
356
|
+
BackendSessionSnapshot | MaterializeSessionSnapshot,
|
|
357
|
+
| SessionNotFoundError
|
|
358
|
+
| SessionStaleWriteError
|
|
359
|
+
| SavePartialFailureError
|
|
360
|
+
| UamFidelityUnsupportedError
|
|
361
|
+
| MaterializeValidationFailedError
|
|
362
|
+
| MaterializeWriteFailedError
|
|
363
|
+
| PathPolicyViolationError
|
|
364
|
+
| InProcessLockConflictError
|
|
181
365
|
| BackendCapabilityUnavailableError
|
|
182
366
|
>
|
|
183
367
|
> {
|
|
@@ -186,16 +370,12 @@ export class AuthoringService {
|
|
|
186
370
|
if (!session || session.closed) {
|
|
187
371
|
return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
|
|
188
372
|
}
|
|
189
|
-
|
|
373
|
+
const fileSystem = session.fileSystem ?? input.fileSystem ?? this.context.fileSystem;
|
|
374
|
+
if (!fileSystem) {
|
|
190
375
|
return failure(
|
|
191
376
|
'authoring',
|
|
192
377
|
startedAt,
|
|
193
|
-
|
|
194
|
-
code: 'capability_unavailable',
|
|
195
|
-
message: 'saveSession requires an injected BackendFileSystem adapter.',
|
|
196
|
-
capability: 'fileSystem',
|
|
197
|
-
requiredAdapter: 'BackendFileSystem',
|
|
198
|
-
},
|
|
378
|
+
createCapabilityUnavailableError('saveSession requires an injected BackendFileSystem adapter.'),
|
|
199
379
|
toSessionSnapshot(session, this.context.capabilities),
|
|
200
380
|
{
|
|
201
381
|
sessionId: session.sessionId,
|
|
@@ -215,7 +395,6 @@ export class AuthoringService {
|
|
|
215
395
|
},
|
|
216
396
|
);
|
|
217
397
|
}
|
|
218
|
-
const fileSystem = this.context.fileSystem;
|
|
219
398
|
const targetViolation = await validateSaveTarget(fileSystem, session.fairyPath, input.targetPath);
|
|
220
399
|
if (targetViolation) {
|
|
221
400
|
return failure(
|
|
@@ -235,6 +414,18 @@ export class AuthoringService {
|
|
|
235
414
|
revision: session.revision,
|
|
236
415
|
});
|
|
237
416
|
}
|
|
417
|
+
if (session.uamFidelity === 'unsupported') {
|
|
418
|
+
return failure(
|
|
419
|
+
'authoring',
|
|
420
|
+
startedAt,
|
|
421
|
+
createUamFidelityUnsupportedError(session),
|
|
422
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
423
|
+
{
|
|
424
|
+
sessionId: session.sessionId,
|
|
425
|
+
revision: session.revision,
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
}
|
|
238
429
|
|
|
239
430
|
const committedPaths: string[] = [];
|
|
240
431
|
const failedPaths: string[] = [];
|
|
@@ -245,8 +436,19 @@ export class AuthoringService {
|
|
|
245
436
|
revision: session.revision,
|
|
246
437
|
});
|
|
247
438
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
439
|
+
await writeSessionProject({
|
|
440
|
+
fileSystem,
|
|
441
|
+
document: materializeUamProject(session.project),
|
|
442
|
+
fairyPath: session.fairyPath,
|
|
443
|
+
staleSourceFiles: [...session.pendingStaleSourceFiles.values()],
|
|
444
|
+
staleResourceFolders: [...session.pendingStaleResourceFolders.values()],
|
|
445
|
+
writtenPaths: committedPaths,
|
|
446
|
+
failedPaths,
|
|
447
|
+
});
|
|
448
|
+
session.fileSystem ??= fileSystem;
|
|
449
|
+
session.pendingStaleSourceFiles.clear();
|
|
450
|
+
session.pendingStaleResourceFolders.clear();
|
|
451
|
+
commitUamProjectSourcePaths(session.project);
|
|
250
452
|
session.lastSavedRevision = session.revision;
|
|
251
453
|
session.dirty = false;
|
|
252
454
|
const cacheEntry = this.cacheService.refreshSession(session);
|
|
@@ -297,4 +499,281 @@ export class AuthoringService {
|
|
|
297
499
|
);
|
|
298
500
|
}
|
|
299
501
|
}
|
|
502
|
+
|
|
503
|
+
public async materializeSession(
|
|
504
|
+
input: MaterializeSessionInput,
|
|
505
|
+
): Promise<
|
|
506
|
+
BackendResult<
|
|
507
|
+
MaterializeSessionSnapshot,
|
|
508
|
+
| SessionNotFoundError
|
|
509
|
+
| SessionStaleWriteError
|
|
510
|
+
| UamFidelityUnsupportedError
|
|
511
|
+
| MaterializeValidationFailedError
|
|
512
|
+
| MaterializeWriteFailedError
|
|
513
|
+
| PathPolicyViolationError
|
|
514
|
+
| InProcessLockConflictError
|
|
515
|
+
| BackendCapabilityUnavailableError
|
|
516
|
+
>
|
|
517
|
+
> {
|
|
518
|
+
return this.runSessionExclusive(input.sessionId, () => this.materializeSessionExclusive(input));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private async materializeSessionExclusive(
|
|
522
|
+
input: MaterializeSessionInput,
|
|
523
|
+
): Promise<
|
|
524
|
+
BackendResult<
|
|
525
|
+
MaterializeSessionSnapshot,
|
|
526
|
+
| SessionNotFoundError
|
|
527
|
+
| SessionStaleWriteError
|
|
528
|
+
| UamFidelityUnsupportedError
|
|
529
|
+
| MaterializeValidationFailedError
|
|
530
|
+
| MaterializeWriteFailedError
|
|
531
|
+
| PathPolicyViolationError
|
|
532
|
+
| InProcessLockConflictError
|
|
533
|
+
| BackendCapabilityUnavailableError
|
|
534
|
+
>
|
|
535
|
+
> {
|
|
536
|
+
const startedAt = Date.now();
|
|
537
|
+
const session = this.context.sessions.get(input.sessionId);
|
|
538
|
+
if (!session || session.closed) {
|
|
539
|
+
return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
|
|
540
|
+
}
|
|
541
|
+
if (input.expectedRevision !== undefined && input.expectedRevision !== session.revision) {
|
|
542
|
+
return failure(
|
|
543
|
+
'authoring',
|
|
544
|
+
startedAt,
|
|
545
|
+
createStaleWriteError(session, input.expectedRevision),
|
|
546
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
547
|
+
{
|
|
548
|
+
sessionId: session.sessionId,
|
|
549
|
+
revision: session.revision,
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const storageTarget = input.storage ? storageCanonicalTarget(input.storage) : null;
|
|
555
|
+
const fileSystem =
|
|
556
|
+
storageTarget?.fileSystem ?? input.fileSystem ?? session.fileSystem ?? this.context.fileSystem;
|
|
557
|
+
if (!fileSystem) {
|
|
558
|
+
return failure(
|
|
559
|
+
'authoring',
|
|
560
|
+
startedAt,
|
|
561
|
+
createCapabilityUnavailableError('materializeSession requires an injected BackendFileSystem adapter.'),
|
|
562
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
563
|
+
{
|
|
564
|
+
sessionId: session.sessionId,
|
|
565
|
+
revision: session.revision,
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const fairyPath = storageTarget?.fairyPath ?? session.fairyPath;
|
|
571
|
+
const targetViolation = await validateSaveTarget(fileSystem, fairyPath, input.targetPath);
|
|
572
|
+
if (targetViolation) {
|
|
573
|
+
return failure(
|
|
574
|
+
'authoring',
|
|
575
|
+
startedAt,
|
|
576
|
+
targetViolation,
|
|
577
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
578
|
+
{
|
|
579
|
+
sessionId: session.sessionId,
|
|
580
|
+
revision: session.revision,
|
|
581
|
+
},
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (storageTarget) {
|
|
586
|
+
const holderSessionId = this.context.sessionsByPath.get(storageTarget.canonicalPathKey);
|
|
587
|
+
if (holderSessionId && holderSessionId !== session.sessionId) {
|
|
588
|
+
return failure(
|
|
589
|
+
'authoring',
|
|
590
|
+
startedAt,
|
|
591
|
+
{
|
|
592
|
+
code: 'lock_conflict',
|
|
593
|
+
kind: 'in_process_session_exists',
|
|
594
|
+
message: `Project is already open in this backend runtime: ${storageTarget.canonicalProjectPath}`,
|
|
595
|
+
canonicalPathKey: storageTarget.canonicalPathKey,
|
|
596
|
+
holderSessionId,
|
|
597
|
+
},
|
|
598
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
599
|
+
{
|
|
600
|
+
sessionId: session.sessionId,
|
|
601
|
+
revision: session.revision,
|
|
602
|
+
},
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (session.uamFidelity === 'unsupported') {
|
|
608
|
+
return failure(
|
|
609
|
+
'authoring',
|
|
610
|
+
startedAt,
|
|
611
|
+
createUamFidelityUnsupportedError(session),
|
|
612
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
613
|
+
{
|
|
614
|
+
sessionId: session.sessionId,
|
|
615
|
+
revision: session.revision,
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const diagnostics = validationDiagnostics(session.project);
|
|
621
|
+
if (diagnostics.length > 0) {
|
|
622
|
+
const error: MaterializeValidationFailedError = {
|
|
623
|
+
code: 'materialize_validation_failed',
|
|
624
|
+
message: `UAM materialize validation failed with ${diagnostics.length} issue(s).`,
|
|
625
|
+
sessionId: session.sessionId,
|
|
626
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
627
|
+
issueCount: diagnostics.length,
|
|
628
|
+
diagnostics,
|
|
629
|
+
};
|
|
630
|
+
return failure('authoring', startedAt, error, toSessionSnapshot(session, this.context.capabilities), {
|
|
631
|
+
sessionId: session.sessionId,
|
|
632
|
+
revision: session.revision,
|
|
633
|
+
diagnostics,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let document: ReturnType<typeof materializeUamProject>;
|
|
638
|
+
try {
|
|
639
|
+
document = materializeUamProject(session.project);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
const diagnosticsFromError: BackendDiagnostic[] = [
|
|
642
|
+
{
|
|
643
|
+
code: 'materialize_validation_failed',
|
|
644
|
+
message: error instanceof Error ? error.message : String(error),
|
|
645
|
+
severity: 'error',
|
|
646
|
+
operationKind: 'materializeSession',
|
|
647
|
+
},
|
|
648
|
+
];
|
|
649
|
+
return failure(
|
|
650
|
+
'authoring',
|
|
651
|
+
startedAt,
|
|
652
|
+
{
|
|
653
|
+
code: 'materialize_validation_failed',
|
|
654
|
+
message: error instanceof Error ? error.message : String(error),
|
|
655
|
+
sessionId: session.sessionId,
|
|
656
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
657
|
+
issueCount: diagnosticsFromError.length,
|
|
658
|
+
diagnostics: diagnosticsFromError,
|
|
659
|
+
},
|
|
660
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
661
|
+
{
|
|
662
|
+
sessionId: session.sessionId,
|
|
663
|
+
revision: session.revision,
|
|
664
|
+
diagnostics: diagnosticsFromError,
|
|
665
|
+
},
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const writtenPaths: string[] = [];
|
|
670
|
+
const failedPaths: string[] = [];
|
|
671
|
+
const skippedPaths: string[] = [];
|
|
672
|
+
this.eventService.emit({
|
|
673
|
+
kind: 'save.started',
|
|
674
|
+
sessionId: session.sessionId,
|
|
675
|
+
canonicalPathKey: storageTarget?.canonicalPathKey ?? session.canonicalPathKey,
|
|
676
|
+
revision: session.revision,
|
|
677
|
+
});
|
|
678
|
+
try {
|
|
679
|
+
const isSessionStorageTarget = fileSystem === session.fileSystem && fairyPath === session.fairyPath;
|
|
680
|
+
await writeSessionProject({
|
|
681
|
+
fileSystem,
|
|
682
|
+
document,
|
|
683
|
+
fairyPath,
|
|
684
|
+
staleSourceFiles: isSessionStorageTarget ? [...session.pendingStaleSourceFiles.values()] : [],
|
|
685
|
+
staleResourceFolders: isSessionStorageTarget ? [...session.pendingStaleResourceFolders.values()] : [],
|
|
686
|
+
writtenPaths,
|
|
687
|
+
failedPaths,
|
|
688
|
+
});
|
|
689
|
+
if (isSessionStorageTarget) {
|
|
690
|
+
session.pendingStaleSourceFiles.clear();
|
|
691
|
+
session.pendingStaleResourceFolders.clear();
|
|
692
|
+
}
|
|
693
|
+
if (storageTarget && !isSessionStorageTarget) {
|
|
694
|
+
session.pendingStaleSourceFiles.clear();
|
|
695
|
+
session.pendingStaleResourceFolders.clear();
|
|
696
|
+
}
|
|
697
|
+
if (isSessionStorageTarget || storageTarget) commitUamProjectSourcePaths(session.project);
|
|
698
|
+
if (storageTarget) {
|
|
699
|
+
this.context.sessionsByPath.delete(session.canonicalPathKey);
|
|
700
|
+
session.fileSystem = storageTarget.fileSystem;
|
|
701
|
+
session.fairyPath = storageTarget.fairyPath;
|
|
702
|
+
session.canonicalProjectPath = storageTarget.canonicalProjectPath;
|
|
703
|
+
session.canonicalPathKey = storageTarget.canonicalPathKey;
|
|
704
|
+
this.context.sessionsByPath.set(session.canonicalPathKey, session.sessionId);
|
|
705
|
+
}
|
|
706
|
+
session.lastSavedRevision = session.revision;
|
|
707
|
+
session.dirty = false;
|
|
708
|
+
const cacheEntry = this.cacheService.refreshSession(session);
|
|
709
|
+
this.eventService.emit({
|
|
710
|
+
kind: 'save.completed',
|
|
711
|
+
sessionId: session.sessionId,
|
|
712
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
713
|
+
revision: session.revision,
|
|
714
|
+
});
|
|
715
|
+
this.eventService.emit({
|
|
716
|
+
kind: 'cache.updated',
|
|
717
|
+
sessionId: session.sessionId,
|
|
718
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
719
|
+
revision: session.revision,
|
|
720
|
+
cacheRevision: cacheEntry.revision,
|
|
721
|
+
});
|
|
722
|
+
return success(
|
|
723
|
+
'authoring',
|
|
724
|
+
startedAt,
|
|
725
|
+
toMaterializeSnapshot(session, this.context.capabilities, {
|
|
726
|
+
reason: input.reason,
|
|
727
|
+
writtenPaths,
|
|
728
|
+
skippedPaths,
|
|
729
|
+
diagnostics: [],
|
|
730
|
+
}),
|
|
731
|
+
{
|
|
732
|
+
sessionId: session.sessionId,
|
|
733
|
+
revision: session.revision,
|
|
734
|
+
},
|
|
735
|
+
);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
const diagnosticsFromError: BackendDiagnostic[] = [
|
|
738
|
+
{
|
|
739
|
+
code: 'write_failed',
|
|
740
|
+
message: error instanceof Error ? error.message : String(error),
|
|
741
|
+
severity: 'error',
|
|
742
|
+
path: failedPaths[0],
|
|
743
|
+
operationKind: 'materializeSession',
|
|
744
|
+
},
|
|
745
|
+
];
|
|
746
|
+
this.cacheService.invalidateSession(session);
|
|
747
|
+
this.eventService.emit({
|
|
748
|
+
kind: 'save.failed',
|
|
749
|
+
sessionId: session.sessionId,
|
|
750
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
751
|
+
revision: session.revision,
|
|
752
|
+
diagnostics: diagnosticsFromError,
|
|
753
|
+
});
|
|
754
|
+
return failure(
|
|
755
|
+
'authoring',
|
|
756
|
+
startedAt,
|
|
757
|
+
{
|
|
758
|
+
code: 'write_failed',
|
|
759
|
+
message: error instanceof Error ? error.message : String(error),
|
|
760
|
+
sessionId: session.sessionId,
|
|
761
|
+
canonicalPathKey: session.canonicalPathKey,
|
|
762
|
+
attemptedRevision: session.revision,
|
|
763
|
+
lastSavedRevision: session.lastSavedRevision,
|
|
764
|
+
writtenPaths,
|
|
765
|
+
failedPaths,
|
|
766
|
+
skippedPaths,
|
|
767
|
+
diagnostics: diagnosticsFromError,
|
|
768
|
+
diskMayBePartiallyUpdated: true,
|
|
769
|
+
},
|
|
770
|
+
toSessionSnapshot(session, this.context.capabilities),
|
|
771
|
+
{
|
|
772
|
+
sessionId: session.sessionId,
|
|
773
|
+
revision: session.revision,
|
|
774
|
+
diagnostics: diagnosticsFromError,
|
|
775
|
+
},
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
300
779
|
}
|