@openfairygui/backend 0.2.0-alpha.3 → 0.2.0-alpha.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,87 +1,175 @@
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
+ staleResourceFolders,
6
+ staleSourceFiles,
7
+ type UamProject,
8
+ validateUamProject,
9
+ } from '@openfairygui/core/uam';
10
+ import { type ApplyUamTransactionAppError, applyUamTransactionApp } from '@openfairygui/functions/uam';
11
+ import type { BackendDiagnostic } from '../contracts.js';
12
+ import { normalizeComparablePath, type PathPolicyViolationError, validateSaveTarget } from '../path-policy.js';
7
13
  import type {
8
14
  ApplySessionTransactionInput,
9
15
  BackendCapabilityUnavailableError,
10
16
  BackendFileSystem,
11
17
  BackendResult,
12
18
  BackendSessionSnapshot,
19
+ InProcessLockConflictError,
20
+ MaterializeSessionInput,
21
+ MaterializeSessionSnapshot,
22
+ MaterializeValidationFailedError,
23
+ MaterializeWriteFailedError,
13
24
  SavePartialFailureError,
25
+ SaveSessionInput,
14
26
  SessionNotFoundError,
15
27
  SessionStaleWriteError,
28
+ UamFidelityUnsupportedError,
16
29
  } from '../runtime.js';
17
- import { validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
30
+ import type { CacheService } from './cache-service.js';
31
+ import { type BackendContext, failure, success } from './context.js';
32
+ import type { EventService } from './event-service.js';
33
+ import { writeSessionProject } from './session-project-writer.js';
18
34
  import { createSessionNotFoundError, createStaleWriteError, toSessionSnapshot } from './session-utils.js';
19
35
 
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;
33
- }
36
+ function sourceFileKey(source: ProjectSourceFile): string {
37
+ return [source.branch, source.packageName, source.path, source.fileName].join('\0');
38
+ }
39
+
40
+ function resourceFolderKey(folder: ProjectResourceFolder): string {
41
+ return [folder.branch, folder.packageName, folder.path].join('\0');
42
+ }
43
+
44
+ function recordStaleProjectFiles(
45
+ session: Parameters<typeof toSessionSnapshot>[0],
46
+ previousProject: UamProject,
47
+ nextProject: UamProject,
48
+ ): void {
49
+ if (!session.fileSystem) return;
50
+ for (const source of staleSourceFiles(previousProject, nextProject)) {
51
+ session.pendingStaleSourceFiles.set(sourceFileKey(source), source);
52
+ }
53
+ for (const source of staleSourceFiles(nextProject, previousProject)) {
54
+ session.pendingStaleSourceFiles.delete(sourceFileKey(source));
34
55
  }
56
+ for (const folder of staleResourceFolders(previousProject, nextProject)) {
57
+ session.pendingStaleResourceFolders.set(resourceFolderKey(folder), folder);
58
+ }
59
+ for (const folder of staleResourceFolders(nextProject, previousProject)) {
60
+ session.pendingStaleResourceFolders.delete(resourceFolderKey(folder));
61
+ }
62
+ }
35
63
 
64
+ function toBackendDiagnostics(error: ApplyUamTransactionAppError): BackendDiagnostic[] {
65
+ return error.diagnostics.length > 0
66
+ ? error.diagnostics.map((diagnostic) => ({ ...diagnostic }))
67
+ : [
68
+ {
69
+ code: error.code,
70
+ message: error.message,
71
+ severity: 'error',
72
+ operationKind: error.operationKind,
73
+ opIndex: error.opIndex,
74
+ opId: error.opId,
75
+ },
76
+ ];
77
+ }
78
+
79
+ function createCapabilityUnavailableError(message: string): BackendCapabilityUnavailableError {
36
80
  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);
53
- });
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
- },
81
+ code: 'capability_unavailable',
82
+ message,
83
+ capability: 'fileSystem',
84
+ requiredAdapter: 'BackendFileSystem',
85
+ };
86
+ }
87
+
88
+ function createUamFidelityUnsupportedError(
89
+ session: Parameters<typeof toSessionSnapshot>[0],
90
+ ): UamFidelityUnsupportedError {
91
+ return {
92
+ code: 'uam_fidelity_unsupported',
93
+ message: 'The source project contains formal properties that the current UAM cannot preserve.',
94
+ sessionId: session.sessionId,
95
+ canonicalPathKey: session.canonicalPathKey,
96
+ };
97
+ }
98
+
99
+ function validationDiagnostics(sessionProject: Parameters<typeof validateUamProject>[0]): BackendDiagnostic[] {
100
+ const issues = validateUamProject(sessionProject);
101
+ return issues.map((issue) => ({
102
+ code: 'materialize_validation_failed',
103
+ message: issue.message,
104
+ severity: 'error',
105
+ path: issue.path,
106
+ operationKind: 'materializeSession',
107
+ }));
108
+ }
109
+
110
+ function toMaterializeSnapshot(
111
+ session: Parameters<typeof toSessionSnapshot>[0],
112
+ capabilities: Parameters<typeof toSessionSnapshot>[1],
113
+ input: {
114
+ reason?: string;
115
+ writtenPaths: string[];
116
+ skippedPaths: string[];
117
+ diagnostics: BackendDiagnostic[];
118
+ },
119
+ ): MaterializeSessionSnapshot {
120
+ return {
121
+ ...toSessionSnapshot(session, capabilities),
122
+ mode: 'fullProject',
123
+ reason: input.reason,
124
+ materializeRevision: session.revision,
125
+ saveRevision: session.lastSavedRevision,
126
+ writtenPaths: [...input.writtenPaths],
127
+ skippedPaths: [...input.skippedPaths],
128
+ diagnostics: input.diagnostics.map((diagnostic) => ({ ...diagnostic })),
129
+ };
130
+ }
131
+
132
+ function storageCanonicalTarget(input: NonNullable<MaterializeSessionInput['storage']>): {
133
+ fileSystem: BackendFileSystem;
134
+ fairyPath: string;
135
+ canonicalProjectPath: string;
136
+ canonicalPathKey: string;
137
+ } {
138
+ const canonicalProjectPath = input.canonicalProjectPath ?? (input.fileSystem.dirname(input.fairyPath) || '.');
139
+ return {
140
+ fileSystem: input.fileSystem,
141
+ fairyPath: input.fairyPath,
142
+ canonicalProjectPath,
143
+ canonicalPathKey: input.canonicalPathKey ?? normalizeComparablePath(canonicalProjectPath),
75
144
  };
76
145
  }
77
146
 
78
147
  export class AuthoringService {
148
+ private readonly sessionOperations = new Map<string, Promise<void>>();
149
+
79
150
  public constructor(
80
151
  private readonly context: BackendContext,
81
152
  private readonly cacheService: CacheService,
82
153
  private readonly eventService: EventService,
83
154
  ) {}
84
155
 
156
+ private async runSessionExclusive<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
157
+ const previous = this.sessionOperations.get(sessionId) ?? Promise.resolve();
158
+ let release = (): void => undefined;
159
+ const current = new Promise<void>((resolve) => {
160
+ release = resolve;
161
+ });
162
+ const tail = previous.then(() => current);
163
+ this.sessionOperations.set(sessionId, tail);
164
+ await previous;
165
+ try {
166
+ return await operation();
167
+ } finally {
168
+ release();
169
+ if (this.sessionOperations.get(sessionId) === tail) this.sessionOperations.delete(sessionId);
170
+ }
171
+ }
172
+
85
173
  public async applyTransaction(
86
174
  input: ApplySessionTransactionInput,
87
175
  ): Promise<
@@ -89,6 +177,17 @@ export class AuthoringService {
89
177
  BackendSessionSnapshot,
90
178
  SessionNotFoundError | SessionStaleWriteError | ApplyUamTransactionAppError
91
179
  >
180
+ > {
181
+ return this.runSessionExclusive(input.sessionId, () => this.applyTransactionExclusive(input));
182
+ }
183
+
184
+ private async applyTransactionExclusive(
185
+ input: ApplySessionTransactionInput,
186
+ ): Promise<
187
+ BackendResult<
188
+ BackendSessionSnapshot,
189
+ SessionNotFoundError | SessionStaleWriteError | ApplyUamTransactionAppError
190
+ >
92
191
  > {
93
192
  const startedAt = Date.now();
94
193
  const session = this.context.sessions.get(input.sessionId);
@@ -119,17 +218,13 @@ export class AuthoringService {
119
218
  operations: input.operations,
120
219
  });
121
220
  if (result.ok === false) {
221
+ const diagnostics = toBackendDiagnostics(result.error);
122
222
  this.eventService.emit({
123
223
  kind: 'transaction.rejected',
124
224
  sessionId: session.sessionId,
125
225
  canonicalPathKey: session.canonicalPathKey,
126
226
  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
- })) ?? [],
227
+ diagnostics,
133
228
  });
134
229
  return failure(
135
230
  'authoring',
@@ -139,10 +234,12 @@ export class AuthoringService {
139
234
  {
140
235
  sessionId: session.sessionId,
141
236
  revision: session.revision,
237
+ diagnostics,
142
238
  },
143
239
  );
144
240
  }
145
241
 
242
+ recordStaleProjectFiles(session, session.project, result.project);
146
243
  session.project = result.project;
147
244
  session.revision += 1;
148
245
  session.dirty = true;
@@ -167,17 +264,48 @@ export class AuthoringService {
167
264
  });
168
265
  }
169
266
 
170
- public async saveSession(input: {
171
- sessionId: string;
172
- expectedRevision?: number;
173
- targetPath?: string;
174
- }): Promise<
267
+ public async saveSession(
268
+ input: SaveSessionInput,
269
+ ): Promise<
175
270
  BackendResult<
176
- BackendSessionSnapshot,
271
+ BackendSessionSnapshot | MaterializeSessionSnapshot,
272
+ | SessionNotFoundError
273
+ | SessionStaleWriteError
274
+ | SavePartialFailureError
275
+ | UamFidelityUnsupportedError
276
+ | MaterializeValidationFailedError
277
+ | MaterializeWriteFailedError
278
+ | PathPolicyViolationError
279
+ | InProcessLockConflictError
280
+ | BackendCapabilityUnavailableError
281
+ >
282
+ > {
283
+ if (input.force === true || input.mode === 'materializeCleanSession') {
284
+ return this.materializeSession({
285
+ sessionId: input.sessionId,
286
+ expectedRevision: input.expectedRevision,
287
+ targetPath: input.targetPath,
288
+ fileSystem: input.fileSystem,
289
+ mode: 'fullProject',
290
+ reason: 'force_save',
291
+ });
292
+ }
293
+ return this.runSessionExclusive(input.sessionId, () => this.saveSessionExclusive(input));
294
+ }
295
+
296
+ private async saveSessionExclusive(
297
+ input: SaveSessionInput,
298
+ ): Promise<
299
+ BackendResult<
300
+ BackendSessionSnapshot | MaterializeSessionSnapshot,
177
301
  | SessionNotFoundError
178
302
  | SessionStaleWriteError
179
303
  | SavePartialFailureError
304
+ | UamFidelityUnsupportedError
305
+ | MaterializeValidationFailedError
306
+ | MaterializeWriteFailedError
180
307
  | PathPolicyViolationError
308
+ | InProcessLockConflictError
181
309
  | BackendCapabilityUnavailableError
182
310
  >
183
311
  > {
@@ -186,16 +314,12 @@ export class AuthoringService {
186
314
  if (!session || session.closed) {
187
315
  return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
188
316
  }
189
- if (!this.context.fileSystem) {
317
+ const fileSystem = session.fileSystem ?? input.fileSystem ?? this.context.fileSystem;
318
+ if (!fileSystem) {
190
319
  return failure(
191
320
  'authoring',
192
321
  startedAt,
193
- {
194
- code: 'capability_unavailable',
195
- message: 'saveSession requires an injected BackendFileSystem adapter.',
196
- capability: 'fileSystem',
197
- requiredAdapter: 'BackendFileSystem',
198
- },
322
+ createCapabilityUnavailableError('saveSession requires an injected BackendFileSystem adapter.'),
199
323
  toSessionSnapshot(session, this.context.capabilities),
200
324
  {
201
325
  sessionId: session.sessionId,
@@ -215,7 +339,6 @@ export class AuthoringService {
215
339
  },
216
340
  );
217
341
  }
218
- const fileSystem = this.context.fileSystem;
219
342
  const targetViolation = await validateSaveTarget(fileSystem, session.fairyPath, input.targetPath);
220
343
  if (targetViolation) {
221
344
  return failure(
@@ -235,6 +358,18 @@ export class AuthoringService {
235
358
  revision: session.revision,
236
359
  });
237
360
  }
361
+ if (session.uamFidelity === 'unsupported') {
362
+ return failure(
363
+ 'authoring',
364
+ startedAt,
365
+ createUamFidelityUnsupportedError(session),
366
+ toSessionSnapshot(session, this.context.capabilities),
367
+ {
368
+ sessionId: session.sessionId,
369
+ revision: session.revision,
370
+ },
371
+ );
372
+ }
238
373
 
239
374
  const committedPaths: string[] = [];
240
375
  const failedPaths: string[] = [];
@@ -245,8 +380,19 @@ export class AuthoringService {
245
380
  revision: session.revision,
246
381
  });
247
382
  try {
248
- const writer = new ProjectWriter(createWriterFileSystem(fileSystem, committedPaths, failedPaths));
249
- await writer.write(materializeUamProject(session.project), session.fairyPath);
383
+ await writeSessionProject({
384
+ fileSystem,
385
+ document: materializeUamProject(session.project),
386
+ fairyPath: session.fairyPath,
387
+ staleSourceFiles: [...session.pendingStaleSourceFiles.values()],
388
+ staleResourceFolders: [...session.pendingStaleResourceFolders.values()],
389
+ writtenPaths: committedPaths,
390
+ failedPaths,
391
+ });
392
+ session.fileSystem ??= fileSystem;
393
+ session.pendingStaleSourceFiles.clear();
394
+ session.pendingStaleResourceFolders.clear();
395
+ commitUamProjectSourcePaths(session.project);
250
396
  session.lastSavedRevision = session.revision;
251
397
  session.dirty = false;
252
398
  const cacheEntry = this.cacheService.refreshSession(session);
@@ -297,4 +443,281 @@ export class AuthoringService {
297
443
  );
298
444
  }
299
445
  }
446
+
447
+ public async materializeSession(
448
+ input: MaterializeSessionInput,
449
+ ): Promise<
450
+ BackendResult<
451
+ MaterializeSessionSnapshot,
452
+ | SessionNotFoundError
453
+ | SessionStaleWriteError
454
+ | UamFidelityUnsupportedError
455
+ | MaterializeValidationFailedError
456
+ | MaterializeWriteFailedError
457
+ | PathPolicyViolationError
458
+ | InProcessLockConflictError
459
+ | BackendCapabilityUnavailableError
460
+ >
461
+ > {
462
+ return this.runSessionExclusive(input.sessionId, () => this.materializeSessionExclusive(input));
463
+ }
464
+
465
+ private async materializeSessionExclusive(
466
+ input: MaterializeSessionInput,
467
+ ): Promise<
468
+ BackendResult<
469
+ MaterializeSessionSnapshot,
470
+ | SessionNotFoundError
471
+ | SessionStaleWriteError
472
+ | UamFidelityUnsupportedError
473
+ | MaterializeValidationFailedError
474
+ | MaterializeWriteFailedError
475
+ | PathPolicyViolationError
476
+ | InProcessLockConflictError
477
+ | BackendCapabilityUnavailableError
478
+ >
479
+ > {
480
+ const startedAt = Date.now();
481
+ const session = this.context.sessions.get(input.sessionId);
482
+ if (!session || session.closed) {
483
+ return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
484
+ }
485
+ if (input.expectedRevision !== undefined && input.expectedRevision !== session.revision) {
486
+ return failure(
487
+ 'authoring',
488
+ startedAt,
489
+ createStaleWriteError(session, input.expectedRevision),
490
+ toSessionSnapshot(session, this.context.capabilities),
491
+ {
492
+ sessionId: session.sessionId,
493
+ revision: session.revision,
494
+ },
495
+ );
496
+ }
497
+
498
+ const storageTarget = input.storage ? storageCanonicalTarget(input.storage) : null;
499
+ const fileSystem =
500
+ storageTarget?.fileSystem ?? input.fileSystem ?? session.fileSystem ?? this.context.fileSystem;
501
+ if (!fileSystem) {
502
+ return failure(
503
+ 'authoring',
504
+ startedAt,
505
+ createCapabilityUnavailableError('materializeSession requires an injected BackendFileSystem adapter.'),
506
+ toSessionSnapshot(session, this.context.capabilities),
507
+ {
508
+ sessionId: session.sessionId,
509
+ revision: session.revision,
510
+ },
511
+ );
512
+ }
513
+
514
+ const fairyPath = storageTarget?.fairyPath ?? session.fairyPath;
515
+ const targetViolation = await validateSaveTarget(fileSystem, fairyPath, input.targetPath);
516
+ if (targetViolation) {
517
+ return failure(
518
+ 'authoring',
519
+ startedAt,
520
+ targetViolation,
521
+ toSessionSnapshot(session, this.context.capabilities),
522
+ {
523
+ sessionId: session.sessionId,
524
+ revision: session.revision,
525
+ },
526
+ );
527
+ }
528
+
529
+ if (storageTarget) {
530
+ const holderSessionId = this.context.sessionsByPath.get(storageTarget.canonicalPathKey);
531
+ if (holderSessionId && holderSessionId !== session.sessionId) {
532
+ return failure(
533
+ 'authoring',
534
+ startedAt,
535
+ {
536
+ code: 'lock_conflict',
537
+ kind: 'in_process_session_exists',
538
+ message: `Project is already open in this backend runtime: ${storageTarget.canonicalProjectPath}`,
539
+ canonicalPathKey: storageTarget.canonicalPathKey,
540
+ holderSessionId,
541
+ },
542
+ toSessionSnapshot(session, this.context.capabilities),
543
+ {
544
+ sessionId: session.sessionId,
545
+ revision: session.revision,
546
+ },
547
+ );
548
+ }
549
+ }
550
+
551
+ if (session.uamFidelity === 'unsupported') {
552
+ return failure(
553
+ 'authoring',
554
+ startedAt,
555
+ createUamFidelityUnsupportedError(session),
556
+ toSessionSnapshot(session, this.context.capabilities),
557
+ {
558
+ sessionId: session.sessionId,
559
+ revision: session.revision,
560
+ },
561
+ );
562
+ }
563
+
564
+ const diagnostics = validationDiagnostics(session.project);
565
+ if (diagnostics.length > 0) {
566
+ const error: MaterializeValidationFailedError = {
567
+ code: 'materialize_validation_failed',
568
+ message: `UAM materialize validation failed with ${diagnostics.length} issue(s).`,
569
+ sessionId: session.sessionId,
570
+ canonicalPathKey: session.canonicalPathKey,
571
+ issueCount: diagnostics.length,
572
+ diagnostics,
573
+ };
574
+ return failure('authoring', startedAt, error, toSessionSnapshot(session, this.context.capabilities), {
575
+ sessionId: session.sessionId,
576
+ revision: session.revision,
577
+ diagnostics,
578
+ });
579
+ }
580
+
581
+ let document: ReturnType<typeof materializeUamProject>;
582
+ try {
583
+ document = materializeUamProject(session.project);
584
+ } catch (error) {
585
+ const diagnosticsFromError: BackendDiagnostic[] = [
586
+ {
587
+ code: 'materialize_validation_failed',
588
+ message: error instanceof Error ? error.message : String(error),
589
+ severity: 'error',
590
+ operationKind: 'materializeSession',
591
+ },
592
+ ];
593
+ return failure(
594
+ 'authoring',
595
+ startedAt,
596
+ {
597
+ code: 'materialize_validation_failed',
598
+ message: error instanceof Error ? error.message : String(error),
599
+ sessionId: session.sessionId,
600
+ canonicalPathKey: session.canonicalPathKey,
601
+ issueCount: diagnosticsFromError.length,
602
+ diagnostics: diagnosticsFromError,
603
+ },
604
+ toSessionSnapshot(session, this.context.capabilities),
605
+ {
606
+ sessionId: session.sessionId,
607
+ revision: session.revision,
608
+ diagnostics: diagnosticsFromError,
609
+ },
610
+ );
611
+ }
612
+
613
+ const writtenPaths: string[] = [];
614
+ const failedPaths: string[] = [];
615
+ const skippedPaths: string[] = [];
616
+ this.eventService.emit({
617
+ kind: 'save.started',
618
+ sessionId: session.sessionId,
619
+ canonicalPathKey: storageTarget?.canonicalPathKey ?? session.canonicalPathKey,
620
+ revision: session.revision,
621
+ });
622
+ try {
623
+ const isSessionStorageTarget = fileSystem === session.fileSystem && fairyPath === session.fairyPath;
624
+ await writeSessionProject({
625
+ fileSystem,
626
+ document,
627
+ fairyPath,
628
+ staleSourceFiles: isSessionStorageTarget ? [...session.pendingStaleSourceFiles.values()] : [],
629
+ staleResourceFolders: isSessionStorageTarget ? [...session.pendingStaleResourceFolders.values()] : [],
630
+ writtenPaths,
631
+ failedPaths,
632
+ });
633
+ if (isSessionStorageTarget) {
634
+ session.pendingStaleSourceFiles.clear();
635
+ session.pendingStaleResourceFolders.clear();
636
+ }
637
+ if (storageTarget && !isSessionStorageTarget) {
638
+ session.pendingStaleSourceFiles.clear();
639
+ session.pendingStaleResourceFolders.clear();
640
+ }
641
+ if (isSessionStorageTarget || storageTarget) commitUamProjectSourcePaths(session.project);
642
+ if (storageTarget) {
643
+ this.context.sessionsByPath.delete(session.canonicalPathKey);
644
+ session.fileSystem = storageTarget.fileSystem;
645
+ session.fairyPath = storageTarget.fairyPath;
646
+ session.canonicalProjectPath = storageTarget.canonicalProjectPath;
647
+ session.canonicalPathKey = storageTarget.canonicalPathKey;
648
+ this.context.sessionsByPath.set(session.canonicalPathKey, session.sessionId);
649
+ }
650
+ session.lastSavedRevision = session.revision;
651
+ session.dirty = false;
652
+ const cacheEntry = this.cacheService.refreshSession(session);
653
+ this.eventService.emit({
654
+ kind: 'save.completed',
655
+ sessionId: session.sessionId,
656
+ canonicalPathKey: session.canonicalPathKey,
657
+ revision: session.revision,
658
+ });
659
+ this.eventService.emit({
660
+ kind: 'cache.updated',
661
+ sessionId: session.sessionId,
662
+ canonicalPathKey: session.canonicalPathKey,
663
+ revision: session.revision,
664
+ cacheRevision: cacheEntry.revision,
665
+ });
666
+ return success(
667
+ 'authoring',
668
+ startedAt,
669
+ toMaterializeSnapshot(session, this.context.capabilities, {
670
+ reason: input.reason,
671
+ writtenPaths,
672
+ skippedPaths,
673
+ diagnostics: [],
674
+ }),
675
+ {
676
+ sessionId: session.sessionId,
677
+ revision: session.revision,
678
+ },
679
+ );
680
+ } catch (error) {
681
+ const diagnosticsFromError: BackendDiagnostic[] = [
682
+ {
683
+ code: 'write_failed',
684
+ message: error instanceof Error ? error.message : String(error),
685
+ severity: 'error',
686
+ path: failedPaths[0],
687
+ operationKind: 'materializeSession',
688
+ },
689
+ ];
690
+ this.cacheService.invalidateSession(session);
691
+ this.eventService.emit({
692
+ kind: 'save.failed',
693
+ sessionId: session.sessionId,
694
+ canonicalPathKey: session.canonicalPathKey,
695
+ revision: session.revision,
696
+ diagnostics: diagnosticsFromError,
697
+ });
698
+ return failure(
699
+ 'authoring',
700
+ startedAt,
701
+ {
702
+ code: 'write_failed',
703
+ message: error instanceof Error ? error.message : String(error),
704
+ sessionId: session.sessionId,
705
+ canonicalPathKey: session.canonicalPathKey,
706
+ attemptedRevision: session.revision,
707
+ lastSavedRevision: session.lastSavedRevision,
708
+ writtenPaths,
709
+ failedPaths,
710
+ skippedPaths,
711
+ diagnostics: diagnosticsFromError,
712
+ diskMayBePartiallyUpdated: true,
713
+ },
714
+ toSessionSnapshot(session, this.context.capabilities),
715
+ {
716
+ sessionId: session.sessionId,
717
+ revision: session.revision,
718
+ diagnostics: diagnosticsFromError,
719
+ },
720
+ );
721
+ }
722
+ }
300
723
  }