@openfairygui/backend 0.2.0-alpha.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,4 +1,1584 @@
1
- import { i as BACKEND_CONTRACT_VERSION, n as BACKEND_CAPABILITY_SCHEMA_VERSION, r as BACKEND_COMPATIBILITY_POLICY, t as BackendRuntime } from "./runtime-DFatY9W0.mjs";
1
+ import { UAM_SUPPORTED_MATERIALIZATION_SCOPE, UAM_SUPPORTED_TRANSACTION_SCOPE, commitUamProjectSourcePaths, liftDocumentToUamProject, materializeUamProject, normalizeUamProject, staleBranchDirectories, staleResourceFolders, staleSourceFiles, validateUamProject } from "@openfairygui/core/uam";
2
+ import { applyUamTransactionAppAsync } from "@openfairygui/functions/uam";
3
+ import { ProjectReader, ProjectWriter } from "@openfairygui/core/project-io";
4
+ //#region src/contracts.ts
5
+ const BACKEND_CONTRACT_VERSION = "1.1.0-p2";
6
+ const BACKEND_CAPABILITY_SCHEMA_VERSION = 2;
7
+ const BACKEND_COMPATIBILITY_POLICY = {
8
+ incompatibleChange: "requires contractVersion bump",
9
+ capabilitySchemaChange: "requires capabilitySchemaVersion bump",
10
+ additiveChange: "allowed without breaking existing consumers"
11
+ };
12
+ //#endregion
13
+ //#region src/path-policy.ts
14
+ function normalizeComparablePath(value) {
15
+ const normalized = value.replace(/[/\\]+$/, "").replace(/\\/g, "/");
16
+ const driveMatch = normalized.match(/^([a-z]:)(?:\/(.*))?$/i);
17
+ const drivePrefix = driveMatch?.[1].toLowerCase() ?? "";
18
+ const remainder = driveMatch ? driveMatch[2] ?? "" : normalized;
19
+ const hasRoot = driveMatch ? true : remainder.startsWith("/");
20
+ const rawSegments = remainder.split("/").filter((segment) => segment.length > 0);
21
+ const segments = [];
22
+ for (const segment of rawSegments) {
23
+ if (segment === ".") continue;
24
+ if (segment === "..") {
25
+ if (segments.length > 0 && segments[segments.length - 1] !== "..") segments.pop();
26
+ else if (!hasRoot) segments.push("..");
27
+ continue;
28
+ }
29
+ segments.push(segment);
30
+ }
31
+ const joined = segments.join("/");
32
+ return (drivePrefix ? `${drivePrefix}/${joined}`.replace(/\/$/, "") : hasRoot ? `/${joined}`.replace(/\/$/, "") : joined || ".").toLowerCase();
33
+ }
34
+ function createRuntimePathPolicy() {
35
+ return {
36
+ canonicalization: "realpath+normalized-casefold",
37
+ sessionIdentity: "project-root",
38
+ saveTarget: "opened-project-only",
39
+ outputTargets: "deferred",
40
+ workspaceBoundary: "project-root-only"
41
+ };
42
+ }
43
+ async function resolveFairyPath(fileSystem, input) {
44
+ const resolvedInput = fileSystem.resolve(input);
45
+ const stat = await fileSystem.stat(resolvedInput);
46
+ if (stat.isFile() && resolvedInput.endsWith(".fairy")) return await fileSystem.resolvePath(resolvedInput);
47
+ if (stat.isDirectory()) {
48
+ const fairyFiles = (await fileSystem.readdir(resolvedInput)).filter((entry) => entry.endsWith(".fairy"));
49
+ if (fairyFiles.length === 1) return await fileSystem.resolvePath(fileSystem.join(resolvedInput, fairyFiles[0]));
50
+ if (fairyFiles.length > 1) throw new Error(`Multiple .fairy files found in ${resolvedInput}: ${fairyFiles.join(", ")}`);
51
+ throw new Error(`No .fairy file found in ${resolvedInput}`);
52
+ }
53
+ throw new Error(`Input is not a .fairy file or directory: ${resolvedInput}`);
54
+ }
55
+ async function resolveCanonicalProjectRoot(fileSystem, input) {
56
+ const fairyPath = await resolveFairyPath(fileSystem, input);
57
+ const canonicalProjectPath = await fileSystem.resolvePath(fileSystem.dirname(fairyPath));
58
+ return {
59
+ fairyPath,
60
+ canonicalProjectPath,
61
+ canonicalPathKey: normalizeComparablePath(canonicalProjectPath)
62
+ };
63
+ }
64
+ async function validateSaveTarget(fileSystem, openedFairyPath, targetPath) {
65
+ if (!targetPath) return null;
66
+ const attemptedPath = await fileSystem.resolvePath(fileSystem.resolve(targetPath));
67
+ const allowedPath = await fileSystem.resolvePath(openedFairyPath);
68
+ if (normalizeComparablePath(attemptedPath) === normalizeComparablePath(allowedPath)) return null;
69
+ return {
70
+ code: "path_policy_violation",
71
+ message: `Save target is restricted to the originally opened project file: ${allowedPath}`,
72
+ policy: "save_target",
73
+ attemptedPath,
74
+ allowedPath
75
+ };
76
+ }
77
+ //#endregion
78
+ //#region src/services/context.ts
79
+ function diagnosticFromError(error) {
80
+ return {
81
+ code: error.code,
82
+ message: error.message,
83
+ severity: "error"
84
+ };
85
+ }
86
+ function randomId$1() {
87
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
88
+ }
89
+ function createMeta(stage, startedAt, options) {
90
+ return {
91
+ requestId: options?.requestId ?? randomId$1(),
92
+ sessionId: options?.sessionId,
93
+ revision: options?.revision,
94
+ durationMs: Math.max(0, Date.now() - startedAt),
95
+ warnings: options?.warnings ?? [],
96
+ diagnostics: options?.diagnostics ?? [],
97
+ stage,
98
+ contractVersion: BACKEND_CONTRACT_VERSION,
99
+ capabilitySchemaVersion: 2
100
+ };
101
+ }
102
+ function success(stage, startedAt, data, options) {
103
+ return {
104
+ ok: true,
105
+ meta: createMeta(stage, startedAt, options),
106
+ data
107
+ };
108
+ }
109
+ function failure(stage, startedAt, error, session, options) {
110
+ const diagnostics = options?.diagnostics ?? [diagnosticFromError(error)];
111
+ return {
112
+ ok: false,
113
+ meta: createMeta(stage, startedAt, {
114
+ ...options,
115
+ diagnostics
116
+ }),
117
+ error,
118
+ session
119
+ };
120
+ }
121
+ //#endregion
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;
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))
159
+ };
160
+ }
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
+ staleBranchDirectories: input.staleBranchDirectories
166
+ });
167
+ }
168
+ //#endregion
169
+ //#region src/services/session-utils.ts
170
+ function toSessionSnapshot(session, capabilities) {
171
+ return {
172
+ sessionId: session.sessionId,
173
+ canonicalProjectPath: session.canonicalProjectPath,
174
+ revision: session.revision,
175
+ lastSavedRevision: session.lastSavedRevision,
176
+ dirty: session.dirty,
177
+ uamFidelity: session.uamFidelity,
178
+ lockHeld: session.lockHeld,
179
+ capabilities: structuredClone(capabilities)
180
+ };
181
+ }
182
+ function createSessionNotFoundError(sessionId) {
183
+ return {
184
+ code: "session_not_found",
185
+ message: `Session was not found: ${sessionId}`,
186
+ sessionId
187
+ };
188
+ }
189
+ function createStaleWriteError(session, expectedRevision) {
190
+ return {
191
+ code: "stale_write",
192
+ message: `Expected revision ${expectedRevision} does not match current revision ${session.revision}.`,
193
+ sessionId: session.sessionId,
194
+ canonicalPathKey: session.canonicalPathKey,
195
+ expectedRevision,
196
+ actualRevision: session.revision
197
+ };
198
+ }
199
+ //#endregion
200
+ //#region src/services/authoring-service.ts
201
+ function sourceFileKey(source) {
202
+ return [
203
+ source.branch,
204
+ source.packageName,
205
+ source.path,
206
+ source.fileName
207
+ ].join("\0");
208
+ }
209
+ function resourceFolderKey(folder) {
210
+ return [
211
+ folder.branch,
212
+ folder.packageName,
213
+ folder.path
214
+ ].join("\0");
215
+ }
216
+ function branchDirectoryKey(directory) {
217
+ return [directory.branch, directory.packageName ?? ""].join("\0");
218
+ }
219
+ function recordStaleProjectFiles(session, previousProject, nextProject) {
220
+ if (!session.fileSystem) return;
221
+ for (const source of staleSourceFiles(previousProject, nextProject)) session.pendingStaleSourceFiles.set(sourceFileKey(source), source);
222
+ for (const source of staleSourceFiles(nextProject, previousProject)) session.pendingStaleSourceFiles.delete(sourceFileKey(source));
223
+ for (const folder of staleResourceFolders(previousProject, nextProject)) session.pendingStaleResourceFolders.set(resourceFolderKey(folder), folder);
224
+ for (const folder of staleResourceFolders(nextProject, previousProject)) session.pendingStaleResourceFolders.delete(resourceFolderKey(folder));
225
+ for (const directory of staleBranchDirectories(previousProject, nextProject)) session.pendingStaleBranchDirectories.set(branchDirectoryKey(directory), directory);
226
+ for (const directory of staleBranchDirectories(nextProject, previousProject)) session.pendingStaleBranchDirectories.delete(branchDirectoryKey(directory));
227
+ }
228
+ function toBackendDiagnostics(error) {
229
+ return error.diagnostics.length > 0 ? error.diagnostics.map((diagnostic) => ({ ...diagnostic })) : [{
230
+ code: error.code,
231
+ message: error.message,
232
+ severity: "error",
233
+ operationKind: error.operationKind,
234
+ opIndex: error.opIndex,
235
+ opId: error.opId
236
+ }];
237
+ }
238
+ function createCapabilityUnavailableError$1(message) {
239
+ return {
240
+ code: "capability_unavailable",
241
+ message,
242
+ capability: "fileSystem",
243
+ requiredAdapter: "BackendFileSystem"
244
+ };
245
+ }
246
+ function createUamFidelityUnsupportedError(session) {
247
+ return {
248
+ code: "uam_fidelity_unsupported",
249
+ message: "The source project contains formal properties that the current UAM cannot preserve.",
250
+ sessionId: session.sessionId,
251
+ canonicalPathKey: session.canonicalPathKey
252
+ };
253
+ }
254
+ function validationDiagnostics(sessionProject) {
255
+ return validateUamProject(sessionProject).map((issue) => ({
256
+ code: "materialize_validation_failed",
257
+ message: issue.message,
258
+ severity: "error",
259
+ path: issue.path,
260
+ operationKind: "materializeSession"
261
+ }));
262
+ }
263
+ function toMaterializeSnapshot(session, capabilities, input) {
264
+ return {
265
+ ...toSessionSnapshot(session, capabilities),
266
+ mode: "fullProject",
267
+ reason: input.reason,
268
+ materializeRevision: session.revision,
269
+ saveRevision: session.lastSavedRevision,
270
+ writtenPaths: [...input.writtenPaths],
271
+ skippedPaths: [...input.skippedPaths],
272
+ diagnostics: input.diagnostics.map((diagnostic) => ({ ...diagnostic }))
273
+ };
274
+ }
275
+ function storageCanonicalTarget(input) {
276
+ const canonicalProjectPath = input.canonicalProjectPath ?? (input.fileSystem.dirname(input.fairyPath) || ".");
277
+ return {
278
+ fileSystem: input.fileSystem,
279
+ fairyPath: input.fairyPath,
280
+ canonicalProjectPath,
281
+ canonicalPathKey: input.canonicalPathKey ?? normalizeComparablePath(canonicalProjectPath)
282
+ };
283
+ }
284
+ function detachSharedByteViews(value, seen = /* @__PURE__ */ new WeakSet()) {
285
+ if (!value || typeof value !== "object" || seen.has(value)) return;
286
+ seen.add(value);
287
+ for (const [key, child] of Object.entries(value)) {
288
+ if (child instanceof Uint8Array) {
289
+ if (typeof SharedArrayBuffer !== "undefined" && child.buffer instanceof SharedArrayBuffer) value[key] = new Uint8Array(child);
290
+ continue;
291
+ }
292
+ detachSharedByteViews(child, seen);
293
+ }
294
+ }
295
+ var AuthoringService = class {
296
+ sessionOperations = /* @__PURE__ */ new Map();
297
+ constructor(context, cacheService, eventService) {
298
+ this.context = context;
299
+ this.cacheService = cacheService;
300
+ this.eventService = eventService;
301
+ }
302
+ async runSessionExclusive(sessionId, operation) {
303
+ const previous = this.sessionOperations.get(sessionId) ?? Promise.resolve();
304
+ let release = () => void 0;
305
+ const current = new Promise((resolve) => {
306
+ release = resolve;
307
+ });
308
+ const tail = previous.then(() => current);
309
+ this.sessionOperations.set(sessionId, tail);
310
+ await previous;
311
+ try {
312
+ return await operation();
313
+ } finally {
314
+ release();
315
+ if (this.sessionOperations.get(sessionId) === tail) this.sessionOperations.delete(sessionId);
316
+ }
317
+ }
318
+ async applyTransaction(input) {
319
+ const queuedInput = structuredClone(input);
320
+ detachSharedByteViews(queuedInput);
321
+ return this.runSessionExclusive(queuedInput.sessionId, () => this.applyTransactionExclusive(queuedInput));
322
+ }
323
+ async applyTransactionExclusive(input) {
324
+ const startedAt = Date.now();
325
+ const session = this.context.sessions.get(input.sessionId);
326
+ if (!session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
327
+ if (input.expectedRevision !== session.revision) {
328
+ this.eventService.emit({
329
+ kind: "transaction.rejected",
330
+ sessionId: session.sessionId,
331
+ canonicalPathKey: session.canonicalPathKey,
332
+ revision: session.revision
333
+ });
334
+ return failure("authoring", startedAt, createStaleWriteError(session, input.expectedRevision), toSessionSnapshot(session, this.context.capabilities), {
335
+ sessionId: session.sessionId,
336
+ revision: session.revision
337
+ });
338
+ }
339
+ const result = await applyUamTransactionAppAsync({
340
+ project: session.project,
341
+ operations: input.operations
342
+ });
343
+ if (this.context.sessions.get(input.sessionId) !== session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
344
+ if (result.ok === false) {
345
+ const diagnostics = toBackendDiagnostics(result.error);
346
+ this.eventService.emit({
347
+ kind: "transaction.rejected",
348
+ sessionId: session.sessionId,
349
+ canonicalPathKey: session.canonicalPathKey,
350
+ revision: session.revision,
351
+ diagnostics
352
+ });
353
+ return failure("authoring", startedAt, result.error, toSessionSnapshot(session, this.context.capabilities), {
354
+ sessionId: session.sessionId,
355
+ revision: session.revision,
356
+ diagnostics
357
+ });
358
+ }
359
+ recordStaleProjectFiles(session, session.project, result.project);
360
+ session.project = result.project;
361
+ session.revision += 1;
362
+ session.dirty = true;
363
+ const cacheEntry = this.cacheService.invalidateSession(session);
364
+ this.eventService.emit({
365
+ kind: "transaction.applied",
366
+ sessionId: session.sessionId,
367
+ canonicalPathKey: session.canonicalPathKey,
368
+ revision: session.revision
369
+ });
370
+ this.eventService.emit({
371
+ kind: "cache.invalidated",
372
+ sessionId: session.sessionId,
373
+ canonicalPathKey: session.canonicalPathKey,
374
+ revision: session.revision,
375
+ cacheRevision: cacheEntry.revision
376
+ });
377
+ return success("authoring", startedAt, toSessionSnapshot(session, this.context.capabilities), {
378
+ sessionId: session.sessionId,
379
+ revision: session.revision
380
+ });
381
+ }
382
+ async saveSession(input) {
383
+ if (input.force === true || input.mode === "materializeCleanSession") return this.materializeSession({
384
+ sessionId: input.sessionId,
385
+ expectedRevision: input.expectedRevision,
386
+ targetPath: input.targetPath,
387
+ fileSystem: input.fileSystem,
388
+ mode: "fullProject",
389
+ reason: "force_save"
390
+ });
391
+ return this.runSessionExclusive(input.sessionId, () => this.saveSessionExclusive(input));
392
+ }
393
+ async saveSessionExclusive(input) {
394
+ const startedAt = Date.now();
395
+ const session = this.context.sessions.get(input.sessionId);
396
+ if (!session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
397
+ const fileSystem = session.fileSystem ?? input.fileSystem ?? this.context.fileSystem;
398
+ if (!fileSystem) return failure("authoring", startedAt, createCapabilityUnavailableError$1("saveSession requires an injected BackendFileSystem adapter."), toSessionSnapshot(session, this.context.capabilities), {
399
+ sessionId: session.sessionId,
400
+ revision: session.revision
401
+ });
402
+ if (input.expectedRevision !== void 0 && input.expectedRevision !== session.revision) return failure("authoring", startedAt, createStaleWriteError(session, input.expectedRevision), toSessionSnapshot(session, this.context.capabilities), {
403
+ sessionId: session.sessionId,
404
+ revision: session.revision
405
+ });
406
+ const targetViolation = await validateSaveTarget(fileSystem, session.fairyPath, input.targetPath);
407
+ if (targetViolation) return failure("authoring", startedAt, targetViolation, toSessionSnapshot(session, this.context.capabilities), {
408
+ sessionId: session.sessionId,
409
+ revision: session.revision
410
+ });
411
+ if (!session.dirty) return success("authoring", startedAt, toSessionSnapshot(session, this.context.capabilities), {
412
+ sessionId: session.sessionId,
413
+ revision: session.revision
414
+ });
415
+ if (session.uamFidelity === "unsupported") return failure("authoring", startedAt, createUamFidelityUnsupportedError(session), toSessionSnapshot(session, this.context.capabilities), {
416
+ sessionId: session.sessionId,
417
+ revision: session.revision
418
+ });
419
+ const committedPaths = [];
420
+ const failedPaths = [];
421
+ this.eventService.emit({
422
+ kind: "save.started",
423
+ sessionId: session.sessionId,
424
+ canonicalPathKey: session.canonicalPathKey,
425
+ revision: session.revision
426
+ });
427
+ try {
428
+ await writeSessionProject({
429
+ fileSystem,
430
+ document: materializeUamProject(session.project),
431
+ fairyPath: session.fairyPath,
432
+ staleSourceFiles: [...session.pendingStaleSourceFiles.values()],
433
+ staleResourceFolders: [...session.pendingStaleResourceFolders.values()],
434
+ staleBranchDirectories: [...session.pendingStaleBranchDirectories.values()],
435
+ writtenPaths: committedPaths,
436
+ failedPaths
437
+ });
438
+ session.fileSystem ??= fileSystem;
439
+ session.pendingStaleSourceFiles.clear();
440
+ session.pendingStaleResourceFolders.clear();
441
+ session.pendingStaleBranchDirectories.clear();
442
+ commitUamProjectSourcePaths(session.project);
443
+ session.lastSavedRevision = session.revision;
444
+ session.dirty = false;
445
+ const cacheEntry = this.cacheService.refreshSession(session);
446
+ this.eventService.emit({
447
+ kind: "save.completed",
448
+ sessionId: session.sessionId,
449
+ canonicalPathKey: session.canonicalPathKey,
450
+ revision: session.revision
451
+ });
452
+ this.eventService.emit({
453
+ kind: "cache.updated",
454
+ sessionId: session.sessionId,
455
+ canonicalPathKey: session.canonicalPathKey,
456
+ revision: session.revision,
457
+ cacheRevision: cacheEntry.revision
458
+ });
459
+ return success("authoring", startedAt, toSessionSnapshot(session, this.context.capabilities), {
460
+ sessionId: session.sessionId,
461
+ revision: session.revision
462
+ });
463
+ } catch (error) {
464
+ this.cacheService.invalidateSession(session);
465
+ this.eventService.emit({
466
+ kind: "save.failed",
467
+ sessionId: session.sessionId,
468
+ canonicalPathKey: session.canonicalPathKey,
469
+ revision: session.revision
470
+ });
471
+ return failure("authoring", startedAt, {
472
+ code: "save_partial_failure",
473
+ message: error instanceof Error ? error.message : String(error),
474
+ sessionId: session.sessionId,
475
+ canonicalPathKey: session.canonicalPathKey,
476
+ attemptedRevision: session.revision,
477
+ lastSavedRevision: session.lastSavedRevision,
478
+ committedPaths,
479
+ failedPaths,
480
+ diskMayBePartiallyUpdated: true
481
+ }, toSessionSnapshot(session, this.context.capabilities), {
482
+ sessionId: session.sessionId,
483
+ revision: session.revision
484
+ });
485
+ }
486
+ }
487
+ async materializeSession(input) {
488
+ return this.runSessionExclusive(input.sessionId, () => this.materializeSessionExclusive(input));
489
+ }
490
+ async materializeSessionExclusive(input) {
491
+ const startedAt = Date.now();
492
+ const session = this.context.sessions.get(input.sessionId);
493
+ if (!session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
494
+ if (input.expectedRevision !== void 0 && input.expectedRevision !== session.revision) return failure("authoring", startedAt, createStaleWriteError(session, input.expectedRevision), toSessionSnapshot(session, this.context.capabilities), {
495
+ sessionId: session.sessionId,
496
+ revision: session.revision
497
+ });
498
+ const storageTarget = input.storage ? storageCanonicalTarget(input.storage) : null;
499
+ const fileSystem = storageTarget?.fileSystem ?? input.fileSystem ?? session.fileSystem ?? this.context.fileSystem;
500
+ if (!fileSystem) return failure("authoring", startedAt, createCapabilityUnavailableError$1("materializeSession requires an injected BackendFileSystem adapter."), toSessionSnapshot(session, this.context.capabilities), {
501
+ sessionId: session.sessionId,
502
+ revision: session.revision
503
+ });
504
+ const fairyPath = storageTarget?.fairyPath ?? session.fairyPath;
505
+ const targetViolation = await validateSaveTarget(fileSystem, fairyPath, input.targetPath);
506
+ if (targetViolation) return failure("authoring", startedAt, targetViolation, toSessionSnapshot(session, this.context.capabilities), {
507
+ sessionId: session.sessionId,
508
+ revision: session.revision
509
+ });
510
+ if (storageTarget) {
511
+ const holderSessionId = this.context.sessionsByPath.get(storageTarget.canonicalPathKey);
512
+ if (holderSessionId && holderSessionId !== session.sessionId) return failure("authoring", startedAt, {
513
+ code: "lock_conflict",
514
+ kind: "in_process_session_exists",
515
+ message: `Project is already open in this backend runtime: ${storageTarget.canonicalProjectPath}`,
516
+ canonicalPathKey: storageTarget.canonicalPathKey,
517
+ holderSessionId
518
+ }, toSessionSnapshot(session, this.context.capabilities), {
519
+ sessionId: session.sessionId,
520
+ revision: session.revision
521
+ });
522
+ }
523
+ if (session.uamFidelity === "unsupported") return failure("authoring", startedAt, createUamFidelityUnsupportedError(session), toSessionSnapshot(session, this.context.capabilities), {
524
+ sessionId: session.sessionId,
525
+ revision: session.revision
526
+ });
527
+ const diagnostics = validationDiagnostics(session.project);
528
+ if (diagnostics.length > 0) return failure("authoring", startedAt, {
529
+ code: "materialize_validation_failed",
530
+ message: `UAM materialize validation failed with ${diagnostics.length} issue(s).`,
531
+ sessionId: session.sessionId,
532
+ canonicalPathKey: session.canonicalPathKey,
533
+ issueCount: diagnostics.length,
534
+ diagnostics
535
+ }, toSessionSnapshot(session, this.context.capabilities), {
536
+ sessionId: session.sessionId,
537
+ revision: session.revision,
538
+ diagnostics
539
+ });
540
+ let document;
541
+ try {
542
+ document = materializeUamProject(session.project);
543
+ } catch (error) {
544
+ const diagnosticsFromError = [{
545
+ code: "materialize_validation_failed",
546
+ message: error instanceof Error ? error.message : String(error),
547
+ severity: "error",
548
+ operationKind: "materializeSession"
549
+ }];
550
+ return failure("authoring", startedAt, {
551
+ code: "materialize_validation_failed",
552
+ message: error instanceof Error ? error.message : String(error),
553
+ sessionId: session.sessionId,
554
+ canonicalPathKey: session.canonicalPathKey,
555
+ issueCount: diagnosticsFromError.length,
556
+ diagnostics: diagnosticsFromError
557
+ }, toSessionSnapshot(session, this.context.capabilities), {
558
+ sessionId: session.sessionId,
559
+ revision: session.revision,
560
+ diagnostics: diagnosticsFromError
561
+ });
562
+ }
563
+ const writtenPaths = [];
564
+ const failedPaths = [];
565
+ const skippedPaths = [];
566
+ this.eventService.emit({
567
+ kind: "save.started",
568
+ sessionId: session.sessionId,
569
+ canonicalPathKey: storageTarget?.canonicalPathKey ?? session.canonicalPathKey,
570
+ revision: session.revision
571
+ });
572
+ try {
573
+ const isSessionStorageTarget = fileSystem === session.fileSystem && fairyPath === session.fairyPath;
574
+ await writeSessionProject({
575
+ fileSystem,
576
+ document,
577
+ fairyPath,
578
+ staleSourceFiles: isSessionStorageTarget ? [...session.pendingStaleSourceFiles.values()] : [],
579
+ staleResourceFolders: isSessionStorageTarget ? [...session.pendingStaleResourceFolders.values()] : [],
580
+ staleBranchDirectories: isSessionStorageTarget ? [...session.pendingStaleBranchDirectories.values()] : [],
581
+ writtenPaths,
582
+ failedPaths
583
+ });
584
+ if (isSessionStorageTarget) {
585
+ session.pendingStaleSourceFiles.clear();
586
+ session.pendingStaleResourceFolders.clear();
587
+ session.pendingStaleBranchDirectories.clear();
588
+ }
589
+ if (storageTarget && !isSessionStorageTarget) {
590
+ session.pendingStaleSourceFiles.clear();
591
+ session.pendingStaleResourceFolders.clear();
592
+ session.pendingStaleBranchDirectories.clear();
593
+ }
594
+ if (isSessionStorageTarget || storageTarget) commitUamProjectSourcePaths(session.project);
595
+ if (storageTarget) {
596
+ this.context.sessionsByPath.delete(session.canonicalPathKey);
597
+ session.fileSystem = storageTarget.fileSystem;
598
+ session.fairyPath = storageTarget.fairyPath;
599
+ session.canonicalProjectPath = storageTarget.canonicalProjectPath;
600
+ session.canonicalPathKey = storageTarget.canonicalPathKey;
601
+ this.context.sessionsByPath.set(session.canonicalPathKey, session.sessionId);
602
+ }
603
+ session.lastSavedRevision = session.revision;
604
+ session.dirty = false;
605
+ const cacheEntry = this.cacheService.refreshSession(session);
606
+ this.eventService.emit({
607
+ kind: "save.completed",
608
+ sessionId: session.sessionId,
609
+ canonicalPathKey: session.canonicalPathKey,
610
+ revision: session.revision
611
+ });
612
+ this.eventService.emit({
613
+ kind: "cache.updated",
614
+ sessionId: session.sessionId,
615
+ canonicalPathKey: session.canonicalPathKey,
616
+ revision: session.revision,
617
+ cacheRevision: cacheEntry.revision
618
+ });
619
+ return success("authoring", startedAt, toMaterializeSnapshot(session, this.context.capabilities, {
620
+ reason: input.reason,
621
+ writtenPaths,
622
+ skippedPaths,
623
+ diagnostics: []
624
+ }), {
625
+ sessionId: session.sessionId,
626
+ revision: session.revision
627
+ });
628
+ } catch (error) {
629
+ const diagnosticsFromError = [{
630
+ code: "write_failed",
631
+ message: error instanceof Error ? error.message : String(error),
632
+ severity: "error",
633
+ path: failedPaths[0],
634
+ operationKind: "materializeSession"
635
+ }];
636
+ this.cacheService.invalidateSession(session);
637
+ this.eventService.emit({
638
+ kind: "save.failed",
639
+ sessionId: session.sessionId,
640
+ canonicalPathKey: session.canonicalPathKey,
641
+ revision: session.revision,
642
+ diagnostics: diagnosticsFromError
643
+ });
644
+ return failure("authoring", startedAt, {
645
+ code: "write_failed",
646
+ message: error instanceof Error ? error.message : String(error),
647
+ sessionId: session.sessionId,
648
+ canonicalPathKey: session.canonicalPathKey,
649
+ attemptedRevision: session.revision,
650
+ lastSavedRevision: session.lastSavedRevision,
651
+ writtenPaths,
652
+ failedPaths,
653
+ skippedPaths,
654
+ diagnostics: diagnosticsFromError,
655
+ diskMayBePartiallyUpdated: true
656
+ }, toSessionSnapshot(session, this.context.capabilities), {
657
+ sessionId: session.sessionId,
658
+ revision: session.revision,
659
+ diagnostics: diagnosticsFromError
660
+ });
661
+ }
662
+ }
663
+ };
664
+ //#endregion
665
+ //#region src/services/cache-service.ts
666
+ function createCacheEntry(session, valid) {
667
+ return {
668
+ canonicalPathKey: session.canonicalPathKey,
669
+ sessionId: session.sessionId,
670
+ revision: session.revision,
671
+ lastSavedRevision: session.lastSavedRevision,
672
+ dirty: session.dirty,
673
+ valid,
674
+ indexedAt: (/* @__PURE__ */ new Date()).toISOString(),
675
+ summary: {
676
+ packageCount: session.project.packages.length,
677
+ resourceCount: session.project.packages.reduce((total, pkg) => total + pkg.resources.length, 0),
678
+ diagnostics: []
679
+ }
680
+ };
681
+ }
682
+ var CacheService = class {
683
+ constructor(context) {
684
+ this.context = context;
685
+ }
686
+ getCacheSnapshot(input) {
687
+ const startedAt = Date.now();
688
+ const session = this.context.sessions.get(input.sessionId);
689
+ if (!session || session.closed) return failure("read", startedAt, createSessionNotFoundError(input.sessionId));
690
+ const entry = this.context.cacheBySession.get(input.sessionId);
691
+ return success("read", startedAt, {
692
+ cacheRevision: entry?.revision ?? session.revision,
693
+ entries: entry ? [structuredClone(entry)] : []
694
+ }, {
695
+ sessionId: session.sessionId,
696
+ revision: session.revision
697
+ });
698
+ }
699
+ refreshSession(session) {
700
+ const entry = createCacheEntry(session, true);
701
+ this.context.cacheBySession.set(session.sessionId, entry);
702
+ return entry;
703
+ }
704
+ invalidateSession(session) {
705
+ const entry = createCacheEntry(session, false);
706
+ this.context.cacheBySession.set(session.sessionId, entry);
707
+ return entry;
708
+ }
709
+ removeSession(sessionId) {
710
+ this.context.cacheBySession.delete(sessionId);
711
+ }
712
+ };
713
+ //#endregion
714
+ //#region src/services/event-service.ts
715
+ const DEFAULT_EVENT_RETENTION_LIMIT = 1e3;
716
+ var EventService = class {
717
+ constructor(context) {
718
+ this.context = context;
719
+ }
720
+ emit(event) {
721
+ const emitted = {
722
+ ...event,
723
+ sequence: this.context.nextEventSequence(),
724
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
725
+ diagnostics: event.diagnostics ?? []
726
+ };
727
+ const sessionId = event.sessionId;
728
+ if (!sessionId) return emitted;
729
+ const events = this.context.eventsBySession.get(sessionId) ?? [];
730
+ events.push(emitted);
731
+ while (events.length > DEFAULT_EVENT_RETENTION_LIMIT) events.shift();
732
+ this.context.eventsBySession.set(sessionId, events);
733
+ return emitted;
734
+ }
735
+ getEvents(input) {
736
+ const startedAt = Date.now();
737
+ const session = this.context.sessions.get(input.sessionId);
738
+ if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
739
+ const events = this.context.eventsBySession.get(input.sessionId) ?? [];
740
+ const oldestSequence = events[0]?.sequence ?? (events.length === 0 ? 1 : 0);
741
+ const currentSequence = events.at(-1)?.sequence ?? 0;
742
+ const after = input.after === void 0 ? 0 : Number(input.after);
743
+ if (!Number.isInteger(after) || after < 0) return failure("runtime", startedAt, {
744
+ code: "event_cursor_invalid",
745
+ message: `Invalid event cursor: ${input.after}`,
746
+ sessionId: input.sessionId,
747
+ after: String(input.after)
748
+ });
749
+ if (events.length > 0 && after !== 0 && after < oldestSequence - 1) return failure("runtime", startedAt, {
750
+ code: "event_cursor_invalid",
751
+ message: `Event cursor has expired: ${after}`,
752
+ sessionId: input.sessionId,
753
+ after: String(input.after)
754
+ });
755
+ if (after > currentSequence) return failure("runtime", startedAt, {
756
+ code: "event_cursor_invalid",
757
+ message: `Unknown event cursor: ${after}`,
758
+ sessionId: input.sessionId,
759
+ after: String(input.after)
760
+ });
761
+ const filtered = events.filter((event) => event.sequence > after);
762
+ const limit = input.limit === void 0 ? filtered.length : Math.max(0, input.limit);
763
+ return success("runtime", startedAt, {
764
+ events: filtered.slice(0, limit).map((event) => structuredClone(event)),
765
+ oldestSequence,
766
+ currentSequence,
767
+ cursorExpired: false
768
+ }, {
769
+ sessionId: session.sessionId,
770
+ revision: session.revision
771
+ });
772
+ }
773
+ removeSession(sessionId) {
774
+ this.context.eventsBySession.delete(sessionId);
775
+ }
776
+ };
777
+ //#endregion
778
+ //#region src/services/job-service.ts
779
+ const COMPLETED_JOB_RETENTION_LIMIT = 100;
780
+ const REFRESH_START_DELAY_MS = 0;
781
+ const REFRESH_COMPLETE_DELAY_MS = 50;
782
+ function isTerminal(status) {
783
+ return status === "completed" || status === "failed" || status === "cancelled";
784
+ }
785
+ var JobService = class {
786
+ constructor(context, cacheService, eventService) {
787
+ this.context = context;
788
+ this.cacheService = cacheService;
789
+ this.eventService = eventService;
790
+ }
791
+ cancellationRequests = /* @__PURE__ */ new Set();
792
+ getJobs(sessionId) {
793
+ return this.context.jobsBySession.get(sessionId) ?? [];
794
+ }
795
+ setJobs(sessionId, jobs) {
796
+ const retained = [];
797
+ let terminalCount = 0;
798
+ for (let index = jobs.length - 1; index >= 0; index -= 1) {
799
+ const job = jobs[index];
800
+ if (isTerminal(job.status)) {
801
+ if (terminalCount >= COMPLETED_JOB_RETENTION_LIMIT) continue;
802
+ terminalCount += 1;
803
+ }
804
+ retained.push(job);
805
+ }
806
+ this.context.jobsBySession.set(sessionId, retained.reverse());
807
+ }
808
+ refreshCache(input) {
809
+ const startedAt = Date.now();
810
+ const session = this.context.sessions.get(input.sessionId);
811
+ if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
812
+ const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
813
+ const job = {
814
+ jobId,
815
+ kind: "cache.refresh",
816
+ status: "queued",
817
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
818
+ sessionId: session.sessionId,
819
+ canonicalPathKey: session.canonicalPathKey,
820
+ revision: session.revision,
821
+ diagnostics: [],
822
+ progress: {
823
+ completed: 0,
824
+ total: 1,
825
+ message: input.reason ?? "manual"
826
+ }
827
+ };
828
+ const jobs = this.getJobs(session.sessionId);
829
+ jobs.push(job);
830
+ this.setJobs(session.sessionId, jobs);
831
+ this.eventService.emit({
832
+ kind: "job.created",
833
+ sessionId: session.sessionId,
834
+ canonicalPathKey: session.canonicalPathKey,
835
+ revision: session.revision,
836
+ jobId
837
+ });
838
+ this.scheduleRefreshJob(session.sessionId, jobId);
839
+ return success("runtime", startedAt, structuredClone(job), {
840
+ sessionId: session.sessionId,
841
+ revision: session.revision
842
+ });
843
+ }
844
+ getJob(input) {
845
+ const startedAt = Date.now();
846
+ const session = this.context.sessions.get(input.sessionId);
847
+ if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
848
+ const job = this.getJobs(input.sessionId).find((item) => item.jobId === input.jobId);
849
+ if (!job) return failure("runtime", startedAt, {
850
+ code: "job_not_found",
851
+ message: `Job was not found: ${input.jobId}`,
852
+ sessionId: input.sessionId,
853
+ jobId: input.jobId
854
+ }, void 0, {
855
+ sessionId: session.sessionId,
856
+ revision: session.revision
857
+ });
858
+ return success("runtime", startedAt, structuredClone(job), {
859
+ sessionId: session.sessionId,
860
+ revision: session.revision
861
+ });
862
+ }
863
+ listJobs(input) {
864
+ const startedAt = Date.now();
865
+ const session = this.context.sessions.get(input.sessionId);
866
+ if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
867
+ let jobs = [...this.getJobs(input.sessionId)];
868
+ if (input.kind) jobs = jobs.filter((job) => job.kind === input.kind);
869
+ if (input.status) if (input.status === "active") jobs = jobs.filter((job) => !isTerminal(job.status));
870
+ else if (input.status === "terminal") jobs = jobs.filter((job) => isTerminal(job.status));
871
+ else jobs = jobs.filter((job) => job.status === input.status);
872
+ if (input.limit !== void 0) jobs = jobs.slice(0, Math.max(0, input.limit));
873
+ return success("runtime", startedAt, { jobs: jobs.map((job) => structuredClone(job)) }, {
874
+ sessionId: session.sessionId,
875
+ revision: session.revision
876
+ });
877
+ }
878
+ cancelJob(input) {
879
+ const startedAt = Date.now();
880
+ const session = this.context.sessions.get(input.sessionId);
881
+ if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
882
+ const job = this.getJobs(input.sessionId).find((item) => item.jobId === input.jobId);
883
+ if (!job) return failure("runtime", startedAt, {
884
+ code: "job_not_found",
885
+ message: `Job was not found: ${input.jobId}`,
886
+ sessionId: input.sessionId,
887
+ jobId: input.jobId
888
+ }, void 0, {
889
+ sessionId: session.sessionId,
890
+ revision: session.revision
891
+ });
892
+ if (isTerminal(job.status)) return failure("runtime", startedAt, {
893
+ code: "job_not_cancellable",
894
+ message: `Job is already terminal: ${input.jobId}`,
895
+ sessionId: input.sessionId,
896
+ jobId: input.jobId,
897
+ status: job.status
898
+ }, void 0, {
899
+ sessionId: session.sessionId,
900
+ revision: session.revision
901
+ });
902
+ this.cancellationRequests.add(job.jobId);
903
+ this.eventService.emit({
904
+ kind: "job.cancelRequested",
905
+ sessionId: input.sessionId,
906
+ canonicalPathKey: session.canonicalPathKey,
907
+ revision: session.revision,
908
+ jobId: job.jobId
909
+ });
910
+ const cancelled = this.cancelRefreshJob(session.sessionId, job);
911
+ return success("runtime", startedAt, structuredClone(cancelled), {
912
+ sessionId: session.sessionId,
913
+ revision: session.revision
914
+ });
915
+ }
916
+ removeSession(sessionId) {
917
+ for (const job of this.getJobs(sessionId)) this.cancellationRequests.delete(job.jobId);
918
+ this.context.jobsBySession.delete(sessionId);
919
+ }
920
+ replaceJob(sessionId, nextJob) {
921
+ const jobs = this.getJobs(sessionId).map((job) => job.jobId === nextJob.jobId ? nextJob : job);
922
+ this.setJobs(sessionId, jobs);
923
+ }
924
+ scheduleRefreshJob(sessionId, jobId) {
925
+ setTimeout(() => this.startRefreshJob(sessionId, jobId), REFRESH_START_DELAY_MS);
926
+ }
927
+ startRefreshJob(sessionId, jobId) {
928
+ const session = this.context.sessions.get(sessionId);
929
+ if (!session || session.closed) return;
930
+ const job = this.getJobs(sessionId).find((item) => item.jobId === jobId);
931
+ if (!job || isTerminal(job.status)) return;
932
+ if (this.cancellationRequests.has(jobId)) {
933
+ this.cancelRefreshJob(sessionId, job);
934
+ return;
935
+ }
936
+ const running = {
937
+ ...job,
938
+ status: "running",
939
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
940
+ progress: {
941
+ completed: 0,
942
+ total: 1,
943
+ message: "refreshing cache"
944
+ }
945
+ };
946
+ this.replaceJob(sessionId, running);
947
+ this.eventService.emit({
948
+ kind: "job.started",
949
+ sessionId,
950
+ canonicalPathKey: session.canonicalPathKey,
951
+ revision: session.revision,
952
+ jobId
953
+ });
954
+ this.eventService.emit({
955
+ kind: "job.progress",
956
+ sessionId,
957
+ canonicalPathKey: session.canonicalPathKey,
958
+ revision: session.revision,
959
+ jobId,
960
+ payload: running.progress
961
+ });
962
+ setTimeout(() => this.completeRefreshJob(sessionId, jobId), REFRESH_COMPLETE_DELAY_MS);
963
+ }
964
+ completeRefreshJob(sessionId, jobId) {
965
+ const session = this.context.sessions.get(sessionId);
966
+ if (!session || session.closed) return;
967
+ const job = this.getJobs(sessionId).find((item) => item.jobId === jobId);
968
+ if (!job || isTerminal(job.status)) return;
969
+ if (this.cancellationRequests.has(jobId)) {
970
+ this.cancelRefreshJob(sessionId, job);
971
+ return;
972
+ }
973
+ try {
974
+ const entry = this.cacheService.refreshSession(session);
975
+ const completed = {
976
+ ...job,
977
+ status: "completed",
978
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
979
+ cacheRevision: entry.revision,
980
+ progress: {
981
+ completed: 1,
982
+ total: 1,
983
+ message: "cache refreshed"
984
+ },
985
+ result: { cacheRevision: entry.revision }
986
+ };
987
+ this.replaceJob(sessionId, completed);
988
+ this.eventService.emit({
989
+ kind: "job.completed",
990
+ sessionId,
991
+ canonicalPathKey: session.canonicalPathKey,
992
+ revision: session.revision,
993
+ cacheRevision: entry.revision,
994
+ jobId
995
+ });
996
+ this.eventService.emit({
997
+ kind: "cache.updated",
998
+ sessionId,
999
+ canonicalPathKey: session.canonicalPathKey,
1000
+ revision: session.revision,
1001
+ cacheRevision: entry.revision
1002
+ });
1003
+ } catch (error) {
1004
+ const failed = {
1005
+ ...job,
1006
+ status: "failed",
1007
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
1008
+ error: {
1009
+ code: "cache_refresh_failed",
1010
+ message: error instanceof Error ? error.message : "Cache refresh failed",
1011
+ sessionId,
1012
+ jobId
1013
+ }
1014
+ };
1015
+ this.replaceJob(sessionId, failed);
1016
+ this.eventService.emit({
1017
+ kind: "job.failed",
1018
+ sessionId,
1019
+ canonicalPathKey: session.canonicalPathKey,
1020
+ revision: session.revision,
1021
+ jobId,
1022
+ diagnostics: failed.diagnostics
1023
+ });
1024
+ }
1025
+ }
1026
+ cancelRefreshJob(sessionId, job) {
1027
+ const session = this.context.sessions.get(sessionId);
1028
+ const cancelled = {
1029
+ ...job,
1030
+ status: "cancelled",
1031
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
1032
+ error: {
1033
+ code: "job_cancelled",
1034
+ message: `Job was cancelled: ${job.jobId}`,
1035
+ sessionId,
1036
+ jobId: job.jobId
1037
+ }
1038
+ };
1039
+ this.replaceJob(sessionId, cancelled);
1040
+ this.cancellationRequests.delete(job.jobId);
1041
+ this.eventService.emit({
1042
+ kind: "job.cancelled",
1043
+ sessionId,
1044
+ canonicalPathKey: session?.canonicalPathKey ?? job.canonicalPathKey,
1045
+ revision: session?.revision ?? job.revision,
1046
+ jobId: job.jobId
1047
+ });
1048
+ return cancelled;
1049
+ }
1050
+ };
1051
+ //#endregion
1052
+ //#region src/services/read-service.ts
1053
+ var ReadService = class {
1054
+ constructor(context) {
1055
+ this.context = context;
1056
+ }
1057
+ getCapabilities() {
1058
+ return success("read", Date.now(), structuredClone(this.context.capabilities));
1059
+ }
1060
+ getSession(input) {
1061
+ const startedAt = Date.now();
1062
+ const session = this.context.sessions.get(input.sessionId);
1063
+ if (!session || session.closed) return failure("read", startedAt, createSessionNotFoundError(input.sessionId));
1064
+ return success("read", startedAt, toSessionSnapshot(session, this.context.capabilities), {
1065
+ sessionId: session.sessionId,
1066
+ revision: session.revision
1067
+ });
1068
+ }
1069
+ };
1070
+ //#endregion
1071
+ //#region src/services/runtime-service.ts
1072
+ function randomId() {
1073
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1074
+ }
1075
+ function createCapabilityUnavailableError(capability) {
1076
+ const artifactCapability = capability === "artifact.publish" || capability === "artifact.restore";
1077
+ return {
1078
+ code: "capability_unavailable",
1079
+ message: artifactCapability ? `${capability} requires the Node bridge boundary exposed by @openfairygui/backend/node.` : `${capability} requires an injected BackendFileSystem adapter.`,
1080
+ capability,
1081
+ requiredAdapter: capability === "fileSystem" ? "BackendFileSystem" : void 0,
1082
+ requiredHost: artifactCapability ? "node" : void 0,
1083
+ bridgeBoundary: artifactCapability ? "external-bridge" : void 0
1084
+ };
1085
+ }
1086
+ function createProjectReaderFileSystem(fileSystem) {
1087
+ return {
1088
+ readFile(filePath) {
1089
+ return fileSystem.readFile(filePath);
1090
+ },
1091
+ readFileRaw(filePath) {
1092
+ return fileSystem.readFileRaw(filePath);
1093
+ },
1094
+ writeFile(filePath, content) {
1095
+ return fileSystem.writeFile(filePath, content);
1096
+ },
1097
+ writeFileRaw(filePath, data) {
1098
+ return fileSystem.writeFileRaw(filePath, data);
1099
+ },
1100
+ async mkdir(dirPath) {
1101
+ await fileSystem.mkdir(dirPath, { recursive: true });
1102
+ },
1103
+ readdir(dirPath) {
1104
+ return fileSystem.readdir(dirPath);
1105
+ },
1106
+ async exists(filePath) {
1107
+ try {
1108
+ await fileSystem.stat(filePath);
1109
+ return true;
1110
+ } catch {
1111
+ return false;
1112
+ }
1113
+ },
1114
+ join(...paths) {
1115
+ return fileSystem.join(...paths);
1116
+ },
1117
+ dirname(filePath) {
1118
+ return fileSystem.dirname(filePath);
1119
+ }
1120
+ };
1121
+ }
1122
+ function createCaptureFileSystem(files, directories) {
1123
+ const normalize = (filePath) => filePath.replace(/\\/g, "/").replace(/\/+/g, "/");
1124
+ return {
1125
+ async readFile(filePath) {
1126
+ const value = files.get(normalize(filePath));
1127
+ if (typeof value !== "string") throw new Error(`Captured text file was not found: ${filePath}`);
1128
+ return value;
1129
+ },
1130
+ async readFileRaw(filePath) {
1131
+ const value = files.get(normalize(filePath));
1132
+ if (!(value instanceof Uint8Array)) throw new Error(`Captured binary file was not found: ${filePath}`);
1133
+ return value.slice();
1134
+ },
1135
+ async writeFile(filePath, content) {
1136
+ files.set(normalize(filePath), content);
1137
+ },
1138
+ async writeFileRaw(filePath, data) {
1139
+ files.set(normalize(filePath), data.slice());
1140
+ },
1141
+ async mkdir(dirPath) {
1142
+ directories.add(normalize(dirPath));
1143
+ },
1144
+ async readdir() {
1145
+ return [];
1146
+ },
1147
+ async exists(filePath) {
1148
+ return files.has(normalize(filePath));
1149
+ },
1150
+ join(...paths) {
1151
+ return normalize(paths.filter(Boolean).join("/"));
1152
+ },
1153
+ dirname(filePath) {
1154
+ const normalized = normalize(filePath);
1155
+ const separator = normalized.lastIndexOf("/");
1156
+ return separator < 0 ? "" : normalized.slice(0, separator);
1157
+ },
1158
+ async unlink(filePath) {
1159
+ files.delete(normalize(filePath));
1160
+ }
1161
+ };
1162
+ }
1163
+ function capturedFilesEqual(left, right) {
1164
+ if (left.size !== right.size) return false;
1165
+ for (const [filePath, leftValue] of left) {
1166
+ const rightValue = right.get(filePath);
1167
+ if (typeof leftValue === "string") {
1168
+ if (leftValue !== rightValue) return false;
1169
+ continue;
1170
+ }
1171
+ if (!(rightValue instanceof Uint8Array) || leftValue.length !== rightValue.length) return false;
1172
+ for (let index = 0; index < leftValue.length; index += 1) if (leftValue[index] !== rightValue[index]) return false;
1173
+ }
1174
+ return true;
1175
+ }
1176
+ function capturedDirectoriesEqual(left, right) {
1177
+ return left.size === right.size && [...left].every((directory) => right.has(directory));
1178
+ }
1179
+ async function hasFullUamFidelity(document, project) {
1180
+ const sourceFiles = /* @__PURE__ */ new Map();
1181
+ const materializedFiles = /* @__PURE__ */ new Map();
1182
+ const sourceDirectories = /* @__PURE__ */ new Set();
1183
+ const materializedDirectories = /* @__PURE__ */ new Set();
1184
+ try {
1185
+ await Promise.all([new ProjectWriter(createCaptureFileSystem(sourceFiles, sourceDirectories)).write(document, "Project.fairy"), new ProjectWriter(createCaptureFileSystem(materializedFiles, materializedDirectories)).write(materializeUamProject(project), "Project.fairy")]);
1186
+ } catch {
1187
+ return false;
1188
+ }
1189
+ return capturedFilesEqual(sourceFiles, materializedFiles) && capturedDirectoriesEqual(sourceDirectories, materializedDirectories);
1190
+ }
1191
+ var RuntimeService = class {
1192
+ constructor(context, cacheService, eventService, jobService) {
1193
+ this.context = context;
1194
+ this.cacheService = cacheService;
1195
+ this.eventService = eventService;
1196
+ this.jobService = jobService;
1197
+ }
1198
+ async openSession(input) {
1199
+ const startedAt = Date.now();
1200
+ if (!this.context.fileSystem) return failure("runtime", startedAt, createCapabilityUnavailableError("fileSystem"));
1201
+ const fileSystem = this.context.fileSystem;
1202
+ const { fairyPath, canonicalProjectPath, canonicalPathKey } = await resolveCanonicalProjectRoot(fileSystem, input.projectPath);
1203
+ const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
1204
+ const lockFilePath = fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
1205
+ if (existingSessionId) return failure("runtime", startedAt, {
1206
+ code: "lock_conflict",
1207
+ kind: "in_process_session_exists",
1208
+ message: `Project is already open in this backend runtime: ${canonicalProjectPath}`,
1209
+ canonicalPathKey,
1210
+ holderSessionId: existingSessionId,
1211
+ lockFilePath
1212
+ });
1213
+ let sessionLock = null;
1214
+ try {
1215
+ sessionLock = await fileSystem.acquireSessionLock(lockFilePath);
1216
+ await sessionLock.writeMetadata(JSON.stringify(this.context.host?.lockMetadata?.({
1217
+ canonicalPathKey,
1218
+ canonicalProjectPath,
1219
+ lockFilePath
1220
+ }) ?? {
1221
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1222
+ canonicalPathKey
1223
+ }));
1224
+ const document = await new ProjectReader(createProjectReaderFileSystem(fileSystem)).read(fairyPath, { hydrateResourceBytes: true });
1225
+ const project = liftDocumentToUamProject(document);
1226
+ const sessionId = randomId();
1227
+ const session = {
1228
+ sessionId,
1229
+ fairyPath,
1230
+ canonicalProjectPath,
1231
+ canonicalPathKey,
1232
+ lockFilePath,
1233
+ sessionLock,
1234
+ fileSystem,
1235
+ project,
1236
+ uamFidelity: await hasFullUamFidelity(document, project) ? "full" : "unsupported",
1237
+ revision: 0,
1238
+ lastSavedRevision: 0,
1239
+ pendingStaleSourceFiles: /* @__PURE__ */ new Map(),
1240
+ pendingStaleResourceFolders: /* @__PURE__ */ new Map(),
1241
+ pendingStaleBranchDirectories: /* @__PURE__ */ new Map(),
1242
+ dirty: false,
1243
+ lockHeld: true,
1244
+ closed: false
1245
+ };
1246
+ this.context.sessions.set(sessionId, session);
1247
+ this.context.sessionsByPath.set(canonicalPathKey, sessionId);
1248
+ this.cacheService.refreshSession(session);
1249
+ this.eventService.emit({
1250
+ kind: "session.opened",
1251
+ sessionId,
1252
+ canonicalPathKey,
1253
+ revision: session.revision
1254
+ });
1255
+ return success("runtime", startedAt, toSessionSnapshot(session, this.context.capabilities), {
1256
+ sessionId: session.sessionId,
1257
+ revision: session.revision
1258
+ });
1259
+ } catch (error) {
1260
+ if (sessionLock) await sessionLock.release().catch(() => void 0);
1261
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") return failure("runtime", startedAt, {
1262
+ code: "lock_conflict",
1263
+ kind: "advisory_lock_conflict",
1264
+ message: `Advisory lock already exists for project: ${canonicalProjectPath}`,
1265
+ canonicalPathKey,
1266
+ lockFilePath
1267
+ });
1268
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOTSUP") return failure("runtime", startedAt, {
1269
+ ...createCapabilityUnavailableError("fileSystem"),
1270
+ message: error instanceof Error ? error.message : String(error)
1271
+ });
1272
+ throw error;
1273
+ }
1274
+ }
1275
+ openProjectSession(input) {
1276
+ const startedAt = Date.now();
1277
+ const sessionId = input.sessionId ?? randomId();
1278
+ const storage = input.storage;
1279
+ const memoryProjectPath = `memory://${sessionId}`;
1280
+ const canonicalProjectPath = storage?.canonicalProjectPath ?? input.canonicalProjectPath ?? (storage ? storage.fileSystem.dirname(storage.fairyPath) || "." : memoryProjectPath);
1281
+ const canonicalPathKey = storage?.canonicalPathKey ?? input.canonicalPathKey ?? (storage ? normalizeComparablePath(canonicalProjectPath) : canonicalProjectPath.toLowerCase());
1282
+ const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
1283
+ if (existingSessionId) return failure("runtime", startedAt, {
1284
+ code: "lock_conflict",
1285
+ kind: "in_process_session_exists",
1286
+ message: `Project is already open in this backend runtime: ${canonicalProjectPath}`,
1287
+ canonicalPathKey,
1288
+ holderSessionId: existingSessionId
1289
+ });
1290
+ const session = {
1291
+ sessionId,
1292
+ fairyPath: storage?.fairyPath ?? canonicalProjectPath,
1293
+ canonicalProjectPath,
1294
+ canonicalPathKey,
1295
+ lockFilePath: "",
1296
+ sessionLock: null,
1297
+ fileSystem: storage?.fileSystem,
1298
+ project: normalizeUamProject(input.project),
1299
+ uamFidelity: "full",
1300
+ revision: 0,
1301
+ lastSavedRevision: 0,
1302
+ pendingStaleSourceFiles: /* @__PURE__ */ new Map(),
1303
+ pendingStaleResourceFolders: /* @__PURE__ */ new Map(),
1304
+ pendingStaleBranchDirectories: /* @__PURE__ */ new Map(),
1305
+ dirty: false,
1306
+ lockHeld: false,
1307
+ closed: false
1308
+ };
1309
+ this.context.sessions.set(sessionId, session);
1310
+ this.context.sessionsByPath.set(canonicalPathKey, sessionId);
1311
+ this.cacheService.refreshSession(session);
1312
+ this.eventService.emit({
1313
+ kind: "session.opened",
1314
+ sessionId,
1315
+ canonicalPathKey,
1316
+ revision: session.revision
1317
+ });
1318
+ return success("runtime", startedAt, toSessionSnapshot(session, this.context.capabilities), {
1319
+ sessionId: session.sessionId,
1320
+ revision: session.revision
1321
+ });
1322
+ }
1323
+ async closeSession(input) {
1324
+ const startedAt = Date.now();
1325
+ const session = this.context.sessions.get(input.sessionId);
1326
+ if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
1327
+ this.eventService.emit({
1328
+ kind: "session.closeRequested",
1329
+ sessionId: session.sessionId,
1330
+ canonicalPathKey: session.canonicalPathKey,
1331
+ revision: session.revision
1332
+ });
1333
+ await session.sessionLock?.release().catch(() => void 0);
1334
+ session.sessionLock = null;
1335
+ session.lockHeld = false;
1336
+ session.closed = true;
1337
+ this.context.sessions.delete(session.sessionId);
1338
+ this.context.sessionsByPath.delete(session.canonicalPathKey);
1339
+ this.cacheService.removeSession(session.sessionId);
1340
+ this.jobService.removeSession(session.sessionId);
1341
+ this.eventService.emit({
1342
+ kind: "session.closed",
1343
+ sessionId: session.sessionId,
1344
+ canonicalPathKey: session.canonicalPathKey,
1345
+ revision: session.revision
1346
+ });
1347
+ this.eventService.removeSession(session.sessionId);
1348
+ return success("runtime", startedAt, {
1349
+ sessionId: session.sessionId,
1350
+ closed: true
1351
+ }, {
1352
+ sessionId: session.sessionId,
1353
+ revision: session.revision
1354
+ });
1355
+ }
1356
+ };
1357
+ //#endregion
1358
+ //#region src/services/artifact-service.ts
1359
+ function createArtifactCapabilities() {
1360
+ const bridge = {
1361
+ available: false,
1362
+ requiredHost: "node",
1363
+ executionBoundary: "external-bridge",
1364
+ bridgeEntrypoint: "@openfairygui/backend/node",
1365
+ reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
1366
+ };
1367
+ return {
1368
+ publish: false,
1369
+ restore: false,
1370
+ status: "bridge-required",
1371
+ publishBridge: bridge,
1372
+ restoreBridge: bridge
1373
+ };
1374
+ }
1375
+ //#endregion
1376
+ //#region src/runtime/capabilities.ts
1377
+ const BACKEND_METHODS = [
1378
+ "getCapabilities",
1379
+ "openSession",
1380
+ "openProjectSession",
1381
+ "getSession",
1382
+ "applyTransaction",
1383
+ "saveSession",
1384
+ "materializeSession",
1385
+ "closeSession",
1386
+ "getEvents",
1387
+ "getJob",
1388
+ "listJobs",
1389
+ "cancelJob",
1390
+ "getCacheSnapshot",
1391
+ "refreshCache"
1392
+ ];
1393
+ const ARTIFACT_BRIDGE_CAPABILITY = {
1394
+ available: false,
1395
+ requiredHost: "node",
1396
+ executionBoundary: "external-bridge",
1397
+ bridgeEntrypoint: "@openfairygui/backend/node",
1398
+ reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
1399
+ };
1400
+ function createCapabilities() {
1401
+ return {
1402
+ contractVersion: BACKEND_CONTRACT_VERSION,
1403
+ capabilitySchemaVersion: 2,
1404
+ transactionKernelOwner: "@openfairygui/core",
1405
+ appSeamOwner: "@openfairygui/functions",
1406
+ runtimeOwner: "@openfairygui/backend",
1407
+ methods: BACKEND_METHODS,
1408
+ read: {
1409
+ capabilitySnapshot: true,
1410
+ sessionSnapshot: true
1411
+ },
1412
+ authoring: {
1413
+ applyTransaction: true,
1414
+ saveSession: true,
1415
+ resourceKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.resourceKinds],
1416
+ nodeKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.nodeKinds],
1417
+ gearKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.gearKinds],
1418
+ transactionScope: {
1419
+ resourceKinds: [...UAM_SUPPORTED_TRANSACTION_SCOPE.resourceKinds],
1420
+ nodeKinds: [...UAM_SUPPORTED_TRANSACTION_SCOPE.nodeKinds],
1421
+ gearKinds: [...UAM_SUPPORTED_TRANSACTION_SCOPE.gearKinds]
1422
+ },
1423
+ unsupported: ["artifact.publish", "artifact.restore"]
1424
+ },
1425
+ artifact: createArtifactCapabilities(),
1426
+ manifest: {
1427
+ browserSafe: true,
1428
+ rootEntrypoint: "@openfairygui/backend",
1429
+ nodeEntrypoint: "@openfairygui/backend/node",
1430
+ adapters: {
1431
+ fileSystem: {
1432
+ injected: true,
1433
+ requiredFor: [
1434
+ "openSession",
1435
+ "saveSession",
1436
+ "materializeSession"
1437
+ ]
1438
+ },
1439
+ projectStorage: {
1440
+ injected: true,
1441
+ browserSafe: true,
1442
+ requiredFor: [
1443
+ "openProjectSession.writeback",
1444
+ "saveSession",
1445
+ "materializeSession"
1446
+ ],
1447
+ adapterFactory: "createBackendStorageFileSystem"
1448
+ },
1449
+ host: {
1450
+ injected: true,
1451
+ requiredFor: ["advisoryLockMetadata"]
1452
+ }
1453
+ },
1454
+ executionBoundaries: {
1455
+ projectSession: "in-process-browser-safe",
1456
+ fileBackedSession: "adapter-backed",
1457
+ artifactPublish: ARTIFACT_BRIDGE_CAPABILITY,
1458
+ artifactRestore: ARTIFACT_BRIDGE_CAPABILITY
1459
+ },
1460
+ diagnostics: {
1461
+ stableCodes: true,
1462
+ errorDiagnosticMirror: true
1463
+ }
1464
+ },
1465
+ compatibilityPolicy: BACKEND_COMPATIBILITY_POLICY,
1466
+ runtime: {
1467
+ sessionRuntime: true,
1468
+ advisoryLocking: true,
1469
+ coordinatedSave: true,
1470
+ atomicSave: false,
1471
+ staleRevisionProtection: true,
1472
+ pathPolicy: createRuntimePathPolicy(),
1473
+ events: {
1474
+ polling: true,
1475
+ subscriptions: false,
1476
+ retentionLimit: 1e3,
1477
+ sequenceScope: "runtime"
1478
+ },
1479
+ jobs: {
1480
+ inMemory: true,
1481
+ cooperativeCancel: true,
1482
+ persistent: false,
1483
+ supportedKinds: ["cache.refresh"],
1484
+ artifactJobs: false,
1485
+ completedRetentionLimit: 100
1486
+ },
1487
+ cache: {
1488
+ derivedReadOnly: true,
1489
+ keyedBy: "canonicalPathKey",
1490
+ sourceOfTruth: false,
1491
+ refreshMethod: "refreshCache"
1492
+ }
1493
+ }
1494
+ };
1495
+ }
1496
+ //#endregion
1497
+ //#region src/runtime.ts
1498
+ var BackendRuntime = class {
1499
+ fileSystem;
1500
+ capabilities;
1501
+ sessions = /* @__PURE__ */ new Map();
1502
+ sessionsByPath = /* @__PURE__ */ new Map();
1503
+ eventsBySession = /* @__PURE__ */ new Map();
1504
+ jobsBySession = /* @__PURE__ */ new Map();
1505
+ cacheBySession = /* @__PURE__ */ new Map();
1506
+ eventSequence = 0;
1507
+ context;
1508
+ readService;
1509
+ runtimeService;
1510
+ authoringService;
1511
+ cacheService;
1512
+ eventService;
1513
+ jobService;
1514
+ constructor(options = {}) {
1515
+ this.fileSystem = options.fileSystem;
1516
+ this.capabilities = createCapabilities();
1517
+ this.context = {
1518
+ fileSystem: this.fileSystem,
1519
+ host: options.host,
1520
+ capabilities: this.capabilities,
1521
+ sessions: this.sessions,
1522
+ sessionsByPath: this.sessionsByPath,
1523
+ eventsBySession: this.eventsBySession,
1524
+ jobsBySession: this.jobsBySession,
1525
+ cacheBySession: this.cacheBySession,
1526
+ nextEventSequence: () => {
1527
+ this.eventSequence += 1;
1528
+ return this.eventSequence;
1529
+ }
1530
+ };
1531
+ this.readService = new ReadService(this.context);
1532
+ this.eventService = new EventService(this.context);
1533
+ this.cacheService = new CacheService(this.context);
1534
+ this.jobService = new JobService(this.context, this.cacheService, this.eventService);
1535
+ this.runtimeService = new RuntimeService(this.context, this.cacheService, this.eventService, this.jobService);
1536
+ this.authoringService = new AuthoringService(this.context, this.cacheService, this.eventService);
1537
+ }
1538
+ getCapabilities() {
1539
+ return this.readService.getCapabilities();
1540
+ }
1541
+ async openSession(input) {
1542
+ return this.runtimeService.openSession(input);
1543
+ }
1544
+ openProjectSession(input) {
1545
+ return this.runtimeService.openProjectSession(input);
1546
+ }
1547
+ getSession(input) {
1548
+ return this.readService.getSession(input);
1549
+ }
1550
+ async applyTransaction(input) {
1551
+ return this.authoringService.applyTransaction(input);
1552
+ }
1553
+ async saveSession(input) {
1554
+ return this.authoringService.saveSession(input);
1555
+ }
1556
+ async materializeSession(input) {
1557
+ return this.authoringService.materializeSession(input);
1558
+ }
1559
+ async closeSession(input) {
1560
+ return this.runtimeService.closeSession(input);
1561
+ }
1562
+ getEvents(input) {
1563
+ return this.eventService.getEvents(input);
1564
+ }
1565
+ getJob(input) {
1566
+ return this.jobService.getJob(input);
1567
+ }
1568
+ listJobs(input) {
1569
+ return this.jobService.listJobs(input);
1570
+ }
1571
+ cancelJob(input) {
1572
+ return this.jobService.cancelJob(input);
1573
+ }
1574
+ getCacheSnapshot(input) {
1575
+ return this.cacheService.getCacheSnapshot(input);
1576
+ }
1577
+ refreshCache(input) {
1578
+ return this.jobService.refreshCache(input);
1579
+ }
1580
+ };
1581
+ //#endregion
2
1582
  //#region src/storage.ts
3
1583
  var StorageFileStat = class {
4
1584
  constructor(kind) {
@@ -16,6 +1596,42 @@ function createPathError(code, message) {
16
1596
  error.code = code;
17
1597
  return error;
18
1598
  }
1599
+ function getWebLockManager() {
1600
+ if (typeof navigator === "undefined") return null;
1601
+ const lockManager = navigator.locks;
1602
+ return lockManager && typeof lockManager.request === "function" ? lockManager : null;
1603
+ }
1604
+ function acquireWebSessionLock(lockManager, lockName) {
1605
+ return new Promise((resolve, reject) => {
1606
+ let releasePlatformLock = () => void 0;
1607
+ const held = new Promise((release) => {
1608
+ releasePlatformLock = release;
1609
+ });
1610
+ lockManager.request(lockName, {
1611
+ mode: "exclusive",
1612
+ ifAvailable: true
1613
+ }, async (lock) => {
1614
+ if (!lock) {
1615
+ reject(createPathError("EEXIST", `Browser session lock is already held: ${lockName}`));
1616
+ return;
1617
+ }
1618
+ let released = false;
1619
+ resolve({
1620
+ writeMetadata() {
1621
+ return Promise.resolve();
1622
+ },
1623
+ release() {
1624
+ if (!released) {
1625
+ released = true;
1626
+ releasePlatformLock();
1627
+ }
1628
+ return Promise.resolve();
1629
+ }
1630
+ });
1631
+ await held;
1632
+ }).catch(reject);
1633
+ });
1634
+ }
19
1635
  function normalizeStoragePath(value) {
20
1636
  const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/");
21
1637
  const absolute = normalized.startsWith("/");
@@ -70,7 +1686,8 @@ async function inferStat(storage, filePath) {
70
1686
  }
71
1687
  }
72
1688
  function createBackendStorageFileSystem(storage) {
73
- const lockedPaths = /* @__PURE__ */ new Set();
1689
+ if (typeof storage.unlink !== "function") throw createPathError("ENOTSUP", "Storage adapter must provide unlink() for project resource lifecycle writes.");
1690
+ if (typeof storage.rmdir !== "function") throw createPathError("ENOTSUP", "Storage adapter must provide rmdir() for project resource folder lifecycle writes.");
74
1691
  const fileSystem = {
75
1692
  stat(filePath) {
76
1693
  return inferStat(storage, fileSystem.resolve(filePath));
@@ -106,29 +1723,18 @@ function createBackendStorageFileSystem(storage) {
106
1723
  const resolved = fileSystem.resolve(filePath);
107
1724
  return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
108
1725
  },
109
- async openExclusive(filePath) {
110
- const resolved = fileSystem.resolve(filePath);
111
- if (storage.openExclusive) return storage.openExclusive(resolved);
112
- if (lockedPaths.has(resolved) || await fileSystem.exists(resolved)) throw createPathError("EEXIST", `Storage path already exists: ${resolved}`);
113
- lockedPaths.add(resolved);
114
- let closed = false;
115
- return {
116
- async writeFile(content) {
117
- if (closed) throw createPathError("EBADF", `Storage lock handle is closed: ${resolved}`);
118
- await fileSystem.mkdir(fileSystem.dirname(resolved), { recursive: true });
119
- await storage.writeFile(resolved, content);
120
- },
121
- async close() {
122
- closed = true;
123
- lockedPaths.delete(resolved);
124
- }
125
- };
1726
+ async acquireSessionLock(lockPath) {
1727
+ const resolved = fileSystem.resolve(lockPath);
1728
+ if (storage.acquireSessionLock) return storage.acquireSessionLock(resolved);
1729
+ const lockManager = getWebLockManager();
1730
+ if (!lockManager) throw createPathError("ENOTSUP", "Browser openSession requires Web Locks or BackendAsyncStorageAdapter.acquireSessionLock().");
1731
+ return acquireWebSessionLock(lockManager, `@openfairygui/backend:${resolved}`);
126
1732
  },
127
1733
  unlink(filePath) {
128
- const resolved = fileSystem.resolve(filePath);
129
- lockedPaths.delete(resolved);
130
- if (storage.unlink) return storage.unlink(resolved);
131
- throw createPathError("ENOTSUP", "Storage adapter does not provide unlink().");
1734
+ return storage.unlink(fileSystem.resolve(filePath));
1735
+ },
1736
+ rmdir(dirPath) {
1737
+ return storage.rmdir(fileSystem.resolve(dirPath));
132
1738
  },
133
1739
  join(...paths) {
134
1740
  return storage.join ? storage.join(...paths) : joinStoragePath(...paths);