@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.
@@ -1,87 +1,231 @@
1
- import { ProjectWriter, type FileSystem } from '@openfairygui/core/project-io';
2
- import { materializeUamProject } from '@openfairygui/core/uam';
3
- import { applyUamTransactionApp, type ApplyUamTransactionAppError } from '@openfairygui/functions/uam';
4
- import type { CacheService } from './cache-service.js';
5
- import { failure, success, type BackendContext } from './context.js';
6
- import type { EventService } from './event-service.js';
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 { validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
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 createWriterFileSystem(
21
- fileSystem: BackendFileSystem,
22
- committedPaths: string[],
23
- failedPaths: string[],
24
- ): FileSystem {
25
- async function trackWrite<T>(targetPath: string, fn: () => Promise<T>): Promise<T> {
26
- try {
27
- const result = await fn();
28
- committedPaths.push(targetPath);
29
- return result;
30
- } catch (error) {
31
- failedPaths.push(targetPath);
32
- throw error;
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
- return {
37
- async readFile(filePath: string): Promise<string> {
38
- return fileSystem.readFile(filePath);
39
- },
40
- async readFileRaw(filePath: string): Promise<Uint8Array> {
41
- return fileSystem.readFileRaw(filePath);
42
- },
43
- async writeFile(filePath: string, content: string): Promise<void> {
44
- await trackWrite(filePath, async () => {
45
- await fileSystem.mkdir(fileSystem.dirname(filePath), { recursive: true });
46
- await fileSystem.writeFile(filePath, content);
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
- async mkdir(dirPath: string): Promise<void> {
56
- await fileSystem.mkdir(dirPath, { recursive: true });
57
- },
58
- async readdir(dirPath: string): Promise<string[]> {
59
- return fileSystem.readdir(dirPath);
60
- },
61
- async exists(filePath: string): Promise<boolean> {
62
- try {
63
- await fileSystem.stat(filePath);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- },
69
- join(...paths: string[]): string {
70
- return fileSystem.join(...paths);
71
- },
72
- dirname(filePath: string): string {
73
- return fileSystem.dirname(filePath);
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(input: {
171
- sessionId: string;
172
- expectedRevision?: number;
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
- if (!this.context.fileSystem) {
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
- const writer = new ProjectWriter(createWriterFileSystem(fileSystem, committedPaths, failedPaths));
249
- await writer.write(materializeUamProject(session.project), session.fairyPath);
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
  }