@openfairygui/backend 0.2.0-alpha.9 → 0.3.0-alpha.1

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,1640 @@
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
+ function toProjectOutline(session) {
1054
+ const project = session.project;
1055
+ return {
1056
+ sessionId: session.sessionId,
1057
+ revision: session.revision,
1058
+ projectId: project.projectId,
1059
+ projectType: project.projectType,
1060
+ version: project.version,
1061
+ branches: [...project.branches],
1062
+ packages: project.packages.map((pkg) => ({
1063
+ id: pkg.id,
1064
+ name: pkg.name,
1065
+ branchNames: [...pkg.branchNames],
1066
+ folders: pkg.folders.map((folder) => ({
1067
+ branch: folder.branch,
1068
+ path: folder.path
1069
+ })),
1070
+ resources: pkg.resources.map((resource) => ({
1071
+ id: resource.id,
1072
+ name: resource.name,
1073
+ path: resource.path,
1074
+ kind: resource.kind,
1075
+ branch: resource.branch,
1076
+ ...resource.kind === "component" ? { component: {
1077
+ displayList: resource.component.displayList.map((node) => ({
1078
+ id: node.id,
1079
+ name: node.name,
1080
+ kind: node.kind
1081
+ })),
1082
+ controllers: resource.component.controllers.map((controller) => ({
1083
+ name: controller.name,
1084
+ pages: controller.pages.map((page) => ({
1085
+ id: page.id,
1086
+ name: page.name
1087
+ }))
1088
+ })),
1089
+ transitions: resource.component.transitions.map((transition) => ({ name: transition.name }))
1090
+ } } : {}
1091
+ }))
1092
+ }))
1093
+ };
1094
+ }
1095
+ var ReadService = class {
1096
+ constructor(context) {
1097
+ this.context = context;
1098
+ }
1099
+ getCapabilities() {
1100
+ return success("read", Date.now(), structuredClone(this.context.capabilities));
1101
+ }
1102
+ getSession(input) {
1103
+ const startedAt = Date.now();
1104
+ const session = this.context.sessions.get(input.sessionId);
1105
+ if (!session || session.closed) return failure("read", startedAt, createSessionNotFoundError(input.sessionId));
1106
+ return success("read", startedAt, toSessionSnapshot(session, this.context.capabilities), {
1107
+ sessionId: session.sessionId,
1108
+ revision: session.revision
1109
+ });
1110
+ }
1111
+ getProjectOutline(input) {
1112
+ const startedAt = Date.now();
1113
+ const session = this.context.sessions.get(input.sessionId);
1114
+ if (!session || session.closed) return failure("read", startedAt, createSessionNotFoundError(input.sessionId));
1115
+ return success("read", startedAt, toProjectOutline(session), {
1116
+ sessionId: session.sessionId,
1117
+ revision: session.revision
1118
+ });
1119
+ }
1120
+ };
1121
+ //#endregion
1122
+ //#region src/services/runtime-service.ts
1123
+ function randomId() {
1124
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1125
+ }
1126
+ function createCapabilityUnavailableError(capability) {
1127
+ const artifactCapability = capability === "artifact.publish" || capability === "artifact.restore";
1128
+ return {
1129
+ code: "capability_unavailable",
1130
+ message: artifactCapability ? `${capability} requires the Node bridge boundary exposed by @openfairygui/backend/node.` : `${capability} requires an injected BackendFileSystem adapter.`,
1131
+ capability,
1132
+ requiredAdapter: capability === "fileSystem" ? "BackendFileSystem" : void 0,
1133
+ requiredHost: artifactCapability ? "node" : void 0,
1134
+ bridgeBoundary: artifactCapability ? "external-bridge" : void 0
1135
+ };
1136
+ }
1137
+ function createProjectReaderFileSystem(fileSystem) {
1138
+ return {
1139
+ readFile(filePath) {
1140
+ return fileSystem.readFile(filePath);
1141
+ },
1142
+ readFileRaw(filePath) {
1143
+ return fileSystem.readFileRaw(filePath);
1144
+ },
1145
+ writeFile(filePath, content) {
1146
+ return fileSystem.writeFile(filePath, content);
1147
+ },
1148
+ writeFileRaw(filePath, data) {
1149
+ return fileSystem.writeFileRaw(filePath, data);
1150
+ },
1151
+ async mkdir(dirPath) {
1152
+ await fileSystem.mkdir(dirPath, { recursive: true });
1153
+ },
1154
+ readdir(dirPath) {
1155
+ return fileSystem.readdir(dirPath);
1156
+ },
1157
+ async exists(filePath) {
1158
+ try {
1159
+ await fileSystem.stat(filePath);
1160
+ return true;
1161
+ } catch {
1162
+ return false;
1163
+ }
1164
+ },
1165
+ join(...paths) {
1166
+ return fileSystem.join(...paths);
1167
+ },
1168
+ dirname(filePath) {
1169
+ return fileSystem.dirname(filePath);
1170
+ }
1171
+ };
1172
+ }
1173
+ function createCaptureFileSystem(files, directories) {
1174
+ const normalize = (filePath) => filePath.replace(/\\/g, "/").replace(/\/+/g, "/");
1175
+ return {
1176
+ async readFile(filePath) {
1177
+ const value = files.get(normalize(filePath));
1178
+ if (typeof value !== "string") throw new Error(`Captured text file was not found: ${filePath}`);
1179
+ return value;
1180
+ },
1181
+ async readFileRaw(filePath) {
1182
+ const value = files.get(normalize(filePath));
1183
+ if (!(value instanceof Uint8Array)) throw new Error(`Captured binary file was not found: ${filePath}`);
1184
+ return value.slice();
1185
+ },
1186
+ async writeFile(filePath, content) {
1187
+ files.set(normalize(filePath), content);
1188
+ },
1189
+ async writeFileRaw(filePath, data) {
1190
+ files.set(normalize(filePath), data.slice());
1191
+ },
1192
+ async mkdir(dirPath) {
1193
+ directories.add(normalize(dirPath));
1194
+ },
1195
+ async readdir() {
1196
+ return [];
1197
+ },
1198
+ async exists(filePath) {
1199
+ return files.has(normalize(filePath));
1200
+ },
1201
+ join(...paths) {
1202
+ return normalize(paths.filter(Boolean).join("/"));
1203
+ },
1204
+ dirname(filePath) {
1205
+ const normalized = normalize(filePath);
1206
+ const separator = normalized.lastIndexOf("/");
1207
+ return separator < 0 ? "" : normalized.slice(0, separator);
1208
+ },
1209
+ async unlink(filePath) {
1210
+ files.delete(normalize(filePath));
1211
+ }
1212
+ };
1213
+ }
1214
+ function capturedFilesEqual(left, right) {
1215
+ if (left.size !== right.size) return false;
1216
+ for (const [filePath, leftValue] of left) {
1217
+ const rightValue = right.get(filePath);
1218
+ if (typeof leftValue === "string") {
1219
+ if (leftValue !== rightValue) return false;
1220
+ continue;
1221
+ }
1222
+ if (!(rightValue instanceof Uint8Array) || leftValue.length !== rightValue.length) return false;
1223
+ for (let index = 0; index < leftValue.length; index += 1) if (leftValue[index] !== rightValue[index]) return false;
1224
+ }
1225
+ return true;
1226
+ }
1227
+ function capturedDirectoriesEqual(left, right) {
1228
+ return left.size === right.size && [...left].every((directory) => right.has(directory));
1229
+ }
1230
+ async function hasFullUamFidelity(document, project) {
1231
+ const sourceFiles = /* @__PURE__ */ new Map();
1232
+ const materializedFiles = /* @__PURE__ */ new Map();
1233
+ const sourceDirectories = /* @__PURE__ */ new Set();
1234
+ const materializedDirectories = /* @__PURE__ */ new Set();
1235
+ try {
1236
+ await Promise.all([new ProjectWriter(createCaptureFileSystem(sourceFiles, sourceDirectories)).write(document, "Project.fairy"), new ProjectWriter(createCaptureFileSystem(materializedFiles, materializedDirectories)).write(materializeUamProject(project), "Project.fairy")]);
1237
+ } catch {
1238
+ return false;
1239
+ }
1240
+ return capturedFilesEqual(sourceFiles, materializedFiles) && capturedDirectoriesEqual(sourceDirectories, materializedDirectories);
1241
+ }
1242
+ var RuntimeService = class {
1243
+ constructor(context, cacheService, eventService, jobService) {
1244
+ this.context = context;
1245
+ this.cacheService = cacheService;
1246
+ this.eventService = eventService;
1247
+ this.jobService = jobService;
1248
+ }
1249
+ async openSession(input) {
1250
+ const startedAt = Date.now();
1251
+ if (!this.context.fileSystem) return failure("runtime", startedAt, createCapabilityUnavailableError("fileSystem"));
1252
+ const fileSystem = this.context.fileSystem;
1253
+ const { fairyPath, canonicalProjectPath, canonicalPathKey } = await resolveCanonicalProjectRoot(fileSystem, input.projectPath);
1254
+ const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
1255
+ const lockFilePath = fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
1256
+ if (existingSessionId) return failure("runtime", startedAt, {
1257
+ code: "lock_conflict",
1258
+ kind: "in_process_session_exists",
1259
+ message: `Project is already open in this backend runtime: ${canonicalProjectPath}`,
1260
+ canonicalPathKey,
1261
+ holderSessionId: existingSessionId,
1262
+ lockFilePath
1263
+ });
1264
+ let sessionLock = null;
1265
+ try {
1266
+ sessionLock = await fileSystem.acquireSessionLock(lockFilePath);
1267
+ await sessionLock.writeMetadata(JSON.stringify(this.context.host?.lockMetadata?.({
1268
+ canonicalPathKey,
1269
+ canonicalProjectPath,
1270
+ lockFilePath
1271
+ }) ?? {
1272
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1273
+ canonicalPathKey
1274
+ }));
1275
+ const document = await new ProjectReader(createProjectReaderFileSystem(fileSystem)).read(fairyPath, { hydrateResourceBytes: true });
1276
+ const project = liftDocumentToUamProject(document);
1277
+ const sessionId = randomId();
1278
+ const session = {
1279
+ sessionId,
1280
+ fairyPath,
1281
+ canonicalProjectPath,
1282
+ canonicalPathKey,
1283
+ lockFilePath,
1284
+ sessionLock,
1285
+ fileSystem,
1286
+ project,
1287
+ uamFidelity: await hasFullUamFidelity(document, project) ? "full" : "unsupported",
1288
+ revision: 0,
1289
+ lastSavedRevision: 0,
1290
+ pendingStaleSourceFiles: /* @__PURE__ */ new Map(),
1291
+ pendingStaleResourceFolders: /* @__PURE__ */ new Map(),
1292
+ pendingStaleBranchDirectories: /* @__PURE__ */ new Map(),
1293
+ dirty: false,
1294
+ lockHeld: true,
1295
+ closed: false
1296
+ };
1297
+ this.context.sessions.set(sessionId, session);
1298
+ this.context.sessionsByPath.set(canonicalPathKey, sessionId);
1299
+ this.cacheService.refreshSession(session);
1300
+ this.eventService.emit({
1301
+ kind: "session.opened",
1302
+ sessionId,
1303
+ canonicalPathKey,
1304
+ revision: session.revision
1305
+ });
1306
+ return success("runtime", startedAt, toSessionSnapshot(session, this.context.capabilities), {
1307
+ sessionId: session.sessionId,
1308
+ revision: session.revision
1309
+ });
1310
+ } catch (error) {
1311
+ if (sessionLock) await sessionLock.release().catch(() => void 0);
1312
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") return failure("runtime", startedAt, {
1313
+ code: "lock_conflict",
1314
+ kind: "advisory_lock_conflict",
1315
+ message: `Advisory lock already exists for project: ${canonicalProjectPath}`,
1316
+ canonicalPathKey,
1317
+ lockFilePath
1318
+ });
1319
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOTSUP") return failure("runtime", startedAt, {
1320
+ ...createCapabilityUnavailableError("fileSystem"),
1321
+ message: error instanceof Error ? error.message : String(error)
1322
+ });
1323
+ throw error;
1324
+ }
1325
+ }
1326
+ openProjectSession(input) {
1327
+ const startedAt = Date.now();
1328
+ const sessionId = input.sessionId ?? randomId();
1329
+ const storage = input.storage;
1330
+ const memoryProjectPath = `memory://${sessionId}`;
1331
+ const canonicalProjectPath = storage?.canonicalProjectPath ?? input.canonicalProjectPath ?? (storage ? storage.fileSystem.dirname(storage.fairyPath) || "." : memoryProjectPath);
1332
+ const canonicalPathKey = storage?.canonicalPathKey ?? input.canonicalPathKey ?? (storage ? normalizeComparablePath(canonicalProjectPath) : canonicalProjectPath.toLowerCase());
1333
+ const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
1334
+ if (existingSessionId) return failure("runtime", startedAt, {
1335
+ code: "lock_conflict",
1336
+ kind: "in_process_session_exists",
1337
+ message: `Project is already open in this backend runtime: ${canonicalProjectPath}`,
1338
+ canonicalPathKey,
1339
+ holderSessionId: existingSessionId
1340
+ });
1341
+ const session = {
1342
+ sessionId,
1343
+ fairyPath: storage?.fairyPath ?? canonicalProjectPath,
1344
+ canonicalProjectPath,
1345
+ canonicalPathKey,
1346
+ lockFilePath: "",
1347
+ sessionLock: null,
1348
+ fileSystem: storage?.fileSystem,
1349
+ project: normalizeUamProject(input.project),
1350
+ uamFidelity: "full",
1351
+ revision: 0,
1352
+ lastSavedRevision: 0,
1353
+ pendingStaleSourceFiles: /* @__PURE__ */ new Map(),
1354
+ pendingStaleResourceFolders: /* @__PURE__ */ new Map(),
1355
+ pendingStaleBranchDirectories: /* @__PURE__ */ new Map(),
1356
+ dirty: false,
1357
+ lockHeld: false,
1358
+ closed: false
1359
+ };
1360
+ this.context.sessions.set(sessionId, session);
1361
+ this.context.sessionsByPath.set(canonicalPathKey, sessionId);
1362
+ this.cacheService.refreshSession(session);
1363
+ this.eventService.emit({
1364
+ kind: "session.opened",
1365
+ sessionId,
1366
+ canonicalPathKey,
1367
+ revision: session.revision
1368
+ });
1369
+ return success("runtime", startedAt, toSessionSnapshot(session, this.context.capabilities), {
1370
+ sessionId: session.sessionId,
1371
+ revision: session.revision
1372
+ });
1373
+ }
1374
+ async closeSession(input) {
1375
+ const startedAt = Date.now();
1376
+ const session = this.context.sessions.get(input.sessionId);
1377
+ if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
1378
+ this.eventService.emit({
1379
+ kind: "session.closeRequested",
1380
+ sessionId: session.sessionId,
1381
+ canonicalPathKey: session.canonicalPathKey,
1382
+ revision: session.revision
1383
+ });
1384
+ await session.sessionLock?.release().catch(() => void 0);
1385
+ session.sessionLock = null;
1386
+ session.lockHeld = false;
1387
+ session.closed = true;
1388
+ this.context.sessions.delete(session.sessionId);
1389
+ this.context.sessionsByPath.delete(session.canonicalPathKey);
1390
+ this.cacheService.removeSession(session.sessionId);
1391
+ this.jobService.removeSession(session.sessionId);
1392
+ this.eventService.emit({
1393
+ kind: "session.closed",
1394
+ sessionId: session.sessionId,
1395
+ canonicalPathKey: session.canonicalPathKey,
1396
+ revision: session.revision
1397
+ });
1398
+ this.eventService.removeSession(session.sessionId);
1399
+ return success("runtime", startedAt, {
1400
+ sessionId: session.sessionId,
1401
+ closed: true
1402
+ }, {
1403
+ sessionId: session.sessionId,
1404
+ revision: session.revision
1405
+ });
1406
+ }
1407
+ };
1408
+ //#endregion
1409
+ //#region src/services/artifact-service.ts
1410
+ function createArtifactCapabilities() {
1411
+ const bridge = {
1412
+ available: false,
1413
+ requiredHost: "node",
1414
+ executionBoundary: "external-bridge",
1415
+ bridgeEntrypoint: "@openfairygui/backend/node",
1416
+ reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
1417
+ };
1418
+ return {
1419
+ publish: false,
1420
+ restore: false,
1421
+ status: "bridge-required",
1422
+ publishBridge: bridge,
1423
+ restoreBridge: bridge
1424
+ };
1425
+ }
1426
+ //#endregion
1427
+ //#region src/runtime/capabilities.ts
1428
+ const BACKEND_METHODS = [
1429
+ "getCapabilities",
1430
+ "openSession",
1431
+ "openProjectSession",
1432
+ "getSession",
1433
+ "getProjectOutline",
1434
+ "applyTransaction",
1435
+ "saveSession",
1436
+ "materializeSession",
1437
+ "closeSession",
1438
+ "getEvents",
1439
+ "getJob",
1440
+ "listJobs",
1441
+ "cancelJob",
1442
+ "getCacheSnapshot",
1443
+ "refreshCache"
1444
+ ];
1445
+ const ARTIFACT_BRIDGE_CAPABILITY = {
1446
+ available: false,
1447
+ requiredHost: "node",
1448
+ executionBoundary: "external-bridge",
1449
+ bridgeEntrypoint: "@openfairygui/backend/node",
1450
+ reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
1451
+ };
1452
+ function createCapabilities() {
1453
+ return {
1454
+ contractVersion: BACKEND_CONTRACT_VERSION,
1455
+ capabilitySchemaVersion: 2,
1456
+ transactionKernelOwner: "@openfairygui/core",
1457
+ appSeamOwner: "@openfairygui/functions",
1458
+ runtimeOwner: "@openfairygui/backend",
1459
+ methods: BACKEND_METHODS,
1460
+ read: {
1461
+ capabilitySnapshot: true,
1462
+ sessionSnapshot: true,
1463
+ projectOutline: true
1464
+ },
1465
+ authoring: {
1466
+ applyTransaction: true,
1467
+ saveSession: true,
1468
+ resourceKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.resourceKinds],
1469
+ nodeKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.nodeKinds],
1470
+ gearKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.gearKinds],
1471
+ transactionScope: {
1472
+ resourceKinds: [...UAM_SUPPORTED_TRANSACTION_SCOPE.resourceKinds],
1473
+ nodeKinds: [...UAM_SUPPORTED_TRANSACTION_SCOPE.nodeKinds],
1474
+ gearKinds: [...UAM_SUPPORTED_TRANSACTION_SCOPE.gearKinds]
1475
+ },
1476
+ unsupported: ["artifact.publish", "artifact.restore"]
1477
+ },
1478
+ artifact: createArtifactCapabilities(),
1479
+ manifest: {
1480
+ browserSafe: true,
1481
+ rootEntrypoint: "@openfairygui/backend",
1482
+ nodeEntrypoint: "@openfairygui/backend/node",
1483
+ adapters: {
1484
+ fileSystem: {
1485
+ injected: true,
1486
+ requiredFor: [
1487
+ "openSession",
1488
+ "saveSession",
1489
+ "materializeSession"
1490
+ ]
1491
+ },
1492
+ projectStorage: {
1493
+ injected: true,
1494
+ browserSafe: true,
1495
+ requiredFor: [
1496
+ "openProjectSession.writeback",
1497
+ "saveSession",
1498
+ "materializeSession"
1499
+ ],
1500
+ adapterFactory: "createBackendStorageFileSystem"
1501
+ },
1502
+ host: {
1503
+ injected: true,
1504
+ requiredFor: ["advisoryLockMetadata"]
1505
+ }
1506
+ },
1507
+ executionBoundaries: {
1508
+ projectSession: "in-process-browser-safe",
1509
+ fileBackedSession: "adapter-backed",
1510
+ artifactPublish: ARTIFACT_BRIDGE_CAPABILITY,
1511
+ artifactRestore: ARTIFACT_BRIDGE_CAPABILITY
1512
+ },
1513
+ diagnostics: {
1514
+ stableCodes: true,
1515
+ errorDiagnosticMirror: true
1516
+ }
1517
+ },
1518
+ compatibilityPolicy: BACKEND_COMPATIBILITY_POLICY,
1519
+ runtime: {
1520
+ sessionRuntime: true,
1521
+ advisoryLocking: true,
1522
+ coordinatedSave: true,
1523
+ atomicSave: false,
1524
+ staleRevisionProtection: true,
1525
+ pathPolicy: createRuntimePathPolicy(),
1526
+ events: {
1527
+ polling: true,
1528
+ subscriptions: false,
1529
+ retentionLimit: 1e3,
1530
+ sequenceScope: "runtime"
1531
+ },
1532
+ jobs: {
1533
+ inMemory: true,
1534
+ cooperativeCancel: true,
1535
+ persistent: false,
1536
+ supportedKinds: ["cache.refresh"],
1537
+ artifactJobs: false,
1538
+ completedRetentionLimit: 100
1539
+ },
1540
+ cache: {
1541
+ derivedReadOnly: true,
1542
+ keyedBy: "canonicalPathKey",
1543
+ sourceOfTruth: false,
1544
+ refreshMethod: "refreshCache"
1545
+ }
1546
+ }
1547
+ };
1548
+ }
1549
+ //#endregion
1550
+ //#region src/runtime.ts
1551
+ var BackendRuntime = class {
1552
+ fileSystem;
1553
+ capabilities;
1554
+ sessions = /* @__PURE__ */ new Map();
1555
+ sessionsByPath = /* @__PURE__ */ new Map();
1556
+ eventsBySession = /* @__PURE__ */ new Map();
1557
+ jobsBySession = /* @__PURE__ */ new Map();
1558
+ cacheBySession = /* @__PURE__ */ new Map();
1559
+ eventSequence = 0;
1560
+ context;
1561
+ readService;
1562
+ runtimeService;
1563
+ authoringService;
1564
+ cacheService;
1565
+ eventService;
1566
+ jobService;
1567
+ constructor(options = {}) {
1568
+ this.fileSystem = options.fileSystem;
1569
+ this.capabilities = createCapabilities();
1570
+ this.context = {
1571
+ fileSystem: this.fileSystem,
1572
+ host: options.host,
1573
+ capabilities: this.capabilities,
1574
+ sessions: this.sessions,
1575
+ sessionsByPath: this.sessionsByPath,
1576
+ eventsBySession: this.eventsBySession,
1577
+ jobsBySession: this.jobsBySession,
1578
+ cacheBySession: this.cacheBySession,
1579
+ nextEventSequence: () => {
1580
+ this.eventSequence += 1;
1581
+ return this.eventSequence;
1582
+ }
1583
+ };
1584
+ this.readService = new ReadService(this.context);
1585
+ this.eventService = new EventService(this.context);
1586
+ this.cacheService = new CacheService(this.context);
1587
+ this.jobService = new JobService(this.context, this.cacheService, this.eventService);
1588
+ this.runtimeService = new RuntimeService(this.context, this.cacheService, this.eventService, this.jobService);
1589
+ this.authoringService = new AuthoringService(this.context, this.cacheService, this.eventService);
1590
+ }
1591
+ getCapabilities() {
1592
+ return this.readService.getCapabilities();
1593
+ }
1594
+ async openSession(input) {
1595
+ return this.runtimeService.openSession(input);
1596
+ }
1597
+ openProjectSession(input) {
1598
+ return this.runtimeService.openProjectSession(input);
1599
+ }
1600
+ getSession(input) {
1601
+ return this.readService.getSession(input);
1602
+ }
1603
+ getProjectOutline(input) {
1604
+ return this.readService.getProjectOutline(input);
1605
+ }
1606
+ async applyTransaction(input) {
1607
+ return this.authoringService.applyTransaction(input);
1608
+ }
1609
+ async saveSession(input) {
1610
+ return this.authoringService.saveSession(input);
1611
+ }
1612
+ async materializeSession(input) {
1613
+ return this.authoringService.materializeSession(input);
1614
+ }
1615
+ async closeSession(input) {
1616
+ return this.runtimeService.closeSession(input);
1617
+ }
1618
+ getEvents(input) {
1619
+ return this.eventService.getEvents(input);
1620
+ }
1621
+ getJob(input) {
1622
+ return this.jobService.getJob(input);
1623
+ }
1624
+ listJobs(input) {
1625
+ return this.jobService.listJobs(input);
1626
+ }
1627
+ cancelJob(input) {
1628
+ return this.jobService.cancelJob(input);
1629
+ }
1630
+ getCacheSnapshot(input) {
1631
+ return this.cacheService.getCacheSnapshot(input);
1632
+ }
1633
+ refreshCache(input) {
1634
+ return this.jobService.refreshCache(input);
1635
+ }
1636
+ };
1637
+ //#endregion
2
1638
  //#region src/storage.ts
3
1639
  var StorageFileStat = class {
4
1640
  constructor(kind) {
@@ -16,6 +1652,42 @@ function createPathError(code, message) {
16
1652
  error.code = code;
17
1653
  return error;
18
1654
  }
1655
+ function getWebLockManager() {
1656
+ if (typeof navigator === "undefined") return null;
1657
+ const lockManager = navigator.locks;
1658
+ return lockManager && typeof lockManager.request === "function" ? lockManager : null;
1659
+ }
1660
+ function acquireWebSessionLock(lockManager, lockName) {
1661
+ return new Promise((resolve, reject) => {
1662
+ let releasePlatformLock = () => void 0;
1663
+ const held = new Promise((release) => {
1664
+ releasePlatformLock = release;
1665
+ });
1666
+ lockManager.request(lockName, {
1667
+ mode: "exclusive",
1668
+ ifAvailable: true
1669
+ }, async (lock) => {
1670
+ if (!lock) {
1671
+ reject(createPathError("EEXIST", `Browser session lock is already held: ${lockName}`));
1672
+ return;
1673
+ }
1674
+ let released = false;
1675
+ resolve({
1676
+ writeMetadata() {
1677
+ return Promise.resolve();
1678
+ },
1679
+ release() {
1680
+ if (!released) {
1681
+ released = true;
1682
+ releasePlatformLock();
1683
+ }
1684
+ return Promise.resolve();
1685
+ }
1686
+ });
1687
+ await held;
1688
+ }).catch(reject);
1689
+ });
1690
+ }
19
1691
  function normalizeStoragePath(value) {
20
1692
  const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/");
21
1693
  const absolute = normalized.startsWith("/");
@@ -70,7 +1742,8 @@ async function inferStat(storage, filePath) {
70
1742
  }
71
1743
  }
72
1744
  function createBackendStorageFileSystem(storage) {
73
- const lockedPaths = /* @__PURE__ */ new Set();
1745
+ if (typeof storage.unlink !== "function") throw createPathError("ENOTSUP", "Storage adapter must provide unlink() for project resource lifecycle writes.");
1746
+ if (typeof storage.rmdir !== "function") throw createPathError("ENOTSUP", "Storage adapter must provide rmdir() for project resource folder lifecycle writes.");
74
1747
  const fileSystem = {
75
1748
  stat(filePath) {
76
1749
  return inferStat(storage, fileSystem.resolve(filePath));
@@ -106,29 +1779,18 @@ function createBackendStorageFileSystem(storage) {
106
1779
  const resolved = fileSystem.resolve(filePath);
107
1780
  return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
108
1781
  },
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
- };
1782
+ async acquireSessionLock(lockPath) {
1783
+ const resolved = fileSystem.resolve(lockPath);
1784
+ if (storage.acquireSessionLock) return storage.acquireSessionLock(resolved);
1785
+ const lockManager = getWebLockManager();
1786
+ if (!lockManager) throw createPathError("ENOTSUP", "Browser openSession requires Web Locks or BackendAsyncStorageAdapter.acquireSessionLock().");
1787
+ return acquireWebSessionLock(lockManager, `@openfairygui/backend:${resolved}`);
126
1788
  },
127
1789
  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().");
1790
+ return storage.unlink(fileSystem.resolve(filePath));
1791
+ },
1792
+ rmdir(dirPath) {
1793
+ return storage.rmdir(fileSystem.resolve(dirPath));
132
1794
  },
133
1795
  join(...paths) {
134
1796
  return storage.join ? storage.join(...paths) : joinStoragePath(...paths);