@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,6 +1,6 @@
1
- import { UAM_SUPPORTED_MATERIALIZATION_SCOPE, UAM_SUPPORTED_TRANSACTION_SCOPE, liftDocumentToUamProject, materializeUamProject, normalizeUamProject } from "@openfairygui/core/uam";
2
- import { ProjectReader, ProjectWriter } from "@openfairygui/core/project-io";
1
+ import { UAM_SUPPORTED_MATERIALIZATION_SCOPE, UAM_SUPPORTED_TRANSACTION_SCOPE, commitUamProjectSourcePaths, liftDocumentToUamProject, materializeUamProject, normalizeUamProject, staleResourceFolders, staleSourceFiles, validateUamProject } from "@openfairygui/core/uam";
3
2
  import { applyUamTransactionApp } from "@openfairygui/functions/uam";
3
+ import { ProjectReader, ProjectWriter } from "@openfairygui/core/project-io";
4
4
  //#region src/contracts.ts
5
5
  const BACKEND_CONTRACT_VERSION = "1.1.0-p2";
6
6
  const BACKEND_CAPABILITY_SCHEMA_VERSION = 2;
@@ -75,24 +75,6 @@ async function validateSaveTarget(fileSystem, openedFairyPath, targetPath) {
75
75
  };
76
76
  }
77
77
  //#endregion
78
- //#region src/services/artifact-service.ts
79
- function createArtifactCapabilities() {
80
- const bridge = {
81
- available: false,
82
- requiredHost: "node",
83
- executionBoundary: "external-bridge",
84
- bridgeEntrypoint: "@openfairygui/backend/node",
85
- reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
86
- };
87
- return {
88
- publish: false,
89
- restore: false,
90
- status: "bridge-required",
91
- publishBridge: bridge,
92
- restoreBridge: bridge
93
- };
94
- }
95
- //#endregion
96
78
  //#region src/services/context.ts
97
79
  function diagnosticFromError(error) {
98
80
  return {
@@ -137,38 +119,50 @@ function failure(stage, startedAt, error, session, options) {
137
119
  };
138
120
  }
139
121
  //#endregion
140
- //#region src/services/snapshot-utils.ts
141
- function cloneJsonValue(value) {
142
- if (value === void 0 || value === null) return value;
143
- return JSON.parse(JSON.stringify(value));
144
- }
145
- function cloneEventSnapshot(event) {
146
- return {
147
- ...event,
148
- diagnostics: event.diagnostics.map((diagnostic) => ({ ...diagnostic })),
149
- payload: cloneJsonValue(event.payload)
150
- };
151
- }
152
- function cloneJobSnapshot(job) {
153
- return {
154
- ...job,
155
- diagnostics: job.diagnostics.map((diagnostic) => ({ ...diagnostic })),
156
- progress: job.progress ? { ...job.progress } : void 0,
157
- result: cloneJsonValue(job.result),
158
- error: cloneJsonValue(job.error)
159
- };
160
- }
161
- function cloneCacheEntrySnapshot(entry) {
162
- return {
163
- ...entry,
164
- summary: {
165
- ...entry.summary,
166
- diagnostics: entry.summary.diagnostics.map((diagnostic) => ({ ...diagnostic }))
122
+ //#region src/services/session-project-writer.ts
123
+ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
124
+ async function trackWrite(targetPath, write) {
125
+ try {
126
+ const result = await write();
127
+ writtenPaths.push(targetPath);
128
+ return result;
129
+ } catch (error) {
130
+ failedPaths.push(targetPath);
131
+ throw error;
167
132
  }
133
+ }
134
+ return {
135
+ readFile: (path) => fileSystem.readFile(path),
136
+ readFileRaw: (path) => fileSystem.readFileRaw(path),
137
+ writeFile: (path, content) => trackWrite(path, async () => {
138
+ await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
139
+ await fileSystem.writeFile(path, content);
140
+ }),
141
+ writeFileRaw: (path, data) => trackWrite(path, async () => {
142
+ await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
143
+ await fileSystem.writeFileRaw(path, data);
144
+ }),
145
+ mkdir: (path) => fileSystem.mkdir(path, { recursive: true }),
146
+ readdir: (path) => fileSystem.readdir(path),
147
+ async exists(path) {
148
+ try {
149
+ await fileSystem.stat(path);
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ },
155
+ join: (...paths) => fileSystem.join(...paths),
156
+ dirname: (path) => fileSystem.dirname(path),
157
+ unlink: (path) => trackWrite(path, () => fileSystem.unlink(path)),
158
+ rmdir: (path) => trackWrite(path, () => fileSystem.rmdir(path))
168
159
  };
169
160
  }
170
- function cloneCapabilitiesSnapshot(capabilities) {
171
- return cloneJsonValue(capabilities);
161
+ async function writeSessionProject(input) {
162
+ await new ProjectWriter(createWriterFileSystem(input.fileSystem, input.writtenPaths, input.failedPaths)).write(input.document, input.fairyPath, {
163
+ staleSourceFiles: input.staleSourceFiles,
164
+ staleResourceFolders: input.staleResourceFolders
165
+ });
172
166
  }
173
167
  //#endregion
174
168
  //#region src/services/session-utils.ts
@@ -179,8 +173,9 @@ function toSessionSnapshot(session, capabilities) {
179
173
  revision: session.revision,
180
174
  lastSavedRevision: session.lastSavedRevision,
181
175
  dirty: session.dirty,
176
+ uamFidelity: session.uamFidelity,
182
177
  lockHeld: session.lockHeld,
183
- capabilities: cloneCapabilitiesSnapshot(capabilities)
178
+ capabilities: structuredClone(capabilities)
184
179
  };
185
180
  }
186
181
  function createSessionNotFoundError(sessionId) {
@@ -202,65 +197,111 @@ function createStaleWriteError(session, expectedRevision) {
202
197
  }
203
198
  //#endregion
204
199
  //#region src/services/authoring-service.ts
205
- function createWriterFileSystem(fileSystem, committedPaths, failedPaths) {
206
- async function trackWrite(targetPath, fn) {
207
- try {
208
- const result = await fn();
209
- committedPaths.push(targetPath);
210
- return result;
211
- } catch (error) {
212
- failedPaths.push(targetPath);
213
- throw error;
214
- }
215
- }
200
+ function sourceFileKey(source) {
201
+ return [
202
+ source.branch,
203
+ source.packageName,
204
+ source.path,
205
+ source.fileName
206
+ ].join("\0");
207
+ }
208
+ function resourceFolderKey(folder) {
209
+ return [
210
+ folder.branch,
211
+ folder.packageName,
212
+ folder.path
213
+ ].join("\0");
214
+ }
215
+ function recordStaleProjectFiles(session, previousProject, nextProject) {
216
+ if (!session.fileSystem) return;
217
+ for (const source of staleSourceFiles(previousProject, nextProject)) session.pendingStaleSourceFiles.set(sourceFileKey(source), source);
218
+ for (const source of staleSourceFiles(nextProject, previousProject)) session.pendingStaleSourceFiles.delete(sourceFileKey(source));
219
+ for (const folder of staleResourceFolders(previousProject, nextProject)) session.pendingStaleResourceFolders.set(resourceFolderKey(folder), folder);
220
+ for (const folder of staleResourceFolders(nextProject, previousProject)) session.pendingStaleResourceFolders.delete(resourceFolderKey(folder));
221
+ }
222
+ function toBackendDiagnostics(error) {
223
+ return error.diagnostics.length > 0 ? error.diagnostics.map((diagnostic) => ({ ...diagnostic })) : [{
224
+ code: error.code,
225
+ message: error.message,
226
+ severity: "error",
227
+ operationKind: error.operationKind,
228
+ opIndex: error.opIndex,
229
+ opId: error.opId
230
+ }];
231
+ }
232
+ function createCapabilityUnavailableError$1(message) {
216
233
  return {
217
- async readFile(filePath) {
218
- return fileSystem.readFile(filePath);
219
- },
220
- async readFileRaw(filePath) {
221
- return fileSystem.readFileRaw(filePath);
222
- },
223
- async writeFile(filePath, content) {
224
- await trackWrite(filePath, async () => {
225
- await fileSystem.mkdir(fileSystem.dirname(filePath), { recursive: true });
226
- await fileSystem.writeFile(filePath, content);
227
- });
228
- },
229
- async writeFileRaw(filePath, data) {
230
- await trackWrite(filePath, async () => {
231
- await fileSystem.mkdir(fileSystem.dirname(filePath), { recursive: true });
232
- await fileSystem.writeFileRaw(filePath, data);
233
- });
234
- },
235
- async mkdir(dirPath) {
236
- await fileSystem.mkdir(dirPath, { recursive: true });
237
- },
238
- async readdir(dirPath) {
239
- return fileSystem.readdir(dirPath);
240
- },
241
- async exists(filePath) {
242
- try {
243
- await fileSystem.stat(filePath);
244
- return true;
245
- } catch {
246
- return false;
247
- }
248
- },
249
- join(...paths) {
250
- return fileSystem.join(...paths);
251
- },
252
- dirname(filePath) {
253
- return fileSystem.dirname(filePath);
254
- }
234
+ code: "capability_unavailable",
235
+ message,
236
+ capability: "fileSystem",
237
+ requiredAdapter: "BackendFileSystem"
238
+ };
239
+ }
240
+ function createUamFidelityUnsupportedError(session) {
241
+ return {
242
+ code: "uam_fidelity_unsupported",
243
+ message: "The source project contains formal properties that the current UAM cannot preserve.",
244
+ sessionId: session.sessionId,
245
+ canonicalPathKey: session.canonicalPathKey
246
+ };
247
+ }
248
+ function validationDiagnostics(sessionProject) {
249
+ return validateUamProject(sessionProject).map((issue) => ({
250
+ code: "materialize_validation_failed",
251
+ message: issue.message,
252
+ severity: "error",
253
+ path: issue.path,
254
+ operationKind: "materializeSession"
255
+ }));
256
+ }
257
+ function toMaterializeSnapshot(session, capabilities, input) {
258
+ return {
259
+ ...toSessionSnapshot(session, capabilities),
260
+ mode: "fullProject",
261
+ reason: input.reason,
262
+ materializeRevision: session.revision,
263
+ saveRevision: session.lastSavedRevision,
264
+ writtenPaths: [...input.writtenPaths],
265
+ skippedPaths: [...input.skippedPaths],
266
+ diagnostics: input.diagnostics.map((diagnostic) => ({ ...diagnostic }))
267
+ };
268
+ }
269
+ function storageCanonicalTarget(input) {
270
+ const canonicalProjectPath = input.canonicalProjectPath ?? (input.fileSystem.dirname(input.fairyPath) || ".");
271
+ return {
272
+ fileSystem: input.fileSystem,
273
+ fairyPath: input.fairyPath,
274
+ canonicalProjectPath,
275
+ canonicalPathKey: input.canonicalPathKey ?? normalizeComparablePath(canonicalProjectPath)
255
276
  };
256
277
  }
257
278
  var AuthoringService = class {
279
+ sessionOperations = /* @__PURE__ */ new Map();
258
280
  constructor(context, cacheService, eventService) {
259
281
  this.context = context;
260
282
  this.cacheService = cacheService;
261
283
  this.eventService = eventService;
262
284
  }
285
+ async runSessionExclusive(sessionId, operation) {
286
+ const previous = this.sessionOperations.get(sessionId) ?? Promise.resolve();
287
+ let release = () => void 0;
288
+ const current = new Promise((resolve) => {
289
+ release = resolve;
290
+ });
291
+ const tail = previous.then(() => current);
292
+ this.sessionOperations.set(sessionId, tail);
293
+ await previous;
294
+ try {
295
+ return await operation();
296
+ } finally {
297
+ release();
298
+ if (this.sessionOperations.get(sessionId) === tail) this.sessionOperations.delete(sessionId);
299
+ }
300
+ }
263
301
  async applyTransaction(input) {
302
+ return this.runSessionExclusive(input.sessionId, () => this.applyTransactionExclusive(input));
303
+ }
304
+ async applyTransactionExclusive(input) {
264
305
  const startedAt = Date.now();
265
306
  const session = this.context.sessions.get(input.sessionId);
266
307
  if (!session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
@@ -281,22 +322,21 @@ var AuthoringService = class {
281
322
  operations: input.operations
282
323
  });
283
324
  if (result.ok === false) {
325
+ const diagnostics = toBackendDiagnostics(result.error);
284
326
  this.eventService.emit({
285
327
  kind: "transaction.rejected",
286
328
  sessionId: session.sessionId,
287
329
  canonicalPathKey: session.canonicalPathKey,
288
330
  revision: session.revision,
289
- diagnostics: result.error.issues?.map((issue) => ({
290
- code: result.error.code,
291
- message: issue.message,
292
- severity: "error"
293
- })) ?? []
331
+ diagnostics
294
332
  });
295
333
  return failure("authoring", startedAt, result.error, toSessionSnapshot(session, this.context.capabilities), {
296
334
  sessionId: session.sessionId,
297
- revision: session.revision
335
+ revision: session.revision,
336
+ diagnostics
298
337
  });
299
338
  }
339
+ recordStaleProjectFiles(session, session.project, result.project);
300
340
  session.project = result.project;
301
341
  session.revision += 1;
302
342
  session.dirty = true;
@@ -320,15 +360,22 @@ var AuthoringService = class {
320
360
  });
321
361
  }
322
362
  async saveSession(input) {
363
+ if (input.force === true || input.mode === "materializeCleanSession") return this.materializeSession({
364
+ sessionId: input.sessionId,
365
+ expectedRevision: input.expectedRevision,
366
+ targetPath: input.targetPath,
367
+ fileSystem: input.fileSystem,
368
+ mode: "fullProject",
369
+ reason: "force_save"
370
+ });
371
+ return this.runSessionExclusive(input.sessionId, () => this.saveSessionExclusive(input));
372
+ }
373
+ async saveSessionExclusive(input) {
323
374
  const startedAt = Date.now();
324
375
  const session = this.context.sessions.get(input.sessionId);
325
376
  if (!session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
326
- if (!this.context.fileSystem) return failure("authoring", startedAt, {
327
- code: "capability_unavailable",
328
- message: "saveSession requires an injected BackendFileSystem adapter.",
329
- capability: "fileSystem",
330
- requiredAdapter: "BackendFileSystem"
331
- }, toSessionSnapshot(session, this.context.capabilities), {
377
+ const fileSystem = session.fileSystem ?? input.fileSystem ?? this.context.fileSystem;
378
+ if (!fileSystem) return failure("authoring", startedAt, createCapabilityUnavailableError$1("saveSession requires an injected BackendFileSystem adapter."), toSessionSnapshot(session, this.context.capabilities), {
332
379
  sessionId: session.sessionId,
333
380
  revision: session.revision
334
381
  });
@@ -336,7 +383,6 @@ var AuthoringService = class {
336
383
  sessionId: session.sessionId,
337
384
  revision: session.revision
338
385
  });
339
- const fileSystem = this.context.fileSystem;
340
386
  const targetViolation = await validateSaveTarget(fileSystem, session.fairyPath, input.targetPath);
341
387
  if (targetViolation) return failure("authoring", startedAt, targetViolation, toSessionSnapshot(session, this.context.capabilities), {
342
388
  sessionId: session.sessionId,
@@ -346,6 +392,10 @@ var AuthoringService = class {
346
392
  sessionId: session.sessionId,
347
393
  revision: session.revision
348
394
  });
395
+ if (session.uamFidelity === "unsupported") return failure("authoring", startedAt, createUamFidelityUnsupportedError(session), toSessionSnapshot(session, this.context.capabilities), {
396
+ sessionId: session.sessionId,
397
+ revision: session.revision
398
+ });
349
399
  const committedPaths = [];
350
400
  const failedPaths = [];
351
401
  this.eventService.emit({
@@ -355,7 +405,19 @@ var AuthoringService = class {
355
405
  revision: session.revision
356
406
  });
357
407
  try {
358
- await new ProjectWriter(createWriterFileSystem(fileSystem, committedPaths, failedPaths)).write(materializeUamProject(session.project), session.fairyPath);
408
+ await writeSessionProject({
409
+ fileSystem,
410
+ document: materializeUamProject(session.project),
411
+ fairyPath: session.fairyPath,
412
+ staleSourceFiles: [...session.pendingStaleSourceFiles.values()],
413
+ staleResourceFolders: [...session.pendingStaleResourceFolders.values()],
414
+ writtenPaths: committedPaths,
415
+ failedPaths
416
+ });
417
+ session.fileSystem ??= fileSystem;
418
+ session.pendingStaleSourceFiles.clear();
419
+ session.pendingStaleResourceFolders.clear();
420
+ commitUamProjectSourcePaths(session.project);
359
421
  session.lastSavedRevision = session.revision;
360
422
  session.dirty = false;
361
423
  const cacheEntry = this.cacheService.refreshSession(session);
@@ -400,6 +462,179 @@ var AuthoringService = class {
400
462
  });
401
463
  }
402
464
  }
465
+ async materializeSession(input) {
466
+ return this.runSessionExclusive(input.sessionId, () => this.materializeSessionExclusive(input));
467
+ }
468
+ async materializeSessionExclusive(input) {
469
+ const startedAt = Date.now();
470
+ const session = this.context.sessions.get(input.sessionId);
471
+ if (!session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
472
+ if (input.expectedRevision !== void 0 && input.expectedRevision !== session.revision) return failure("authoring", startedAt, createStaleWriteError(session, input.expectedRevision), toSessionSnapshot(session, this.context.capabilities), {
473
+ sessionId: session.sessionId,
474
+ revision: session.revision
475
+ });
476
+ const storageTarget = input.storage ? storageCanonicalTarget(input.storage) : null;
477
+ const fileSystem = storageTarget?.fileSystem ?? input.fileSystem ?? session.fileSystem ?? this.context.fileSystem;
478
+ if (!fileSystem) return failure("authoring", startedAt, createCapabilityUnavailableError$1("materializeSession requires an injected BackendFileSystem adapter."), toSessionSnapshot(session, this.context.capabilities), {
479
+ sessionId: session.sessionId,
480
+ revision: session.revision
481
+ });
482
+ const fairyPath = storageTarget?.fairyPath ?? session.fairyPath;
483
+ const targetViolation = await validateSaveTarget(fileSystem, fairyPath, input.targetPath);
484
+ if (targetViolation) return failure("authoring", startedAt, targetViolation, toSessionSnapshot(session, this.context.capabilities), {
485
+ sessionId: session.sessionId,
486
+ revision: session.revision
487
+ });
488
+ if (storageTarget) {
489
+ const holderSessionId = this.context.sessionsByPath.get(storageTarget.canonicalPathKey);
490
+ if (holderSessionId && holderSessionId !== session.sessionId) return failure("authoring", startedAt, {
491
+ code: "lock_conflict",
492
+ kind: "in_process_session_exists",
493
+ message: `Project is already open in this backend runtime: ${storageTarget.canonicalProjectPath}`,
494
+ canonicalPathKey: storageTarget.canonicalPathKey,
495
+ holderSessionId
496
+ }, toSessionSnapshot(session, this.context.capabilities), {
497
+ sessionId: session.sessionId,
498
+ revision: session.revision
499
+ });
500
+ }
501
+ if (session.uamFidelity === "unsupported") return failure("authoring", startedAt, createUamFidelityUnsupportedError(session), toSessionSnapshot(session, this.context.capabilities), {
502
+ sessionId: session.sessionId,
503
+ revision: session.revision
504
+ });
505
+ const diagnostics = validationDiagnostics(session.project);
506
+ if (diagnostics.length > 0) return failure("authoring", startedAt, {
507
+ code: "materialize_validation_failed",
508
+ message: `UAM materialize validation failed with ${diagnostics.length} issue(s).`,
509
+ sessionId: session.sessionId,
510
+ canonicalPathKey: session.canonicalPathKey,
511
+ issueCount: diagnostics.length,
512
+ diagnostics
513
+ }, toSessionSnapshot(session, this.context.capabilities), {
514
+ sessionId: session.sessionId,
515
+ revision: session.revision,
516
+ diagnostics
517
+ });
518
+ let document;
519
+ try {
520
+ document = materializeUamProject(session.project);
521
+ } catch (error) {
522
+ const diagnosticsFromError = [{
523
+ code: "materialize_validation_failed",
524
+ message: error instanceof Error ? error.message : String(error),
525
+ severity: "error",
526
+ operationKind: "materializeSession"
527
+ }];
528
+ return failure("authoring", startedAt, {
529
+ code: "materialize_validation_failed",
530
+ message: error instanceof Error ? error.message : String(error),
531
+ sessionId: session.sessionId,
532
+ canonicalPathKey: session.canonicalPathKey,
533
+ issueCount: diagnosticsFromError.length,
534
+ diagnostics: diagnosticsFromError
535
+ }, toSessionSnapshot(session, this.context.capabilities), {
536
+ sessionId: session.sessionId,
537
+ revision: session.revision,
538
+ diagnostics: diagnosticsFromError
539
+ });
540
+ }
541
+ const writtenPaths = [];
542
+ const failedPaths = [];
543
+ const skippedPaths = [];
544
+ this.eventService.emit({
545
+ kind: "save.started",
546
+ sessionId: session.sessionId,
547
+ canonicalPathKey: storageTarget?.canonicalPathKey ?? session.canonicalPathKey,
548
+ revision: session.revision
549
+ });
550
+ try {
551
+ const isSessionStorageTarget = fileSystem === session.fileSystem && fairyPath === session.fairyPath;
552
+ await writeSessionProject({
553
+ fileSystem,
554
+ document,
555
+ fairyPath,
556
+ staleSourceFiles: isSessionStorageTarget ? [...session.pendingStaleSourceFiles.values()] : [],
557
+ staleResourceFolders: isSessionStorageTarget ? [...session.pendingStaleResourceFolders.values()] : [],
558
+ writtenPaths,
559
+ failedPaths
560
+ });
561
+ if (isSessionStorageTarget) {
562
+ session.pendingStaleSourceFiles.clear();
563
+ session.pendingStaleResourceFolders.clear();
564
+ }
565
+ if (storageTarget && !isSessionStorageTarget) {
566
+ session.pendingStaleSourceFiles.clear();
567
+ session.pendingStaleResourceFolders.clear();
568
+ }
569
+ if (isSessionStorageTarget || storageTarget) commitUamProjectSourcePaths(session.project);
570
+ if (storageTarget) {
571
+ this.context.sessionsByPath.delete(session.canonicalPathKey);
572
+ session.fileSystem = storageTarget.fileSystem;
573
+ session.fairyPath = storageTarget.fairyPath;
574
+ session.canonicalProjectPath = storageTarget.canonicalProjectPath;
575
+ session.canonicalPathKey = storageTarget.canonicalPathKey;
576
+ this.context.sessionsByPath.set(session.canonicalPathKey, session.sessionId);
577
+ }
578
+ session.lastSavedRevision = session.revision;
579
+ session.dirty = false;
580
+ const cacheEntry = this.cacheService.refreshSession(session);
581
+ this.eventService.emit({
582
+ kind: "save.completed",
583
+ sessionId: session.sessionId,
584
+ canonicalPathKey: session.canonicalPathKey,
585
+ revision: session.revision
586
+ });
587
+ this.eventService.emit({
588
+ kind: "cache.updated",
589
+ sessionId: session.sessionId,
590
+ canonicalPathKey: session.canonicalPathKey,
591
+ revision: session.revision,
592
+ cacheRevision: cacheEntry.revision
593
+ });
594
+ return success("authoring", startedAt, toMaterializeSnapshot(session, this.context.capabilities, {
595
+ reason: input.reason,
596
+ writtenPaths,
597
+ skippedPaths,
598
+ diagnostics: []
599
+ }), {
600
+ sessionId: session.sessionId,
601
+ revision: session.revision
602
+ });
603
+ } catch (error) {
604
+ const diagnosticsFromError = [{
605
+ code: "write_failed",
606
+ message: error instanceof Error ? error.message : String(error),
607
+ severity: "error",
608
+ path: failedPaths[0],
609
+ operationKind: "materializeSession"
610
+ }];
611
+ this.cacheService.invalidateSession(session);
612
+ this.eventService.emit({
613
+ kind: "save.failed",
614
+ sessionId: session.sessionId,
615
+ canonicalPathKey: session.canonicalPathKey,
616
+ revision: session.revision,
617
+ diagnostics: diagnosticsFromError
618
+ });
619
+ return failure("authoring", startedAt, {
620
+ code: "write_failed",
621
+ message: error instanceof Error ? error.message : String(error),
622
+ sessionId: session.sessionId,
623
+ canonicalPathKey: session.canonicalPathKey,
624
+ attemptedRevision: session.revision,
625
+ lastSavedRevision: session.lastSavedRevision,
626
+ writtenPaths,
627
+ failedPaths,
628
+ skippedPaths,
629
+ diagnostics: diagnosticsFromError,
630
+ diskMayBePartiallyUpdated: true
631
+ }, toSessionSnapshot(session, this.context.capabilities), {
632
+ sessionId: session.sessionId,
633
+ revision: session.revision,
634
+ diagnostics: diagnosticsFromError
635
+ });
636
+ }
637
+ }
403
638
  };
404
639
  //#endregion
405
640
  //#region src/services/cache-service.ts
@@ -430,7 +665,7 @@ var CacheService = class {
430
665
  const entry = this.context.cacheBySession.get(input.sessionId);
431
666
  return success("read", startedAt, {
432
667
  cacheRevision: entry?.revision ?? session.revision,
433
- entries: entry ? [cloneCacheEntrySnapshot(entry)] : []
668
+ entries: entry ? [structuredClone(entry)] : []
434
669
  }, {
435
670
  sessionId: session.sessionId,
436
671
  revision: session.revision
@@ -501,7 +736,7 @@ var EventService = class {
501
736
  const filtered = events.filter((event) => event.sequence > after);
502
737
  const limit = input.limit === void 0 ? filtered.length : Math.max(0, input.limit);
503
738
  return success("runtime", startedAt, {
504
- events: filtered.slice(0, limit).map(cloneEventSnapshot),
739
+ events: filtered.slice(0, limit).map((event) => structuredClone(event)),
505
740
  oldestSequence,
506
741
  currentSequence,
507
742
  cursorExpired: false
@@ -576,7 +811,7 @@ var JobService = class {
576
811
  jobId
577
812
  });
578
813
  this.scheduleRefreshJob(session.sessionId, jobId);
579
- return success("runtime", startedAt, cloneJobSnapshot(job), {
814
+ return success("runtime", startedAt, structuredClone(job), {
580
815
  sessionId: session.sessionId,
581
816
  revision: session.revision
582
817
  });
@@ -595,7 +830,7 @@ var JobService = class {
595
830
  sessionId: session.sessionId,
596
831
  revision: session.revision
597
832
  });
598
- return success("runtime", startedAt, cloneJobSnapshot(job), {
833
+ return success("runtime", startedAt, structuredClone(job), {
599
834
  sessionId: session.sessionId,
600
835
  revision: session.revision
601
836
  });
@@ -610,7 +845,7 @@ var JobService = class {
610
845
  else if (input.status === "terminal") jobs = jobs.filter((job) => isTerminal(job.status));
611
846
  else jobs = jobs.filter((job) => job.status === input.status);
612
847
  if (input.limit !== void 0) jobs = jobs.slice(0, Math.max(0, input.limit));
613
- return success("runtime", startedAt, { jobs: jobs.map(cloneJobSnapshot) }, {
848
+ return success("runtime", startedAt, { jobs: jobs.map((job) => structuredClone(job)) }, {
614
849
  sessionId: session.sessionId,
615
850
  revision: session.revision
616
851
  });
@@ -647,7 +882,8 @@ var JobService = class {
647
882
  revision: session.revision,
648
883
  jobId: job.jobId
649
884
  });
650
- return success("runtime", startedAt, cloneJobSnapshot(this.cancelRefreshJob(session.sessionId, job)), {
885
+ const cancelled = this.cancelRefreshJob(session.sessionId, job);
886
+ return success("runtime", startedAt, structuredClone(cancelled), {
651
887
  sessionId: session.sessionId,
652
888
  revision: session.revision
653
889
  });
@@ -794,7 +1030,7 @@ var ReadService = class {
794
1030
  this.context = context;
795
1031
  }
796
1032
  getCapabilities() {
797
- return success("read", Date.now(), cloneCapabilitiesSnapshot(this.context.capabilities));
1033
+ return success("read", Date.now(), structuredClone(this.context.capabilities));
798
1034
  }
799
1035
  getSession(input) {
800
1036
  const startedAt = Date.now();
@@ -858,6 +1094,75 @@ function createProjectReaderFileSystem(fileSystem) {
858
1094
  }
859
1095
  };
860
1096
  }
1097
+ function createCaptureFileSystem(files, directories) {
1098
+ const normalize = (filePath) => filePath.replace(/\\/g, "/").replace(/\/+/g, "/");
1099
+ return {
1100
+ async readFile(filePath) {
1101
+ const value = files.get(normalize(filePath));
1102
+ if (typeof value !== "string") throw new Error(`Captured text file was not found: ${filePath}`);
1103
+ return value;
1104
+ },
1105
+ async readFileRaw(filePath) {
1106
+ const value = files.get(normalize(filePath));
1107
+ if (!(value instanceof Uint8Array)) throw new Error(`Captured binary file was not found: ${filePath}`);
1108
+ return value.slice();
1109
+ },
1110
+ async writeFile(filePath, content) {
1111
+ files.set(normalize(filePath), content);
1112
+ },
1113
+ async writeFileRaw(filePath, data) {
1114
+ files.set(normalize(filePath), data.slice());
1115
+ },
1116
+ async mkdir(dirPath) {
1117
+ directories.add(normalize(dirPath));
1118
+ },
1119
+ async readdir() {
1120
+ return [];
1121
+ },
1122
+ async exists(filePath) {
1123
+ return files.has(normalize(filePath));
1124
+ },
1125
+ join(...paths) {
1126
+ return normalize(paths.filter(Boolean).join("/"));
1127
+ },
1128
+ dirname(filePath) {
1129
+ const normalized = normalize(filePath);
1130
+ const separator = normalized.lastIndexOf("/");
1131
+ return separator < 0 ? "" : normalized.slice(0, separator);
1132
+ },
1133
+ async unlink(filePath) {
1134
+ files.delete(normalize(filePath));
1135
+ }
1136
+ };
1137
+ }
1138
+ function capturedFilesEqual(left, right) {
1139
+ if (left.size !== right.size) return false;
1140
+ for (const [filePath, leftValue] of left) {
1141
+ const rightValue = right.get(filePath);
1142
+ if (typeof leftValue === "string") {
1143
+ if (leftValue !== rightValue) return false;
1144
+ continue;
1145
+ }
1146
+ if (!(rightValue instanceof Uint8Array) || leftValue.length !== rightValue.length) return false;
1147
+ for (let index = 0; index < leftValue.length; index += 1) if (leftValue[index] !== rightValue[index]) return false;
1148
+ }
1149
+ return true;
1150
+ }
1151
+ function capturedDirectoriesEqual(left, right) {
1152
+ return left.size === right.size && [...left].every((directory) => right.has(directory));
1153
+ }
1154
+ async function hasFullUamFidelity(document, project) {
1155
+ const sourceFiles = /* @__PURE__ */ new Map();
1156
+ const materializedFiles = /* @__PURE__ */ new Map();
1157
+ const sourceDirectories = /* @__PURE__ */ new Set();
1158
+ const materializedDirectories = /* @__PURE__ */ new Set();
1159
+ try {
1160
+ await Promise.all([new ProjectWriter(createCaptureFileSystem(sourceFiles, sourceDirectories)).write(document, "Project.fairy"), new ProjectWriter(createCaptureFileSystem(materializedFiles, materializedDirectories)).write(materializeUamProject(project), "Project.fairy")]);
1161
+ } catch {
1162
+ return false;
1163
+ }
1164
+ return capturedFilesEqual(sourceFiles, materializedFiles) && capturedDirectoriesEqual(sourceDirectories, materializedDirectories);
1165
+ }
861
1166
  var RuntimeService = class {
862
1167
  constructor(context, cacheService, eventService, jobService) {
863
1168
  this.context = context;
@@ -892,7 +1197,8 @@ var RuntimeService = class {
892
1197
  canonicalPathKey
893
1198
  }));
894
1199
  await advisoryLock.close();
895
- const project = liftDocumentToUamProject(await new ProjectReader(createProjectReaderFileSystem(fileSystem)).read(fairyPath));
1200
+ const document = await new ProjectReader(createProjectReaderFileSystem(fileSystem)).read(fairyPath, { hydrateResourceBytes: true });
1201
+ const project = liftDocumentToUamProject(document);
896
1202
  const sessionId = randomId();
897
1203
  const session = {
898
1204
  sessionId,
@@ -900,9 +1206,13 @@ var RuntimeService = class {
900
1206
  canonicalProjectPath,
901
1207
  canonicalPathKey,
902
1208
  lockFilePath,
1209
+ fileSystem,
903
1210
  project,
1211
+ uamFidelity: await hasFullUamFidelity(document, project) ? "full" : "unsupported",
904
1212
  revision: 0,
905
1213
  lastSavedRevision: 0,
1214
+ pendingStaleSourceFiles: /* @__PURE__ */ new Map(),
1215
+ pendingStaleResourceFolders: /* @__PURE__ */ new Map(),
906
1216
  dirty: false,
907
1217
  lockHeld: true,
908
1218
  closed: false
@@ -938,8 +1248,10 @@ var RuntimeService = class {
938
1248
  openProjectSession(input) {
939
1249
  const startedAt = Date.now();
940
1250
  const sessionId = input.sessionId ?? randomId();
941
- const canonicalProjectPath = input.canonicalProjectPath ?? `memory://${sessionId}`;
942
- const canonicalPathKey = input.canonicalPathKey ?? canonicalProjectPath.toLowerCase();
1251
+ const storage = input.storage;
1252
+ const memoryProjectPath = `memory://${sessionId}`;
1253
+ const canonicalProjectPath = storage?.canonicalProjectPath ?? input.canonicalProjectPath ?? (storage ? storage.fileSystem.dirname(storage.fairyPath) || "." : memoryProjectPath);
1254
+ const canonicalPathKey = storage?.canonicalPathKey ?? input.canonicalPathKey ?? (storage ? normalizeComparablePath(canonicalProjectPath) : canonicalProjectPath.toLowerCase());
943
1255
  const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
944
1256
  if (existingSessionId) return failure("runtime", startedAt, {
945
1257
  code: "lock_conflict",
@@ -950,13 +1262,17 @@ var RuntimeService = class {
950
1262
  });
951
1263
  const session = {
952
1264
  sessionId,
953
- fairyPath: canonicalProjectPath,
1265
+ fairyPath: storage?.fairyPath ?? canonicalProjectPath,
954
1266
  canonicalProjectPath,
955
1267
  canonicalPathKey,
956
1268
  lockFilePath: "",
1269
+ fileSystem: storage?.fileSystem,
957
1270
  project: normalizeUamProject(input.project),
1271
+ uamFidelity: "full",
958
1272
  revision: 0,
959
1273
  lastSavedRevision: 0,
1274
+ pendingStaleSourceFiles: /* @__PURE__ */ new Map(),
1275
+ pendingStaleResourceFolders: /* @__PURE__ */ new Map(),
960
1276
  dirty: false,
961
1277
  lockHeld: false,
962
1278
  closed: false
@@ -1009,7 +1325,25 @@ var RuntimeService = class {
1009
1325
  }
1010
1326
  };
1011
1327
  //#endregion
1012
- //#region src/runtime.ts
1328
+ //#region src/services/artifact-service.ts
1329
+ function createArtifactCapabilities() {
1330
+ const bridge = {
1331
+ available: false,
1332
+ requiredHost: "node",
1333
+ executionBoundary: "external-bridge",
1334
+ bridgeEntrypoint: "@openfairygui/backend/node",
1335
+ reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
1336
+ };
1337
+ return {
1338
+ publish: false,
1339
+ restore: false,
1340
+ status: "bridge-required",
1341
+ publishBridge: bridge,
1342
+ restoreBridge: bridge
1343
+ };
1344
+ }
1345
+ //#endregion
1346
+ //#region src/runtime/capabilities.ts
1013
1347
  const BACKEND_METHODS = [
1014
1348
  "getCapabilities",
1015
1349
  "openSession",
@@ -1017,6 +1351,7 @@ const BACKEND_METHODS = [
1017
1351
  "getSession",
1018
1352
  "applyTransaction",
1019
1353
  "saveSession",
1354
+ "materializeSession",
1020
1355
  "closeSession",
1021
1356
  "getEvents",
1022
1357
  "getJob",
@@ -1065,7 +1400,21 @@ function createCapabilities() {
1065
1400
  adapters: {
1066
1401
  fileSystem: {
1067
1402
  injected: true,
1068
- requiredFor: ["openSession", "saveSession"]
1403
+ requiredFor: [
1404
+ "openSession",
1405
+ "saveSession",
1406
+ "materializeSession"
1407
+ ]
1408
+ },
1409
+ projectStorage: {
1410
+ injected: true,
1411
+ browserSafe: true,
1412
+ requiredFor: [
1413
+ "openProjectSession.writeback",
1414
+ "saveSession",
1415
+ "materializeSession"
1416
+ ],
1417
+ adapterFactory: "createBackendStorageFileSystem"
1069
1418
  },
1070
1419
  host: {
1071
1420
  injected: true,
@@ -1114,6 +1463,8 @@ function createCapabilities() {
1114
1463
  }
1115
1464
  };
1116
1465
  }
1466
+ //#endregion
1467
+ //#region src/runtime.ts
1117
1468
  var BackendRuntime = class {
1118
1469
  fileSystem;
1119
1470
  capabilities;
@@ -1172,6 +1523,9 @@ var BackendRuntime = class {
1172
1523
  async saveSession(input) {
1173
1524
  return this.authoringService.saveSession(input);
1174
1525
  }
1526
+ async materializeSession(input) {
1527
+ return this.authoringService.materializeSession(input);
1528
+ }
1175
1529
  async closeSession(input) {
1176
1530
  return this.runtimeService.closeSession(input);
1177
1531
  }