@openfairygui/backend 0.2.0-alpha.7 → 0.2.0-alpha.9

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,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,13 +10,18 @@ import type {
10
10
  BackendFileSystem,
11
11
  BackendResult,
12
12
  BackendSessionSnapshot,
13
+ InProcessLockConflictError,
14
+ MaterializeSessionInput,
15
+ MaterializeSessionSnapshot,
16
+ MaterializeValidationFailedError,
17
+ MaterializeWriteFailedError,
13
18
  SaveSessionInput,
14
19
  SavePartialFailureError,
15
20
  SessionNotFoundError,
16
21
  SessionStaleWriteError,
17
22
  } from '../runtime.js';
18
23
  import type { BackendDiagnostic } from '../contracts.js';
19
- import { validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
24
+ import { normalizeComparablePath, validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
20
25
  import { createSessionNotFoundError, createStaleWriteError, toSessionSnapshot } from './session-utils.js';
21
26
 
22
27
  function createWriterFileSystem(
@@ -92,6 +97,63 @@ function toBackendDiagnostics(error: ApplyUamTransactionAppError): BackendDiagno
92
97
  ];
93
98
  }
94
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
+
95
157
  export class AuthoringService {
96
158
  public constructor(
97
159
  private readonly context: BackendContext,
@@ -183,14 +245,27 @@ export class AuthoringService {
183
245
 
184
246
  public async saveSession(input: SaveSessionInput): Promise<
185
247
  BackendResult<
186
- BackendSessionSnapshot,
248
+ BackendSessionSnapshot | MaterializeSessionSnapshot,
187
249
  | SessionNotFoundError
188
250
  | SessionStaleWriteError
189
251
  | SavePartialFailureError
252
+ | MaterializeValidationFailedError
253
+ | MaterializeWriteFailedError
190
254
  | PathPolicyViolationError
255
+ | InProcessLockConflictError
191
256
  | BackendCapabilityUnavailableError
192
257
  >
193
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
+ }
194
269
  const startedAt = Date.now();
195
270
  const session = this.context.sessions.get(input.sessionId);
196
271
  if (!session || session.closed) {
@@ -201,12 +276,9 @@ export class AuthoringService {
201
276
  return failure(
202
277
  'authoring',
203
278
  startedAt,
204
- {
205
- code: 'capability_unavailable',
206
- message: 'saveSession requires an injected BackendFileSystem adapter.',
207
- capability: 'fileSystem',
208
- requiredAdapter: 'BackendFileSystem',
209
- },
279
+ createCapabilityUnavailableError(
280
+ 'saveSession requires an injected BackendFileSystem adapter.',
281
+ ),
210
282
  toSessionSnapshot(session, this.context.capabilities),
211
283
  {
212
284
  sessionId: session.sessionId,
@@ -307,4 +379,237 @@ export class AuthoringService {
307
379
  );
308
380
  }
309
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
+ }
310
615
  }